hangout-api / server.js
iosrvk0's picture
Upload 3 files
69c4be2 verified
Raw
History Blame Contribute Delete
77.2 kB
// ============================================================
// HangOut Main Backend β€” SINGLE FILE EDITION
// ============================================================
// Express + Mongoose (MongoDB Atlas) + JWT + Nodemailer (Gmail)
// + Google Drive (avatars/images) + All 28+ REST routes
// Designed for Hugging Face Docker Space (port 7860, mobile deploy)
// Presented by RVK EDITION Β· v1.0.0
// ============================================================
// ΰ€―ΰ€Ή ΰ€«ΰ€Ύΰ€‡ΰ€² ΰ€ͺΰ₯‚ΰ€°ΰ€Ύ backend ΰ€Ήΰ₯ˆ β€” config, models, middleware, routes ΰ€Έΰ€¬ ΰ€ΰ€• ΰ€œΰ€—ΰ€Ήΰ₯€
// ΰ€¬ΰ€Έ ΰ€―ΰ€Ή ΰ€«ΰ€Ύΰ€‡ΰ€² + package.json + Dockerfile HF Space ΰ€ͺΰ€° push ΰ€•ΰ€°ΰ₯‹ΰ₯€
// ============================================================
require('dotenv').config()
const express = require('express')
const cors = require('cors')
const helmet = require('helmet')
const rateLimit = require('express-rate-limit')
const mongoose = require('mongoose')
const multer = require('multer')
const stream = require('stream')
const jwt = require('jsonwebtoken')
const bcrypt = require('bcryptjs')
const cookieParser = require('cookie-parser')
const { google } = require('googleapis')
const { nanoid } = require('nanoid')
const nodemailer = require('nodemailer')
const PORT = process.env.PORT || 7860
const HOST = process.env.HOST || '0.0.0.0'
const JWT_SECRET = process.env.JWT_SECRET || 'change-this-to-a-32-char-random-string'
const TOKEN_COOKIE = 'hangout_token'
// Reels backend URL (already deployed on HF Space)
const REELS_BACKEND_URL = process.env.REELS_BACKEND_URL || 'https://iosrvk0-hangout-reels-backend.hf.space'
// ============================================================
// SECTION 1 β€” CONFIG (MongoDB, Google Drive, Nodemailer)
// ============================================================
// ─── MongoDB Atlas Connection (Mongoose, non-blocking) ───
let dbConnected = false
async function connectDB() {
if (dbConnected && mongoose.connection.readyState === 1) return
const MONGODB_URI = process.env.MONGODB_URI
if (!MONGODB_URI) {
console.warn('[db] MONGODB_URI not set β€” endpoints will error')
return
}
try {
console.log('[db] Connecting to MongoDB Atlas...')
await mongoose.connect(MONGODB_URI, {
serverSelectionTimeoutMS: 10000,
maxPoolSize: 10,
minPoolSize: 1,
socketTimeoutMS: 45000,
family: 4,
})
dbConnected = true
console.log('[db] βœ“ Connected to MongoDB Atlas')
mongoose.connection.on('error', (err) => console.error('[db] error:', err.message))
mongoose.connection.on('disconnected', () => {
dbConnected = false
console.warn('[db] disconnected')
})
} catch (err) {
console.error('[db] βœ— MongoDB connect failed:', err.message)
}
}
// ─── Google Drive Client (for avatars + post images) ───
let cachedDrive = null
let cachedAuth = null
function getCredentials() {
const raw = process.env.GOOGLE_CREDENTIALS_JSON
if (!raw) throw new Error('GOOGLE_CREDENTIALS_JSON env var is not set')
try {
return typeof raw === 'string' ? JSON.parse(raw) : raw
} catch (err) {
throw new Error(`GOOGLE_CREDENTIALS_JSON invalid JSON: ${err.message}`)
}
}
function getAuthClient() {
if (cachedAuth) return cachedAuth
cachedAuth = new google.auth.GoogleAuth({
credentials: getCredentials(),
scopes: ['https://www.googleapis.com/auth/drive.file'],
})
return cachedAuth
}
function getDriveClient() {
if (cachedDrive) return cachedDrive
cachedDrive = google.drive({ version: 'v3', auth: getAuthClient() })
return cachedDrive
}
function getDriveFolderId() {
const fid = process.env.GOOGLE_DRIVE_FOLDER_ID
if (!fid) throw new Error('GOOGLE_DRIVE_FOLDER_ID env var is not set')
return fid
}
// Upload buffer to Drive (for avatars/post images)
async function uploadBufferToDrive(buffer, fileName, mimeType) {
const drive = getDriveClient()
const folderId = getDriveFolderId()
const readable = stream.Readable.from(buffer)
const response = await drive.files.create({
requestBody: { name: fileName, parents: [folderId] },
media: { mimeType, body: readable },
fields: 'id, name',
})
if (!response.data?.id) throw new Error('Drive upload returned no file ID')
return response.data.id
}
// ─── Nodemailer Transporter (Gmail App Password) ───
let cachedTransporter = null
function getMailer() {
if (cachedTransporter) return cachedTransporter
const user = process.env.EMAIL_USER
const pass = process.env.EMAIL_APP_PASSWORD
if (!user || !pass) {
console.warn('[mailer] EMAIL_USER or EMAIL_APP_PASSWORD not set β€” OTP will fall back to console.log')
return null
}
cachedTransporter = nodemailer.createTransport({
service: 'gmail',
auth: { user, pass },
})
return cachedTransporter
}
async function sendOtpEmail(toEmail, code) {
const transporter = getMailer()
if (!transporter) {
// Fallback: console.log
console.log(`\n=========== HANGOUT OTP (console fallback) ===========`)
console.log(` To: ${toEmail}`)
console.log(` OTP: ${code}`)
console.log(`=====================================================\n`)
return { fallback: true }
}
try {
await transporter.sendMail({
from: `"HangOut Β· RVK EDITION" <${process.env.EMAIL_USER}>`,
to: toEmail,
subject: `HangOut β€” Your OTP is ${code}`,
text: `Your HangOut verification code is: ${code}\n\nThis code expires in 10 minutes.\n\nIf you didn't request this, ignore this email.\n\nβ€” RVK EDITION`,
html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, sans-serif; max-width: 480px; margin: 0 auto; background: #0a0e14; color: #e8edf5; padding: 32px; border-radius: 16px;">
<h1 style="color: #00d9a3; margin: 0 0 8px; font-size: 24px;">HangOut</h1>
<p style="color: #8a93a5; margin: 0 0 24px; font-size: 13px;">Presented by RVK EDITION</p>
<h2 style="margin: 0 0 16px; font-size: 18px;">Your verification code</h2>
<div style="background: #161b24; padding: 24px; border-radius: 12px; text-align: center; margin: 0 0 16px;">
<div style="font-size: 36px; font-weight: 800; color: #00d9a3; letter-spacing: 8px; font-family: monospace;">${code}</div>
</div>
<p style="color: #8a93a5; font-size: 12px; margin: 0;">This code expires in 10 minutes. If you didn't request this, you can safely ignore this email.</p>
</div>
`,
})
console.log(`[mailer] βœ“ OTP email sent to ${toEmail}`)
return { fallback: false }
} catch (err) {
console.error(`[mailer] βœ— Failed to send email to ${toEmail}:`, err.message)
// Fallback to console.log so user can still get OTP
console.log(`\n=========== HANGOUT OTP (send failed, console fallback) ===========`)
console.log(` To: ${toEmail}`)
console.log(` OTP: ${code}`)
console.log(`===================================================================\n`)
return { fallback: true, error: err.message }
}
}
// ============================================================
// SECTION 2 β€” MONGOOSE MODELS
// ============================================================
const { Schema, model } = mongoose
// ─── User ───
const UserSchema = new Schema({
userId: { type: String, required: true, unique: true, index: true, default: () => `u_${nanoid(10)}` },
email: { type: String, required: true, unique: true, lowercase: true, index: true },
phone: { type: String, default: null, index: true },
password: { type: String, required: true },
profileName: { type: String, required: true },
avatar: { type: String, default: null }, // Drive file ID β€” fetched via /api/file/:fileId
bio: { type: String, default: null },
about: { type: String, default: null },
language: { type: String, default: 'en' },
theme: { type: String, default: 'dark' },
wallpaper:{ type: String, default: 'default' },
isOnline: { type: Boolean, default: false },
lastSeen: { type: Date, default: Date.now },
createdAt:{ type: Date, default: Date.now },
updatedAt:{ type: Date, default: Date.now },
})
const User = model('User', UserSchema)
// ─── UserSettings ───
const UserSettingsSchema = new Schema({
userId: { type: Schema.Types.ObjectId, ref: 'User', required: true, unique: true, index: true },
lastSeenVisible: { type: Boolean, default: true },
profilePhotoVisible:{ type: String, default: 'everyone' },
statusVisible: { type: String, default: 'contacts' },
readReceipts: { type: Boolean, default: true },
groupsEveryone: { type: Boolean, default: true },
notifMessages: { type: Boolean, default: true },
notifCalls: { type: Boolean, default: true },
notifReactions: { type: Boolean, default: true },
notifSound: { type: Boolean, default: true },
notifVibrate: { type: Boolean, default: true },
enterToSend: { type: Boolean, default: true },
showOnlineStatus: { type: Boolean, default: true },
fontScale: { type: Number, default: 1.0 },
mediaAutoDownload: { type: Boolean, default: true },
dataSaver: { type: Boolean, default: false },
updatedAt: { type: Date, default: Date.now },
})
const UserSettings = model('UserSettings', UserSettingsSchema)
// ─── Friendship ───
const FriendshipSchema = new Schema({
requesterId: { type: Schema.Types.ObjectId, ref: 'User', required: true, index: true },
receiverId: { type: Schema.Types.ObjectId, ref: 'User', required: true, index: true },
status: { type: String, default: 'pending' }, // pending | accepted | declined | blocked
createdAt: { type: Date, default: Date.now },
updatedAt: { type: Date, default: Date.now },
})
FriendshipSchema.index({ requesterId: 1, receiverId: 1 }, { unique: true })
const Friendship = model('Friendship', FriendshipSchema)
// ─── Block ───
const BlockSchema = new Schema({
blockerId: { type: Schema.Types.ObjectId, ref: 'User', required: true, index: true },
blockedId: { type: Schema.Types.ObjectId, ref: 'User', required: true, index: true },
createdAt: { type: Date, default: Date.now },
})
BlockSchema.index({ blockerId: 1, blockedId: 1 }, { unique: true })
const Block = model('Block', BlockSchema)
// ─── Conversation ───
const ConversationSchema = new Schema({
userAId: { type: Schema.Types.ObjectId, ref: 'User', required: true, index: true },
userBId: { type: Schema.Types.ObjectId, ref: 'User', required: true, index: true },
lastMessageId: { type: Schema.Types.ObjectId, ref: 'Message', default: null },
lastMessageAt: { type: Date, default: Date.now },
unreadA: { type: Number, default: 0 },
unreadB: { type: Number, default: 0 },
createdAt: { type: Date, default: Date.now },
updatedAt: { type: Date, default: Date.now },
})
ConversationSchema.index({ userAId: 1, userBId: 1 }, { unique: true })
const Conversation = model('Conversation', ConversationSchema)
// ─── Message ───
const MessageSchema = new Schema({
conversationId: { type: Schema.Types.ObjectId, ref: 'Conversation', required: true, index: true },
senderId: { type: Schema.Types.ObjectId, ref: 'User', required: true, index: true },
receiverId: { type: Schema.Types.ObjectId, ref: 'User', required: true, index: true },
content: { type: String, default: '' },
type: { type: String, default: 'text' }, // text | image | video | audio | file | system
mediaUrl: { type: String, default: null }, // Drive file ID for media messages
replyToId: { type: Schema.Types.ObjectId, ref: 'Message', default: null },
status: { type: String, default: 'sent' }, // sent | delivered | read
starred: { type: Boolean, default: false },
deleted: { type: Boolean, default: false },
createdAt: { type: Date, default: Date.now },
})
MessageSchema.index({ conversationId: 1, createdAt: 1 })
const Message = model('Message', MessageSchema)
// ─── Status ───
const StatusSchema = new Schema({
userId: { type: Schema.Types.ObjectId, ref: 'User', required: true, index: true },
type: { type: String, default: 'text' }, // text | image | video
content:{ type: String, default: '' },
bgColor:{ type: String, default: '#0f172a' },
mediaUrl: { type: String, default: null }, // Drive file ID
caption: { type: String, default: null },
expiresAt: { type: Date, required: true },
createdAt: { type: Date, default: Date.now },
})
StatusSchema.index({ userId: 1, createdAt: -1 })
const Status = model('Status', StatusSchema)
// ─── StatusView ───
const StatusViewSchema = new Schema({
statusId: { type: Schema.Types.ObjectId, ref: 'Status', required: true, index: true },
userId: { type: Schema.Types.ObjectId, ref: 'User', required: true, index: true },
createdAt:{ type: Date, default: Date.now },
})
StatusViewSchema.index({ statusId: 1, userId: 1 }, { unique: true })
const StatusView = model('StatusView', StatusViewSchema)
// ─── Post ───
const PostSchema = new Schema({
userId: { type: Schema.Types.ObjectId, ref: 'User', required: true, index: true },
content:{ type: String, default: '' },
mediaUrl: { type: String, default: null }, // Drive file ID
mediaType:{ type: String, default: null }, // image | video
location:{ type: String, default: null },
feeling: { type: String, default: null },
createdAt: { type: Date, default: Date.now },
updatedAt: { type: Date, default: Date.now },
})
PostSchema.index({ createdAt: -1 })
const Post = model('Post', PostSchema)
// ─── PostLike ───
const PostLikeSchema = new Schema({
postId: { type: Schema.Types.ObjectId, ref: 'Post', required: true, index: true },
userId: { type: Schema.Types.ObjectId, ref: 'User', required: true, index: true },
createdAt: { type: Date, default: Date.now },
})
PostLikeSchema.index({ postId: 1, userId: 1 }, { unique: true })
const PostLike = model('PostLike', PostLikeSchema)
// ─── PostComment ───
const PostCommentSchema = new Schema({
postId: { type: Schema.Types.ObjectId, ref: 'Post', required: true, index: true },
userId: { type: Schema.Types.ObjectId, ref: 'User', required: true },
content:{ type: String, required: true },
createdAt: { type: Date, default: Date.now },
})
PostCommentSchema.index({ postId: 1, createdAt: -1 })
const PostComment = model('PostComment', PostCommentSchema)
// ─── Reel ───
const ReelSchema = new Schema({
userId: { type: Schema.Types.ObjectId, ref: 'User', required: true, index: true },
// videoId β€” HF reels backend ΰ€•ΰ€Ύ unique ID (ΰ€…ΰ€Έΰ€²ΰ₯€ video ΰ€΅ΰ€Ήΰ€Ύΰ€ ΰ€Ήΰ₯ˆ)
videoId: { type: String, required: true, index: true },
driveFileId: { type: String, default: null }, // HF reels backend ΰ€•ΰ€Ύ Drive ID
caption: { type: String, default: null },
musicName: { type: String, default: null },
createdAt: { type: Date, default: Date.now },
})
ReelSchema.index({ createdAt: -1 })
const Reel = model('Reel', ReelSchema)
// ─── ReelLike ───
const ReelLikeSchema = new Schema({
reelId: { type: Schema.Types.ObjectId, ref: 'Reel', required: true, index: true },
userId: { type: Schema.Types.ObjectId, ref: 'User', required: true, index: true },
createdAt: { type: Date, default: Date.now },
})
ReelLikeSchema.index({ reelId: 1, userId: 1 }, { unique: true })
const ReelLike = model('ReelLike', ReelLikeSchema)
// ─── ReelComment ───
const ReelCommentSchema = new Schema({
reelId: { type: Schema.Types.ObjectId, ref: 'Reel', required: true, index: true },
userId: { type: Schema.Types.ObjectId, ref: 'User', required: true },
content:{ type: String, required: true },
createdAt: { type: Date, default: Date.now },
})
ReelCommentSchema.index({ reelId: 1, createdAt: -1 })
const ReelComment = model('ReelComment', ReelCommentSchema)
// ─── Call ───
const CallSchema = new Schema({
callerId: { type: Schema.Types.ObjectId, ref: 'User', required: true, index: true },
receiverId: { type: Schema.Types.ObjectId, ref: 'User', required: true, index: true },
type: { type: String, default: 'audio' }, // audio | video
status: { type: String, default: 'missed' }, // missed | answered | declined | failed
duration:{ type: Number, default: 0 },
startedAt: { type: Date, default: Date.now },
endedAt: { type: Date, default: null },
})
CallSchema.index({ callerId: 1, startedAt: -1 })
CallSchema.index({ receiverId: 1, startedAt: -1 })
const Call = model('Call', CallSchema)
// ─── OtpCode ───
const OtpCodeSchema = new Schema({
identifier: { type: String, required: true, index: true }, // email or phone
code: { type: String, required: true },
purpose: { type: String, default: 'signup' }, // signup | login
consumed: { type: Boolean, default: false },
expiresAt: { type: Date, required: true },
createdAt: { type: Date, default: Date.now },
})
OtpCodeSchema.index({ identifier: 1, createdAt: -1 })
const OtpCode = model('OtpCode', OtpCodeSchema)
// ============================================================
// SECTION 3 β€” HELPER FUNCTIONS
// ============================================================
// Sign JWT for a user
function signToken(user) {
return jwt.sign(
{
id: user._id.toString(),
userId: user.userId,
email: user.email,
profileName: user.profileName,
avatar: user.avatar,
},
JWT_SECRET,
{ expiresIn: '30d' }
)
}
// Verify JWT
function verifyToken(token) {
try {
return jwt.verify(token, JWT_SECRET)
} catch {
return null
}
}
// Get authenticated user from request
async function getAuthUser(req) {
let token = null
// Try cookie first
if (req.cookies && req.cookies[TOKEN_COOKIE]) {
token = req.cookies[TOKEN_COOKIE]
}
// Then Authorization header
if (!token && req.headers.authorization?.startsWith('Bearer ')) {
token = req.headers.authorization.slice(7)
}
if (!token) return null
const decoded = verifyToken(token)
if (!decoded) return null
// Fetch fresh user from DB (avatar/name may have changed)
const user = await User.findById(decoded.id).lean()
return user
}
// Set auth cookie
function setAuthCookie(res, token) {
res.cookie(TOKEN_COOKIE, token, {
httpOnly: false, // APK WebView ΰ€•ΰ₯‹ JS ΰ€Έΰ₯‡ access ΰ€•ΰ€°ΰ€¨ΰ₯‡ ΰ€¦ΰ₯‡ΰ€¨ΰ€Ύ ΰ€Ήΰ₯ˆ
sameSite: 'none', // cross-origin (APK β†’ HF Space)
secure: true, // HTTPS required
path: '/',
maxAge: 60 * 60 * 24 * 30, // 30 days
})
}
function clearAuthCookie(res) {
res.clearCookie(TOKEN_COOKIE, { path: '/' })
}
// Hash password
async function hashPassword(p) {
return bcrypt.hash(p, 10)
}
async function comparePassword(p, hash) {
return bcrypt.compare(p, hash)
}
// Generate 6-digit OTP
function generateOtp() {
return Math.floor(100000 + Math.random() * 900000).toString()
}
// Find user by email or phone
async function findUserByIdentifier(identifier) {
const isEmail = identifier.includes('@')
if (isEmail) {
return User.findOne({ email: identifier.toLowerCase() }).lean()
}
return User.findOne({ phone: identifier }).lean()
}
// Initialize default settings for new user
async function initUserSettings(userId) {
const existing = await UserSettings.findOne({ userId })
if (!existing) {
await UserSettings.create({ userId })
}
}
// Get or create conversation between two users
async function getOrCreateConversation(userAId, userBId) {
// Sort IDs to ensure consistent ordering
const [a, b] = userAId < userBId ? [userAId, userBId] : [userBId, userAId]
let conv = await Conversation.findOne({ userAId: a, userBId: b })
if (!conv) {
conv = await Conversation.create({ userAId: a, userBId: b })
}
return conv
}
// Public-safe user object (no password)
function publicUser(u) {
if (!u) return null
return {
id: u._id.toString(),
userId: u.userId,
profileName: u.profileName,
avatar: u.avatar ? `/api/file/${u.avatar}` : null, // proxy via our stream route
bio: u.bio,
about: u.about,
isOnline: u.isOnline,
lastSeen: u.lastSeen,
}
}
// ============================================================
// SECTION 4 β€” MIDDLEWARE
// ============================================================
// Auth middleware (for protected routes)
function authMiddleware(req, res, next) {
getAuthUser(req)
.then((user) => {
if (!user) {
return res.status(401).json({ error: 'Unauthorized' })
}
req.user = user
next()
})
.catch((err) => {
console.error('[auth] error:', err.message)
res.status(500).json({ error: 'Auth error' })
})
}
// Multer config β€” STRICT memoryStorage (no disk)
const MAX_UPLOAD_SIZE = 10 * 1024 * 1024 // 10 MB for images/avatars
const ALLOWED_IMAGE_MIME = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: MAX_UPLOAD_SIZE, files: 1 },
fileFilter: (req, file, cb) => {
if (ALLOWED_IMAGE_MIME.includes(file.mimetype)) {
cb(null, true)
} else {
cb(new Error(`Unsupported MIME: ${file.mimetype}. Allowed: ${ALLOWED_IMAGE_MIME.join(', ')}`), false)
}
},
})
// ============================================================
// SECTION 5 β€” ROUTES (all 28+ in one place)
// ============================================================
const app = express()
// ─── GLOBAL MIDDLEWARE (must be before routes) ───
// Security headers
app.use(
helmet({
crossOriginResourcePolicy: { policy: 'cross-origin' },
contentSecurityPolicy: false,
})
)
// CORS (configurable for APK WebView)
const allowedOrigins = (process.env.CORS_ORIGINS || '*')
.split(',')
.map((s) => s.trim())
.filter(Boolean)
app.use(
cors({
origin: allowedOrigins.includes('*') ? true : allowedOrigins,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'HEAD', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-API-Key', 'Range', 'Cookie'],
exposedHeaders: ['Content-Length', 'Content-Range', 'Accept-Ranges', 'Content-Type', 'Set-Cookie'],
credentials: true, // ⚠️ Required for cookies to work cross-origin (APK β†’ HF Space)
maxAge: 86400,
})
)
app.set('trust proxy', 1)
// Cookie parser (needed for JWT cookie auth)
app.use(cookieParser())
// Body parsers (must be before routes!)
app.use(express.json({ limit: '5mb' }))
app.use(express.urlencoded({ extended: true, limit: '5mb' }))
// ─── Health & Info Routes ───
app.get('/health', (req, res) => {
res.json({
status: 'ok',
service: 'hangout-main-backend',
version: '1.0.0',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
})
})
app.get('/health/deep', async (req, res) => {
const checks = { service: 'ok', database: 'unknown', drive: 'unknown', mailer: 'unknown' }
try {
await connectDB()
checks.database = dbConnected ? 'ok' : 'error'
} catch (err) {
checks.database = `error: ${err.message}`
}
try {
getDriveClient()
getDriveFolderId()
checks.drive = 'configured'
} catch (err) {
checks.drive = `error: ${err.message}`
}
checks.mailer = getMailer() ? 'configured' : 'fallback-console'
const isHealthy = checks.database === 'ok' && checks.drive === 'configured'
res.status(isHealthy ? 200 : 503).json({
status: isHealthy ? 'ok' : 'degraded',
checks,
timestamp: new Date().toISOString(),
})
})
app.get('/info', (req, res) => {
res.json({
service: 'hangout-main-backend',
version: '1.0.0',
reelsBackendUrl: REELS_BACKEND_URL,
upload: {
maxFileSize: MAX_UPLOAD_SIZE,
maxFileSizeMB: MAX_UPLOAD_SIZE / 1024 / 1024,
allowedMimeTypes: ALLOWED_IMAGE_MIME,
fieldName: 'file',
},
authMode: 'jwt-cookie',
otpTransport: getMailer() ? 'email' : 'console',
})
})
app.get('/', (req, res) => {
res.json({
name: 'HangOut Main Backend',
version: '1.0.0',
description: 'Single-file Express backend for HangOut β€” auth, chats, friends, posts, reels, calls, settings',
author: 'RVK EDITION',
endpoints: {
auth: '/api/auth/{register,login,verify-otp,me,logout}',
chats: '/api/chats/{list,messages,send}',
friends: '/api/friends/{list,search,request,accept,decline,remove,requests}',
status: '/api/status/{list,create,view}',
posts: '/api/posts/{list,create,like,comment}',
reels: '/api/reels/{list,create,like,comment}',
calls: '/api/calls/{list,log}',
settings: '/api/settings',
profile: '/api/profile/{update,block,blocked,unblock}',
file: '/api/file/:fileId (stream from Drive)',
upload: '/api/upload (image to Drive)',
discover: '/api/discover/users',
seed: '/api/seed',
health: '/health, /health/deep, /info',
},
})
})
// ─── AUTH ROUTES ───
// POST /api/auth/register β€” send OTP
app.post('/api/auth/register', async (req, res) => {
try {
const { identifier, password, userId, profileName } = req.body
if (!identifier || !password || !userId || !profileName) {
return res.status(400).json({ error: 'All fields required: identifier, password, userId, profileName' })
}
if (password.length < 6) {
return res.status(400).json({ error: 'Password must be at least 6 characters' })
}
if (userId.length < 3) {
return res.status(400).json({ error: 'User ID must be at least 3 characters' })
}
const isEmail = identifier.includes('@')
if (isEmail) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(identifier)) {
return res.status(400).json({ error: 'Invalid email format' })
}
} else {
const phoneRegex = /^\+?[0-9]{10,15}$/
if (!phoneRegex.test(identifier)) {
return res.status(400).json({ error: 'Invalid phone format' })
}
}
const existing = await findUserByIdentifier(identifier)
if (existing) {
return res.status(409).json({ error: 'This email/phone is already registered' })
}
const existingUserId = await User.findOne({ userId })
if (existingUserId) {
return res.status(409).json({ error: 'This user ID is taken' })
}
const otp = generateOtp()
const expiresAt = new Date(Date.now() + 10 * 60 * 1000)
await OtpCode.create({ identifier, code: otp, purpose: 'signup', expiresAt })
// Send OTP
if (isEmail) {
const result = await sendOtpEmail(identifier, otp)
return res.json({
message: result.fallback
? 'OTP generated (email send failed β€” check server console)'
: 'OTP sent to your email',
identifier,
otp: result.fallback ? otp : undefined, // only return OTP if fallback
expiresAt: expiresAt.toISOString(),
})
} else {
// Phone β€” console fallback (no SMS gateway)
console.log(`\n=========== HANGOUT OTP (phone, console) ===========`)
console.log(` To: ${identifier}`)
console.log(` OTP: ${otp}`)
console.log(`====================================================\n`)
return res.json({
message: 'OTP generated (phone β€” check server console)',
identifier,
otp,
expiresAt: expiresAt.toISOString(),
})
}
} catch (e) {
console.error('[auth/register] error:', e)
res.status(500).json({ error: e?.message || 'Server error' })
}
})
// POST /api/auth/login β€” verify password + send OTP
app.post('/api/auth/login', async (req, res) => {
try {
const { identifier, password } = req.body
if (!identifier || !password) {
return res.status(400).json({ error: 'identifier and password required' })
}
const user = await findUserByIdentifier(identifier)
if (!user) {
return res.status(404).json({ error: 'No account found with this email/phone' })
}
const ok = await comparePassword(password, user.password)
if (!ok) {
return res.status(401).json({ error: 'Incorrect password' })
}
const otp = generateOtp()
const expiresAt = new Date(Date.now() + 10 * 60 * 1000)
await OtpCode.create({ identifier, code: otp, purpose: 'login', expiresAt })
const isEmail = identifier.includes('@')
if (isEmail) {
const result = await sendOtpEmail(identifier, otp)
return res.json({
message: result.fallback ? 'OTP generated (email failed β€” console)' : 'OTP sent to your email',
requireOtp: true,
identifier,
otp: result.fallback ? otp : undefined,
expiresAt: expiresAt.toISOString(),
})
} else {
console.log(`\n=========== HANGOUT LOGIN OTP (phone) ===========`)
console.log(` To: ${identifier}`)
console.log(` OTP: ${otp}`)
console.log(`=================================================\n`)
return res.json({
message: 'OTP sent (phone β€” check console)',
requireOtp: true,
identifier,
otp,
expiresAt: expiresAt.toISOString(),
})
}
} catch (e) {
console.error('[auth/login] error:', e)
res.status(500).json({ error: e?.message || 'Server error' })
}
})
// POST /api/auth/verify-otp β€” verify + create/login user
app.post('/api/auth/verify-otp', async (req, res) => {
try {
const { purpose = 'signup', identifier, otp, password, userId, profileName } = req.body
if (!identifier || !otp) {
return res.status(400).json({ error: 'identifier and otp are required' })
}
const record = await OtpCode.findOne({
identifier,
consumed: false,
purpose,
}).sort({ createdAt: -1 })
if (!record) {
return res.status(404).json({ error: 'No active OTP found. Please request a new one.' })
}
if (record.expiresAt < new Date()) {
return res.status(410).json({ error: 'OTP expired. Please request a new one.' })
}
if (record.code !== otp) {
return res.status(400).json({ error: 'Invalid OTP code' })
}
// Mark OTP as consumed
record.consumed = true
await record.save()
if (purpose === 'signup') {
if (!password || !userId || !profileName) {
return res.status(400).json({ error: 'password, userId, profileName required for signup' })
}
const isEmail = identifier.includes('@')
const existing = await findUserByIdentifier(identifier)
if (existing) {
return res.status(409).json({ error: 'User already exists' })
}
const existingUserId = await User.findOne({ userId })
if (existingUserId) {
return res.status(409).json({ error: 'User ID taken' })
}
const passwordHash = await hashPassword(password)
const newUser = await User.create({
userId,
email: isEmail ? identifier.toLowerCase() : `${identifier}@phone.hangout`,
phone: isEmail ? null : identifier,
password: passwordHash,
profileName,
})
await initUserSettings(newUser._id)
const token = signToken(newUser)
setAuthCookie(res, token)
return res.json({
token,
user: {
id: newUser._id.toString(),
userId: newUser.userId,
email: newUser.email,
phone: newUser.phone,
profileName: newUser.profileName,
avatar: newUser.avatar,
bio: newUser.bio,
about: newUser.about,
language: newUser.language,
},
})
} else if (purpose === 'login') {
const user = await findUserByIdentifier(identifier)
if (!user) {
return res.status(404).json({ error: 'User not found' })
}
const token = signToken(user)
setAuthCookie(res, token)
return res.json({
token,
user: {
id: user._id.toString(),
userId: user.userId,
email: user.email,
phone: user.phone,
profileName: user.profileName,
avatar: user.avatar,
bio: user.bio,
about: user.about,
language: user.language,
},
})
} else {
return res.status(400).json({ error: 'Invalid purpose' })
}
} catch (e) {
console.error('[auth/verify-otp] error:', e)
res.status(500).json({ error: e?.message || 'Server error' })
}
})
// GET /api/auth/me
app.get('/api/auth/me', async (req, res) => {
const user = await getAuthUser(req)
if (!user) return res.json({ user: null })
const settings = await UserSettings.findOne({ userId: user._id }).lean()
return res.json({
user: {
id: user._id.toString(),
userId: user.userId,
email: user.email,
phone: user.phone,
profileName: user.profileName,
avatar: user.avatar ? `/api/file/${user.avatar}` : null,
bio: user.bio,
about: user.about,
language: user.language,
theme: user.theme,
wallpaper: user.wallpaper,
isOnline: user.isOnline,
lastSeen: user.lastSeen,
createdAt: user.createdAt,
settings,
},
})
})
// POST /api/auth/logout
app.post('/api/auth/logout', (req, res) => {
clearAuthCookie(res)
res.json({ ok: true })
})
// ─── FILE ROUTE β€” Stream image from Google Drive (avatars, post images) ───
app.get('/api/file/:fileId', async (req, res) => {
const { fileId } = req.params
if (!fileId) return res.status(400).json({ error: 'fileId required' })
try {
const drive = getDriveClient()
// Get metadata for Content-Type + size
const meta = await drive.files.get({ fileId, fields: 'size, mimeType, name' })
const mimeType = meta.data.mimeType || 'application/octet-stream'
const size = parseInt(meta.data.size || '0', 10)
// Stream file content
const driveResponse = await drive.files.get(
{ fileId, alt: 'media' },
{ responseType: 'stream' }
)
const driveStream = driveResponse.data
res.set({
'Content-Type': mimeType,
'Cache-Control': 'public, max-age=86400', // 24h browser cache
})
if (size > 0) res.set('Content-Length', String(size))
// Memory leak prevention
const onClose = () => {
if (driveStream && !driveStream.destroyed) driveStream.destroy()
}
req.on('close', onClose)
req.on('aborted', onClose)
driveStream.on('error', (err) => {
console.error('[file] stream error:', err.message)
if (!res.headersSent) res.status(502).json({ error: 'Drive stream error' })
else res.end()
onClose()
})
driveStream.pipe(res)
res.on('finish', () => {
req.removeListener('close', onClose)
req.removeListener('aborted', onClose)
onClose()
})
} catch (err) {
console.error('[file] error:', err.message)
if (!res.headersSent) res.status(502).json({ error: 'Failed to fetch file', detail: err.message })
}
})
// ─── UPLOAD ROUTE β€” Image upload to Google Drive ───
app.post('/api/upload', authMiddleware, upload.single('file'), async (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded. Field name must be "file".' })
}
try {
const { buffer, originalname, mimetype, size } = req.file
const ext = (originalname.split('.').pop() || 'jpg').toLowerCase()
const fileName = `uploads/${req.user._id}/${Date.now()}-${nanoid(10)}.${ext}`
const fileId = await uploadBufferToDrive(buffer, fileName, mimetype)
return res.json({
fileId,
url: `/api/file/${fileId}`,
fileName,
mimeType: mimetype,
size,
})
} catch (err) {
console.error('[upload] error:', err.message)
res.status(502).json({ error: 'Upload failed', detail: err.message })
}
})
// ─── CHATS ROUTES ───
// GET /api/chats/list
app.get('/api/chats/list', authMiddleware, async (req, res) => {
try {
const convs = await Conversation.find({
$or: [{ userAId: req.user._id }, { userBId: req.user._id }],
})
.populate('userAId')
.populate('userBId')
.populate('lastMessageId')
.sort({ lastMessageAt: -1 })
.lean()
const result = convs.map((c) => {
const other = c.userAId._id.toString() === req.user._id.toString() ? c.userBId : c.userAId
const unread = c.userAId._id.toString() === req.user._id.toString() ? c.unreadA : c.unreadB
return {
id: c._id.toString(),
otherUser: publicUser(other),
lastMessage: c.lastMessageId
? {
id: c.lastMessageId._id.toString(),
content: c.lastMessageId.content,
type: c.lastMessageId.type,
senderId: c.lastMessageId.senderId.toString(),
createdAt: c.lastMessageId.createdAt,
status: c.lastMessageId.status,
}
: null,
lastMessageAt: c.lastMessageAt,
unread,
}
})
res.json({ conversations: result })
} catch (e) {
console.error('[chats/list] error:', e)
res.status(500).json({ error: e?.message || 'Server error' })
}
})
// GET /api/chats/messages?userId=xxx
app.get('/api/chats/messages', authMiddleware, async (req, res) => {
try {
const otherUserId = req.query.userId
if (!otherUserId) return res.status(400).json({ error: 'userId required' })
const otherUser = await User.findById(otherUserId).lean()
if (!otherUser) return res.status(404).json({ error: 'User not found' })
const conv = await getOrCreateConversation(req.user._id, otherUserId)
const limit = Math.min(parseInt(req.query.limit || '200', 10), 500)
const messages = await Message.find({ conversationId: conv._id, deleted: false })
.sort({ createdAt: 1 })
.limit(limit)
.lean()
// Mark messages from other user as read
await Message.updateMany(
{ conversationId: conv._id, receiverId: req.user._id, status: { $ne: 'read' } },
{ $set: { status: 'read' } }
)
// Reset unread counter for current user
const isUserA = conv.userAId.toString() === req.user._id.toString()
if (isUserA) {
await Conversation.updateOne({ _id: conv._id }, { $set: { unreadA: 0 } })
} else {
await Conversation.updateOne({ _id: conv._id }, { $set: { unreadB: 0 } })
}
res.json({
conversation: { id: conv._id.toString() },
messages: messages.map((m) => ({
id: m._id.toString(),
conversationId: m.conversationId.toString(),
senderId: m.senderId.toString(),
receiverId: m.receiverId.toString(),
content: m.content,
type: m.type,
mediaUrl: m.mediaUrl ? `/api/file/${m.mediaUrl}` : null,
status: m.status,
starred: m.starred,
createdAt: m.createdAt,
})),
})
} catch (e) {
console.error('[chats/messages] error:', e)
res.status(500).json({ error: e?.message || 'Server error' })
}
})
// POST /api/chats/send
app.post('/api/chats/send', authMiddleware, async (req, res) => {
try {
const { receiverId, content, type = 'text', mediaUrl, replyToId } = req.body
if (!receiverId || (!content && !mediaUrl)) {
return res.status(400).json({ error: 'receiverId and content/mediaUrl required' })
}
const receiver = await User.findById(receiverId).lean()
if (!receiver) return res.status(404).json({ error: 'Receiver not found' })
const conv = await getOrCreateConversation(req.user._id, receiverId)
const message = await Message.create({
conversationId: conv._id,
senderId: req.user._id,
receiverId,
content: content || '',
type,
mediaUrl: mediaUrl || null,
replyToId: replyToId || null,
status: 'sent',
})
const isUserA = conv.userAId.toString() === req.user._id.toString()
const update = {
lastMessageId: message._id,
lastMessageAt: new Date(),
}
if (isUserA) update.$inc = { unreadB: 1 }
else update.$inc = { unreadA: 1 }
await Conversation.updateOne({ _id: conv._id }, update)
res.json({
message: {
id: message._id.toString(),
conversationId: message.conversationId.toString(),
senderId: message.senderId.toString(),
receiverId: message.receiverId.toString(),
content: message.content,
type: message.type,
mediaUrl: message.mediaUrl ? `/api/file/${message.mediaUrl}` : null,
status: message.status,
createdAt: message.createdAt,
clientId: req.body.clientId || null,
},
})
} catch (e) {
console.error('[chats/send] error:', e)
res.status(500).json({ error: e?.message || 'Server error' })
}
})
// ─── FRIENDS ROUTES ───
// GET /api/friends/list
app.get('/api/friends/list', authMiddleware, async (req, res) => {
try {
const friendships = await Friendship.find({
$or: [{ requesterId: req.user._id }, { receiverId: req.user._id }],
status: 'accepted',
})
.populate('requesterId')
.populate('receiverId')
.lean()
const friends = friendships.map((f) =>
f.requesterId._id.toString() === req.user._id.toString() ? f.receiverId : f.requesterId
)
res.json({ friends: friends.map(publicUser) })
} catch (e) {
console.error('[friends/list] error:', e)
res.status(500).json({ error: e?.message || 'Server error' })
}
})
// GET /api/friends/search?q=keyword
app.get('/api/friends/search', authMiddleware, async (req, res) => {
try {
const q = (req.query.q || '').trim()
if (q.length < 1) return res.json({ users: [] })
const users = await User.find({
_id: { $ne: req.user._id },
$or: [
{ userId: { $regex: q, $options: 'i' } },
{ profileName: { $regex: q, $options: 'i' } },
{ email: { $regex: q, $options: 'i' } },
{ phone: { $regex: q, $options: 'i' } },
],
}).limit(30).lean()
const ids = users.map((u) => u._id)
const friendships = await Friendship.find({
$or: [
{ requesterId: req.user._id, receiverId: { $in: ids } },
{ receiverId: req.user._id, requesterId: { $in: ids } },
],
}).lean()
const statusMap = new Map()
for (const f of friendships) {
const otherId = f.requesterId.toString() === req.user._id.toString() ? f.receiverId : f.requesterId
statusMap.set(otherId.toString(), f.status)
}
const blocks = await Block.find({
$or: [{ blockerId: req.user._id }, { blockedId: req.user._id }],
}).lean()
const blockSet = new Set()
for (const b of blocks) {
if (b.blockerId.toString() === req.user._id.toString()) blockSet.add(b.blockedId.toString())
else blockSet.add(b.blockerId.toString())
}
res.json({
users: users.map((u) => ({
...publicUser(u),
friendStatus: statusMap.get(u._id.toString()) || 'none',
isBlocked: blockSet.has(u._id.toString()),
})),
})
} catch (e) {
console.error('[friends/search] error:', e)
res.status(500).json({ error: e?.message || 'Server error' })
}
})
// GET /api/friends/requests?direction=received|sent
app.get('/api/friends/requests', authMiddleware, async (req, res) => {
try {
const direction = req.query.direction || 'received'
let where, includeOther
if (direction === 'sent') {
where = { requesterId: req.user._id, status: 'pending' }
includeOther = 'receiverId'
} else {
where = { receiverId: req.user._id, status: 'pending' }
includeOther = 'requesterId'
}
const requests = await Friendship.find(where).populate(includeOther).sort({ createdAt: -1 }).lean()
res.json({
requests: requests.map((r) => ({
id: r._id.toString(),
user: publicUser(r[includeOther]),
createdAt: r.createdAt,
})),
})
} catch (e) {
console.error('[friends/requests] error:', e)
res.status(500).json({ error: e?.message || 'Server error' })
}
})
// POST /api/friends/request
app.post('/api/friends/request', authMiddleware, async (req, res) => {
try {
const { receiverId } = req.body
if (!receiverId) return res.status(400).json({ error: 'receiverId required' })
if (receiverId === req.user._id.toString()) {
return res.status(400).json({ error: 'Cannot friend yourself' })
}
const block = await Block.findOne({
$or: [
{ blockerId: req.user._id, blockedId: receiverId },
{ blockerId: receiverId, blockedId: req.user._id },
],
})
if (block) return res.status(403).json({ error: 'Cannot send request β€” blocked' })
const existing = await Friendship.findOne({
$or: [
{ requesterId: req.user._id, receiverId: receiverId },
{ requesterId: receiverId, receiverId: req.user._id },
],
})
if (existing) {
if (existing.status === 'accepted') {
return res.status(409).json({ error: 'Already friends' })
}
if (existing.status === 'pending') {
if (existing.receiverId.toString() === req.user._id.toString()) {
await Friendship.updateOne({ _id: existing._id }, { $set: { status: 'accepted' } })
return res.json({ status: 'accepted', message: 'You are now friends' })
}
return res.status(409).json({ error: 'Request already sent' })
}
}
const friend = await Friendship.create({
requesterId: req.user._id,
receiverId: receiverId,
status: 'pending',
})
res.json({ status: 'pending', id: friend._id.toString() })
} catch (e) {
console.error('[friends/request] error:', e)
res.status(500).json({ error: e?.message || 'Server error' })
}
})
// POST /api/friends/accept
app.post('/api/friends/accept', authMiddleware, async (req, res) => {
try {
const { requestId, userId } = req.body
let friendship
if (requestId) {
friendship = await Friendship.findById(requestId)
} else if (userId) {
friendship = await Friendship.findOne({ requesterId: userId, receiverId: req.user._id, status: 'pending' })
}
if (!friendship) return res.status(404).json({ error: 'Friend request not found' })
if (friendship.receiverId.toString() !== req.user._id.toString()) {
return res.status(403).json({ error: 'Not your request to accept' })
}
await Friendship.updateOne({ _id: friendship._id }, { $set: { status: 'accepted' } })
res.json({ status: 'accepted' })
} catch (e) {
console.error('[friends/accept] error:', e)
res.status(500).json({ error: e?.message || 'Server error' })
}
})
// POST /api/friends/decline
app.post('/api/friends/decline', authMiddleware, async (req, res) => {
try {
const { requestId, userId } = req.body
if (requestId) {
await Friendship.deleteOne({ _id: requestId, receiverId: req.user._id })
} else if (userId) {
await Friendship.deleteOne({ requesterId: userId, receiverId: req.user._id })
} else {
return res.status(400).json({ error: 'requestId or userId required' })
}
res.json({ status: 'declined' })
} catch (e) {
console.error('[friends/decline] error:', e)
res.status(500).json({ error: e?.message || 'Server error' })
}
})
// POST /api/friends/remove
app.post('/api/friends/remove', authMiddleware, async (req, res) => {
try {
const { userId } = req.body
if (!userId) return res.status(400).json({ error: 'userId required' })
await Friendship.deleteOne({
$or: [
{ requesterId: req.user._id, receiverId: userId },
{ requesterId: userId, receiverId: req.user._id },
],
})
res.json({ status: 'removed' })
} catch (e) {
console.error('[friends/remove] error:', e)
res.status(500).json({ error: e?.message || 'Server error' })
}
})
// ─── STATUS ROUTES ───
// GET /api/status/list
app.get('/api/status/list', authMiddleware, async (req, res) => {
try {
const friendships = await Friendship.find({
$or: [{ requesterId: req.user._id }, { receiverId: req.user._id }],
status: 'accepted',
}).lean()
const friendIds = friendships.map((f) =>
f.requesterId.toString() === req.user._id.toString() ? f.receiverId : f.requesterId
)
const visibleUserIds = [req.user._id, ...friendIds]
const since = new Date(Date.now() - 24 * 60 * 60 * 1000)
const statuses = await Status.find({
userId: { $in: visibleUserIds },
expiresAt: { $gt: new Date() },
createdAt: { $gt: since },
})
.populate('userId')
.lean()
// Get views for these statuses
const statusIds = statuses.map((s) => s._id)
const views = await StatusView.find({ statusId: { $in: statusIds } }).populate('userId').lean()
const viewsByStatus = new Map()
for (const v of views) {
if (!viewsByStatus.has(v.statusId.toString())) viewsByStatus.set(v.statusId.toString(), [])
viewsByStatus.get(v.statusId.toString()).push(v)
}
// Group by user
const byUser = new Map()
for (const s of statuses) {
const uid = s.userId._id.toString()
if (!byUser.has(uid)) {
byUser.set(uid, { user: publicUser(s.userId), statuses: [], hasUnviewed: false, allViewed: true })
}
const viewed = (viewsByStatus.get(s._id.toString()) || []).some((v) => v.userId._id.toString() === req.user._id.toString())
if (!viewed) {
byUser.get(uid).allViewed = false
byUser.get(uid).hasUnviewed = true
}
byUser.get(uid).statuses.push({
id: s._id.toString(),
type: s.type,
content: s.content,
bgColor: s.bgColor,
mediaUrl: s.mediaUrl ? `/api/file/${s.mediaUrl}` : null,
caption: s.caption,
createdAt: s.createdAt,
expiresAt: s.expiresAt,
viewed,
views: (viewsByStatus.get(s._id.toString()) || []).map((v) => ({
userId: v.userId._id.toString(),
name: v.userId.profileName,
avatar: v.userId.avatar ? `/api/file/${v.userId.avatar}` : null,
viewedAt: v.createdAt,
})),
})
}
const arr = Array.from(byUser.values())
arr.sort((a, b) => {
if (a.user.id === req.user._id.toString()) return -1
if (b.user.id === req.user._id.toString()) return 1
if (a.hasUnviewed && !b.hasUnviewed) return -1
if (!a.hasUnviewed && b.hasUnviewed) return 1
return 0
})
res.json({ statuses: arr })
} catch (e) {
console.error('[status/list] error:', e)
res.status(500).json({ error: e?.message || 'Server error' })
}
})
// POST /api/status/create
app.post('/api/status/create', authMiddleware, async (req, res) => {
try {
const { type = 'text', content, bgColor = '#0f172a', mediaUrl, caption } = req.body
if (!content && !mediaUrl) {
return res.status(400).json({ error: 'content or mediaUrl required' })
}
const status = await Status.create({
userId: req.user._id,
type,
content: content || '',
bgColor,
mediaUrl: mediaUrl || null,
caption: caption || null,
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
})
res.json({ status })
} catch (e) {
console.error('[status/create] error:', e)
res.status(500).json({ error: e?.message || 'Server error' })
}
})
// POST /api/status/view
app.post('/api/status/view', authMiddleware, async (req, res) => {
try {
const { statusId } = req.body
if (!statusId) return res.status(400).json({ error: 'statusId required' })
await StatusView.updateOne(
{ statusId, userId: req.user._id },
{ $setOnInsert: { statusId, userId: req.user._id, createdAt: new Date() } },
{ upsert: true }
)
res.json({ ok: true })
} catch (e) {
console.error('[status/view] error:', e)
res.status(500).json({ error: e?.message || 'Server error' })
}
})
// ─── POSTS ROUTES ───
// GET /api/posts/list
app.get('/api/posts/list', authMiddleware, async (req, res) => {
try {
const limit = Math.min(parseInt(req.query.limit || '50', 10), 100)
const posts = await Post.find().sort({ createdAt: -1 }).limit(limit).populate('userId').lean()
const postIds = posts.map((p) => p._id)
const likes = await PostLike.find({ postId: { $in: postIds } }).lean()
const likesByPost = new Map()
for (const l of likes) {
if (!likesByPost.has(l.postId.toString())) likesByPost.set(l.postId.toString(), [])
likesByPost.get(l.postId.toString()).push(l)
}
const comments = await PostComment.find({ postId: { $in: postIds } })
.sort({ createdAt: -1 })
.limit(50)
.populate('userId')
.lean()
const commentsByPost = new Map()
for (const c of comments) {
if (!commentsByPost.has(c.postId.toString())) commentsByPost.set(c.postId.toString(), [])
commentsByPost.get(c.postId.toString()).push(c)
}
res.json({
posts: posts.map((p) => ({
id: p._id.toString(),
content: p.content,
mediaUrl: p.mediaUrl ? `/api/file/${p.mediaUrl}` : null,
mediaType: p.mediaType,
location: p.location,
feeling: p.feeling,
createdAt: p.createdAt,
user: publicUser(p.userId),
likeCount: (likesByPost.get(p._id.toString()) || []).length,
likedByMe: (likesByPost.get(p._id.toString()) || []).some((l) => l.userId.toString() === req.user._id.toString()),
comments: (commentsByPost.get(p._id.toString()) || []).map((c) => ({
id: c._id.toString(),
content: c.content,
createdAt: c.createdAt,
user: publicUser(c.userId),
})),
})),
})
} catch (e) {
console.error('[posts/list] error:', e)
res.status(500).json({ error: e?.message || 'Server error' })
}
})
// POST /api/posts/create
app.post('/api/posts/create', authMiddleware, async (req, res) => {
try {
const { content, mediaUrl, mediaType, location, feeling } = req.body
if (!content && !mediaUrl) {
return res.status(400).json({ error: 'content or mediaUrl required' })
}
const post = await Post.create({
userId: req.user._id,
content: content || '',
mediaUrl: mediaUrl || null,
mediaType: mediaType || null,
location: location || null,
feeling: feeling || null,
})
res.json({ post })
} catch (e) {
console.error('[posts/create] error:', e)
res.status(500).json({ error: e?.message || 'Server error' })
}
})
// POST /api/posts/like (toggle)
app.post('/api/posts/like', authMiddleware, async (req, res) => {
try {
const { postId } = req.body
if (!postId) return res.status(400).json({ error: 'postId required' })
const existing = await PostLike.findOne({ postId, userId: req.user._id })
if (existing) {
await PostLike.deleteOne({ _id: existing._id })
return res.json({ liked: false })
} else {
await PostLike.create({ postId, userId: req.user._id })
return res.json({ liked: true })
}
} catch (e) {
console.error('[posts/like] error:', e)
res.status(500).json({ error: e?.message || 'Server error' })
}
})
// POST /api/posts/comment
app.post('/api/posts/comment', authMiddleware, async (req, res) => {
try {
const { postId, content } = req.body
if (!postId || !content) return res.status(400).json({ error: 'postId and content required' })
const comment = await PostComment.create({ postId, userId: req.user._id, content })
const populated = await PostComment.findById(comment._id).populate('userId').lean()
res.json({
comment: {
id: populated._id.toString(),
content: populated.content,
createdAt: populated.createdAt,
user: publicUser(populated.userId),
},
})
} catch (e) {
console.error('[posts/comment] error:', e)
res.status(500).json({ error: e?.message || 'Server error' })
}
})
// ─── REELS ROUTES ───
// GET /api/reels/list
app.get('/api/reels/list', authMiddleware, async (req, res) => {
try {
const limit = Math.min(parseInt(req.query.limit || '50', 10), 100)
const reels = await Reel.find().sort({ createdAt: -1 }).limit(limit).populate('userId').lean()
const reelIds = reels.map((r) => r._id)
const likes = await ReelLike.find({ reelId: { $in: reelIds } }).lean()
const likesByReel = new Map()
for (const l of likes) {
if (!likesByReel.has(l.reelId.toString())) likesByReel.set(l.reelId.toString(), [])
likesByReel.get(l.reelId.toString()).push(l)
}
const comments = await ReelComment.find({ reelId: { $in: reelIds } })
.sort({ createdAt: -1 })
.limit(30)
.populate('userId')
.lean()
const commentsByReel = new Map()
for (const c of comments) {
if (!commentsByReel.has(c.reelId.toString())) commentsByReel.set(c.reelId.toString(), [])
commentsByReel.get(c.reelId.toString()).push(c)
}
res.json({
reels: reels.map((r) => {
const streamUrl = r.videoId ? `${REELS_BACKEND_URL}/api/stream/${r.videoId}` : null
return {
id: r._id.toString(),
videoId: r.videoId,
videoUrl: streamUrl, // HF reels backend stream URL
streamUrl,
caption: r.caption,
musicName: r.musicName,
createdAt: r.createdAt,
user: publicUser(r.userId),
likeCount: (likesByReel.get(r._id.toString()) || []).length,
likedByMe: (likesByReel.get(r._id.toString()) || []).some((l) => l.userId.toString() === req.user._id.toString()),
comments: (commentsByReel.get(r._id.toString()) || []).map((c) => ({
id: c._id.toString(),
content: c.content,
createdAt: c.createdAt,
user: publicUser(c.userId),
})),
}
}),
})
} catch (e) {
console.error('[reels/list] error:', e)
res.status(500).json({ error: e?.message || 'Server error' })
}
})
// POST /api/reels/create β€” accept videoId from frontend (after HF reels backend upload)
app.post('/api/reels/create', authMiddleware, async (req, res) => {
try {
const { videoId, driveFileId, caption, musicName } = req.body
if (!videoId) {
return res.status(400).json({ error: 'videoId is required (upload to reels backend first)' })
}
const reel = await Reel.create({
userId: req.user._id,
videoId,
driveFileId: driveFileId || null,
caption: caption || null,
musicName: musicName || null,
})
res.json({
reel: {
id: reel._id.toString(),
videoId: reel.videoId,
driveFileId: reel.driveFileId,
streamUrl: `${REELS_BACKEND_URL}/api/stream/${reel.videoId}`,
},
})
} catch (e) {
console.error('[reels/create] error:', e)
res.status(500).json({ error: e?.message || 'Server error' })
}
})
// POST /api/reels/like (toggle)
app.post('/api/reels/like', authMiddleware, async (req, res) => {
try {
const { reelId } = req.body
if (!reelId) return res.status(400).json({ error: 'reelId required' })
const existing = await ReelLike.findOne({ reelId, userId: req.user._id })
if (existing) {
await ReelLike.deleteOne({ _id: existing._id })
return res.json({ liked: false })
} else {
await ReelLike.create({ reelId, userId: req.user._id })
return res.json({ liked: true })
}
} catch (e) {
console.error('[reels/like] error:', e)
res.status(500).json({ error: e?.message || 'Server error' })
}
})
// POST /api/reels/comment
app.post('/api/reels/comment', authMiddleware, async (req, res) => {
try {
const { reelId, content } = req.body
if (!reelId || !content) return res.status(400).json({ error: 'reelId and content required' })
const comment = await ReelComment.create({ reelId, userId: req.user._id, content })
const populated = await ReelComment.findById(comment._id).populate('userId').lean()
res.json({
comment: {
id: populated._id.toString(),
content: populated.content,
createdAt: populated.createdAt,
user: publicUser(populated.userId),
},
})
} catch (e) {
console.error('[reels/comment] error:', e)
res.status(500).json({ error: e?.message || 'Server error' })
}
})
// ─── CALLS ROUTES ───
// GET /api/calls/list
app.get('/api/calls/list', authMiddleware, async (req, res) => {
try {
const calls = await Call.find({
$or: [{ callerId: req.user._id }, { receiverId: req.user._id }],
})
.populate('callerId')
.populate('receiverId')
.sort({ startedAt: -1 })
.limit(200)
.lean()
res.json({
calls: calls.map((c) => {
const isCaller = c.callerId._id.toString() === req.user._id.toString()
const other = isCaller ? c.receiverId : c.callerId
return {
id: c._id.toString(),
type: c.type,
status: c.status,
duration: c.duration,
startedAt: c.startedAt,
endedAt: c.endedAt,
direction: isCaller ? 'outgoing' : 'incoming',
otherUser: publicUser(other),
}
}),
})
} catch (e) {
console.error('[calls/list] error:', e)
res.status(500).json({ error: e?.message || 'Server error' })
}
})
// POST /api/calls/log
app.post('/api/calls/log', authMiddleware, async (req, res) => {
try {
const { receiverId, type = 'audio', status = 'answered', duration = 0, endedAt } = req.body
if (!receiverId) return res.status(400).json({ error: 'receiverId required' })
const call = await Call.create({
callerId: req.user._id,
receiverId,
type,
status,
duration,
endedAt: endedAt ? new Date(endedAt) : new Date(),
})
res.json({ call })
} catch (e) {
console.error('[calls/log] error:', e)
res.status(500).json({ error: e?.message || 'Server error' })
}
})
// ─── SETTINGS ROUTES ───
// GET /api/settings
app.get('/api/settings', authMiddleware, async (req, res) => {
try {
const user = await User.findById(req.user._id).lean()
let settings = await UserSettings.findOne({ userId: req.user._id }).lean()
if (!settings) {
settings = await UserSettings.create({ userId: req.user._id })
settings = settings.toObject()
}
res.json({
profile: {
id: user._id.toString(),
userId: user.userId,
email: user.email,
phone: user.phone,
profileName: user.profileName,
avatar: user.avatar ? `/api/file/${user.avatar}` : null,
bio: user.bio,
about: user.about,
language: user.language,
theme: user.theme,
wallpaper: user.wallpaper,
},
settings,
})
} catch (e) {
console.error('[settings/get] error:', e)
res.status(500).json({ error: e?.message || 'Server error' })
}
})
// PUT /api/settings
app.put('/api/settings', authMiddleware, async (req, res) => {
try {
const { profile, settings } = req.body
if (profile) {
const allowed = {}
const profileFields = ['profileName', 'avatar', 'bio', 'about', 'language', 'theme', 'wallpaper']
for (const k of profileFields) {
if (k in profile) allowed[k] = profile[k]
}
if (Object.keys(allowed).length > 0) {
await User.updateOne({ _id: req.user._id }, { $set: allowed })
}
}
if (settings) {
const allowed = {}
const settingFields = [
'lastSeenVisible', 'profilePhotoVisible', 'statusVisible', 'readReceipts', 'groupsEveryone',
'notifMessages', 'notifCalls', 'notifReactions', 'notifSound', 'notifVibrate',
'enterToSend', 'showOnlineStatus', 'fontScale', 'mediaAutoDownload', 'dataSaver',
]
for (const k of settingFields) {
if (k in settings) allowed[k] = settings[k]
}
if (Object.keys(allowed).length > 0) {
await UserSettings.updateOne(
{ userId: req.user._id },
{ $set: { ...allowed, updatedAt: new Date() } },
{ upsert: true }
)
}
}
const refreshedUser = await User.findById(req.user._id).lean()
const refreshedSettings = await UserSettings.findOne({ userId: req.user._id }).lean()
res.json({
profile: {
id: refreshedUser._id.toString(),
userId: refreshedUser.userId,
email: refreshedUser.email,
phone: refreshedUser.phone,
profileName: refreshedUser.profileName,
avatar: refreshedUser.avatar ? `/api/file/${refreshedUser.avatar}` : null,
bio: refreshedUser.bio,
about: refreshedUser.about,
language: refreshedUser.language,
theme: refreshedUser.theme,
wallpaper: refreshedUser.wallpaper,
},
settings: refreshedSettings,
})
} catch (e) {
console.error('[settings/put] error:', e)
res.status(500).json({ error: e?.message || 'Server error' })
}
})
// ─── PROFILE ROUTES ───
// PUT /api/profile/update
app.put('/api/profile/update', authMiddleware, async (req, res) => {
try {
const allowed = {}
const fields = ['profileName', 'avatar', 'bio', 'about', 'language', 'theme', 'wallpaper']
for (const k of fields) {
if (k in req.body) allowed[k] = req.body[k]
}
if (Object.keys(allowed).length === 0) {
return res.status(400).json({ error: 'No updatable fields provided' })
}
const user = await User.findByIdAndUpdate(req.user._id, { $set: allowed }, { new: true }).lean()
res.json({
user: {
id: user._id.toString(),
profileName: user.profileName,
avatar: user.avatar ? `/api/file/${user.avatar}` : null,
bio: user.bio,
about: user.about,
},
})
} catch (e) {
console.error('[profile/update] error:', e)
res.status(500).json({ error: e?.message || 'Server error' })
}
})
// POST /api/profile/block
app.post('/api/profile/block', authMiddleware, async (req, res) => {
try {
const { userId } = req.body
if (!userId || userId === req.user._id.toString()) {
return res.status(400).json({ error: 'Invalid userId' })
}
await Friendship.deleteOne({
$or: [
{ requesterId: req.user._id, receiverId: userId },
{ requesterId: userId, receiverId: req.user._id },
],
})
await Block.updateOne(
{ blockerId: req.user._id, blockedId: userId },
{ $setOnInsert: { blockerId: req.user._id, blockedId: userId, createdAt: new Date() } },
{ upsert: true }
)
res.json({ status: 'blocked' })
} catch (e) {
console.error('[profile/block] error:', e)
res.status(500).json({ error: e?.message || 'Server error' })
}
})
// GET /api/profile/blocked
app.get('/api/profile/blocked', authMiddleware, async (req, res) => {
try {
const blocks = await Block.find({ blockerId: req.user._id })
.populate('blockedId')
.sort({ createdAt: -1 })
.lean()
res.json({ blocked: blocks.map((b) => publicUser(b.blockedId)) })
} catch (e) {
console.error('[profile/blocked] error:', e)
res.status(500).json({ error: e?.message || 'Server error' })
}
})
// POST /api/profile/unblock
app.post('/api/profile/unblock', authMiddleware, async (req, res) => {
try {
const { userId } = req.body
if (!userId) return res.status(400).json({ error: 'userId required' })
await Block.deleteOne({ blockerId: req.user._id, blockedId: userId })
res.json({ status: 'unblocked' })
} catch (e) {
console.error('[profile/unblock] error:', e)
res.status(500).json({ error: e?.message || 'Server error' })
}
})
// ─── DISCOVER ROUTE ───
// GET /api/discover/users
app.get('/api/discover/users', authMiddleware, async (req, res) => {
try {
const friendships = await Friendship.find({
$or: [{ requesterId: req.user._id }, { receiverId: req.user._id }],
}).lean()
const excludeIds = new Set([req.user._id.toString()])
for (const f of friendships) {
if (f.requesterId.toString() === req.user._id.toString()) excludeIds.add(f.receiverId.toString())
else excludeIds.add(f.requesterId.toString())
}
const blocks = await Block.find({
$or: [{ blockerId: req.user._id }, { blockedId: req.user._id }],
}).lean()
for (const b of blocks) {
if (b.blockerId.toString() === req.user._id.toString()) excludeIds.add(b.blockedId.toString())
else excludeIds.add(b.blockerId.toString())
}
const users = await User.find({ _id: { $nin: Array.from(excludeIds) } })
.sort({ createdAt: -1 })
.limit(30)
.lean()
res.json({ users: users.map(publicUser) })
} catch (e) {
console.error('[discover/users] error:', e)
res.status(500).json({ error: e?.message || 'Server error' })
}
})
// ─── SEED ROUTE ───
// POST /api/seed
app.post('/api/seed', async (req, res) => {
try {
const userCount = await User.countDocuments()
if (userCount >= 5) {
return res.json({ message: 'Already seeded', userCount })
}
const passwordHash = await hashPassword('demo1234')
const demoUsers = [
{ userId: 'aarav_07', profileName: 'Aarav Sharma', email: 'aarav@hangout.app', avatar: null, bio: 'Photographer & traveler', about: 'Living one moment at a time' },
{ userId: 'priya_s', profileName: 'Priya Singh', email: 'priya@hangout.app', avatar: null, bio: 'Foodie | Dancer', about: 'Spice lover, chai enthusiast' },
{ userId: 'rahul_d', profileName: 'Rahul Das', email: 'rahul@hangout.app', avatar: null, bio: 'Cricket | Code', about: 'Building cool stuff' },
{ userId: 'meera_22', profileName: 'Meera Iyer', email: 'meera@hangout.app', avatar: null, bio: 'Artist & dreamer', about: 'Painting my world' },
{ userId: 'karan_x', profileName: 'Karan Verma', email: 'karan@hangout.app', avatar: null, bio: 'Music is life', about: 'Guitarist' },
{ userId: 'sara_11', profileName: 'Sara Khan', email: 'sara@hangout.app', avatar: null, bio: 'Bookworm', about: 'Lost in stories' },
{ userId: 'vikas_ji', profileName: 'Vikas Reddy', email: 'vikas@hangout.app', avatar: null, bio: 'Fitness coach', about: 'No pain, no gain' },
{ userId: 'neha_88', profileName: 'Neha Gupta', email: 'neha@hangout.app', avatar: null, bio: 'Travel blogger', about: 'Wanderlust soul' },
]
const createdUsers = []
for (const u of demoUsers) {
const user = await User.create({ ...u, password: passwordHash, phone: null })
await initUserSettings(user._id)
createdUsers.push(user)
}
const samplePosts = [
{ userId: createdUsers[0]._id, content: 'Caught the most beautiful sunset today πŸŒ…', location: 'Goa, India', feeling: 'feeling peaceful' },
{ userId: createdUsers[1]._id, content: 'Made authentic butter chicken from scratch today! πŸ›', location: 'Mumbai', feeling: 'feeling hungry' },
{ userId: createdUsers[2]._id, content: 'Shipped a new feature today. Late-night coding sessions hit different πŸš€', feeling: 'feeling productive' },
{ userId: createdUsers[3]._id, content: 'Working on a new watercolor series. Sneak peek coming soon 🎨', location: 'Bangalore', feeling: 'feeling creative' },
{ userId: createdUsers[4]._id, content: 'New cover on the guitar. Check it out! 🎸🎢', feeling: 'feeling musical' },
{ userId: createdUsers[5]._id, content: 'Currently reading "The Midnight Library". Mind-blowing so far πŸ“š', feeling: 'feeling thoughtful' },
{ userId: createdUsers[6]._id, content: 'Morning workout done. 5km run + 100 pushups πŸ’ͺ', location: 'Hyderabad', feeling: 'feeling strong' },
{ userId: createdUsers[7]._id, content: 'Just got back from Manali. The mountains are calling πŸ”οΈ', location: 'Manali', feeling: 'feeling adventurous' },
]
for (const p of samplePosts) {
await Post.create(p)
}
const sampleStatuses = [
{ userId: createdUsers[0]._id, content: 'Good morning everyone! Have a great day β˜€οΈ', bgColor: '#7c3aed' },
{ userId: createdUsers[1]._id, content: 'Cooking up a storm today 🍳', bgColor: '#dc2626' },
{ userId: createdUsers[2]._id, content: 'Coffee + Code = ❀️', bgColor: '#059669' },
{ userId: createdUsers[4]._id, content: 'New song dropping soon 🎡', bgColor: '#ea580c' },
{ userId: createdUsers[7]._id, content: 'On top of the world πŸ”οΈ', bgColor: '#2563eb' },
]
for (const s of sampleStatuses) {
await Status.create({
...s,
type: 'text',
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
})
}
res.json({
message: 'Seed complete',
usersCreated: createdUsers.length,
postsCreated: samplePosts.length,
statusesCreated: sampleStatuses.length,
demoCredentials: { email: 'aarav@hangout.app', password: 'demo1234' },
})
} catch (e) {
console.error('[seed] error:', e)
res.status(500).json({ error: e?.message || 'Server error' })
}
})
// ============================================================
// SECTION 6 β€” EXPRESS APP SETUP (Error handlers + Start)
// ============================================================
// Note: All global middleware (helmet, cors, cookieParser, body parsers)
// are already applied in SECTION 5 before routes.
// Rate limiters (defined but not enforced per-route for simplicity β€”
// auth middleware already protects sensitive endpoints)
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 50,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many auth attempts, please try later' },
})
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 500,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many requests, please try later' },
})
// 404 handler
app.use((req, res) => {
res.status(404).json({
error: 'Route not found',
path: req.path,
method: req.method,
})
})
// Multer-specific error handler
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
if (err.name === 'MulterError') {
let message = err.message
if (err.code === 'LIMIT_FILE_SIZE') {
message = `File too large. Max size: ${(err.limit / 1024 / 1024).toFixed(0)} MB`
} else if (err.code === 'LIMIT_UNEXPECTED_FILE') {
message = `Unexpected field name: ${err.field}. Use field name "file".`
}
return res.status(400).json({ error: 'Upload error', code: err.code, detail: message })
}
console.error('[server] Unhandled error:', err)
return res.status(500).json({ error: 'Internal server error', detail: err.message })
})
// ============================================================
// SECTION 7 β€” START SERVER + GRACEFUL SHUTDOWN
// ============================================================
async function start() {
console.log('==========================================')
console.log(' HangOut Main Backend v1.0.0')
console.log(' Single File Edition')
console.log(' Presented by RVK EDITION')
console.log('==========================================')
console.log(` Port: ${PORT}`)
console.log(` Host: ${HOST}`)
console.log(` CORS_ORIGINS: ${process.env.CORS_ORIGINS || '*'}`)
console.log(` REELS_BACKEND_URL: ${REELS_BACKEND_URL}`)
console.log(` OTP transport: ${getMailer() ? 'email (Gmail)' : 'console (fallback)'}`)
console.log('==========================================')
// Start HTTP server FIRST (non-blocking)
app.listen(PORT, HOST, () => {
console.log(`[server] βœ“ Listening on http://${HOST}:${PORT}`)
console.log('[server] Endpoints:')
console.log(' Auth: /api/auth/{register,login,verify-otp,me,logout}')
console.log(' Chats: /api/chats/{list,messages,send}')
console.log(' Friends: /api/friends/{list,search,request,accept,decline,remove,requests}')
console.log(' Status: /api/status/{list,create,view}')
console.log(' Posts: /api/posts/{list,create,like,comment}')
console.log(' Reels: /api/reels/{list,create,like,comment}')
console.log(' Calls: /api/calls/{list,log}')
console.log(' Settings:/api/settings')
console.log(' Profile: /api/profile/{update,block,blocked,unblock}')
console.log(' File: /api/file/:fileId (stream from Drive)')
console.log(' Upload: /api/upload (image to Drive)')
console.log(' Discover:/api/discover/users')
console.log(' Seed: /api/seed')
console.log(' Health: /health, /health/deep, /info')
// Connect to MongoDB in background
connectDB()
.then(() => console.log('[server] βœ“ MongoDB connected (background)'))
.catch((err) => console.error('[server] ⚠ MongoDB failed β€” endpoints will error:', err.message))
})
}
async function shutdown(signal) {
console.log(`[server] ${signal} received, shutting down...`)
try {
if (dbConnected) await mongoose.disconnect()
} catch (e) {
/* ignore */
}
process.exit(0)
}
process.on('SIGTERM', () => shutdown('SIGTERM'))
process.on('SIGINT', () => shutdown('SIGINT'))
process.on('unhandledRejection', (err) => console.error('[server] Unhandled rejection:', err))
process.on('uncaughtException', (err) => {
console.error('[server] Uncaught exception:', err)
shutdown('uncaughtException')
})
start()