// ============================================================ // 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: `

HangOut

Presented by RVK EDITION

Your verification code

${code}

This code expires in 10 minutes. If you didn't request this, you can safely ignore this email.

`, }) 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()