feat(api): implement chestny-znak web app with True API integration

- Fastify backend with auth (CryptoPro CAdES-BES), KM check, UPD parsing
- React frontend with auth page, code check, XML upload, results table
- Client-side signing via crypto-pro 2.3.0 (browser plugin)
- Token caching with auto-refresh, retry with exponential backoff
- Input validation schemas, file size limits, global 401 interceptor
- Docker compose with backend + frontend (nginx) services
This commit is contained in:
kislovdm
2026-07-08 16:53:35 +03:00
parent d2819ff03c
commit 70eac33c8c
19 changed files with 501 additions and 240 deletions
+26 -2
View File
@@ -2,11 +2,27 @@ import type { AuthStatus, CheckCodesResponse, UpdUploadResponse } from '../types
const BASE_URL = ''
let onUnauthorized: (() => void) | null = null
export function setOnUnauthorized(cb: () => void): void {
onUnauthorized = cb
}
async function request<T>(url: string, options?: RequestInit): Promise<T> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
if (options?.headers) {
Object.assign(headers, options.headers)
}
const res = await fetch(`${BASE_URL}${url}`, {
headers: { 'Content-Type': 'application/json', ...options?.headers },
headers,
...options,
})
if (res.status === 401 && onUnauthorized) {
onUnauthorized()
}
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }))
throw new Error(err.error || `HTTP ${res.status}`)
@@ -19,7 +35,7 @@ export async function getAuthKey(): Promise<{ uuid: string; data: string }> {
}
export async function signIn(uuid: string, data: string): Promise<{ token: string }> {
return request('/auth/signin', {
return request('/auth/simpleSignIn', {
method: 'POST',
body: JSON.stringify({ uuid, data }),
})
@@ -29,6 +45,10 @@ export async function getAuthStatus(): Promise<AuthStatus> {
return request('/auth/status')
}
export async function logout(): Promise<void> {
await request('/auth/logout', { method: 'POST' })
}
export async function checkCodesPublic(codes: string[]): Promise<CheckCodesResponse> {
return request('/api/check-codes/public', {
method: 'POST',
@@ -52,6 +72,10 @@ export async function uploadUpd(file: File): Promise<UpdUploadResponse> {
body: formData,
})
if (res.status === 401 && onUnauthorized) {
onUnauthorized()
}
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }))
throw new Error(err.error || `HTTP ${res.status}`)
+59
View File
@@ -0,0 +1,59 @@
import {
getUserCertificates,
createAttachedSignature,
} from 'crypto-pro'
export interface CertInfo {
name: string
thumbprint: string
issuer: string
validTo: string
valid: boolean
}
export async function isCadesPluginAvailable(): Promise<boolean> {
try {
await getUserCertificates(true)
return true
} catch (err) {
if (err instanceof Error && (
err.message.includes('undefined') || err.message.includes('not defined')
)) return false
throw err
}
}
export async function listCertificates(): Promise<CertInfo[]> {
const certs = await getUserCertificates(true)
const now = Date.now()
return certs.map(c => {
const validTo = new Date(c.validTo)
return {
name: c.name || 'Без имени',
thumbprint: c.thumbprint,
issuer: c.issuerName,
validTo: c.validTo,
valid: validTo.getTime() > now,
}
})
}
export async function signWithCadesPlugin(dataBase64: string, thumbprint?: string): Promise<string> {
const certs = await getUserCertificates(true)
const now = Date.now()
const validCerts = certs.filter(c => c.name && new Date(c.validTo).getTime() > now)
if (validCerts.length === 0) {
throw new Error('Нет доступных сертификатов с действующим сроком')
}
let tp = thumbprint
if (!tp) {
tp = validCerts[0].thumbprint
} else {
const found = validCerts.find(c => c.thumbprint.toUpperCase() === tp!.toUpperCase())
if (!found) throw new Error(`Сертификат с отпечатком ${tp} не найден или просрочен`)
}
return createAttachedSignature(tp, dataBase64)
}