full
This commit is contained in:
267
controllers/application/auth/googleController.js
Normal file
267
controllers/application/auth/googleController.js
Normal file
@@ -0,0 +1,267 @@
|
||||
const UserModel = require('../../../models/UserModel')
|
||||
const TokenService = require('../../../services/TokenService')
|
||||
const { buildLoginResponseForUser } = require('../../../utils/loginResponse')
|
||||
const {
|
||||
expireBlockSuspensionIfNeeded,
|
||||
getBlockedAccountResponse,
|
||||
} = require('../../../utils/blockSuspension')
|
||||
const {
|
||||
findOrCreateGoogleUser,
|
||||
findExistingGoogleUser,
|
||||
} = require('../../../utils/googleUserAuth')
|
||||
const { verifyGoogleIdToken } = require('../../../utils/googleTokenVerify')
|
||||
|
||||
const DEFAULT_GOOGLE_CLIENT_ID =
|
||||
'91028483292-vba2ktgbvus1p5ce18ir04h1b5c50gnn.apps.googleusercontent.com'
|
||||
|
||||
function getGoogleClientIds() {
|
||||
const raw =
|
||||
process.env.GOOGLE_CLIENT_ID ||
|
||||
process.env.GOOGLE_CLIENT_IDS ||
|
||||
DEFAULT_GOOGLE_CLIENT_ID
|
||||
|
||||
return [...new Set(String(raw).split(',').map((id) => id.trim()).filter(Boolean))]
|
||||
}
|
||||
|
||||
function googleAuthError(res, status, message, extra = {}) {
|
||||
return res.status(status).json({
|
||||
error: true,
|
||||
message,
|
||||
...extra,
|
||||
})
|
||||
}
|
||||
|
||||
const googleAuth = async (req, res) => {
|
||||
try {
|
||||
if (!process.env.APP_SECRET) {
|
||||
console.error('[googleAuth] APP_SECRET is not configured')
|
||||
return googleAuthError(
|
||||
res,
|
||||
503,
|
||||
'پیکربندی سرور برای ورود با گوگل کامل نیست (APP_SECRET)'
|
||||
)
|
||||
}
|
||||
|
||||
const { credential } = req.body
|
||||
if (!credential) {
|
||||
return googleAuthError(res, 422, 'توکن گوگل ارسال نشده است')
|
||||
}
|
||||
|
||||
const clientIds = getGoogleClientIds()
|
||||
if (clientIds.length === 0) {
|
||||
return googleAuthError(res, 503, 'ورود با گوگل در حال حاضر فعال نیست')
|
||||
}
|
||||
|
||||
const payload = await verifyGoogleIdToken(credential, clientIds)
|
||||
const googleId = payload?.sub
|
||||
const email = payload?.email?.toLowerCase()?.trim()
|
||||
|
||||
if (!googleId || !email) {
|
||||
return googleAuthError(res, 422, 'اطلاعات حساب گوگل ناقص است')
|
||||
}
|
||||
|
||||
if (!payload.email_verified) {
|
||||
return googleAuthError(res, 422, 'ایمیل حساب گوگل تایید نشده است')
|
||||
}
|
||||
|
||||
let user
|
||||
try {
|
||||
user = await findOrCreateGoogleUser({
|
||||
googleId,
|
||||
email,
|
||||
})
|
||||
} catch (err) {
|
||||
if (err?.code === 'EMAIL_LINKED_TO_OTHER_GOOGLE') {
|
||||
return googleAuthError(
|
||||
res,
|
||||
409,
|
||||
'این ایمیل قبلاً با حساب گوگل دیگری ثبت شده است. با همان حساب وارد شوید یا از موبایل/نام کاربری استفاده کنید.'
|
||||
)
|
||||
}
|
||||
|
||||
if (err?.code === 'MISSING_GOOGLE_IDENTITY') {
|
||||
return googleAuthError(res, 422, 'اطلاعات حساب گوگل ناقص است')
|
||||
}
|
||||
|
||||
// فقط duplicate key واقعی — نه هر MongoServerError
|
||||
if (err?.code === 11000) {
|
||||
user = await findExistingGoogleUser({ googleId, email })
|
||||
if (!user) {
|
||||
console.error('[googleAuth] duplicate key unresolved', {
|
||||
googleId,
|
||||
email,
|
||||
keyValue: err.keyValue,
|
||||
message: err.message,
|
||||
})
|
||||
return googleAuthError(
|
||||
res,
|
||||
409,
|
||||
'ثبت این حساب گوگل ممکن نشد. اگر قبلاً ثبتنام کردهاید با همان روش وارد شوید؛ در غیر این صورت چند لحظه بعد دوباره تلاش کنید.',
|
||||
{ en_message: err.message, keyValue: err.keyValue || null }
|
||||
)
|
||||
}
|
||||
} else {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
if (!user?._id) {
|
||||
return googleAuthError(res, 500, 'ورود با گوگل ناموفق بود')
|
||||
}
|
||||
|
||||
user = await expireBlockSuspensionIfNeeded(user)
|
||||
const blockResponse = getBlockedAccountResponse(user)
|
||||
if (blockResponse) {
|
||||
return res.status(403).json(blockResponse)
|
||||
}
|
||||
|
||||
const token = TokenService.sign({ id: String(user._id) })
|
||||
const loginPayload = await buildLoginResponseForUser(user, token)
|
||||
return res.json({
|
||||
...loginPayload,
|
||||
id: loginPayload.id != null ? String(loginPayload.id) : String(user._id),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[googleAuth] failed:', error)
|
||||
|
||||
if (
|
||||
error?.name === 'TokenExpiredError' ||
|
||||
error?.message?.includes('jwt expired') ||
|
||||
error?.message?.includes('Token used too late')
|
||||
) {
|
||||
return googleAuthError(
|
||||
res,
|
||||
401,
|
||||
'توکن گوگل منقضی شده است. دوباره تلاش کنید.'
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
error?.name === 'JsonWebTokenError' ||
|
||||
error?.message?.includes('Invalid token') ||
|
||||
error?.message?.includes('jwt audience invalid') ||
|
||||
error?.message?.includes('Signing key not found')
|
||||
) {
|
||||
return googleAuthError(
|
||||
res,
|
||||
401,
|
||||
'توکن گوگل نامعتبر است. دوباره تلاش کنید.'
|
||||
)
|
||||
}
|
||||
|
||||
if (error?.message?.includes('JWKS unavailable')) {
|
||||
return googleAuthError(
|
||||
res,
|
||||
503,
|
||||
'سرور به Google دسترسی ندارد. فایل googleJwksCache.json را روی سرور آپلود کنید.',
|
||||
{ en_message: error.message }
|
||||
)
|
||||
}
|
||||
|
||||
if (error?.name === 'ValidationError') {
|
||||
return googleAuthError(
|
||||
res,
|
||||
422,
|
||||
'ذخیره حساب گوگل ناموفق بود. مدل کاربر روی سرور بهروز نیست.',
|
||||
{ en_message: error.message }
|
||||
)
|
||||
}
|
||||
|
||||
return googleAuthError(
|
||||
res,
|
||||
500,
|
||||
'خطا در ورود با گوگل. لطفاً چند لحظه بعد دوباره تلاش کنید.',
|
||||
{ en_message: error.message }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const linkGoogleAccount = async (req, res) => {
|
||||
try {
|
||||
const user = req.user
|
||||
const { credential } = req.body
|
||||
|
||||
if (!credential) {
|
||||
return googleAuthError(res, 422, 'توکن گوگل ارسال نشده است')
|
||||
}
|
||||
|
||||
if (user.google_id) {
|
||||
return googleAuthError(res, 400, 'حساب گوگل قبلاً به این کاربر متصل شده است')
|
||||
}
|
||||
|
||||
const clientIds = getGoogleClientIds()
|
||||
if (clientIds.length === 0) {
|
||||
return googleAuthError(res, 503, 'ورود با گوگل در حال حاضر فعال نیست')
|
||||
}
|
||||
|
||||
const payload = await verifyGoogleIdToken(credential, clientIds)
|
||||
const googleId = payload?.sub
|
||||
const email = payload?.email?.toLowerCase()?.trim()
|
||||
|
||||
if (!googleId || !email) {
|
||||
return googleAuthError(res, 422, 'اطلاعات حساب گوگل ناقص است')
|
||||
}
|
||||
|
||||
if (!payload.email_verified) {
|
||||
return googleAuthError(res, 422, 'ایمیل حساب گوگل تایید نشده است')
|
||||
}
|
||||
|
||||
const conflict = await UserModel.findOne({
|
||||
$or: [
|
||||
{ google_id: googleId },
|
||||
{ email },
|
||||
{ email: new RegExp(`^${String(email).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'i') },
|
||||
],
|
||||
_id: { $ne: user._id },
|
||||
})
|
||||
|
||||
if (conflict) {
|
||||
return googleAuthError(
|
||||
res,
|
||||
409,
|
||||
'این حساب گوگل یا ایمیل قبلاً به کاربر دیگری متصل شده است'
|
||||
)
|
||||
}
|
||||
|
||||
await UserModel.updateOne(
|
||||
{ _id: user._id },
|
||||
{
|
||||
$set: {
|
||||
google_id: googleId,
|
||||
email: user.email || email,
|
||||
email_verified: true,
|
||||
},
|
||||
},
|
||||
{ strict: false }
|
||||
)
|
||||
|
||||
return res.json({
|
||||
message: 'حساب گوگل با موفقیت متصل شد',
|
||||
email: user.email || email,
|
||||
linked: true,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[linkGoogleAccount]', error)
|
||||
return googleAuthError(res, 500, 'خطا در اتصال حساب گوگل')
|
||||
}
|
||||
}
|
||||
|
||||
const getGoogleLinkStatus = async (req, res) => {
|
||||
try {
|
||||
const user = req.user
|
||||
return res.json({
|
||||
linked: Boolean(user.google_id),
|
||||
email: user.email || null,
|
||||
auth_provider: user.auth_provider || 'mobile',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[getGoogleLinkStatus]', error)
|
||||
return googleAuthError(res, 500, 'خطا در دریافت وضعیت حساب گوگل')
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
googleAuth,
|
||||
linkGoogleAccount,
|
||||
getGoogleLinkStatus,
|
||||
}
|
||||
134
controllers/application/auth/instagramComplianceController.js
Normal file
134
controllers/application/auth/instagramComplianceController.js
Normal file
@@ -0,0 +1,134 @@
|
||||
const crypto = require('crypto')
|
||||
const UserModel = require('../../../models/UserModel')
|
||||
const PostModel = require('../../../models/PostModel')
|
||||
const InstagramImportedPostModel = require('../../../models/InstagramImportedPostModel')
|
||||
const { parseSignedRequest } = require('../../../utils/metaSignedRequest')
|
||||
|
||||
/**
|
||||
* Meta calls these two endpoints server-to-server (no user session, no bearer
|
||||
* token) — registered as the "Deauthorize callback URL" / "Data deletion request
|
||||
* URL" in the Instagram Business Login settings. Auth is the signed_request's
|
||||
* HMAC, not our normal middleware.
|
||||
*/
|
||||
function getAppSecret () {
|
||||
return process.env.INSTAGRAM_APP_SECRET
|
||||
}
|
||||
|
||||
async function findUserByInstagramScopedId (instagramId) {
|
||||
if (!instagramId) return null
|
||||
return UserModel.findOne({ instagram_id: String(instagramId) })
|
||||
}
|
||||
|
||||
/**
|
||||
* Same effect as the user-initiated "disconnect" button (instagramController.js):
|
||||
* revoke the stored token, keep instagram_id so a future reconnect matches the
|
||||
* same account. Shared so deauthorize/data-deletion/manual-disconnect can't drift.
|
||||
*/
|
||||
async function revokeInstagramConnection (userId) {
|
||||
await UserModel.updateOne(
|
||||
{ _id: userId },
|
||||
{
|
||||
$set: {
|
||||
instagram_access_token_enc: null,
|
||||
instagram_token_expires_at: null,
|
||||
'instagram_sync_prefs.auto_update': false,
|
||||
},
|
||||
},
|
||||
{ strict: false }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes everything that was actually obtained *through* the Instagram API —
|
||||
* imported posts + cached profile fields — without touching the rest of the
|
||||
* Modstagram account (posts/content the user created directly in the app).
|
||||
* instagram_id is $unset (not set to null): the field is a sparse unique index,
|
||||
* and a sparse index still counts a literal `null` as present, so multiple
|
||||
* deleted-and-relinked users would collide on a duplicate key.
|
||||
*/
|
||||
async function deleteInstagramSourcedData (userId) {
|
||||
const importedPosts = await InstagramImportedPostModel.find({ user: userId }).select('post').lean()
|
||||
const postIds = importedPosts.map((row) => row.post)
|
||||
|
||||
if (postIds.length) {
|
||||
await PostModel.deleteMany({ _id: { $in: postIds } })
|
||||
}
|
||||
await InstagramImportedPostModel.deleteMany({ user: userId })
|
||||
|
||||
await UserModel.updateOne(
|
||||
{ _id: userId },
|
||||
{
|
||||
$set: {
|
||||
instagram_username: null,
|
||||
instagram_account_type: null,
|
||||
instagram_access_token_enc: null,
|
||||
instagram_token_expires_at: null,
|
||||
instagram_connected_at: null,
|
||||
instagram_last_synced_at: null,
|
||||
'instagram_sync_prefs.posts': false,
|
||||
'instagram_sync_prefs.auto_update': false,
|
||||
},
|
||||
$unset: { instagram_id: '' },
|
||||
},
|
||||
{ strict: false }
|
||||
)
|
||||
}
|
||||
|
||||
const instagramDeauthorize = async (req, res) => {
|
||||
const secret = getAppSecret()
|
||||
if (!secret) {
|
||||
console.error('[instagramDeauthorize] INSTAGRAM_APP_SECRET is not configured')
|
||||
return res.status(503).end()
|
||||
}
|
||||
|
||||
const data = parseSignedRequest(req.body?.signed_request, secret)
|
||||
if (!data?.user_id) {
|
||||
return res.status(400).json({ error: true, message: 'invalid signed_request' })
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await findUserByInstagramScopedId(data.user_id)
|
||||
if (user) {
|
||||
await revokeInstagramConnection(user._id)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[instagramDeauthorize]', err)
|
||||
}
|
||||
|
||||
return res.status(200).json({ success: true })
|
||||
}
|
||||
|
||||
const instagramDataDeletion = async (req, res) => {
|
||||
const secret = getAppSecret()
|
||||
if (!secret) {
|
||||
console.error('[instagramDataDeletion] INSTAGRAM_APP_SECRET is not configured')
|
||||
return res.status(503).end()
|
||||
}
|
||||
|
||||
const data = parseSignedRequest(req.body?.signed_request, secret)
|
||||
if (!data?.user_id) {
|
||||
return res.status(400).json({ error: true, message: 'invalid signed_request' })
|
||||
}
|
||||
|
||||
const confirmationCode = crypto.randomBytes(16).toString('hex')
|
||||
|
||||
try {
|
||||
const user = await findUserByInstagramScopedId(data.user_id)
|
||||
if (user) {
|
||||
await deleteInstagramSourcedData(user._id)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[instagramDataDeletion]', err)
|
||||
}
|
||||
|
||||
const appSite = process.env.APP_SITE || 'https://modstagram.com'
|
||||
return res.status(200).json({
|
||||
url: `${appSite}/instagram-data-deletion?code=${confirmationCode}`,
|
||||
confirmation_code: confirmationCode,
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
instagramDeauthorize,
|
||||
instagramDataDeletion,
|
||||
}
|
||||
285
controllers/application/auth/instagramController.js
Normal file
285
controllers/application/auth/instagramController.js
Normal file
@@ -0,0 +1,285 @@
|
||||
const UserModel = require('../../../models/UserModel')
|
||||
const TokenService = require('../../../services/TokenService')
|
||||
const { buildLoginResponseForUser } = require('../../../utils/loginResponse')
|
||||
const {
|
||||
expireBlockSuspensionIfNeeded,
|
||||
getBlockedAccountResponse,
|
||||
} = require('../../../utils/blockSuspension')
|
||||
const {
|
||||
findOrCreateInstagramUser,
|
||||
} = require('../../../utils/instagramUserAuth')
|
||||
const InstagramGraphService = require('../../../services/InstagramGraphService')
|
||||
const { encryptSecret } = require('../../../utils/secretCipher')
|
||||
|
||||
const ALLOWED_ACCOUNT_TYPES = ['BUSINESS', 'MEDIA_CREATOR']
|
||||
|
||||
function instagramAuthError(res, status, message, extra = {}) {
|
||||
return res.status(status).json({
|
||||
error: true,
|
||||
message,
|
||||
...extra,
|
||||
})
|
||||
}
|
||||
|
||||
function isConfigured() {
|
||||
return Boolean(
|
||||
process.env.INSTAGRAM_CLIENT_ID &&
|
||||
process.env.INSTAGRAM_APP_SECRET &&
|
||||
process.env.INSTAGRAM_REDIRECT_URI
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange the OAuth `code` for a long-lived token + profile.
|
||||
* Shared by both the login/register callback and the link-to-existing-user flow.
|
||||
*/
|
||||
async function resolveInstagramIdentity(code) {
|
||||
const { accessToken: shortLivedToken } = await InstagramGraphService.exchangeCodeForToken(code)
|
||||
if (!shortLivedToken) {
|
||||
const err = new Error('NO_SHORT_LIVED_TOKEN')
|
||||
err.code = 'NO_SHORT_LIVED_TOKEN'
|
||||
throw err
|
||||
}
|
||||
|
||||
const { accessToken: longLivedToken, expiresAt } =
|
||||
await InstagramGraphService.exchangeForLongLivedToken(shortLivedToken)
|
||||
|
||||
const profile = await InstagramGraphService.getProfile(longLivedToken)
|
||||
|
||||
return { longLivedToken, expiresAt, profile }
|
||||
}
|
||||
|
||||
const instagramAuth = async (req, res) => {
|
||||
try {
|
||||
if (!process.env.APP_SECRET) {
|
||||
console.error('[instagramAuth] APP_SECRET is not configured')
|
||||
return instagramAuthError(
|
||||
res,
|
||||
503,
|
||||
'پیکربندی سرور کامل نیست (APP_SECRET)'
|
||||
)
|
||||
}
|
||||
|
||||
if (!isConfigured()) {
|
||||
return instagramAuthError(res, 503, 'ورود با اینستاگرام در حال حاضر فعال نیست')
|
||||
}
|
||||
|
||||
const { code } = req.body
|
||||
if (!code) {
|
||||
return instagramAuthError(res, 422, 'کد اینستاگرام ارسال نشده است')
|
||||
}
|
||||
|
||||
let longLivedToken, expiresAt, profile
|
||||
try {
|
||||
;({ longLivedToken, expiresAt, profile } = await resolveInstagramIdentity(code))
|
||||
} catch (err) {
|
||||
console.error('[instagramAuth] token exchange failed:', err?.response?.data || err.message)
|
||||
return instagramAuthError(
|
||||
res,
|
||||
401,
|
||||
'اتصال به اینستاگرام ناموفق بود. دوباره تلاش کنید.',
|
||||
{ en_message: err?.response?.data?.error_message || err.message }
|
||||
)
|
||||
}
|
||||
|
||||
if (!profile?.id) {
|
||||
return instagramAuthError(res, 422, 'اطلاعات حساب اینستاگرام ناقص است')
|
||||
}
|
||||
|
||||
if (profile.accountType && !ALLOWED_ACCOUNT_TYPES.includes(profile.accountType)) {
|
||||
return instagramAuthError(
|
||||
res,
|
||||
422,
|
||||
'فقط حسابهای Business یا Creator اینستاگرام قابل اتصال هستند. لطفاً حساب خود را در اینستاگرام به یکی از این دو نوع تغییر دهید.'
|
||||
)
|
||||
}
|
||||
|
||||
let user
|
||||
try {
|
||||
user = await findOrCreateInstagramUser({
|
||||
instagramId: profile.id,
|
||||
username: profile.username,
|
||||
accountType: profile.accountType,
|
||||
tokenEnc: encryptSecret(longLivedToken),
|
||||
tokenExpiresAt: expiresAt,
|
||||
profilePictureUrl: profile.profilePictureUrl,
|
||||
})
|
||||
} catch (err) {
|
||||
if (err?.code === 'MISSING_INSTAGRAM_IDENTITY') {
|
||||
return instagramAuthError(res, 422, 'اطلاعات حساب اینستاگرام ناقص است')
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
if (!user?._id) {
|
||||
return instagramAuthError(res, 500, 'ورود با اینستاگرام ناموفق بود')
|
||||
}
|
||||
|
||||
user = await expireBlockSuspensionIfNeeded(user)
|
||||
const blockResponse = getBlockedAccountResponse(user)
|
||||
if (blockResponse) {
|
||||
return res.status(403).json(blockResponse)
|
||||
}
|
||||
|
||||
const token = TokenService.sign({ id: String(user._id) })
|
||||
const loginPayload = await buildLoginResponseForUser(user, token)
|
||||
|
||||
return res.json({
|
||||
...loginPayload,
|
||||
id: loginPayload.id != null ? String(loginPayload.id) : String(user._id),
|
||||
instagram_username: profile.username || null,
|
||||
instagram_full_name: profile.name || null,
|
||||
instagram_bio: profile.biography || null,
|
||||
instagram_profile_picture_url: profile.profilePictureUrl || null,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[instagramAuth] failed:', error)
|
||||
return instagramAuthError(
|
||||
res,
|
||||
500,
|
||||
'خطا در ورود با اینستاگرام. لطفاً چند لحظه بعد دوباره تلاش کنید.',
|
||||
{ en_message: error.message }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const linkInstagramAccount = async (req, res) => {
|
||||
try {
|
||||
const user = req.user
|
||||
const { code } = req.body
|
||||
|
||||
if (!code) {
|
||||
return instagramAuthError(res, 422, 'کد اینستاگرام ارسال نشده است')
|
||||
}
|
||||
|
||||
if (user.instagram_id && user.instagram_access_token_enc) {
|
||||
return instagramAuthError(res, 400, 'حساب اینستاگرام قبلاً به این کاربر متصل شده است')
|
||||
}
|
||||
|
||||
if (!isConfigured()) {
|
||||
return instagramAuthError(res, 503, 'اتصال اینستاگرام در حال حاضر فعال نیست')
|
||||
}
|
||||
|
||||
let longLivedToken, expiresAt, profile
|
||||
try {
|
||||
;({ longLivedToken, expiresAt, profile } = await resolveInstagramIdentity(code))
|
||||
} catch (err) {
|
||||
console.error('[linkInstagramAccount] token exchange failed:', err?.response?.data || err.message)
|
||||
return instagramAuthError(
|
||||
res,
|
||||
401,
|
||||
'اتصال به اینستاگرام ناموفق بود. دوباره تلاش کنید.',
|
||||
{ en_message: err?.response?.data?.error_message || err.message }
|
||||
)
|
||||
}
|
||||
|
||||
if (!profile?.id) {
|
||||
return instagramAuthError(res, 422, 'اطلاعات حساب اینستاگرام ناقص است')
|
||||
}
|
||||
|
||||
if (profile.accountType && !ALLOWED_ACCOUNT_TYPES.includes(profile.accountType)) {
|
||||
return instagramAuthError(
|
||||
res,
|
||||
422,
|
||||
'فقط حسابهای Business یا Creator اینستاگرام قابل اتصال هستند. لطفاً حساب خود را در اینستاگرام به یکی از این دو نوع تغییر دهید.'
|
||||
)
|
||||
}
|
||||
|
||||
const conflict = await UserModel.findOne({
|
||||
instagram_id: String(profile.id),
|
||||
_id: { $ne: user._id },
|
||||
})
|
||||
|
||||
if (conflict) {
|
||||
return instagramAuthError(
|
||||
res,
|
||||
409,
|
||||
'این حساب اینستاگرام قبلاً به کاربر دیگری متصل شده است'
|
||||
)
|
||||
}
|
||||
|
||||
await UserModel.updateOne(
|
||||
{ _id: user._id },
|
||||
{
|
||||
$set: {
|
||||
instagram_id: String(profile.id),
|
||||
instagram_username: profile.username || null,
|
||||
instagram_account_type: profile.accountType || null,
|
||||
instagram_access_token_enc: encryptSecret(longLivedToken),
|
||||
instagram_token_expires_at: expiresAt,
|
||||
instagram_connected_at: new Date(),
|
||||
},
|
||||
},
|
||||
{ strict: false }
|
||||
)
|
||||
|
||||
return res.json({
|
||||
message: 'حساب اینستاگرام با موفقیت متصل شد',
|
||||
instagram_username: profile.username || null,
|
||||
linked: true,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[linkInstagramAccount]', error)
|
||||
return instagramAuthError(res, 500, 'خطا در اتصال حساب اینستاگرام')
|
||||
}
|
||||
}
|
||||
|
||||
const getInstagramLinkStatus = async (req, res) => {
|
||||
try {
|
||||
const user = req.user
|
||||
return res.json({
|
||||
linked: Boolean(user.instagram_id),
|
||||
connected: Boolean(user.instagram_id && user.instagram_access_token_enc),
|
||||
instagram_username: user.instagram_username || null,
|
||||
instagram_account_type: user.instagram_account_type || null,
|
||||
connected_at: user.instagram_connected_at || null,
|
||||
auth_provider: user.auth_provider || 'mobile',
|
||||
instagram_sync_prefs: {
|
||||
posts: Boolean(user.instagram_sync_prefs?.posts),
|
||||
auto_update: Boolean(user.instagram_sync_prefs?.auto_update),
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[getInstagramLinkStatus]', error)
|
||||
return instagramAuthError(res, 500, 'خطا در دریافت وضعیت حساب اینستاگرام')
|
||||
}
|
||||
}
|
||||
|
||||
const disconnectInstagram = async (req, res) => {
|
||||
try {
|
||||
const user = req.user
|
||||
|
||||
if (!user.instagram_id) {
|
||||
return instagramAuthError(res, 400, 'حساب اینستاگرامی متصل نیست')
|
||||
}
|
||||
|
||||
// instagram_id عمداً حذف نمیشود تا محتوای قبلاً ایمپورتشده و امکان اتصال مجدد حفظ شود
|
||||
await UserModel.updateOne(
|
||||
{ _id: user._id },
|
||||
{
|
||||
$set: {
|
||||
instagram_access_token_enc: null,
|
||||
instagram_token_expires_at: null,
|
||||
'instagram_sync_prefs.auto_update': false,
|
||||
},
|
||||
},
|
||||
{ strict: false }
|
||||
)
|
||||
|
||||
return res.json({
|
||||
message: 'اتصال اینستاگرام قطع شد',
|
||||
linked: true,
|
||||
connected: false,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[disconnectInstagram]', error)
|
||||
return instagramAuthError(res, 500, 'خطا در قطع اتصال اینستاگرام')
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
instagramAuth,
|
||||
linkInstagramAccount,
|
||||
getInstagramLinkStatus,
|
||||
disconnectInstagram,
|
||||
}
|
||||
141
controllers/application/auth/instagramImportController.js
Normal file
141
controllers/application/auth/instagramImportController.js
Normal file
@@ -0,0 +1,141 @@
|
||||
const UserModel = require('../../../models/UserModel')
|
||||
const InstagramImportedPostModel = require('../../../models/InstagramImportedPostModel')
|
||||
const InstagramGraphService = require('../../../services/InstagramGraphService')
|
||||
const { decryptSecret } = require('../../../utils/secretCipher')
|
||||
const { importInstagramMedia, SUPPORTED_MEDIA_TYPES } = require('../../../utils/instagramMediaImport')
|
||||
|
||||
function importError (res, status, message) {
|
||||
return res.status(status).json({ error: true, message })
|
||||
}
|
||||
|
||||
async function requireConnectedInstagram (user) {
|
||||
if (!user.instagram_id || !user.instagram_access_token_enc) {
|
||||
const err = new Error('NOT_CONNECTED')
|
||||
err.code = 'NOT_CONNECTED'
|
||||
throw err
|
||||
}
|
||||
return decryptSecret(user.instagram_access_token_enc)
|
||||
}
|
||||
|
||||
/**
|
||||
* List the user's Instagram media, flagged with whether each item was already
|
||||
* imported — feeds the manual picker UI.
|
||||
*/
|
||||
const getInstagramMedia = async (req, res) => {
|
||||
try {
|
||||
const user = req.user
|
||||
let accessToken
|
||||
try {
|
||||
accessToken = await requireConnectedInstagram(user)
|
||||
} catch (err) {
|
||||
if (err.code === 'NOT_CONNECTED') {
|
||||
return importError(res, 400, 'ابتدا حساب اینستاگرام خود را متصل کنید')
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
const { items } = await InstagramGraphService.getMedia(accessToken)
|
||||
const importedIds = new Set(
|
||||
(
|
||||
await InstagramImportedPostModel.find({ user: user._id }).select('instagram_media_id').lean()
|
||||
).map((row) => row.instagram_media_id)
|
||||
)
|
||||
|
||||
const media = items.map((item) => ({
|
||||
id: item.id,
|
||||
caption: item.caption || null,
|
||||
media_type: item.media_type,
|
||||
media_url: item.media_url,
|
||||
permalink: item.permalink,
|
||||
timestamp: item.timestamp,
|
||||
supported: SUPPORTED_MEDIA_TYPES.includes(item.media_type),
|
||||
already_imported: importedIds.has(String(item.id)),
|
||||
}))
|
||||
|
||||
return res.json({ media })
|
||||
} catch (error) {
|
||||
console.error('[getInstagramMedia]', error)
|
||||
return importError(res, 500, 'خطا در دریافت پستهای اینستاگرام')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* mode: 'manual' — import exactly the given mediaIds.
|
||||
* mode: 'automatic' — turn on ongoing sync and import everything not yet imported.
|
||||
*/
|
||||
const importInstagramPosts = async (req, res) => {
|
||||
try {
|
||||
const user = req.user
|
||||
const { mode, mediaIds } = req.body
|
||||
|
||||
if (mode !== 'manual' && mode !== 'automatic') {
|
||||
return importError(res, 422, 'نوع انتقال نامعتبر است')
|
||||
}
|
||||
|
||||
let accessToken
|
||||
try {
|
||||
accessToken = await requireConnectedInstagram(user)
|
||||
} catch (err) {
|
||||
if (err.code === 'NOT_CONNECTED') {
|
||||
return importError(res, 400, 'ابتدا حساب اینستاگرام خود را متصل کنید')
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
const { items } = await InstagramGraphService.getMedia(accessToken)
|
||||
|
||||
let targetItems
|
||||
if (mode === 'manual') {
|
||||
const requestedIds = new Set((Array.isArray(mediaIds) ? mediaIds : []).map(String))
|
||||
if (requestedIds.size === 0) {
|
||||
return importError(res, 422, 'هیچ پستی انتخاب نشده است')
|
||||
}
|
||||
targetItems = items.filter((item) => requestedIds.has(String(item.id)))
|
||||
} else {
|
||||
targetItems = items
|
||||
}
|
||||
|
||||
let importedCount = 0
|
||||
for (const item of targetItems) {
|
||||
const post = await importInstagramMedia(user, item)
|
||||
if (post) importedCount += 1
|
||||
}
|
||||
|
||||
const $set = { 'instagram_sync_prefs.posts': true }
|
||||
if (mode === 'automatic') {
|
||||
$set['instagram_sync_prefs.auto_update'] = true
|
||||
$set.instagram_last_synced_at = new Date()
|
||||
}
|
||||
await UserModel.updateOne({ _id: user._id }, { $set }, { strict: false })
|
||||
|
||||
return res.json({
|
||||
message: 'انتقال پستهای اینستاگرام انجام شد',
|
||||
imported_count: importedCount,
|
||||
auto_update: mode === 'automatic',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[importInstagramPosts]', error)
|
||||
return importError(res, 500, 'خطا در انتقال پستهای اینستاگرام')
|
||||
}
|
||||
}
|
||||
|
||||
const disableAutoImport = async (req, res) => {
|
||||
try {
|
||||
const user = req.user
|
||||
await UserModel.updateOne(
|
||||
{ _id: user._id },
|
||||
{ $set: { 'instagram_sync_prefs.auto_update': false } },
|
||||
{ strict: false }
|
||||
)
|
||||
return res.json({ message: 'همگامسازی خودکار غیرفعال شد', auto_update: false })
|
||||
} catch (error) {
|
||||
console.error('[disableAutoImport]', error)
|
||||
return importError(res, 500, 'خطا در غیرفعالسازی همگامسازی خودکار')
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getInstagramMedia,
|
||||
importInstagramPosts,
|
||||
disableAutoImport,
|
||||
}
|
||||
148
controllers/application/auth/totpController.js
Normal file
148
controllers/application/auth/totpController.js
Normal file
@@ -0,0 +1,148 @@
|
||||
const UserModel = require('../../../models/UserModel')
|
||||
const TokenService = require('../../../services/TokenService')
|
||||
const { buildAuthResponse } = require('../../../utils/authSteps')
|
||||
const { verifyTemp2faToken } = require('../../../utils/loginResponse')
|
||||
const {
|
||||
generateSecret,
|
||||
verifyTotp,
|
||||
buildOtpAuthUrl,
|
||||
} = require('../../../utils/totp')
|
||||
const {
|
||||
readTotpState,
|
||||
setPendingSecret,
|
||||
activateTotp,
|
||||
deactivateTotp,
|
||||
} = require('../../../utils/totpStore')
|
||||
|
||||
const ISSUER = 'Modstagram'
|
||||
|
||||
async function getTotpStatus (req, res) {
|
||||
const user = req.user
|
||||
const state = await readTotpState(user._id)
|
||||
|
||||
if (!state) {
|
||||
return res.status(404).json({ message: 'کاربر یافت نشد' })
|
||||
}
|
||||
|
||||
return res.json({
|
||||
enabled: state.totp_enabled,
|
||||
linked: Boolean(state.totp_secret),
|
||||
})
|
||||
}
|
||||
|
||||
async function setupTotp (req, res) {
|
||||
const user = req.user
|
||||
if (!user?._id) {
|
||||
return res.status(401).json({ message: 'کاربر احراز هویت نشده است' })
|
||||
}
|
||||
|
||||
const state = await readTotpState(user._id)
|
||||
if (!state) {
|
||||
return res.status(404).json({ message: 'کاربر یافت نشد' })
|
||||
}
|
||||
|
||||
if (state.totp_enabled) {
|
||||
return res.status(400).json({ message: 'ورود دو مرحلهای قبلاً فعال شده است' })
|
||||
}
|
||||
|
||||
const secret = generateSecret()
|
||||
await setPendingSecret(user._id, secret)
|
||||
|
||||
const accountName =
|
||||
user.user_name || user.email || user.mobile || String(user._id)
|
||||
|
||||
return res.json({
|
||||
secret,
|
||||
otpauth_url: buildOtpAuthUrl({
|
||||
secret,
|
||||
accountName,
|
||||
issuer: ISSUER,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
async function enableTotp (req, res) {
|
||||
const { code } = req.body
|
||||
|
||||
if (!code) {
|
||||
return res.status(422).json({ message: 'کد تایید الزامی است' })
|
||||
}
|
||||
|
||||
const state = await readTotpState(req.user._id)
|
||||
if (!state) {
|
||||
return res.status(404).json({ message: 'کاربر یافت نشد' })
|
||||
}
|
||||
|
||||
if (!state.totp_pending_secret) {
|
||||
return res.status(400).json({ message: 'ابتدا راهاندازی ورود دو مرحلهای را انجام دهید' })
|
||||
}
|
||||
|
||||
if (!verifyTotp(state.totp_pending_secret, code)) {
|
||||
return res.status(422).json({ message: 'کد تایید نامعتبر است' })
|
||||
}
|
||||
|
||||
await activateTotp(req.user._id, state.totp_pending_secret)
|
||||
|
||||
return res.json({ message: 'ورود دو مرحلهای فعال شد', enabled: true })
|
||||
}
|
||||
|
||||
async function disableTotp (req, res) {
|
||||
const { code } = req.body
|
||||
const state = await readTotpState(req.user._id)
|
||||
|
||||
if (!state) {
|
||||
return res.status(404).json({ message: 'کاربر یافت نشد' })
|
||||
}
|
||||
|
||||
if (!state.totp_enabled || !state.totp_secret) {
|
||||
return res.status(400).json({ message: 'ورود دو مرحلهای فعال نیست' })
|
||||
}
|
||||
|
||||
if (!code || !verifyTotp(state.totp_secret, code)) {
|
||||
return res.status(422).json({ message: 'کد تایید نامعتبر است' })
|
||||
}
|
||||
|
||||
await deactivateTotp(req.user._id)
|
||||
|
||||
return res.json({ message: 'ورود دو مرحلهای غیرفعال شد', enabled: false })
|
||||
}
|
||||
|
||||
async function verifyLoginTotp (req, res) {
|
||||
const { temp_token: tempToken, code } = req.body
|
||||
|
||||
if (!tempToken || !code) {
|
||||
return res.status(422).json({ message: 'اطلاعات ورود ناقص است' })
|
||||
}
|
||||
|
||||
let decoded
|
||||
try {
|
||||
decoded = verifyTemp2faToken(tempToken)
|
||||
} catch {
|
||||
return res.status(401).json({ message: 'نشست ورود منقضی شده است. دوباره وارد شوید' })
|
||||
}
|
||||
|
||||
const state = await readTotpState(decoded.id)
|
||||
if (!state?.totp_enabled || !state.totp_secret) {
|
||||
return res.status(401).json({ message: 'ورود دو مرحلهای برای این حساب فعال نیست' })
|
||||
}
|
||||
|
||||
if (!verifyTotp(state.totp_secret, code)) {
|
||||
return res.status(422).json({ message: 'کد دو مرحلهای نامعتبر است' })
|
||||
}
|
||||
|
||||
const user = await UserModel.findById(decoded.id)
|
||||
if (!user) {
|
||||
return res.status(401).json({ message: 'کاربر یافت نشد' })
|
||||
}
|
||||
|
||||
const token = TokenService.sign({ id: user._id })
|
||||
return res.json(buildAuthResponse(user, token))
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getTotpStatus,
|
||||
setupTotp,
|
||||
enableTotp,
|
||||
disableTotp,
|
||||
verifyLoginTotp,
|
||||
}
|
||||
Reference in New Issue
Block a user