Spaces:
Sleeping
Sleeping
| // ============================================================ | |
| // 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() | |