- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
351 lines
12 KiB
TypeScript
351 lines
12 KiB
TypeScript
import type { Request, Response, NextFunction } from 'express';
|
|
import { verifyAccessToken, verifyBotServiceToken, verifySuperAdminToken } from '../utils/jwt';
|
|
import { storage } from '../storage';
|
|
import { withTenant, openTenantCtx, openSuperAdminCtx, _tenantCtx } from '../db';
|
|
import { eq } from 'drizzle-orm';
|
|
import { trackUserActivity } from '../utils/userActivity';
|
|
|
|
export interface AuthenticatedRequest extends Request {
|
|
user?: any;
|
|
organizationId?: number;
|
|
/** True when the request was authenticated with a bot-service JWT (type: 'bot_service').
|
|
* Routes should skip bot-trigger logic when this flag is set to prevent message loops. */
|
|
isBotToken?: boolean;
|
|
}
|
|
|
|
export interface SuperAdminRequest extends Request {
|
|
superAdmin?: { id: number; email: string; name?: string };
|
|
}
|
|
|
|
const BILLING_EXEMPT_PREFIXES = [
|
|
'/api/auth/',
|
|
'/api/superadmin/',
|
|
'/api/billing/',
|
|
'/api/health',
|
|
];
|
|
|
|
// Validates Bearer JWT and populates req.user. After successful auth, opens a
|
|
// per-request pg client with SET LOCAL app.current_org_id so all subsequent
|
|
// db.* calls in the handler automatically use the tenant-scoped connection.
|
|
// This covers every route that uses authenticateToken — no per-route wiring needed.
|
|
export const authenticateToken = async (
|
|
req: AuthenticatedRequest,
|
|
res: Response,
|
|
next: NextFunction
|
|
) => {
|
|
// Already authenticated as bot-service by tryBotServiceToken — skip user lookup.
|
|
if (req.isBotToken) {
|
|
return next();
|
|
}
|
|
|
|
const authHeader = req.headers['authorization'];
|
|
const headerToken = authHeader && authHeader.split(' ')[1];
|
|
const cookieToken = (req as any).cookies?.access_token;
|
|
const token = cookieToken || headerToken;
|
|
|
|
if (!token) {
|
|
return res.status(401).json({ error: 'Токен доступа отсутствует' });
|
|
}
|
|
|
|
try {
|
|
const decoded = verifyAccessToken(token);
|
|
// Use JWT organizationId to set tenant context for the users table lookup,
|
|
// preventing auth failure when FORCE RLS is active on the users table.
|
|
const user = await withTenant(decoded.organizationId, () =>
|
|
storage.getUserWithOrganization(decoded.userId)
|
|
);
|
|
|
|
if (!user || !user.isActive) {
|
|
return res.status(401).json({ error: 'Пользователь не найден или заблокирован' });
|
|
}
|
|
if (user.organization && !user.organization.isActive) {
|
|
return res.status(403).json({ error: 'Доступ организации заблокирован' });
|
|
}
|
|
|
|
req.user = user;
|
|
req.organizationId = user.organizationId;
|
|
trackUserActivity(user.id);
|
|
|
|
const isBillingExempt = BILLING_EXEMPT_PREFIXES.some(p => req.path.startsWith(p));
|
|
if (!isBillingExempt && user.organization?.billingBlocked) {
|
|
return res.status(402).json({
|
|
error: 'Доступ приостановлен',
|
|
blocked: true,
|
|
reason: 'insufficient_balance',
|
|
message: 'Баланс организации исчерпан. Обратитесь к администратору для пополнения.',
|
|
});
|
|
}
|
|
|
|
// Open a per-request tenant context if one is not already active.
|
|
// SSE connections (text/event-stream) skip the long-lived transaction —
|
|
// their individual DB calls use withTenant point-operations instead.
|
|
if (_tenantCtx.getStore()) {
|
|
return next();
|
|
}
|
|
const isSSE = req.headers.accept?.includes('text/event-stream');
|
|
if (isSSE) {
|
|
return next();
|
|
}
|
|
|
|
openTenantCtx(user.organizationId)
|
|
.then((handle) => {
|
|
handle.run(() => {
|
|
const guard = setTimeout(() => {
|
|
console.warn(`[POOL] Force-releasing tenant connection after 30s timeout (${req.method} ${req.path})`);
|
|
handle.release();
|
|
}, 30_000);
|
|
res.once('finish', () => { clearTimeout(guard); handle.release(); });
|
|
res.once('close', () => { clearTimeout(guard); handle.release(); });
|
|
next();
|
|
});
|
|
})
|
|
.catch((err) => next(err as Error));
|
|
} catch {
|
|
return res.status(403).json({ error: 'Недействительный токен' });
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Optional pre-middleware: detects a bot-service JWT (type: 'bot_service') and sets
|
|
* req.isBotToken = true when found. authenticateToken then skips its user-lookup step.
|
|
*
|
|
* Apply only to routes that must accept both user tokens and bot service tokens, e.g.:
|
|
* router.post('/…', tryBotServiceToken, authenticateToken, handler)
|
|
*
|
|
* Routes that only ever handle human users must NOT use this middleware, preserving
|
|
* the invariant that req.user is always set after authenticateToken.
|
|
*/
|
|
export const tryBotServiceToken = (
|
|
req: AuthenticatedRequest,
|
|
_res: Response,
|
|
next: NextFunction
|
|
): void => {
|
|
const authHeader = req.headers['authorization'];
|
|
const token = authHeader && authHeader.split(' ')[1];
|
|
if (token) {
|
|
try {
|
|
const botDecoded = verifyBotServiceToken(token);
|
|
req.isBotToken = true;
|
|
req.organizationId = botDecoded.organizationId;
|
|
} catch {
|
|
// Not a bot-service token — authenticateToken will handle it normally.
|
|
}
|
|
}
|
|
next();
|
|
};
|
|
|
|
// Like authenticateToken but also accepts ?token= for file-download routes.
|
|
export const authenticateFileToken = async (
|
|
req: AuthenticatedRequest,
|
|
res: Response,
|
|
next: NextFunction
|
|
) => {
|
|
const authHeader = req.headers['authorization'];
|
|
const headerToken = authHeader && authHeader.split(' ')[1];
|
|
const queryToken = typeof req.query.token === 'string' ? req.query.token : null;
|
|
const cookieToken = (req as any).cookies?.access_token;
|
|
const token = cookieToken || headerToken || queryToken;
|
|
|
|
if (!token) {
|
|
return res.status(401).json({ error: 'Токен доступа отсутствует' });
|
|
}
|
|
|
|
try {
|
|
const decoded = verifyAccessToken(token);
|
|
const user = await withTenant(decoded.organizationId, () =>
|
|
storage.getUserWithOrganization(decoded.userId)
|
|
);
|
|
|
|
if (!user || !user.isActive) {
|
|
return res.status(401).json({ error: 'Пользователь не найден или заблокирован' });
|
|
}
|
|
if (user.organization && !user.organization.isActive) {
|
|
return res.status(403).json({ error: 'Доступ организации заблокирован' });
|
|
}
|
|
|
|
req.user = user;
|
|
req.organizationId = user.organizationId;
|
|
trackUserActivity(user.id);
|
|
|
|
if (_tenantCtx.getStore()) return next();
|
|
|
|
openTenantCtx(user.organizationId)
|
|
.then((handle) => {
|
|
handle.run(() => {
|
|
const guard = setTimeout(() => {
|
|
console.warn(`[POOL] Force-releasing tenant connection after 30s timeout (${req.method} ${req.path})`);
|
|
handle.release();
|
|
}, 30_000);
|
|
res.once('finish', () => { clearTimeout(guard); handle.release(); });
|
|
res.once('close', () => { clearTimeout(guard); handle.release(); });
|
|
next();
|
|
});
|
|
})
|
|
.catch((err) => next(err as Error));
|
|
} catch {
|
|
return res.status(403).json({ error: 'Недействительный токен' });
|
|
}
|
|
};
|
|
|
|
/**
|
|
* @deprecated Use requirePermission(...) instead.
|
|
* Kept for transitional compatibility during the role refactor.
|
|
*/
|
|
export const requireAdmin = (
|
|
req: AuthenticatedRequest,
|
|
res: Response,
|
|
next: NextFunction
|
|
) => {
|
|
const role = req.user?.appRole;
|
|
if (!req.user || role !== 'admin') {
|
|
return res.status(403).json({ error: 'Требуются права администратора' });
|
|
}
|
|
next();
|
|
};
|
|
|
|
// Permission cache (appRole slug -> string[] of permission codes)
|
|
let _permissionCache: Map<string, string[]> | null = null;
|
|
let _permissionCacheTs = 0;
|
|
const PERMISSION_CACHE_TTL = 60_000; // 1 minute
|
|
|
|
async function loadPermissionCache(): Promise<Map<string, string[]>> {
|
|
const now = Date.now();
|
|
if (_permissionCache && (now - _permissionCacheTs) < PERMISSION_CACHE_TTL) {
|
|
return _permissionCache;
|
|
}
|
|
const { db } = await import('../db');
|
|
const { appRoles: arTable, permissions: pTable, appRolePermissions: arpTable } = await import('@shared/schema');
|
|
const rows = await db.select({
|
|
appRoleSlug: arTable.slug,
|
|
permissionCode: pTable.code,
|
|
}).from(arpTable)
|
|
.innerJoin(arTable, eq(arpTable.appRoleId, arTable.id))
|
|
.innerJoin(pTable, eq(arpTable.permissionId, pTable.id));
|
|
const map = new Map<string, string[]>();
|
|
for (const r of rows) {
|
|
if (!map.has(r.appRoleSlug)) map.set(r.appRoleSlug, []);
|
|
map.get(r.appRoleSlug)!.push(r.permissionCode);
|
|
}
|
|
_permissionCache = map;
|
|
_permissionCacheTs = now;
|
|
return map;
|
|
}
|
|
|
|
export async function hasAppRolePermission(appRole: string, ...codes: string[]): Promise<boolean> {
|
|
const cache = await loadPermissionCache();
|
|
const perms = cache.get(appRole) || [];
|
|
return codes.some(c => perms.includes(c));
|
|
}
|
|
|
|
export const requirePermission = (...codes: string[]) => {
|
|
return async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
|
|
if (!req.user) {
|
|
return res.status(403).json({ error: 'Нет доступа' });
|
|
}
|
|
const role = req.user.appRole;
|
|
if (!role) {
|
|
return res.status(403).json({ error: 'Нет доступа' });
|
|
}
|
|
const has = await hasAppRolePermission(role, ...codes);
|
|
if (!has) {
|
|
return res.status(403).json({ error: 'Недостаточно прав' });
|
|
}
|
|
next();
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Requires either a global app-role permission OR admin-level access to the
|
|
* form identified by `formIdParam` (author or formAccessRules.accessLevel === 'admin').
|
|
* Use this for mutating form endpoints so that form admins can manage their own
|
|
* forms without needing the global `forms.manage` permission.
|
|
*/
|
|
export const requireFormAdminOrPermission = (permissionCode: string, formIdParam = 'id') => {
|
|
return async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
|
|
if (!req.user) {
|
|
return res.status(403).json({ error: 'Нет доступа' });
|
|
}
|
|
const role = req.user.appRole;
|
|
if (!role) {
|
|
return res.status(403).json({ error: 'Нет доступа' });
|
|
}
|
|
const hasGlobal = await hasAppRolePermission(role, permissionCode);
|
|
if (hasGlobal) {
|
|
return next();
|
|
}
|
|
|
|
const rawFormId = req.params[formIdParam];
|
|
const formId = typeof rawFormId === 'string' ? parseInt(rawFormId, 10) : NaN;
|
|
if (!isNaN(formId) && req.organizationId) {
|
|
const isFormAdmin = await storage.canUserAccessForm(req.user.id, formId, req.organizationId, 'admin');
|
|
if (isFormAdmin) {
|
|
return next();
|
|
}
|
|
}
|
|
|
|
return res.status(403).json({ error: 'Недостаточно прав' });
|
|
};
|
|
};
|
|
|
|
export const requireActiveUser = (
|
|
req: AuthenticatedRequest,
|
|
res: Response,
|
|
next: NextFunction
|
|
) => {
|
|
if (!req.user || !req.user.isActive) {
|
|
return res.status(403).json({ error: 'Аккаунт заблокирован' });
|
|
}
|
|
next();
|
|
};
|
|
|
|
// Validates superadmin JWT and opens a per-request SA-scoped connection
|
|
// (SET LOCAL app.is_superadmin='true') so all storage queries in any
|
|
// superadmin route automatically bypass tenant RLS.
|
|
export const requireSuperAdmin = async (
|
|
req: SuperAdminRequest,
|
|
res: Response,
|
|
next: NextFunction
|
|
): Promise<void> => {
|
|
const authHeader = req.headers['authorization'];
|
|
const headerToken = authHeader && authHeader.split(' ')[1];
|
|
const cookieToken = (req as any).cookies?.superadmin_token;
|
|
const token = headerToken || cookieToken;
|
|
|
|
if (!token) {
|
|
res.status(401).json({ error: 'Токен суперадмина отсутствует' });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const payload = verifySuperAdminToken(token);
|
|
const admin = await storage.getSuperAdminById(payload.superAdminId);
|
|
if (!admin) {
|
|
res.status(403).json({ error: 'Суперадмин не найден или был удалён' });
|
|
return;
|
|
}
|
|
req.superAdmin = { id: admin.id, email: admin.email };
|
|
} catch {
|
|
res.status(403).json({ error: 'Недействительный токен суперадмина' });
|
|
return;
|
|
}
|
|
|
|
if (req.headers.accept?.includes('text/event-stream')) {
|
|
next();
|
|
return;
|
|
}
|
|
|
|
openSuperAdminCtx()
|
|
.then((handle) => {
|
|
handle.run(() => {
|
|
const guard = setTimeout(() => {
|
|
console.warn(`[POOL] Force-releasing superadmin connection after 30s timeout (${req.method} ${req.path})`);
|
|
handle.release();
|
|
}, 30_000);
|
|
res.once('finish', () => { clearTimeout(guard); handle.release(); });
|
|
res.once('close', () => { clearTimeout(guard); handle.release(); });
|
|
next();
|
|
});
|
|
})
|
|
.catch((err) => next(err as Error));
|
|
};
|