feat(censorcheck): добавить статус блокировок и launcher curl | bash
Docker / build (push) Failing after 25s

Прогон с VPS через HMAC-токен матчится к существующим серверам; UI /blocking показывает текущие проверки и историю.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-08-22 10:52:22 +07:00
co-authored by Cursor
parent 9e0311b53a
commit 5c43d88f1a
52 changed files with 3847 additions and 53 deletions
+1
View File
@@ -16,6 +16,7 @@
"@cfdm/shared": "workspace:*",
"@fastify/cors": "^11.0.1",
"@fastify/jwt": "^10.2.0",
"@fastify/rate-limit": "^11.2.0",
"@fastify/sensible": "^6.0.3",
"@fastify/static": "^8.2.0",
"better-sqlite3": "^11.10.0",
+7
View File
@@ -0,0 +1,7 @@
Vendor pin of https://github.com/vernette/censorcheck
- File: `censorcheck.sh`
- Commit: `12c5839` (2026-08-11)
- License: MIT (see upstream repository)
Do not fetch this script from GitHub at runtime — some probe networks block github.com.
File diff suppressed because it is too large Load Diff
+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env bash
# VPS Tracker launcher for vernette/censorcheck (vendor pin 12c5839).
# Fetched via: curl -fsSL https://vt.shnt.top/cc | bash
set -euo pipefail
VT_API_URL="${VT_API_URL:-__VT_API_URL__}"
VT_INGEST_TOKEN="${VT_INGEST_TOKEN:-__VT_INGEST_TOKEN__}"
LAUNCHER_VERSION="1"
VENDOR_SHA="12c5839"
trap 'exit 130' INT
log() { printf '%s\n' "$*"; }
die() { printf 'error: %s\n' "$*" >&2; exit 1; }
require_cmd() {
command -v "$1" >/dev/null 2>&1 || die "Нужна команда '$1' (установите пакет и повторите)."
}
require_cmd curl
require_cmd jq
require_cmd bash
detect_public_ip() {
local ip=""
ip="$(curl -fsS --max-time 8 https://api.ipify.org 2>/dev/null || true)"
if [ -z "$ip" ]; then
ip="$(curl -fsS --max-time 8 https://icanhazip.com 2>/dev/null | tr -d '[:space:]' || true)"
fi
if [ -z "$ip" ] && command -v dig >/dev/null 2>&1; then
ip="$(dig +short myip.opendns.com @resolver1.opendns.com 2>/dev/null | tr -d '[:space:]' || true)"
fi
printf '%s' "$ip"
}
write_vendor() {
local dest="$1"
if [ -n "${CENSORCHECK_VENDOR_B64:-}" ]; then
printf '%s' "$CENSORCHECK_VENDOR_B64" | base64 -d >"$dest" 2>/dev/null \
|| printf '%s' "$CENSORCHECK_VENDOR_B64" | base64 -D >"$dest"
return 0
fi
curl -fsSL --max-time 30 "${VT_API_URL}/cc/vendor" -o "$dest"
}
uuid4() {
if [ -r /proc/sys/kernel/random/uuid ]; then
tr -d '[:space:]' </proc/sys/kernel/random/uuid
return
fi
if command -v python3 >/dev/null 2>&1; then
python3 -c 'import uuid; print(uuid.uuid4())'
return
fi
openssl rand -hex 16
}
VT_API_URL="${VT_API_URL%/}"
[ -n "$VT_API_URL" ] || die "VT_API_URL пуст"
[ -n "$VT_INGEST_TOKEN" ] || die "VT_INGEST_TOKEN пуст"
TMPDIR="$(mktemp -d /tmp/vt-censorcheck.XXXXXX)"
cleanup() { rm -rf "$TMPDIR"; }
trap 'cleanup; exit 130' INT
trap 'cleanup' EXIT
VENDOR="$TMPDIR/censorcheck.sh"
write_vendor "$VENDOR"
chmod +x "$VENDOR"
PUBLIC_IP="$(detect_public_ip)"
[ -n "$PUBLIC_IP" ] || die "Не удалось определить публичный IP"
RUN_ID="$(uuid4)"
[ -n "$RUN_ID" ] || die "Не удалось сгенерировать runId"
log "censorcheck launcher ${LAUNCHER_VERSION} (vendor ${VENDOR_SHA})"
log "probe IP: ${PUBLIC_IP}"
log "runId: ${RUN_ID}"
set +e
RAW_JSON="$(bash "$VENDOR" --mode both --json --no-header --no-dns 2>/tmp/vt-censorcheck-err.$$)"
CC_EXIT=$?
set -e
if [ "$CC_EXIT" -ne 0 ]; then
log "censorcheck завершился с кодом ${CC_EXIT}" >&2
if [ -s /tmp/vt-censorcheck-err.$$ ]; then
cat /tmp/vt-censorcheck-err.$$ >&2 || true
fi
rm -f /tmp/vt-censorcheck-err.$$
exit 1
fi
rm -f /tmp/vt-censorcheck-err.$$
PAYLOAD="$TMPDIR/payload.json"
printf '%s' "$RAW_JSON" | jq --arg runId "$RUN_ID" --arg ip "$PUBLIC_IP" --arg lv "$LAUNCHER_VERSION" '
{
schemaVersion: 1,
runId: $runId,
probe: { publicIp: $ip },
launcherVersion: $lv,
censorcheck: {
version: ((.version | tostring) // "1"),
mode: "both"
},
results: ((.results // []) | map({
service: .service,
raw: .
}))
}
' >"$PAYLOAD"
FALLBACK="/tmp/vt-censorcheck-${RUN_ID}.json"
set +e
RESP="$(curl -fsS --max-time 60 -X POST "${VT_API_URL}/api/integrations/censorcheck/runs" \
-H "Authorization: Bearer ${VT_INGEST_TOKEN}" \
-H "Content-Type: application/json" \
--data-binary @"$PAYLOAD")"
POST_EXIT=$?
set -e
if [ "$POST_EXIT" -ne 0 ]; then
cp "$PAYLOAD" "$FALLBACK"
log "API недоступен (curl exit ${POST_EXIT}). JSON сохранён: ${FALLBACK}" >&2
exit 2
fi
CHECK_ID="$(printf '%s' "$RESP" | jq -r '.id // empty')"
MATCHED="$(printf '%s' "$RESP" | jq -r '.matchedVpsId // "unmatched"')"
if [ -z "$CHECK_ID" ]; then
cp "$PAYLOAD" "$FALLBACK"
log "Некорректный ответ API. JSON сохранён: ${FALLBACK}" >&2
log "$RESP" >&2
exit 2
fi
log "Check ID: ${CHECK_ID}"
log "VPS: ${MATCHED}"
exit 0
+14
View File
@@ -2,6 +2,7 @@ import Fastify from 'fastify'
import cors from '@fastify/cors'
import sensible from '@fastify/sensible'
import staticPlugin from '@fastify/static'
import rateLimit from '@fastify/rate-limit'
import { existsSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -25,6 +26,8 @@ import { dashboardRoutes } from './routes/dashboard.js'
import { auditRoutes } from './routes/audit.js'
import { notificationsRoutes } from './routes/notifications.js'
import { integrationsCfdmRoutes } from './routes/integrations-cfdm.js'
import { censorcheckRoutes } from './routes/censorcheck.js'
import { launcherRoutes } from './routes/launcher.js'
import { appSwitcherRoutes } from './routes/app-switcher.js'
import { startScheduler } from './services/scheduler.js'
import { authPlugin } from './plugins/auth.js'
@@ -43,18 +46,28 @@ export async function buildApp(opts: BuildAppOptions = {}) {
if (opts.dbPath) process.env.DB_PATH = opts.dbPath
getDb()
const trustProxy =
process.env.TRUST_PROXY === '1' ||
process.env.TRUST_PROXY === 'true' ||
(process.env.NODE_ENV === 'production' &&
process.env.TRUST_PROXY !== '0' &&
process.env.TRUST_PROXY !== 'false')
const app = Fastify({
logger: {
level: process.env.LOG_LEVEL ?? 'info',
},
trustProxy,
})
await app.register(cors, { origin: true })
await app.register(sensible)
await app.register(rateLimit, { global: false })
await app.register(authPlugin)
await app.register(spacePlugin)
app.get('/health', async () => ({ ok: true }))
await app.register(launcherRoutes)
await app.register(spacesRoutes)
await app.register(portalUsersRoutes)
@@ -75,6 +88,7 @@ export async function buildApp(opts: BuildAppOptions = {}) {
await app.register(auditRoutes)
await app.register(notificationsRoutes)
await app.register(integrationsCfdmRoutes)
await app.register(censorcheckRoutes)
await app.register(appSwitcherRoutes)
const staticDir = opts.staticDir ?? join(__dirname, '..', '..', 'web', 'dist')
+1
View File
@@ -20,6 +20,7 @@ describe('hasPermission hierarchy', () => {
describe('permissionForRequest', () => {
it('maps vps CRUD', () => {
expect(permissionForRequest('GET', '/api/vps')).toBe('vps:vps:read')
expect(permissionForRequest('GET', '/api/censorcheck/current')).toBe('vps:vps:read')
expect(permissionForRequest('POST', '/api/vps')).toBe('vps:vps:write')
expect(permissionForRequest('DELETE', '/api/vps/abc')).toBe('vps:vps:write')
})
+2 -1
View File
@@ -51,7 +51,8 @@ const RULES: Rule[] = [
p.startsWith('/api/vps/') ||
p.startsWith('/api/projects') ||
p.startsWith('/api/topology') ||
p.startsWith('/api/data'),
p.startsWith('/api/data') ||
p.startsWith('/api/censorcheck'),
permission: 'vps:vps:read',
},
{
+2
View File
@@ -74,7 +74,9 @@ function isPublicPath(url: string): boolean {
const path = url.split('?')[0] ?? url
if (path === '/health' || path === '/ready') return true
if (path === '/api/auth/config') return true
if (path === '/cc' || path.startsWith('/cc/')) return true
if (path.startsWith('/api/integrations/cfdm')) return true
if (path.startsWith('/api/integrations/censorcheck')) return true
return false
}
+163
View File
@@ -0,0 +1,163 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { closeDb, MAIN_SPACE_ID, runWithSpace } from '@cfdm/db'
import { vpsRepository } from '@cfdm/db/repositories/vps'
import { resetTestDb, seedTestProvider, seedTestProviderAccount } from '@cfdm/db/test-setup'
import { buildApp } from '../index.js'
import { mintIngestToken } from '../services/censorcheck/ingest-token.js'
const SECRET = 'test-censorcheck-ingest-secret'
function ingestPayload(overrides: Record<string, unknown> = {}) {
return {
schemaVersion: 1,
runId: '11111111-1111-4111-8111-111111111111',
probe: { publicIp: '203.0.113.10' },
launcherVersion: '1',
censorcheck: { version: '1', mode: 'both' },
results: [
{
service: 'youtube.com',
raw: { https: { ipv4: { status: 200 } } },
},
],
...overrides,
}
}
describe('censorcheck ingest + reads', () => {
let app: Awaited<ReturnType<typeof buildApp>>
beforeEach(async () => {
process.env.CENSORCHECK_INGEST_SECRET = SECRET
process.env.CENSORCHECK_RATE_LIMIT = '0'
process.env.TRUST_PROXY = '1'
resetTestDb()
seedTestProvider('p1')
seedTestProviderAccount('a1', 'p1')
app = await buildApp()
})
afterEach(async () => {
await app.close()
closeDb()
delete process.env.TRUST_PROXY
})
async function post(body: unknown, token?: string, headers: Record<string, string> = {}) {
return app.inject({
method: 'POST',
url: '/api/integrations/censorcheck/runs',
headers: {
authorization: `Bearer ${token ?? mintIngestToken(SECRET)}`,
'content-type': 'application/json',
...headers,
},
payload: body as object,
})
}
it('отклоняет запрос без токена', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/integrations/censorcheck/runs',
payload: ingestPayload(),
})
expect(res.statusCode).toBe(401)
})
it('отклоняет просроченный токен', async () => {
const token = mintIngestToken(SECRET, 60, Date.now() - 120_000)
const res = await post(ingestPayload(), token)
expect(res.statusCode).toBe(401)
})
it('отклоняет невалидный payload', async () => {
const res = await post({ schemaVersion: 1, runId: 'short' })
expect(res.statusCode).toBe(400)
})
it('принимает прогон и оставляет unmatched', async () => {
const res = await post(ingestPayload())
expect(res.statusCode).toBe(200)
const json = res.json() as { matchedVpsId: string | null; probePublicIp: string }
expect(json.matchedVpsId).toBeNull()
expect(json.probePublicIp).toBe('203.0.113.10')
})
it('матчит VPS по IPv4 и IPv6', async () => {
const vps = runWithSpace(MAIN_SPACE_ID, () =>
vpsRepository.create({
ip: '203.0.113.10',
ipv6: '2001:db8::55',
dns: 'edge.example.com',
providerId: 'p1',
providerAccountId: 'a1',
status: 'active',
tariffType: 'monthly',
currency: 'RUB',
vcpu: 2,
ramGb: 4,
diskGb: 40,
}),
)
const v4 = await post(ingestPayload({ runId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }))
expect(v4.json().matchedVpsId).toBe(vps.id)
const v6 = await post(
ingestPayload({
runId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',
probe: { publicIp: '2001:db8::55' },
}),
)
expect(v6.json().matchedVpsId).toBe(vps.id)
})
it('повторяет duplicate runId без второй записи', async () => {
const first = await post(ingestPayload())
const second = await post(ingestPayload())
expect(first.statusCode).toBe(200)
expect(second.statusCode).toBe(200)
expect(second.json().id).toBe(first.json().id)
expect(second.json().replayed).toBe(true)
const current = await app.inject({ method: 'GET', url: '/api/censorcheck/current' })
expect(current.json().items).toHaveLength(1)
})
it('берёт observed XFF, а claimed сохраняет если расходится', async () => {
runWithSpace(MAIN_SPACE_ID, () =>
vpsRepository.create({
ip: '198.51.100.20',
providerId: 'p1',
providerAccountId: 'a1',
status: 'active',
tariffType: 'monthly',
currency: 'RUB',
vcpu: 1,
ramGb: 1,
diskGb: 10,
}),
)
const res = await post(ingestPayload({
runId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc',
probe: { publicIp: '203.0.113.10' },
}), undefined, { 'x-forwarded-for': '198.51.100.20' })
const json = res.json() as { probePublicIp: string; matchedVpsId: string | null }
expect(json.probePublicIp).toBe('198.51.100.20')
expect(json.matchedVpsId).not.toBeNull()
const current = await app.inject({ method: 'GET', url: '/api/censorcheck/current' })
expect(current.json().items[0].claimedPublicIp).toBe('203.0.113.10')
})
it('отдаёт историю и детали', async () => {
await post(ingestPayload())
const list = await app.inject({ method: 'GET', url: '/api/censorcheck/runs?limit=10' })
expect(list.statusCode).toBe(200)
const items = list.json().items as Array<{ id: string }>
expect(items).toHaveLength(1)
const detail = await app.inject({ method: 'GET', url: `/api/censorcheck/runs/${items[0]!.id}` })
expect(detail.statusCode).toBe(200)
expect(detail.json().results).toHaveLength(1)
})
})
+127
View File
@@ -0,0 +1,127 @@
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'
import { censorcheckIngestBodySchema } from '@cfdm/shared/contracts/censorcheck'
import { censorcheckRepository } from '@cfdm/db/repositories/censorcheck'
import { actorFromRequest } from '../lib/audit-actor.js'
import {
bearerToken,
ingestSecret,
verifyIngestToken,
} from '../services/censorcheck/ingest-token.js'
import { matchVpsByPublicIp, resolveProbeIp } from '../services/censorcheck/match-ip.js'
import { normalizeIngestResult, summarizeResults } from '../services/censorcheck/normalize.js'
const BODY_LIMIT = 512 * 1024
function sendError(reply: FastifyReply, status: number, code: string, message: string) {
return reply.code(status).send({ error: { code, message } })
}
function requireIngestToken(request: FastifyRequest, reply: FastifyReply): boolean {
const secret = ingestSecret()
if (!secret) {
void sendError(reply, 503, 'UNAVAILABLE', 'Censorcheck ingest не настроен')
return false
}
const token = bearerToken(request.headers.authorization)
if (!token || !verifyIngestToken(token, secret)) {
void sendError(reply, 401, 'UNAUTHORIZED', 'Недействительный ingest-токен')
return false
}
return true
}
export const censorcheckRoutes: FastifyPluginAsync = async (app) => {
const ingestOpts = {
bodyLimit: BODY_LIMIT,
...(process.env.VITEST || process.env.CENSORCHECK_RATE_LIMIT === '0'
? {}
: { config: { rateLimit: { max: 6, timeWindow: '1 minute' } } }),
}
app.post(
'/api/integrations/censorcheck/runs',
ingestOpts,
async (request, reply) => {
if (!requireIngestToken(request, reply)) return
const parsed = censorcheckIngestBodySchema.safeParse(request.body)
if (!parsed.success) {
return sendError(reply, 400, 'VALIDATION', parsed.error.message)
}
const existing = censorcheckRepository.getByClientRunId(parsed.data.runId)
if (existing) {
return {
id: existing.id,
runId: existing.runId,
matchedVpsId: existing.matchedVpsId,
probePublicIp: existing.probePublicIp,
summary: existing.summary,
replayed: true,
}
}
const claimed = parsed.data.probe.publicIp
const observed = actorFromRequest(request).ip ?? request.ip
const probePublicIp = resolveProbeIp(observed, claimed)
const claimedPublicIp =
claimed && claimed !== probePublicIp ? claimed : null
const match = matchVpsByPublicIp(probePublicIp)
const results = parsed.data.results.map(normalizeIngestResult)
const { summary, runStatus } = summarizeResults(results)
const created = censorcheckRepository.create({
spaceId: match.spaceId,
runId: parsed.data.runId,
probePublicIp,
claimedPublicIp,
matchedVpsId: match.vpsId,
status: runStatus,
schemaVersion: parsed.data.schemaVersion,
launcherVersion: parsed.data.launcherVersion ?? null,
censorcheckVersion: parsed.data.censorcheck?.version ?? null,
summary,
observedSourceIp: observed ?? null,
results,
})
return {
id: created.id,
runId: created.runId,
matchedVpsId: created.matchedVpsId,
probePublicIp: created.probePublicIp,
summary: created.summary,
}
},
)
app.get('/api/censorcheck/current', async () => ({
items: censorcheckRepository.listCurrent(),
}))
app.get('/api/censorcheck/runs', async (request) => {
const q = request.query as Record<string, string | undefined>
const matched =
q.matched === '1' || q.matched === 'true'
? true
: q.matched === '0' || q.matched === 'false'
? false
: undefined
return censorcheckRepository.listHistory({
cursor: q.cursor,
limit: q.limit ? Number(q.limit) : undefined,
q: q.q,
status: q.status,
matched,
})
})
app.get('/api/censorcheck/runs/:id', async (request, reply) => {
const { id } = request.params as { id: string }
const run = censorcheckRepository.getById(id)
if (!run) {
return sendError(reply, 404, 'NOT_FOUND', 'Прогон не найден')
}
return run
})
}
+39
View File
@@ -0,0 +1,39 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { closeDb } from '@cfdm/db'
import { resetTestDb } from '@cfdm/db/test-setup'
import { buildApp } from '../index.js'
describe('GET /cc launcher', () => {
let app: Awaited<ReturnType<typeof buildApp>>
beforeEach(async () => {
process.env.CENSORCHECK_INGEST_SECRET = 'launcher-secret-key'
process.env.CENSORCHECK_PUBLIC_URL = 'https://vt.shnt.top'
process.env.CENSORCHECK_RATE_LIMIT = '0'
resetTestDb()
app = await buildApp()
})
afterEach(async () => {
await app.close()
closeDb()
})
it('отдаёт bash-скрипт с токеном и no-store', async () => {
const res = await app.inject({ method: 'GET', url: '/cc' })
expect(res.statusCode).toBe(200)
expect(res.headers['content-type']).toMatch(/text\/plain/)
expect(res.headers['cache-control']).toMatch(/no-store/)
expect(res.body).toContain('https://vt.shnt.top')
expect(res.body).toContain('VT_INGEST_TOKEN')
expect(res.body).not.toContain('__VT_API_URL__')
expect(res.body).not.toContain('__VT_INGEST_TOKEN__')
})
it('отдаёт vendor-скрипт', async () => {
const res = await app.inject({ method: 'GET', url: '/cc/vendor' })
expect(res.statusCode).toBe(200)
expect(res.body).toContain('#!/usr/bin/env bash')
expect(res.body).toContain('SCRIPT_NAME')
})
})
+62
View File
@@ -0,0 +1,62 @@
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'
import { ingestSecret, mintIngestToken } from '../services/censorcheck/ingest-token.js'
const __dirname = dirname(fileURLToPath(import.meta.url))
const SCRIPT_DIR = join(__dirname, '..', '..', 'scripts', 'censorcheck')
export function censorcheckPublicUrl(env: NodeJS.ProcessEnv = process.env): string {
if (env.CENSORCHECK_PUBLIC_URL?.trim()) {
return env.CENSORCHECK_PUBLIC_URL.replace(/\/$/, '')
}
const host = env.VPS_LAUNCHER_DOMAIN || env.VPS_DOMAIN || 'vt.shnt.top'
return `https://${host}`
}
function sendPlain(reply: FastifyReply, body: string, cache: 'no-store' | 'public'): void {
void reply
.header('Content-Type', 'text/plain; charset=utf-8')
.header(
'Cache-Control',
cache === 'no-store' ? 'no-store, no-cache, must-revalidate' : 'public, max-age=3600',
)
.send(body)
}
export const launcherRoutes: FastifyPluginAsync = async (app) => {
const secret = ingestSecret()
const ccOpts =
process.env.VITEST || process.env.CENSORCHECK_RATE_LIMIT === '0'
? {}
: { config: { rateLimit: { max: 30, timeWindow: '1 minute' } } }
app.get('/cc', ccOpts, async (_request: FastifyRequest, reply: FastifyReply) => {
if (!secret) {
return reply.code(503).send('censorcheck ingest is not configured\n')
}
const apiUrl = censorcheckPublicUrl()
const token = mintIngestToken(secret)
let template: string
try {
template = readFileSync(join(SCRIPT_DIR, 'launcher.sh'), 'utf8')
} catch {
return reply.code(500).send('launcher template missing\n')
}
const script = template
.replaceAll('__VT_API_URL__', apiUrl)
.replaceAll('__VT_INGEST_TOKEN__', token)
sendPlain(reply, script, 'no-store')
})
app.get('/cc/vendor', async (_request, reply) => {
try {
const body = readFileSync(join(SCRIPT_DIR, 'censorcheck.sh'), 'utf8')
sendPlain(reply, body, 'public')
} catch {
return reply.code(500).send('vendor script missing\n')
}
})
}
+2
View File
@@ -6,6 +6,8 @@ import {
const TABLE_ORDER_DELETE = [
'vps_grants',
'censorcheck_results',
'censorcheck_runs',
'notification_log',
'notification_state',
'vps_health_checks',
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import { mintIngestToken, verifyIngestToken } from './ingest-token.js'
describe('censorcheck ingest token', () => {
const secret = 'test-ingest-secret-key'
it('принимает свежий токен', () => {
const token = mintIngestToken(secret)
expect(verifyIngestToken(token, secret)).toBe(true)
})
it('отклоняет просроченный токен', () => {
const token = mintIngestToken(secret, 20 * 60, Date.now() - 21 * 60 * 1000)
expect(verifyIngestToken(token, secret)).toBe(false)
})
it('отклоняет подпись с другим секретом', () => {
const token = mintIngestToken(secret)
expect(verifyIngestToken(token, 'other-secret-key')).toBe(false)
})
it('отклоняет мусор', () => {
expect(verifyIngestToken('not-a-token', secret)).toBe(false)
expect(verifyIngestToken('', secret)).toBe(false)
})
})
@@ -0,0 +1,62 @@
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
const TTL_SEC = 20 * 60
export function ingestSecret(env: NodeJS.ProcessEnv = process.env): string {
return (
env.CENSORCHECK_INGEST_SECRET ||
env.AUTH_JWT_SECRET ||
env.JWT_SECRET ||
(env.NODE_ENV === 'production' ? '' : 'dev-secret-change-me')
)
}
export function mintIngestToken(
secret: string,
ttlSec = TTL_SEC,
nowMs = Date.now(),
): string {
const payload = Buffer.from(
JSON.stringify({
jti: randomBytes(16).toString('hex'),
exp: Math.floor(nowMs / 1000) + ttlSec,
}),
'utf8',
).toString('base64url')
const sig = createHmac('sha256', secret).update(payload).digest('base64url')
return `${payload}.${sig}`
}
export function verifyIngestToken(
token: string,
secret: string,
nowMs = Date.now(),
): boolean {
if (!secret || !token) return false
const [payload, sig] = token.split('.')
if (!payload || !sig) return false
const expected = createHmac('sha256', secret).update(payload).digest()
let given: Buffer
try {
given = Buffer.from(sig, 'base64url')
} catch {
return false
}
if (expected.length !== given.length || !timingSafeEqual(expected, given)) return false
try {
const data = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as {
exp?: unknown
}
if (typeof data.exp !== 'number') return false
return data.exp * 1000 > nowMs
} catch {
return false
}
}
export function bearerToken(header: string | string[] | undefined): string | null {
const raw = Array.isArray(header) ? header[0] : header
if (!raw) return null
const match = /^Bearer\s+(.+)$/i.exec(raw.trim())
return match?.[1]?.trim() || null
}
@@ -0,0 +1,49 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { closeDb, MAIN_SPACE_ID, runWithSpace } from '@cfdm/db'
import { vpsRepository } from '@cfdm/db/repositories/vps'
import { resetTestDb, seedTestProvider, seedTestProviderAccount } from '@cfdm/db/test-setup'
import { matchVpsByPublicIp, resolveProbeIp } from './match-ip.js'
describe('censorcheck match-ip', () => {
beforeEach(() => {
resetTestDb()
seedTestProvider('p1')
seedTestProviderAccount('a1', 'p1')
})
afterEach(() => {
closeDb()
})
it('берёт claimed если observed приватный', () => {
expect(resolveProbeIp('127.0.0.1', '203.0.113.10')).toBe('203.0.113.10')
expect(resolveProbeIp('203.0.113.55', '203.0.113.10')).toBe('203.0.113.55')
})
it('матчит VPS по IPv4 и отдаёт spaceId', () => {
const vps = runWithSpace(MAIN_SPACE_ID, () =>
vpsRepository.create({
ip: '203.0.113.10',
ipv6: '2001:db8::aa',
providerId: 'p1',
providerAccountId: 'a1',
status: 'active',
tariffType: 'monthly',
currency: 'RUB',
vcpu: 1,
ramGb: 1,
diskGb: 10,
}),
)
const created = Array.isArray(vps) ? vps[0]! : vps
expect(matchVpsByPublicIp('203.0.113.10')).toEqual({
vpsId: created.id,
spaceId: MAIN_SPACE_ID,
})
expect(matchVpsByPublicIp('2001:db8::aa').vpsId).toBe(created.id)
expect(matchVpsByPublicIp('198.51.100.1')).toEqual({
vpsId: null,
spaceId: MAIN_SPACE_ID,
})
})
})
@@ -0,0 +1,20 @@
import { MAIN_SPACE_ID } from '@cfdm/db'
import { findVpsIdByIps, isPrivateOrLoopbackIp } from '@cfdm/db/repositories/ip-match'
import { vpsRepository } from '@cfdm/db/repositories/vps'
export { isPrivateOrLoopbackIp }
export function resolveProbeIp(observed: string | null | undefined, claimed: string): string {
const obs = observed?.trim() ?? ''
const claim = claimed.trim()
if (obs && !isPrivateOrLoopbackIp(obs)) return obs
return claim
}
export function matchVpsByPublicIp(ip: string): { vpsId: string | null; spaceId: string } {
const all = vpsRepository.listAllSpaces()
const vpsId = findVpsIdByIps(all, [ip])
if (!vpsId) return { vpsId: null, spaceId: MAIN_SPACE_ID }
const vps = all.find((row) => row.id === vpsId)
return { vpsId, spaceId: vps?.spaceId ?? MAIN_SPACE_ID }
}
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest'
import {
normalizeIngestResult,
statusFromErrorCode,
statusFromHttpCode,
summarizeResults,
} from './normalize.js'
describe('censorcheck normalize', () => {
it('мапит HTTP-коды HTTPS IPv4', () => {
expect(statusFromHttpCode(200)).toBe('available')
expect(statusFromHttpCode(403)).toBe('denied')
expect(statusFromHttpCode(301)).toBe('redirected')
expect(statusFromHttpCode(0)).toBe('timeout')
expect(statusFromHttpCode(-1)).toBe('blocked')
})
it('мапит error_code', () => {
expect(statusFromErrorCode('blocked_by_ip')).toBe('blocked')
expect(statusFromErrorCode('nxdomain')).toBe('error')
expect(statusFromErrorCode('no_dns_record')).toBe('error')
})
it('берёт HTTPS IPv4 как primary', () => {
const row = normalizeIngestResult({
service: 'YouTube.com',
raw: {
http: { ipv4: { status: 403 } },
https: { ipv4: { status: 200 } },
},
})
expect(row.serviceKey).toBe('youtube.com')
expect(row.category).toBe('dpi')
expect(row.status).toBe('available')
expect(row.httpStatus).toBe(200)
})
it('нормализует geoblock и timeout 000', () => {
const row = normalizeIngestResult({
service: 'netflix.com',
raw: { https: { ipv4: { status: '000' } } },
})
expect(row.category).toBe('geoblock')
expect(row.status).toBe('timeout')
expect(row.httpStatus).toBe(0)
})
it('считает summary и partial при timeout', () => {
const { summary, runStatus } = summarizeResults([
{ status: 'available' },
{ status: 'timeout' },
{ status: 'blocked' },
])
expect(summary.total).toBe(3)
expect(summary.available).toBe(1)
expect(summary.timeout).toBe(1)
expect(summary.blocked).toBe(1)
expect(runStatus).toBe('partial')
})
})
@@ -0,0 +1,125 @@
import {
emptyCensorcheckSummary,
inferCensorcheckCategory,
type CensorcheckCategory,
type CensorcheckIngestResult,
type CensorcheckRunStatus,
type CensorcheckStatus,
type CensorcheckSummary,
} from '@cfdm/shared/contracts/censorcheck'
function parseHttpStatus(raw: unknown): number | null {
if (typeof raw === 'number' && Number.isFinite(raw)) return raw
if (typeof raw === 'string' && raw.trim() !== '') {
const n = Number(raw)
return Number.isFinite(n) ? n : null
}
return null
}
function protocolStatus(proto: unknown): { httpStatus: number | null; redirectUrl?: string } {
if (!proto || typeof proto !== 'object') return { httpStatus: null }
const rec = proto as { status?: unknown; redirect_url?: unknown }
const redirectUrl = typeof rec.redirect_url === 'string' ? rec.redirect_url : undefined
return { httpStatus: parseHttpStatus(rec.status), redirectUrl }
}
function pickPrimaryProtocol(raw: Record<string, unknown>): {
httpStatus: number | null
redirectUrl?: string
} {
const https = raw.https
if (https && typeof https === 'object') {
const ipv4 = (https as { ipv4?: unknown }).ipv4
if (ipv4) return protocolStatus(ipv4)
}
const http = raw.http
if (http && typeof http === 'object') {
const ipv4 = (http as { ipv4?: unknown }).ipv4
if (ipv4) return protocolStatus(ipv4)
}
return { httpStatus: null }
}
export function statusFromHttpCode(code: number | null): CensorcheckStatus {
if (code == null) return 'error'
if (code === 200) return 'available'
if (code === 403) return 'denied'
if (code >= 300 && code < 400) return 'redirected'
if (code === 0) return 'timeout'
if (code === -1) return 'blocked'
if (code >= 400) return 'denied'
return 'error'
}
export function statusFromErrorCode(code: string | undefined): CensorcheckStatus {
if (code === 'blocked_by_ip') return 'blocked'
return 'error'
}
export type NormalizedServiceResult = {
serviceKey: string
serviceLabel: string
category: CensorcheckCategory
status: CensorcheckStatus
httpStatus: number | null
detail: string | null
rawJson: string | null
}
function compactRaw(raw: Record<string, unknown>): string | null {
try {
const compact: Record<string, unknown> = {}
if (raw.service != null) compact.service = raw.service
if (raw.error != null) compact.error = raw.error
if (raw.error_code != null) compact.error_code = raw.error_code
if (raw.http != null) compact.http = raw.http
if (raw.https != null) compact.https = raw.https
return JSON.stringify(compact)
} catch {
return null
}
}
export function normalizeIngestResult(item: CensorcheckIngestResult): NormalizedServiceResult {
const serviceKey = item.service.trim().toLowerCase()
const raw = item.raw ?? {}
const errorCode = typeof raw.error_code === 'string' ? raw.error_code : undefined
const errorText = typeof raw.error === 'string' ? raw.error : undefined
const primary = pickPrimaryProtocol(raw)
let status: CensorcheckStatus
let httpStatus = primary.httpStatus
let detail: string | null = primary.redirectUrl ?? errorText ?? null
if (errorCode || (raw.http == null && raw.https == null && errorText)) {
status = statusFromErrorCode(errorCode)
if (!detail) detail = errorCode ?? errorText ?? null
} else {
status = statusFromHttpCode(httpStatus)
}
return {
serviceKey,
serviceLabel: item.service.trim(),
category: item.category ?? inferCensorcheckCategory(serviceKey),
status,
httpStatus,
detail,
rawJson: compactRaw(raw),
}
}
export function summarizeResults(results: { status: CensorcheckStatus }[]): {
summary: CensorcheckSummary
runStatus: CensorcheckRunStatus
} {
const summary = emptyCensorcheckSummary()
summary.total = results.length
for (const row of results) {
summary[row.status] += 1
}
const runStatus: CensorcheckRunStatus =
summary.timeout > 0 || summary.error > 0 ? 'partial' : 'complete'
return { summary, runStatus }
}
@@ -0,0 +1,99 @@
import { describe, expect, it } from 'vitest'
import type { Filter } from '@/components/reui/filters'
import { filterCensorcheckRuns, groupRunsByService } from './blocking-filters'
import type { CensorcheckRunDto } from './types'
const run = (overrides: Partial<CensorcheckRunDto> = {}): CensorcheckRunDto => ({
id: 'ccrun-1',
spaceId: 'space-main',
runId: '11111111-1111-4111-8111-111111111111',
probePublicIp: '203.0.113.10',
claimedPublicIp: null,
matchedVpsId: 'vps-1',
status: 'complete',
schemaVersion: 1,
launcherVersion: '1',
censorcheckVersion: '1',
summary: {
total: 2,
available: 1,
redirected: 0,
denied: 0,
blocked: 1,
timeout: 0,
error: 0,
},
createdAt: '2026-08-22T00:00:00.000Z',
completedAt: '2026-08-22T00:00:00.000Z',
observedSourceIp: '203.0.113.10',
vps: {
id: 'vps-1',
ip: '203.0.113.10',
dns: 'edge.example.com',
providerId: 'p1',
providerName: 'Hoster',
country: 'Нидерланды',
city: 'Amsterdam',
datacenter: 'AMS',
vcpu: 2,
ramGb: 4,
diskGb: 40,
},
results: [
{
id: 'r1',
runId: 'ccrun-1',
serviceKey: 'youtube.com',
serviceLabel: 'youtube.com',
category: 'dpi',
status: 'blocked',
httpStatus: -1,
detail: null,
},
{
id: 'r2',
runId: 'ccrun-1',
serviceKey: 'netflix.com',
serviceLabel: 'netflix.com',
category: 'geoblock',
status: 'available',
httpStatus: 200,
detail: null,
},
],
...overrides,
})
describe('filterCensorcheckRuns', () => {
it('фильтрует по статусу сервиса', () => {
const filters: Filter[] = [
{ id: '1', field: 'status', operator: 'is_any_of', values: ['blocked'] },
]
expect(filterCensorcheckRuns([run()], filters)).toHaveLength(1)
expect(
filterCensorcheckRuns([run()], [
{ id: '1', field: 'status', operator: 'is_any_of', values: ['timeout'] },
]),
).toHaveLength(0)
})
it('ищет по IP и DNS', () => {
const filters: Filter[] = [
{ id: '1', field: 'q', operator: 'contains', values: ['edge.example'] },
]
expect(filterCensorcheckRuns([run()], filters)).toHaveLength(1)
expect(
filterCensorcheckRuns([run()], [
{ id: '1', field: 'q', operator: 'contains', values: ['missing'] },
]),
).toHaveLength(0)
})
})
describe('groupRunsByService', () => {
it('собирает пробы по сервису', () => {
const groups = groupRunsByService([run()])
expect(groups.map((g) => g.serviceKey)).toEqual(['netflix.com', 'youtube.com'])
expect(groups[1]?.probes[0]?.status).toBe('blocked')
})
})
@@ -0,0 +1,105 @@
import { getActiveFilters } from '@/components/reui-kit'
import type { Filter } from '@/components/reui/filters'
import { runSearchText, type CensorcheckRunDto } from './types'
export function filterCensorcheckRuns(
runs: CensorcheckRunDto[],
filters: Filter[],
): CensorcheckRunDto[] {
const active = getActiveFilters(filters)
if (active.length === 0) return runs
return runs.filter((run) => {
for (const filter of active) {
const values = filter.values.map((value) => String(value))
if (filter.field === 'status') {
const statuses = (run.results ?? []).map((row) => row.status)
const hit = values.some((value) => statuses.includes(value))
if (filter.operator === 'is_not_any_of' ? hit : !hit) return false
continue
}
if (filter.field === 'service') {
const hay = (run.results ?? [])
.map((row) => `${row.serviceKey} ${row.serviceLabel}`)
.join(' ')
.toLowerCase()
const hit = values.some((value) =>
hay.includes(value.toLowerCase()) || (run.results ?? []).some((row) => row.serviceKey === value),
)
if (!hit) return false
continue
}
if (filter.field === 'hoster') {
const name = (run.vps?.providerName ?? '').toLowerCase()
const hit = values.some((value) => name.includes(value.toLowerCase()) || name === value.toLowerCase())
if (!hit) return false
continue
}
if (filter.field === 'country') {
const country = (run.vps?.country ?? '').toLowerCase()
const hit = values.some((value) => country.includes(value.toLowerCase()) || country === value.toLowerCase())
if (!hit) return false
continue
}
if (filter.field === 'matched') {
const matched = run.matchedVpsId ? 'matched' : 'unmatched'
if (!values.includes(matched)) return false
continue
}
if (filter.field === 'q') {
const hay = runSearchText(run)
const hit = values.some((token) => hay.includes(token.toLowerCase()))
if (!hit) return false
}
}
return true
})
}
export type BlockingServiceRow = {
id: string
serviceKey: string
serviceLabel: string
category: string
probes: Array<{
runId: string
probePublicIp: string
matchedVpsId: string | null
dns: string
country: string
status: string
createdAt: string
vpsId: string | null
}>
}
export function groupRunsByService(runs: CensorcheckRunDto[]): BlockingServiceRow[] {
const map = new Map<string, BlockingServiceRow>()
for (const run of runs) {
for (const result of run.results ?? []) {
const existing = map.get(result.serviceKey)
const probe = {
runId: run.id,
probePublicIp: run.probePublicIp,
matchedVpsId: run.matchedVpsId,
dns: run.vps?.dns ?? '',
country: run.vps?.country ?? '',
status: result.status,
createdAt: run.createdAt,
vpsId: run.matchedVpsId,
}
if (existing) {
existing.probes.push(probe)
} else {
map.set(result.serviceKey, {
id: result.serviceKey,
serviceKey: result.serviceKey,
serviceLabel: result.serviceLabel,
category: result.category,
probes: [probe],
})
}
}
}
return [...map.values()].sort((a, b) => a.serviceKey.localeCompare(b.serviceKey))
}
@@ -0,0 +1,217 @@
import type { ReactNode } from 'react'
import { Link } from '@tanstack/react-router'
import { GlobeIcon, MapPinIcon, ServerIcon, ShieldAlertIcon } from 'lucide-react'
import type { DataGridColumn } from '@/components/data-grid-types'
import { dataGridCellStack, dataGridCellWithFlag } from '@/components/data-grid-cells'
import { CountryFlag } from '@/components/country-flag'
import { StatusBadge } from '@/components/status-badge'
import { columnDefFromDataGrid, ExpandableResourceGrid } from '@/components/reui-kit'
import { Badge } from '@/components/reui/badge'
import {
CENSORCHECK_STATUS_LABELS,
formatCheckedAt,
formatVpsResources,
type CensorcheckRunDto,
} from './types'
import type { BlockingServiceRow } from './blocking-filters'
function SummaryBadges({ run }: { run: CensorcheckRunDto }) {
const { summary } = run
return (
<div className="flex flex-wrap items-center gap-1">
{summary.available > 0 ? (
<Badge variant="success" size="sm">{summary.available} ок</Badge>
) : null}
{summary.blocked > 0 ? (
<Badge variant="destructive" size="sm">{summary.blocked} блок</Badge>
) : null}
{summary.denied > 0 ? (
<Badge variant="destructive" size="sm">{summary.denied} отказ</Badge>
) : null}
{summary.timeout > 0 ? (
<Badge variant="warning" size="sm">{summary.timeout} timeout</Badge>
) : null}
{summary.error > 0 ? (
<Badge variant="outline" size="sm">{summary.error} err</Badge>
) : null}
</div>
)
}
function NestedList({
rows,
}: {
rows: Array<{ key: string; primary: string; secondary?: string; status: string }>
}) {
return (
<div className="bg-muted/30 flex flex-col gap-1 px-4 py-3">
{rows.map((row) => (
<div key={row.key} className="flex items-center justify-between gap-3 text-sm">
<div className="flex min-w-0 flex-col">
<span className="truncate font-medium">{row.primary}</span>
{row.secondary ? (
<span className="text-muted-foreground truncate text-xs">{row.secondary}</span>
) : null}
</div>
<StatusBadge
status={row.status}
label={CENSORCHECK_STATUS_LABELS[row.status] ?? row.status}
/>
</div>
))}
</div>
)
}
const vpsColumns: DataGridColumn<CensorcheckRunDto>[] = [
{
key: 'vps',
header: 'VPS / IP',
icon: ServerIcon,
sortValue: (row) => row.vps?.dns || row.probePublicIp,
cell: (row) => {
const title = row.vps?.dns || row.probePublicIp
const ip = row.probePublicIp
const link = row.matchedVpsId ? (
<Link
to="/vps/$vpsId"
params={{ vpsId: row.matchedVpsId }}
className="hover:text-primary font-medium"
onClick={(event) => event.stopPropagation()}
>
{title}
</Link>
) : (
<span className="font-medium">Unknown VPS</span>
)
return dataGridCellStack(link, ip)
},
},
{
key: 'dns',
header: 'DNS',
icon: GlobeIcon,
sortValue: (row) => row.vps?.dns ?? '',
cell: (row) => row.vps?.dns || '—',
},
{
key: 'hoster',
header: 'Хостер',
sortValue: (row) => row.vps?.providerName ?? '',
cell: (row) => row.vps?.providerName || '—',
},
{
key: 'country',
header: 'Страна',
icon: MapPinIcon,
sortValue: (row) => row.vps?.country ?? '',
cell: (row) =>
row.vps?.country
? dataGridCellWithFlag(<CountryFlag country={row.vps.country} />, row.vps.country)
: '—',
},
{
key: 'resources',
header: 'Ресурсы',
sortValue: (row) => row.vps?.vcpu ?? 0,
cell: (row) =>
row.vps
? formatVpsResources(row.vps.vcpu, row.vps.ramGb, row.vps.diskGb)
: '—',
},
{
key: 'summary',
header: 'Сводка',
cell: (row) => <SummaryBadges run={row} />,
},
{
key: 'checked',
header: 'Проверено',
sortValue: (row) => row.createdAt,
cell: (row) => formatCheckedAt(row.createdAt),
},
]
const serviceColumns: DataGridColumn<BlockingServiceRow>[] = [
{
key: 'service',
header: 'Сервис',
icon: ShieldAlertIcon,
sortValue: (row) => row.serviceKey,
cell: (row) => dataGridCellStack(row.serviceLabel, row.category),
},
{
key: 'probes',
header: 'Пробы',
sortValue: (row) => row.probes.length,
sortingFn: 'basic',
cell: (row) => row.probes.length,
},
]
export function BlockingVpsGrid({
runs,
onRowClick,
emptyAction,
}: {
runs: CensorcheckRunDto[]
onRowClick: (run: CensorcheckRunDto) => void
emptyAction?: ReactNode
}) {
return (
<ExpandableResourceGrid
columns={columnDefFromDataGrid(vpsColumns)}
data={runs}
rowId={(row) => row.id}
dense
pagination={runs.length > 10}
emptyTitle="Нет проверок"
emptyDescription="Запустите launcher на VPS, чтобы увидеть статусы блокировок."
emptyAction={emptyAction}
onRowClick={onRowClick}
getRowCanExpand={(row) => (row.results?.length ?? 0) > 0}
expandedContent={(row) => (
<NestedList
rows={(row.results ?? []).map((item) => ({
key: item.id,
primary: item.serviceLabel,
secondary: item.category,
status: item.status,
}))}
/>
)}
/>
)
}
export function BlockingServiceGrid({
groups,
emptyAction,
}: {
groups: BlockingServiceRow[]
emptyAction?: ReactNode
}) {
return (
<ExpandableResourceGrid
columns={columnDefFromDataGrid(serviceColumns)}
data={groups}
rowId={(row) => row.id}
dense
pagination={groups.length > 10}
emptyTitle="Нет сервисов"
emptyAction={emptyAction}
getRowCanExpand={(row) => row.probes.length > 0}
expandedContent={(row) => (
<NestedList
rows={row.probes.map((probe) => ({
key: `${probe.runId}-${probe.probePublicIp}`,
primary: probe.dns || probe.probePublicIp,
secondary: `${probe.probePublicIp} · ${formatCheckedAt(probe.createdAt)}`,
status: probe.status,
}))}
/>
)}
/>
)
}
@@ -0,0 +1,268 @@
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
BanIcon,
CopyIcon,
GlobeIcon,
ServerIcon,
ShieldAlertIcon,
} from 'lucide-react'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { Button } from '@cfdm/ui/components/button'
import { ToggleGroup, ToggleGroupItem } from '@cfdm/ui/components/toggle-group'
import { CountedLineTabs } from '@/components/counted-line-tabs'
import { KpiStatGrid, ResourcePage, columnDefFromDataGrid } from '@/components/reui-kit'
import { Filters, type Filter, type FilterFieldConfig } from '@/components/reui/filters'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import { copyText } from '@/lib/clipboard'
import { useSpaceId } from '@/lib/space'
import {
censorcheckCurrentQueryOptions,
censorcheckHistoryQueryOptions,
} from '@/queries/censorcheck'
import { StatusBadge } from '@/components/status-badge'
import type { DataGridColumn } from '@/components/data-grid-types'
import { BlockingServiceGrid, BlockingVpsGrid } from './blocking-grid'
import { CheckRunSheet } from './check-run-sheet'
import { filterCensorcheckRuns, groupRunsByService } from './blocking-filters'
import {
CENSORCHECK_STATUS_LABELS,
LAUNCHER_CMD,
formatCheckedAt,
type CensorcheckRunDto,
} from './types'
type GroupMode = 'vps' | 'service'
type TabId = 'current' | 'history'
const FILTER_FIELDS: FilterFieldConfig[] = [
{ key: 'q', label: 'Поиск', type: 'text', defaultOperator: 'contains', placeholder: 'IP, DNS, хостер, сервис' },
{
key: 'status',
label: 'Статус',
type: 'multiselect',
defaultOperator: 'is_any_of',
options: [
{ value: 'available', label: 'Доступен' },
{ value: 'blocked', label: 'Заблокирован' },
{ value: 'denied', label: 'Отказ' },
{ value: 'timeout', label: 'Таймаут' },
{ value: 'redirected', label: 'Редирект' },
{ value: 'error', label: 'Ошибка' },
],
},
{ key: 'service', label: 'Сервис', type: 'text', defaultOperator: 'is_any_of' },
{ key: 'hoster', label: 'Хостер', type: 'text', defaultOperator: 'contains' },
{ key: 'country', label: 'Страна', type: 'text', defaultOperator: 'contains' },
{
key: 'matched',
label: 'Привязка',
type: 'select',
defaultOperator: 'is',
options: [
{ value: 'matched', label: 'Известный VPS' },
{ value: 'unmatched', label: 'Unknown VPS' },
],
},
]
const historyColumns: DataGridColumn<CensorcheckRunDto>[] = [
{
key: 'ip',
header: 'IP',
sortValue: (row) => row.probePublicIp,
cell: (row) => row.probePublicIp,
},
{
key: 'vps',
header: 'VPS',
sortValue: (row) => row.vps?.dns ?? '',
cell: (row) => row.vps?.dns || (row.matchedVpsId ? row.matchedVpsId : 'Unknown VPS'),
},
{
key: 'status',
header: 'Статус',
cell: (row) => (
<StatusBadge status={row.status} label={CENSORCHECK_STATUS_LABELS[row.status] ?? row.status} />
),
},
{
key: 'summary',
header: 'Блок / всего',
sortValue: (row) => row.summary.blocked,
sortingFn: 'basic',
cell: (row) => `${row.summary.blocked} / ${row.summary.total}`,
},
{
key: 'createdAt',
header: 'Проверено',
sortValue: (row) => row.createdAt,
cell: (row) => formatCheckedAt(row.createdAt),
},
]
export function BlockingPage() {
const { spaceId } = useSpaceId()
const [tab, setTab] = useState<TabId>('current')
const [group, setGroup] = useState<GroupMode>('vps')
const [filters, setFilters] = useState<Filter[]>([])
const [selected, setSelected] = useState<CensorcheckRunDto | null>(null)
const currentQuery = useQuery(censorcheckCurrentQueryOptions(spaceId))
const historyQuery = useQuery({
...censorcheckHistoryQueryOptions({ limit: 50 }, spaceId),
enabled: tab === 'history',
})
const runs = currentQuery.data?.items ?? []
const filtered = useMemo(() => filterCensorcheckRuns(runs, filters), [runs, filters])
const serviceGroups = useMemo(() => groupRunsByService(filtered), [filtered])
const matched = filtered.filter((row) => row.matchedVpsId).length
const blocked = filtered.reduce((sum, row) => sum + row.summary.blocked, 0)
const copyLauncher = (
<Button
type="button"
variant="outline"
onClick={() => void copyText(LAUNCHER_CMD, 'Команда скопирована')}
>
<CopyIcon data-icon="inline-start" />
Скопировать команду
</Button>
)
return (
<PageShell>
<PageHeader
title="Статус блокировок"
description="Проверки DPI и геоблокировок с VPS через censorcheck."
actions={copyLauncher}
/>
<KpiStatGrid
items={[
{
id: 'probes',
label: 'Пробы',
value: filtered.length,
icon: <GlobeIcon />,
},
{
id: 'matched',
label: 'Известные VPS',
value: matched,
icon: <ServerIcon />,
},
{
id: 'unmatched',
label: 'Unknown VPS',
value: filtered.length - matched,
icon: <ShieldAlertIcon />,
},
{
id: 'blocked',
label: 'Блокировки',
value: blocked,
icon: <BanIcon />,
variant: blocked > 0 ? 'destructive' : 'default',
},
]}
isLoading={currentQuery.isLoading}
/>
<CountedLineTabs
tabs={[
{ id: 'current', label: 'Текущие', count: filtered.length },
{ id: 'history', label: 'История', count: historyQuery.data?.items.length },
]}
value={tab}
onValueChange={(value) => setTab(value as TabId)}
/>
{tab === 'current' ? (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<Filters
filters={filters}
fields={FILTER_FIELDS}
onChange={setFilters}
trigger={
<Button type="button" variant="outline">
Фильтры
</Button>
}
/>
<ToggleGroup
variant="outline"
size="sm"
spacing={0}
value={[group]}
onValueChange={(next) => {
const selectedMode = next[0]
if (selectedMode === 'vps' || selectedMode === 'service') setGroup(selectedMode)
}}
aria-label="Группировка"
>
<ToggleGroupItem value="vps">По VPS</ToggleGroupItem>
<ToggleGroupItem value="service">По сервису</ToggleGroupItem>
</ToggleGroup>
</div>
<QueryState
data={filtered}
isLoading={currentQuery.isLoading}
isError={currentQuery.isError}
error={currentQuery.error}
onRetry={() => void currentQuery.refetch()}
empty={filtered.length === 0}
emptyTitle="Пока нет проверок"
emptyDescription={`На VPS выполните: ${LAUNCHER_CMD}`}
emptyAction={copyLauncher}
skeleton={<TableSkeleton />}
>
{(rows) =>
group === 'vps' ? (
<BlockingVpsGrid
runs={rows}
onRowClick={setSelected}
emptyAction={copyLauncher}
/>
) : (
<BlockingServiceGrid groups={serviceGroups} emptyAction={copyLauncher} />
)
}
</QueryState>
</div>
) : (
<ResourcePage
title="История проверок"
description="Все сохранённые прогоны censorcheck."
hideHeader
columns={columnDefFromDataGrid(historyColumns)}
data={historyQuery.data?.items ?? []}
getRowId={(row) => row.id}
isLoading={historyQuery.isLoading}
isError={historyQuery.isError}
error={historyQuery.error instanceof Error ? historyQuery.error : null}
onRetry={() => void historyQuery.refetch()}
onRowClick={setSelected}
emptyState={{
title: 'История пуста',
description: `На VPS выполните: ${LAUNCHER_CMD}`,
action: copyLauncher,
}}
/>
)}
<CheckRunSheet
run={selected}
open={Boolean(selected)}
onOpenChange={(open) => {
if (!open) setSelected(null)
}}
/>
</PageShell>
)
}
@@ -0,0 +1,117 @@
import { Link } from '@tanstack/react-router'
import { GlobeIcon, MapPinIcon, ServerIcon, ShieldAlertIcon } from 'lucide-react'
import { useQuery } from '@tanstack/react-query'
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@cfdm/ui/components/sheet'
import { DetailPanel } from '@/components/reui-kit/detail-panel'
import { StatusBadge } from '@/components/status-badge'
import { censorcheckRunQueryOptions } from '@/queries/censorcheck'
import {
CENSORCHECK_STATUS_LABELS,
formatCheckedAt,
formatVpsResources,
type CensorcheckRunDto,
} from './types'
interface CheckRunSheetProps {
run: CensorcheckRunDto | null
open: boolean
onOpenChange: (open: boolean) => void
}
export function CheckRunSheet({ run, open, onOpenChange }: CheckRunSheetProps) {
const needFetch = Boolean(run && !run.results)
const { data: fetched } = useQuery({
...censorcheckRunQueryOptions(needFetch ? run?.id ?? null : null),
})
const detail = run?.results ? run : fetched ?? run
const title = detail?.vps?.dns || detail?.probePublicIp || 'Проверка'
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full sm:max-w-md">
<SheetHeader>
<SheetTitle>{title}</SheetTitle>
<SheetDescription>
{detail ? formatCheckedAt(detail.createdAt) : 'Загрузка…'}
</SheetDescription>
</SheetHeader>
{detail ? (
<DetailPanel>
<DetailPanel.Metrics
cards={[
{
id: 'ip',
icon: <GlobeIcon />,
label: 'IP',
description: detail.probePublicIp,
},
{
id: 'vps',
icon: <ServerIcon />,
label: 'VPS',
description: detail.matchedVpsId ? detail.vps?.dns || detail.matchedVpsId : 'Unknown VPS',
footer: detail.matchedVpsId ? (
<Link
to="/vps/$vpsId"
params={{ vpsId: detail.matchedVpsId }}
className="text-primary text-xs"
>
Открыть карточку
</Link>
) : undefined,
},
{
id: 'geo',
icon: <MapPinIcon />,
label: 'Локация',
description: detail.vps?.country || '—',
},
]}
/>
<DetailPanel.Section title="Сводка">
<div className="flex flex-wrap items-center gap-2">
<StatusBadge
status={detail.status}
label={CENSORCHECK_STATUS_LABELS[detail.status] ?? detail.status}
/>
{detail.vps ? (
<span className="text-muted-foreground text-sm">
{formatVpsResources(detail.vps.vcpu, detail.vps.ramGb, detail.vps.diskGb)}
</span>
) : null}
</div>
</DetailPanel.Section>
<DetailPanel.Section title="Сервисы">
<div className="flex flex-col gap-2">
{(detail.results ?? []).map((item) => (
<div key={item.id} className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium">{item.serviceLabel}</span>
<span className="text-muted-foreground text-xs">{item.category}</span>
</div>
<StatusBadge
status={item.status}
label={CENSORCHECK_STATUS_LABELS[item.status] ?? item.status}
/>
</div>
))}
</div>
</DetailPanel.Section>
</DetailPanel>
) : (
<div className="text-muted-foreground flex items-center gap-2 p-4 text-sm">
<ShieldAlertIcon className="size-4" />
Нет данных прогона
</div>
)}
</SheetContent>
</Sheet>
)
}
@@ -0,0 +1,80 @@
import type { CensorcheckSummary } from '@cfdm/shared/contracts/censorcheck'
export type CensorcheckResultDto = {
id: string
runId: string
serviceKey: string
serviceLabel: string
category: string
status: string
httpStatus: number | null
detail: string | null
}
export type CensorcheckVpsInfo = {
id: string
ip: string
dns: string
providerId: string
providerName: string
country: string
city: string
datacenter: string
vcpu: number
ramGb: number
diskGb: number
}
export type CensorcheckRunDto = {
id: string
spaceId: string
runId: string
probePublicIp: string
claimedPublicIp: string | null
matchedVpsId: string | null
status: string
schemaVersion: number
launcherVersion: string | null
censorcheckVersion: string | null
summary: CensorcheckSummary
createdAt: string
completedAt: string
observedSourceIp: string | null
vps: CensorcheckVpsInfo | null
results?: CensorcheckResultDto[]
}
export const CENSORCHECK_STATUS_LABELS: Record<string, string> = {
available: 'Доступен',
redirected: 'Редирект',
denied: 'Отказ',
blocked: 'Заблокирован',
timeout: 'Таймаут',
error: 'Ошибка',
complete: 'Полный',
partial: 'Частичный',
}
export const LAUNCHER_CMD = 'curl -fsSL https://vt.shnt.top/cc | bash'
export function formatVpsResources(vcpu: number, ramGb: number, diskGb: number): string {
return `${vcpu} vCPU / ${ramGb} GB / ${diskGb} GB`
}
export function formatCheckedAt(iso: string): string {
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return iso
return date.toLocaleString('ru-RU')
}
export function runSearchText(run: CensorcheckRunDto): string {
const parts = [
run.probePublicIp,
run.claimedPublicIp ?? '',
run.vps?.dns ?? '',
run.vps?.providerName ?? '',
run.vps?.country ?? '',
...(run.results ?? []).map((row) => `${row.serviceKey} ${row.serviceLabel}`),
]
return parts.join(' ').toLowerCase()
}
@@ -8,6 +8,7 @@ import {
FolderKanbanIcon,
LayoutDashboardIcon,
SearchIcon,
ShieldAlertIcon,
} from 'lucide-react'
import {
@@ -64,6 +65,10 @@ export function GlobalSearch({ open, onOpenChange }: GlobalSearchProps) {
<ServerIcon />
<span>Все VPS</span>
</CommandItem>
<CommandItem onSelect={() => go('/blocking')}>
<ShieldAlertIcon />
<span>Статус блокировок</span>
</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="VPS">
@@ -14,6 +14,7 @@ import {
HistoryIcon,
UsersIcon,
Network,
ShieldAlert,
} from 'lucide-react'
import {
@@ -82,6 +83,7 @@ const NAV_GROUPS: NavGroup[] = [
label: 'Инфраструктура',
items: [
{ to: '/vps', label: 'VPS', icon: Server },
{ to: '/blocking', label: 'Статус блокировок', icon: ShieldAlert },
{ to: '/topology', label: 'Схема', icon: Network },
{ to: '/tariffs', label: 'Активные тарифы', icon: ServerCog },
{ to: '/providers', label: 'Хостеры', icon: Building2 },
@@ -0,0 +1,28 @@
import type { ReactNode } from 'react'
import {
FrameDataGrid,
type FrameDataGridProps,
} from './frame-data-grid'
/**
* Frame + DataGrid with expandable rows.
* Preview: https://reui.io/preview/base/components/c-data-grid-8
* Docs: https://reui.io/docs/components/base/data-grid
*/
export function ExpandableResourceGrid<TData extends object>({
expandedContent,
getRowCanExpand,
...props
}: FrameDataGridProps<TData> & {
expandedContent: (row: TData) => ReactNode
getRowCanExpand?: (row: TData) => boolean
}) {
return (
<FrameDataGrid
{...props}
expandedContent={expandedContent}
getRowCanExpand={getRowCanExpand}
/>
)
}
@@ -4,14 +4,16 @@ import {
getCoreRowModel,
getSortedRowModel,
getPaginationRowModel,
getExpandedRowModel,
flexRender,
type ColumnDef,
type SortingState,
type RowSelectionState,
type VisibilityState,
type ExpandedState,
type OnChangeFn,
} from '@tanstack/react-table'
import { Columns3Icon } from 'lucide-react'
import { ChevronDownIcon, ChevronRightIcon, Columns3Icon } from 'lucide-react'
import { Checkbox } from '@cfdm/ui/components/checkbox'
import { Button } from '@cfdm/ui/components/button'
@@ -125,6 +127,9 @@ export interface FrameDataGridProps<TData extends object> {
/** Начальная видимость колонок (перекрывает localStorage для отсутствующих ключей). */
initialColumnVisibility?: VisibilityState
className?: string
/** Expandable rows — c-data-grid-8 / https://reui.io/preview/base/components/c-data-grid-8 */
expandedContent?: (row: TData) => ReactNode
getRowCanExpand?: (row: TData) => boolean
}
function DataGridSectionHeader({
@@ -251,9 +256,12 @@ export function FrameDataGrid<TData extends object>({
columnVisibilityStorageKey,
initialColumnVisibility,
className,
expandedContent,
getRowCanExpand,
}: FrameDataGridProps<TData>) {
const [sorting, setSorting] = useState<SortingState>(initialSorting ?? [])
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [expanded, setExpanded] = useState<ExpandedState>({})
const [internalColumnVisibility, setInternalColumnVisibility] = useState<VisibilityState>(() => {
const stored = columnVisibilityStorageKey
? loadStoredColumnVisibility(columnVisibilityStorageKey)
@@ -295,7 +303,42 @@ export function FrameDataGrid<TData extends object>({
meta: { cellClassName: 'w-10' },
}
const tableColumns = enableRowSelection ? [selectColumn, ...columns] : columns
const expandColumn: ColumnDef<TData, unknown> = {
id: 'expand',
header: () => null,
cell: ({ row }) =>
row.getCanExpand() ? (
<Button
type="button"
variant="ghost"
size="icon-sm"
className="size-6 text-muted-foreground"
aria-label={row.getIsExpanded() ? 'Свернуть' : 'Развернуть'}
onClick={(event) => {
event.stopPropagation()
row.toggleExpanded()
}}
>
{row.getIsExpanded() ? (
<ChevronDownIcon className="size-4" />
) : (
<ChevronRightIcon className="size-4" />
)}
</Button>
) : null,
enableSorting: false,
enableHiding: false,
meta: {
cellClassName: 'w-10',
expandedContent,
},
}
const tableColumns = [
...(expandedContent ? [expandColumn] : []),
...(enableRowSelection ? [selectColumn] : []),
...columns,
]
const lastColId = pinLastColumn ? tableColumns[tableColumns.length - 1]?.id ?? '' : ''
@@ -307,9 +350,11 @@ export function FrameDataGrid<TData extends object>({
state: {
sorting,
columnVisibility,
expanded,
...(enableRowSelection ? { rowSelection } : {}),
},
onSortingChange: setSorting,
onExpandedChange: setExpanded,
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: enableRowSelection
? (updater) => {
@@ -325,6 +370,7 @@ export function FrameDataGrid<TData extends object>({
: undefined,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getExpandedRowModel: expandedContent ? getExpandedRowModel() : undefined,
getPaginationRowModel: showPagination ? getPaginationRowModel() : undefined,
initialState: {
...(showPagination ? { pagination: { pageIndex: 0, pageSize } } : {}),
@@ -333,6 +379,9 @@ export function FrameDataGrid<TData extends object>({
getRowId: rowId
? (row, index) => rowId(row, index)
: undefined,
getRowCanExpand: expandedContent
? (row) => (getRowCanExpand ? getRowCanExpand(row.original) : true)
: undefined,
enableColumnPinning: pinLastColumn,
enableRowSelection,
enableHiding: enableColumnVisibility,
@@ -20,6 +20,7 @@ export {
type FrameDataGridProps,
type DataGridColumnVisibilityOption,
} from './frame-data-grid'
export { ExpandableResourceGrid } from './expandable-resource-grid'
export { OpsDashboard } from './ops-dashboard'
export { DetailPanel, type DetailMetricCard } from './detail-panel'
export { SettingsShell, type SettingsTabConfig } from './settings-shell'
+7
View File
@@ -8,12 +8,19 @@ const STATUS_VARIANT: Record<string, BadgeVariant> = {
active: 'success',
ok: 'success',
paid: 'success',
available: 'success',
complete: 'success',
paused: 'secondary',
archived: 'outline',
error: 'destructive',
denied: 'destructive',
blocked: 'destructive',
running: 'info',
overdue: 'warning',
stale: 'warning',
timeout: 'warning',
redirected: 'warning',
partial: 'warning',
}
export function StatusBadge({ status, label }: { status: string; label?: string }) {
+31
View File
@@ -417,6 +417,37 @@ export const api = {
actorUserId?: string | null
createdAt: string
}>>(`/api/audit?limit=${limit}`),
fetchCensorcheckCurrent: () =>
fetchApi<{ items: import('@/components/censorcheck/types').CensorcheckRunDto[] }>(
'/api/censorcheck/current',
),
fetchCensorcheckRuns: (params: {
cursor?: string
limit?: number
q?: string
status?: string
matched?: boolean
} = {}) => {
const search = new URLSearchParams()
if (params.cursor) search.set('cursor', params.cursor)
if (params.limit) search.set('limit', String(params.limit))
if (params.q) search.set('q', params.q)
if (params.status) search.set('status', params.status)
if (params.matched === true) search.set('matched', '1')
if (params.matched === false) search.set('matched', '0')
const qs = search.toString()
return fetchApi<{
items: import('@/components/censorcheck/types').CensorcheckRunDto[]
nextCursor: string | null
}>(`/api/censorcheck/runs${qs ? `?${qs}` : ''}`)
},
fetchCensorcheckRun: (id: string) =>
fetchApi<import('@/components/censorcheck/types').CensorcheckRunDto>(
`/api/censorcheck/runs/${encodeURIComponent(id)}`,
),
}
export type {
+1
View File
@@ -234,6 +234,7 @@ export function permissionForPath(pathname: string): string | null {
if (pathname.startsWith('/dashboard')) return 'vps:dashboard:read'
if (
pathname.startsWith('/vps') ||
pathname.startsWith('/blocking') ||
pathname.startsWith('/topology') ||
pathname.startsWith('/tariffs') ||
pathname.startsWith('/projects') ||
+40
View File
@@ -0,0 +1,40 @@
import { queryClient } from '../lib/queryClient'
import { api } from '../lib/api-client'
import { getStoredSpaceId } from '../lib/space'
export const censorcheckKeys = {
all: ['censorcheck'] as const,
current: (spaceId: string | null) => ['censorcheck', 'current', spaceId ?? 'default'] as const,
history: (spaceId: string | null, params: Record<string, unknown>) =>
['censorcheck', 'history', spaceId ?? 'default', params] as const,
detail: (id: string) => ['censorcheck', 'run', id] as const,
}
export const censorcheckCurrentQueryOptions = (spaceId?: string | null) => {
const id = spaceId === undefined ? getStoredSpaceId() : spaceId
return {
queryKey: censorcheckKeys.current(id),
queryFn: () => api.fetchCensorcheckCurrent(),
staleTime: 15_000,
}
}
export const censorcheckHistoryQueryOptions = (
params: { cursor?: string; limit?: number; q?: string; status?: string; matched?: boolean } = {},
spaceId?: string | null,
) => {
const id = spaceId === undefined ? getStoredSpaceId() : spaceId
return {
queryKey: censorcheckKeys.history(id, params),
queryFn: () => api.fetchCensorcheckRuns({ limit: 50, ...params }),
staleTime: 15_000,
}
}
export const censorcheckRunQueryOptions = (id: string | null) => ({
queryKey: censorcheckKeys.detail(id ?? ''),
queryFn: () => api.fetchCensorcheckRun(id!),
enabled: Boolean(id),
})
export { queryClient }
+21
View File
@@ -24,6 +24,7 @@ import { Route as AuthProvidersRouteImport } from './routes/_auth/providers'
import { Route as AuthProjectsRouteImport } from './routes/_auth/projects'
import { Route as AuthPaymentsRouteImport } from './routes/_auth/payments'
import { Route as AuthDashboardRouteImport } from './routes/_auth/dashboard'
import { Route as AuthBlockingRouteImport } from './routes/_auth/blocking'
import { Route as AuthBalanceRouteImport } from './routes/_auth/balance'
import { Route as AuthAuditRouteImport } from './routes/_auth/audit'
import { Route as AuthAccountsRouteImport } from './routes/_auth/accounts'
@@ -109,6 +110,11 @@ const AuthDashboardRoute = AuthDashboardRouteImport.update({
path: '/dashboard',
getParentRoute: () => AuthRoute,
} as any)
const AuthBlockingRoute = AuthBlockingRouteImport.update({
id: '/blocking',
path: '/blocking',
getParentRoute: () => AuthRoute,
} as any)
const AuthBalanceRoute = AuthBalanceRouteImport.update({
id: '/balance',
path: '/balance',
@@ -168,6 +174,7 @@ export interface FileRoutesByFullPath {
'/accounts': typeof AuthAccountsRoute
'/audit': typeof AuthAuditRoute
'/balance': typeof AuthBalanceRoute
'/blocking': typeof AuthBlockingRoute
'/dashboard': typeof AuthDashboardRoute
'/payments': typeof AuthPaymentsRoute
'/projects': typeof AuthProjectsRouteWithChildren
@@ -193,6 +200,7 @@ export interface FileRoutesByTo {
'/accounts': typeof AuthAccountsRoute
'/audit': typeof AuthAuditRoute
'/balance': typeof AuthBalanceRoute
'/blocking': typeof AuthBlockingRoute
'/dashboard': typeof AuthDashboardRoute
'/payments': typeof AuthPaymentsRoute
'/projects': typeof AuthProjectsRouteWithChildren
@@ -221,6 +229,7 @@ export interface FileRoutesById {
'/_auth/accounts': typeof AuthAccountsRoute
'/_auth/audit': typeof AuthAuditRoute
'/_auth/balance': typeof AuthBalanceRoute
'/_auth/blocking': typeof AuthBlockingRoute
'/_auth/dashboard': typeof AuthDashboardRoute
'/_auth/payments': typeof AuthPaymentsRoute
'/_auth/projects': typeof AuthProjectsRouteWithChildren
@@ -249,6 +258,7 @@ export interface FileRouteTypes {
| '/accounts'
| '/audit'
| '/balance'
| '/blocking'
| '/dashboard'
| '/payments'
| '/projects'
@@ -274,6 +284,7 @@ export interface FileRouteTypes {
| '/accounts'
| '/audit'
| '/balance'
| '/blocking'
| '/dashboard'
| '/payments'
| '/projects'
@@ -301,6 +312,7 @@ export interface FileRouteTypes {
| '/_auth/accounts'
| '/_auth/audit'
| '/_auth/balance'
| '/_auth/blocking'
| '/_auth/dashboard'
| '/_auth/payments'
| '/_auth/projects'
@@ -435,6 +447,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthDashboardRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/blocking': {
id: '/_auth/blocking'
path: '/blocking'
fullPath: '/blocking'
preLoaderRoute: typeof AuthBlockingRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/balance': {
id: '/_auth/balance'
path: '/balance'
@@ -553,6 +572,7 @@ interface AuthRouteChildren {
AuthAccountsRoute: typeof AuthAccountsRoute
AuthAuditRoute: typeof AuthAuditRoute
AuthBalanceRoute: typeof AuthBalanceRoute
AuthBlockingRoute: typeof AuthBlockingRoute
AuthDashboardRoute: typeof AuthDashboardRoute
AuthPaymentsRoute: typeof AuthPaymentsRoute
AuthProjectsRoute: typeof AuthProjectsRouteWithChildren
@@ -572,6 +592,7 @@ const AuthRouteChildren: AuthRouteChildren = {
AuthAccountsRoute: AuthAccountsRoute,
AuthAuditRoute: AuthAuditRoute,
AuthBalanceRoute: AuthBalanceRoute,
AuthBlockingRoute: AuthBlockingRoute,
AuthDashboardRoute: AuthDashboardRoute,
AuthPaymentsRoute: AuthPaymentsRoute,
AuthProjectsRoute: AuthProjectsRouteWithChildren,
+10
View File
@@ -0,0 +1,10 @@
import { createFileRoute } from '@tanstack/react-router'
import { BlockingPage } from '@/components/censorcheck/blocking-page'
import { censorcheckCurrentQueryOptions } from '@/queries/censorcheck'
export const Route = createFileRoute('/_auth/blocking')({
loader: ({ context: { queryClient } }) =>
queryClient.ensureQueryData(censorcheckCurrentQueryOptions()),
component: BlockingPage,
})
-1
View File
@@ -1,7 +1,6 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@cfdm/ui/components/*": ["../../packages/ui/src/components/*"],