This commit is contained in:
Your Name
2026-07-08 15:10:29 +00:00
parent d63ed3e7ef
commit 8f0f0cd36e
22 changed files with 1129 additions and 1032 deletions
+2 -1
View File
@@ -1,5 +1,6 @@
PORT=3001
HOST=0.0.0.0
TRUE_API_URL=https://markirovka.sandbox.crptech.ru/api/v3/true-api
SANDBOX_URL=https://markirovka.sandbox.crptech.ru/api/v3/true-api
PROD_URL=https://markirovka.crpt.ru/api/v3/true-api
PUBLIC_CHECK_URL=https://mobile.api.crpt.ru/mobile/check
CORS_ORIGIN=*
+13 -2
View File
@@ -1,3 +1,16 @@
export type TrueApiEnv = 'sandbox' | 'prod'
export const envUrls: Record<TrueApiEnv, { baseUrl: string; publicCheckUrl: string }> = {
sandbox: {
baseUrl: process.env.SANDBOX_URL || 'https://markirovka.sandbox.crptech.ru/api/v3/true-api',
publicCheckUrl: process.env.SANDBOX_PUBLIC_CHECK_URL || 'https://mobile.api.crpt.ru/mobile/check',
},
prod: {
baseUrl: process.env.PROD_URL || 'https://markirovka.crpt.ru/api/v3/true-api',
publicCheckUrl: process.env.PROD_PUBLIC_CHECK_URL || 'https://mobile.api.crpt.ru/mobile/check',
},
}
export const config = {
port: parseInt(process.env.PORT || '3001', 10),
host: process.env.HOST || '0.0.0.0',
@@ -5,8 +18,6 @@ export const config = {
corsOrigin: process.env.CORS_ORIGIN || '*',
trueApi: {
baseUrl: process.env.TRUE_API_URL || 'https://markirovka.sandbox.crptech.ru/api/v3/true-api',
publicCheckUrl: process.env.PUBLIC_CHECK_URL || 'https://mobile.api.crpt.ru/mobile/check',
authPath: '/auth',
cisesInfoPath: '/cises/info',
codesCheckPath: '/codes/check',
+2
View File
@@ -5,6 +5,7 @@ import { config } from './config.js'
import { authRoutes } from './routes/auth.js'
import { cisesRoutes } from './routes/cises.js'
import { updRoutes } from './routes/upd.js'
import { plansRoutes } from './routes/plans.js'
const app = Fastify({ logger: true })
@@ -20,6 +21,7 @@ await app.register(multipart, {
await app.register(authRoutes)
await app.register(cisesRoutes)
await app.register(updRoutes)
await app.register(plansRoutes)
app.get('/health', async () => ({ status: 'ok', timestamp: new Date().toISOString() }))
+57 -21
View File
@@ -1,6 +1,15 @@
import type { FastifyInstance } from 'fastify'
import { getAuthKey, signIn, clearToken, isAuthenticated } from '../services/auth-service.js'
import { getTokenTTL } from '../services/token-cache.js'
import type { TrueApiEnv } from '../config.js'
import { getAuthKey, signIn, clearToken, getAuthStatus } from '../services/auth-service.js'
import { AppError, extractDebugInfo } from '../services/cises-service.js'
const VALID_ENVS = ['sandbox', 'prod']
function parseEnv(query: Record<string, string>): TrueApiEnv {
const env = query.env
if (env && VALID_ENVS.includes(env)) return env as TrueApiEnv
return 'sandbox'
}
const signInSchema = {
body: {
@@ -14,28 +23,55 @@ const signInSchema = {
}
export async function authRoutes(app: FastifyInstance): Promise<void> {
app.get('/auth/key', async () => {
const authKey = await getAuthKey()
return { uuid: authKey.uuid, data: authKey.data }
})
app.post<{ Body: { uuid: string; data: string } }>('/auth/simpleSignIn', { schema: signInSchema }, async (request) => {
const { uuid, data } = request.body
const result = await signIn({ uuid, data })
return result
})
app.get('/auth/status', async () => {
const authenticated = isAuthenticated()
const ttl = getTokenTTL()
return {
authenticated,
tokenExpiresAt: ttl ? new Date(ttl).toISOString() : null,
app.get('/auth/key', async (request, reply) => {
const env = parseEnv(request.query as Record<string, string>)
try {
const authKey = await getAuthKey(env)
return { uuid: authKey.uuid, data: authKey.data }
} catch (err) {
if (err instanceof AppError) {
return reply.status(err.statusCode).send({
error: err.message,
debugInfo: err.debugInfo,
})
}
const debugInfo = extractDebugInfo(err)
return reply.status(502).send({
error: 'Failed to get auth key from True API',
debugInfo,
})
}
})
app.post('/auth/logout', async () => {
clearToken()
app.post<{ Body: { uuid: string; data: string } }>('/auth/simpleSignIn', { schema: signInSchema }, async (request, reply) => {
const env = parseEnv(request.query as Record<string, string>)
try {
const { uuid, data } = request.body
const result = await signIn(env, { uuid, data })
return result
} catch (err) {
if (err instanceof AppError) {
return reply.status(err.statusCode).send({
error: err.message,
debugInfo: err.debugInfo,
})
}
const debugInfo = extractDebugInfo(err)
return reply.status(502).send({
error: 'Failed to sign in to True API',
debugInfo,
})
}
})
app.get('/auth/status', async (request) => {
const env = parseEnv(request.query as Record<string, string>)
return getAuthStatus(env)
})
app.post('/auth/logout', async (request) => {
const env = parseEnv(request.query as Record<string, string>)
clearToken(env)
return { success: true }
})
}
+17 -3
View File
@@ -1,7 +1,16 @@
import type { FastifyInstance } from 'fastify'
import type { TrueApiEnv } from '../config.js'
import { checkCodesPublic, checkCodesAuth, AppError } from '../services/cises-service.js'
import type { CheckCodesRequest } from '../types/index.js'
const VALID_ENVS = ['sandbox', 'prod']
function parseEnv(query: Record<string, string>): TrueApiEnv {
const env = query.env
if (env && VALID_ENVS.includes(env)) return env as TrueApiEnv
return 'sandbox'
}
const checkCodesSchema = {
body: {
type: 'object',
@@ -19,15 +28,20 @@ const checkCodesSchema = {
export async function cisesRoutes(app: FastifyInstance): Promise<void> {
app.post<{ Body: CheckCodesRequest }>('/api/check-codes/public', { schema: checkCodesSchema }, async (request) => {
return checkCodesPublic(request.body)
const env = parseEnv(request.query as Record<string, string>)
return checkCodesPublic(env, request.body)
})
app.post<{ Body: CheckCodesRequest }>('/api/check-codes/auth', { schema: checkCodesSchema }, async (request, reply) => {
const env = parseEnv(request.query as Record<string, string>)
try {
return await checkCodesAuth(request.body)
return await checkCodesAuth(env, request.body)
} catch (err) {
if (err instanceof AppError) {
return reply.status(err.statusCode).send({ error: err.message })
return reply.status(err.statusCode).send({
error: err.message,
debugInfo: err.debugInfo,
})
}
throw err
}
+41
View File
@@ -0,0 +1,41 @@
import { readdir, readFile, stat } from 'node:fs/promises'
import { resolve, extname } from 'node:path'
import type { FastifyInstance } from 'fastify'
const PLANS_DIR = resolve(import.meta.dirname || __dirname, '../../plans')
export async function plansRoutes(app: FastifyInstance): Promise<void> {
app.get('/api/plans', async () => {
const files = await readdir(PLANS_DIR)
const mdFiles: { name: string; path: string; size: number }[] = []
for (const file of files) {
if (extname(file) !== '.md') continue
const fullPath = resolve(PLANS_DIR, file)
const stats = await stat(fullPath)
mdFiles.push({ name: file, path: file, size: stats.size })
}
mdFiles.sort((a, b) => b.name.localeCompare(a.name))
return mdFiles
})
app.get<{ Params: { filename: string } }>('/api/plans/:filename', async (request, reply) => {
const { filename } = request.params
if (extname(filename) !== '.md' || filename.includes('..') || filename.includes('/')) {
return reply.status(400).send({ error: 'Invalid filename' })
}
const fullPath = resolve(PLANS_DIR, filename)
try {
await stat(fullPath)
} catch {
return reply.status(404).send({ error: 'File not found' })
}
const content = await readFile(fullPath, 'utf-8')
return { name: filename, content }
})
}
+15 -2
View File
@@ -1,9 +1,19 @@
import type { FastifyInstance } from 'fastify'
import type { TrueApiEnv } from '../config.js'
import { parseUpdXml } from '../services/upd-parser.js'
import { checkCodesAuth, AppError } from '../services/cises-service.js'
const VALID_ENVS = ['sandbox', 'prod']
function parseEnv(query: Record<string, string>): TrueApiEnv {
const env = query.env
if (env && VALID_ENVS.includes(env)) return env as TrueApiEnv
return 'sandbox'
}
export async function updRoutes(app: FastifyInstance): Promise<void> {
app.post('/api/upload-upd', async (request, reply) => {
const env = parseEnv(request.query as Record<string, string>)
const data = await request.file()
if (!data) {
return reply.status(400).send({ error: 'No file uploaded' })
@@ -25,7 +35,7 @@ export async function updRoutes(app: FastifyInstance): Promise<void> {
}
try {
const checkResults = await checkCodesAuth({ codes: parsed.codes })
const checkResults = await checkCodesAuth(env, { codes: parsed.codes })
return {
document: {
@@ -40,7 +50,10 @@ export async function updRoutes(app: FastifyInstance): Promise<void> {
}
} catch (err) {
if (err instanceof AppError) {
return reply.status(err.statusCode).send({ error: err.message })
return reply.status(err.statusCode).send({
error: err.message,
debugInfo: err.debugInfo,
})
}
throw err
}
+47 -23
View File
@@ -1,34 +1,58 @@
import got from 'got'
import { config } from '../config.js'
import { getToken, setToken, invalidateToken } from './token-cache.js'
import { config, envUrls } from '../config.js'
import type { TrueApiEnv } from '../config.js'
import { getToken, setToken, invalidateToken, hasToken, getTokenTTL } from './token-cache.js'
import { AppError, extractDebugInfo } from './cises-service.js'
import type { AuthKeyResponse, AuthSignInRequest, AuthSignInResponse } from '../types/index.js'
const authClient = got.extend({
prefixUrl: config.trueApi.baseUrl + config.trueApi.authPath,
responseType: 'json',
})
export async function getAuthKey(): Promise<AuthKeyResponse> {
const { body } = await authClient.get<AuthKeyResponse>('key')
return body
}
export async function signIn(body: AuthSignInRequest): Promise<AuthSignInResponse> {
const { body: response } = await authClient.post<AuthSignInResponse>('simpleSignIn', {
json: body,
function authClient(env: TrueApiEnv) {
return got.extend({
prefixUrl: envUrls[env].baseUrl + config.trueApi.authPath,
responseType: 'json',
})
setToken(response.token)
return response
}
export function getCachedToken(): string | undefined {
return getToken()
export async function getAuthKey(env: TrueApiEnv): Promise<AuthKeyResponse> {
try {
const { body } = await authClient(env).get<AuthKeyResponse>('key')
return body
} catch (err) {
const info = extractDebugInfo(err)
const status = info?.response.statusCode || 502
throw new AppError(info?.response.body ? String(info.response.body) : 'Failed to get auth key', status, info)
}
}
export function clearToken(): void {
invalidateToken()
export async function signIn(env: TrueApiEnv, body: AuthSignInRequest): Promise<AuthSignInResponse> {
try {
const { body: response } = await authClient(env).post<AuthSignInResponse>('simpleSignIn', {
json: body,
})
setToken(env, response.token)
return response
} catch (err) {
const info = extractDebugInfo(err)
const status = info?.response.statusCode || 502
throw new AppError(info?.response.body ? String(info.response.body) : 'Failed to sign in', status, info)
}
}
export function isAuthenticated(): boolean {
return !!getToken()
export function getCachedToken(env: TrueApiEnv): string | undefined {
return getToken(env)
}
export function clearToken(env: TrueApiEnv): void {
invalidateToken(env)
}
export function isAuthenticated(env: TrueApiEnv): boolean {
return hasToken(env)
}
export function getAuthStatus(env: TrueApiEnv): { authenticated: boolean; tokenExpiresAt: string | null } {
const ttl = getTokenTTL(env)
return {
authenticated: hasToken(env),
tokenExpiresAt: ttl ? new Date(ttl).toISOString() : null,
}
}
+77 -21
View File
@@ -1,5 +1,6 @@
import got from 'got'
import { config } from '../config.js'
import { config, envUrls } from '../config.js'
import type { TrueApiEnv } from '../config.js'
import { getCachedToken, clearToken } from './auth-service.js'
import type {
CisInfoResponse,
@@ -9,12 +10,14 @@ import type {
SingleCodeResult,
} from '../types/index.js'
const apiClient = got.extend({
prefixUrl: config.trueApi.baseUrl,
responseType: 'json',
})
function apiClient(env: TrueApiEnv) {
return got.extend({
prefixUrl: envUrls[env].baseUrl,
responseType: 'json',
})
}
export async function checkCodesPublic(body: CheckCodesRequest): Promise<CheckCodesResponse> {
export async function checkCodesPublic(env: TrueApiEnv, body: CheckCodesRequest): Promise<CheckCodesResponse> {
const results: SingleCodeResult[] = []
const queue = [...body.codes]
@@ -23,7 +26,7 @@ export async function checkCodesPublic(body: CheckCodesRequest): Promise<CheckCo
const code = queue.shift()!
try {
const { body: response } = await got.get<PublicCheckResponse>(
`${config.trueApi.publicCheckUrl}?code=${encodeURIComponent(code)}`,
`${envUrls[env].publicCheckUrl}?code=${encodeURIComponent(code)}`,
{ responseType: 'json', timeout: { request: 10000 } }
)
results.push({
@@ -50,21 +53,21 @@ export async function checkCodesPublic(body: CheckCodesRequest): Promise<CheckCo
return buildResponse(results)
}
function getToken(): string {
const token = getCachedToken()
function getToken(env: TrueApiEnv): string {
const token = getCachedToken(env)
if (!token) throw new AppError('Not authenticated', 401)
return token
}
export async function checkCodesAuth(body: CheckCodesRequest): Promise<CheckCodesResponse> {
const token = getToken()
export async function checkCodesAuth(env: TrueApiEnv, body: CheckCodesRequest): Promise<CheckCodesResponse> {
const token = getToken(env)
const results: SingleCodeResult[] = []
try {
await processBatch(body.codes, token, results, 0)
await processBatch(env, body.codes, token, results, 0)
} catch (err) {
if (err instanceof AppError && err.statusCode === 401) {
clearToken()
clearToken(env)
throw err
}
for (const code of body.codes) {
@@ -77,8 +80,58 @@ export async function checkCodesAuth(body: CheckCodesRequest): Promise<CheckCode
return buildResponse(results)
}
export interface ApiDebugInfo {
request: {
method: string
url: string
headers: Record<string, string>
body: unknown
}
response: {
statusCode: number
headers: Record<string, string>
body: unknown
}
}
export function extractDebugInfo(err: unknown): ApiDebugInfo | undefined {
if (!err || typeof err !== 'object') return undefined
const gotErr = err as Record<string, unknown>
const response = gotErr.response as Record<string, unknown> | undefined
const options = gotErr.options as Record<string, unknown> | undefined
if (!response) return undefined
const reqHeaders = options?.headers as Record<string, string> | undefined
const cleanHeaders: Record<string, string> = {}
if (reqHeaders) {
for (const [k, v] of Object.entries(reqHeaders)) {
if (k.toLowerCase() === 'authorization') {
cleanHeaders[k] = 'Bearer ***'
} else {
cleanHeaders[k] = String(v)
}
}
}
const resHeaders = response.headers as Record<string, string> | undefined
return {
request: {
method: String(options?.method || 'POST'),
url: String(response.requestUrl || (options?.url?.toString()) || 'unknown'),
headers: cleanHeaders,
body: options?.json || options?.body || null,
},
response: {
statusCode: Number(response.statusCode),
headers: resHeaders || {},
body: response.body || null,
},
}
}
async function processBatch(
codes: string[], token: string, results: SingleCodeResult[], batchIndex: number
env: TrueApiEnv, codes: string[], token: string, results: SingleCodeResult[], batchIndex: number
): Promise<void> {
if (batchIndex >= codes.length) return
@@ -86,16 +139,16 @@ async function processBatch(
const end = Math.min(start + config.trueApi.batchSize, codes.length)
const batch = codes.slice(start, end)
await retryOnError(batch, token, results)
await retryOnError(env, batch, token, results)
await processBatch(codes, token, results, end)
await processBatch(env, codes, token, results, end)
}
async function retryOnError(
batch: string[], token: string, results: SingleCodeResult[], attempt = 0
env: TrueApiEnv, batch: string[], token: string, results: SingleCodeResult[], attempt = 0
): Promise<void> {
try {
const { body: response } = await apiClient.post<CisInfoResponse>(
const { body: response } = await apiClient(env).post<CisInfoResponse>(
config.trueApi.cisesInfoPath,
{
json: { cisList: batch },
@@ -118,14 +171,15 @@ async function retryOnError(
}
} catch (err) {
if (is401(err)) {
throw new AppError('Token expired', 401)
const debugInfo = extractDebugInfo(err)
throw new AppError('Token expired', 401, debugInfo)
}
const isRetryable = isRetryableError(err)
if (isRetryable && attempt < config.trueApi.maxRetries) {
const delay = Math.pow(2, attempt) * 500
await sleep(delay)
return retryOnError(batch, token, results, attempt + 1)
return retryOnError(env, batch, token, results, attempt + 1)
}
throw err
@@ -151,9 +205,11 @@ function sleep(ms: number): Promise<void> {
export class AppError extends Error {
statusCode: number
constructor(message: string, statusCode: number) {
debugInfo?: ApiDebugInfo
constructor(message: string, statusCode: number, debugInfo?: ApiDebugInfo) {
super(message)
this.statusCode = statusCode
this.debugInfo = debugInfo
}
}
+15 -12
View File
@@ -1,29 +1,32 @@
import NodeCache from 'node-cache'
import { config } from '../config.js'
import type { TrueApiEnv } from '../config.js'
const cache = new NodeCache({
stdTTL: config.trueApi.tokenTTL / 1000,
checkperiod: 600,
})
const TOKEN_KEY = 'true_api_token'
export function getToken(): string | undefined {
return cache.get<string>(TOKEN_KEY)
function tokenKey(env: TrueApiEnv): string {
return `true_api_token_${env}`
}
export function setToken(token: string): void {
cache.set(TOKEN_KEY, token)
export function getToken(env: TrueApiEnv): string | undefined {
return cache.get<string>(tokenKey(env))
}
export function hasToken(): boolean {
return cache.has(TOKEN_KEY)
export function setToken(env: TrueApiEnv, token: string): void {
cache.set(tokenKey(env), token)
}
export function getTokenTTL(): number | undefined {
return cache.getTtl(TOKEN_KEY)
export function hasToken(env: TrueApiEnv): boolean {
return cache.has(tokenKey(env))
}
export function invalidateToken(): void {
cache.del(TOKEN_KEY)
export function getTokenTTL(env: TrueApiEnv): number | undefined {
return cache.getTtl(tokenKey(env))
}
export function invalidateToken(env: TrueApiEnv): void {
cache.del(tokenKey(env))
}