247 lines
6.2 KiB
JavaScript
247 lines
6.2 KiB
JavaScript
import 'dotenv/config';
|
|
|
|
import { readFileSync } from 'node:fs';
|
|
|
|
import bodyParser from 'body-parser';
|
|
import cors from 'cors';
|
|
import express from 'express';
|
|
import { ApolloServer } from '@apollo/server';
|
|
import { expressMiddleware } from '@as-integrations/express5';
|
|
|
|
import {
|
|
createMessengerStartSession,
|
|
consumeMessengerStartSession,
|
|
extractAuthTokenFromRequest,
|
|
hasMessengerStartSession,
|
|
issueTemporaryLoginToken,
|
|
verifyAccessToken,
|
|
} from './auth.js';
|
|
import { buildContext } from './context.js';
|
|
import { sendMessengerMessage } from './messenger.js';
|
|
import { prisma } from './prisma-client.js';
|
|
import { resolvers } from './resolvers.js';
|
|
|
|
const typeDefs = readFileSync(new URL('./schema.graphql', import.meta.url), 'utf8');
|
|
|
|
const server = new ApolloServer({
|
|
typeDefs,
|
|
resolvers,
|
|
});
|
|
|
|
await server.start();
|
|
|
|
const app = express();
|
|
app.use(cors());
|
|
app.use(bodyParser.json({ limit: '1mb' }));
|
|
|
|
app.get('/healthz', (_, res) => {
|
|
res.json({ status: 'ok' });
|
|
});
|
|
|
|
function buildDefaultFullName(email) {
|
|
const localPart = email.split('@')[0]?.trim();
|
|
if (!localPart) {
|
|
return 'Новый пользователь';
|
|
}
|
|
|
|
return localPart
|
|
.replace(/[._-]+/g, ' ')
|
|
.split(' ')
|
|
.filter(Boolean)
|
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
.join(' ');
|
|
}
|
|
|
|
async function resolveUserForMessenger({ userId, email }) {
|
|
if (userId) {
|
|
const byId = await prisma.user.findUnique({ where: { id: userId } });
|
|
if (byId) {
|
|
return byId;
|
|
}
|
|
}
|
|
|
|
if (!email) {
|
|
return null;
|
|
}
|
|
|
|
const normalizedEmail = email.trim().toLowerCase();
|
|
if (!normalizedEmail) {
|
|
return null;
|
|
}
|
|
|
|
return prisma.user.upsert({
|
|
where: { email: normalizedEmail },
|
|
update: {},
|
|
create: {
|
|
email: normalizedEmail,
|
|
fullName: buildDefaultFullName(normalizedEmail),
|
|
role: 'CLIENT',
|
|
},
|
|
});
|
|
}
|
|
|
|
function normalizeRedirectPath(value) {
|
|
const redirectPath = String(value || '').trim();
|
|
if (!redirectPath.startsWith('/')) {
|
|
return '';
|
|
}
|
|
return redirectPath;
|
|
}
|
|
|
|
async function resolveAuthenticatedUserFromRequest(req) {
|
|
const authToken = extractAuthTokenFromRequest(req);
|
|
const auth = verifyAccessToken(authToken);
|
|
if (!auth?.userId) {
|
|
return null;
|
|
}
|
|
|
|
return prisma.user.findUnique({
|
|
where: { id: auth.userId },
|
|
});
|
|
}
|
|
|
|
app.post('/auth/messenger-start', async (req, res) => {
|
|
const channel = String(req.body?.channel || '').toUpperCase();
|
|
if (channel !== 'TELEGRAM' && channel !== 'MAX') {
|
|
res.status(400).json({ error: 'Unsupported channel.' });
|
|
return;
|
|
}
|
|
|
|
const authenticatedUser = await resolveAuthenticatedUserFromRequest(req);
|
|
const providedEmail = String(req.body?.email || '').trim().toLowerCase();
|
|
const email = authenticatedUser?.email?.trim().toLowerCase() || providedEmail;
|
|
const userId = authenticatedUser?.id ?? null;
|
|
const redirectPath = normalizeRedirectPath(req.body?.redirectPath);
|
|
|
|
if (!userId && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
res.status(400).json({ error: 'A valid email is required.' });
|
|
return;
|
|
}
|
|
|
|
const session = createMessengerStartSession({
|
|
channel,
|
|
email,
|
|
userId,
|
|
redirectPath,
|
|
});
|
|
|
|
res.json({
|
|
ok: true,
|
|
startToken: session.startToken,
|
|
expiresAt: session.expiresAt.toISOString(),
|
|
mode: userId ? 'connect' : 'login',
|
|
});
|
|
});
|
|
|
|
app.post('/bot/messenger-login', async (req, res) => {
|
|
const webhookToken = process.env.BOT_LOGIN_WEBHOOK_TOKEN;
|
|
const providedToken = req.body?.token;
|
|
if (!webhookToken || providedToken !== webhookToken) {
|
|
res.status(401).json({ error: 'Unauthorized bot request.' });
|
|
return;
|
|
}
|
|
|
|
const channel = String(req.body?.channel || '').toUpperCase();
|
|
if (channel !== 'TELEGRAM' && channel !== 'MAX') {
|
|
res.status(400).json({ error: 'Unsupported channel.' });
|
|
return;
|
|
}
|
|
|
|
const startToken = String(req.body?.startToken || '').trim();
|
|
const channelId = String(req.body?.channelId || '').trim();
|
|
const skipDispatch = req.body?.skipDispatch === true;
|
|
if (!channelId || !startToken) {
|
|
res.status(400).json({ error: 'channelId and startToken are required.' });
|
|
return;
|
|
}
|
|
|
|
if (!hasMessengerStartSession(startToken)) {
|
|
res.status(400).json({ error: 'Messenger start token is invalid or expired.' });
|
|
return;
|
|
}
|
|
|
|
const startSession = consumeMessengerStartSession(startToken);
|
|
|
|
if (startSession.channel !== channel) {
|
|
res.status(400).json({ error: 'Start token channel mismatch.' });
|
|
return;
|
|
}
|
|
|
|
const user = await resolveUserForMessenger({
|
|
userId: startSession.userId,
|
|
email: startSession.email,
|
|
});
|
|
|
|
if (!user) {
|
|
res.status(404).json({ error: 'User not found.' });
|
|
return;
|
|
}
|
|
|
|
const login = issueTemporaryLoginToken({
|
|
userId: user.id,
|
|
messengerConnection: {
|
|
type: channel,
|
|
channelId,
|
|
},
|
|
});
|
|
const frontendUrl = (
|
|
process.env.WEB_FRONTEND_URL ||
|
|
process.env.NUXT_PUBLIC_SITE_URL ||
|
|
'http://localhost:3000'
|
|
).replace(/\/$/, '');
|
|
const nextPath = startSession.redirectPath || (
|
|
startSession.userId
|
|
? `/profile/notifications?status=success&connected=${channel.toLowerCase()}`
|
|
: ''
|
|
);
|
|
const loginQuery = new URLSearchParams({
|
|
login_token: login.loginToken,
|
|
});
|
|
if (nextPath) {
|
|
loginQuery.set('next', nextPath);
|
|
}
|
|
const loginUrl = `${frontendUrl}/login?${loginQuery.toString()}`;
|
|
|
|
if (!skipDispatch) {
|
|
const dispatch = await sendMessengerMessage({
|
|
type: channel,
|
|
channelId,
|
|
message: 'Вход подтвержден. Нажмите кнопку, чтобы открыть личный кабинет.',
|
|
buttonUrl: loginUrl,
|
|
buttonText: 'Открыть кабинет',
|
|
});
|
|
|
|
if (!dispatch.success) {
|
|
res.status(502).json({ error: dispatch.detail });
|
|
return;
|
|
}
|
|
}
|
|
|
|
res.json({
|
|
ok: true,
|
|
mode: startSession.userId ? 'connect' : 'login',
|
|
loginUrl,
|
|
expiresAt: login.expiresAt.toISOString(),
|
|
});
|
|
});
|
|
|
|
app.use(
|
|
'/graphql',
|
|
expressMiddleware(server, {
|
|
context: async ({ req }) => buildContext(req),
|
|
}),
|
|
);
|
|
|
|
const port = Number(process.env.PORT ?? 4000);
|
|
app.listen(port, () => {
|
|
console.log(`apollo-backend running at http://localhost:${port}/graphql`);
|
|
});
|
|
|
|
async function shutdown() {
|
|
await server.stop();
|
|
await prisma.$disconnect();
|
|
}
|
|
|
|
process.on('SIGINT', shutdown);
|
|
process.on('SIGTERM', shutdown);
|