first
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
dist
|
||||||
|
.env
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
coverage/
|
||||||
|
.nyc_output/
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
FROM node:20-alpine AS base
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm install --production=false
|
||||||
|
|
||||||
|
COPY tsconfig.json ./
|
||||||
|
COPY src ./src
|
||||||
|
|
||||||
|
ENV PORT=20205
|
||||||
|
ENV DB_NAME=Identity_verification
|
||||||
|
ENV DB_USER=root
|
||||||
|
ENV DB_PASS=changeme
|
||||||
|
ENV DB_HOST=mysql
|
||||||
|
ENV DB_PORT=3306
|
||||||
|
ENV JIBIT_API_KEY=change_me
|
||||||
|
ENV JIBIT_SECRET_KEY=change_me
|
||||||
|
|
||||||
|
EXPOSE 20205
|
||||||
|
|
||||||
|
CMD ["npx", "tsx", "src/index.ts"]
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
version: '3.9'
|
||||||
|
services:
|
||||||
|
mysql:
|
||||||
|
image: mysql:8.0
|
||||||
|
container_name: iv_mysql
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MYSQL_DATABASE: ${DB_NAME:-Identity_verification}
|
||||||
|
MYSQL_ROOT_PASSWORD: ${DB_PASS:-changeme}
|
||||||
|
ports:
|
||||||
|
- "3307:3306"
|
||||||
|
volumes:
|
||||||
|
- mysql_data:/var/lib/mysql
|
||||||
|
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
container_name: iv_app
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
PORT: ${PORT:-20205}
|
||||||
|
DB_NAME: ${DB_NAME:-Identity_verification}
|
||||||
|
DB_USER: ${DB_USER:-root}
|
||||||
|
DB_PASS: ${DB_PASS:-changeme}
|
||||||
|
DB_HOST: ${DB_HOST:-mysql}
|
||||||
|
DB_PORT: ${DB_PORT:-3306}
|
||||||
|
JIBIT_API_KEY: ${JIBIT_API_KEY:-change_me}
|
||||||
|
JIBIT_SECRET_KEY: ${JIBIT_SECRET_KEY:-change_me}
|
||||||
|
ports:
|
||||||
|
- "${PORT:-20205}:${PORT:-20205}"
|
||||||
|
depends_on:
|
||||||
|
- mysql
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mysql_data:
|
||||||
Generated
+1322
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "identity-verification-api",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"start": "tsx src/index.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@hono/node-server": "^1.8.0",
|
||||||
|
"@hono/swagger-ui": "^0.2.0",
|
||||||
|
"@hono/zod-openapi": "^1.1.4",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"hono": "^4.0.0",
|
||||||
|
"mysql2": "^3.6.0",
|
||||||
|
"sequelize": "^6.35.0",
|
||||||
|
"zod": "^4.1.12"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.11.0",
|
||||||
|
"tsx": "^4.7.0",
|
||||||
|
"typescript": "^5.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { Sequelize } from 'sequelize';
|
||||||
|
|
||||||
|
const DB_NAME = process.env.DB_NAME || 'Identity_verification';
|
||||||
|
const DB_USER = process.env.DB_USER || 'root';
|
||||||
|
const DB_PASS = process.env.DB_PASS || '@8@w!1o*8Aq@8P$L20';
|
||||||
|
const DB_HOST = process.env.DB_HOST || '185.97.116.24';
|
||||||
|
const DB_PORT = parseInt(process.env.DB_PORT || '3306', 10);
|
||||||
|
|
||||||
|
// اتصال به دیتابیس MySQL
|
||||||
|
export const sequelize = new Sequelize(DB_NAME, DB_USER, DB_PASS, {
|
||||||
|
host: DB_HOST,
|
||||||
|
port: DB_PORT,
|
||||||
|
dialect: 'mysql',
|
||||||
|
logging: false,
|
||||||
|
pool: {
|
||||||
|
max: 5,
|
||||||
|
min: 0,
|
||||||
|
acquire: 30000,
|
||||||
|
idle: 10000
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// تست اتصال
|
||||||
|
export const connectDatabase = async () => {
|
||||||
|
try {
|
||||||
|
await sequelize.authenticate();
|
||||||
|
console.log('✅ Database connected');
|
||||||
|
await sequelize.sync({ alter: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Database connection failed:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { Context } from 'hono';
|
||||||
|
import { User } from '../models/User.model.js';
|
||||||
|
import { Token } from '../models/Token.model.js';
|
||||||
|
import { Person } from '../models/Person.model.js';
|
||||||
|
import { Log } from '../models/Log.model.js';
|
||||||
|
import { generateTokens, refreshTokens } from '../services/jibit.service.js';
|
||||||
|
import { generateToken } from '../utils/token.js';
|
||||||
|
|
||||||
|
export const checkUser = async (c: Context) => {
|
||||||
|
try {
|
||||||
|
const authHeader = c.req.header('authorization') || '';
|
||||||
|
const providedToken = authHeader.startsWith('Bearer ') ? authHeader.substring(7) : authHeader;
|
||||||
|
|
||||||
|
const body = await c.req.json().catch(() => ({} as any));
|
||||||
|
const number = String((body as any).number || '');
|
||||||
|
const national = String((body as any).national || '');
|
||||||
|
const hostBody = (body as any).host ? String((body as any).host) : null;
|
||||||
|
|
||||||
|
if (!number || !national) {
|
||||||
|
return c.json({ matched: false, error: 'number and national are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userCount = await User.count();
|
||||||
|
if (userCount === 0) {
|
||||||
|
await User.create({ username: 'admin', token: 'admin-token', domain: null, host: null, isAdmin: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const requester = await User.findOne({ where: { token: providedToken } });
|
||||||
|
if (!requester) {
|
||||||
|
return c.json({ matched: false, error: 'unauthorized' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
let subjectUser: User = requester as any;
|
||||||
|
if (hostBody) {
|
||||||
|
if (!(requester as any).isAdmin && (requester as any).host && (requester as any).host !== hostBody) {
|
||||||
|
return c.json({ matched: false, error: 'forbidden_host' }, 403);
|
||||||
|
}
|
||||||
|
const existing = await User.findOne({ where: { host: hostBody } });
|
||||||
|
subjectUser = existing ? (existing as any) : (await User.create({ username: hostBody, host: hostBody, token: generateToken(24), isAdmin: false })) as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = await Person.findOne({ where: { national, number, userId: (subjectUser as any).id } });
|
||||||
|
if (cached) {
|
||||||
|
return c.json({ matched: !!cached.matched });
|
||||||
|
}
|
||||||
|
|
||||||
|
let tokenRow = await Token.findOne();
|
||||||
|
if (!tokenRow) {
|
||||||
|
const generated = await generateTokens();
|
||||||
|
if (generated.ok) {
|
||||||
|
tokenRow = await Token.create({ accessToken: generated.accessToken!, refreshToken: generated.refreshToken! });
|
||||||
|
} else {
|
||||||
|
return c.json({ matched: false, error: generated.error || 'no Jibit tokens available', code: generated.code, status: generated.status });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = await loggedRequestJibit((subjectUser as any).id, tokenRow.accessToken, national, number);
|
||||||
|
if ((response as any).code === 'forbidden' || (response as any).status === 403 || (response as any).status === 401) {
|
||||||
|
const refreshed = await refreshTokens(tokenRow.refreshToken);
|
||||||
|
if (refreshed.ok) {
|
||||||
|
await Token.update({ accessToken: refreshed.accessToken!, refreshToken: refreshed.refreshToken! }, { where: { id: (tokenRow as any).id } });
|
||||||
|
response = await loggedRequestJibit((subjectUser as any).id, refreshed.accessToken!, national, number);
|
||||||
|
} else {
|
||||||
|
const generated = await generateTokens();
|
||||||
|
if (generated.ok) {
|
||||||
|
await Token.update({ accessToken: generated.accessToken!, refreshToken: generated.refreshToken! }, { where: { id: (tokenRow as any).id } });
|
||||||
|
response = await loggedRequestJibit((subjectUser as any).id, generated.accessToken!, national, number);
|
||||||
|
} else {
|
||||||
|
return c.json({ matched: false, error: generated.error || 'failed to refresh/generate token', code: generated.code, status: generated.status });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response && !(response as any).matched && ((response as any).error || (response as any).code || (response as any).status)) {
|
||||||
|
return c.json({ matched: false, error: (response as any).error || (response as any).message || 'jibit_error', code: (response as any).code, status: (response as any).status });
|
||||||
|
}
|
||||||
|
|
||||||
|
const matched = !!(response as any).matched;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Person.create({ national, number, matched, userId: (subjectUser as any).id });
|
||||||
|
} catch (e: any) {
|
||||||
|
const name = (e && e.name) || '';
|
||||||
|
if (name.includes('Unique') || name.includes('Validation')) {
|
||||||
|
const existing = await Person.findOne({ where: { national, number, userId: (subjectUser as any).id } });
|
||||||
|
return c.json({ matched: !!existing?.matched });
|
||||||
|
}
|
||||||
|
console.error('Person.create error:', e);
|
||||||
|
return c.json({ matched });
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ matched });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
return c.json({ matched: false, error: error?.message || 'internal_error' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loggedRequestJibit(userId: number, accessToken: string, nationalCode: string, mobileNumber: string) {
|
||||||
|
const targetUrl = 'https://napi.jibit.ir/ide/v1/services/matching?' + new URLSearchParams({ nationalCode, mobileNumber }).toString();
|
||||||
|
const start = Date.now();
|
||||||
|
let status = 0;
|
||||||
|
let respBody: any = null;
|
||||||
|
const reqBody = { nationalCode, mobileNumber };
|
||||||
|
try {
|
||||||
|
const resp = await fetch(targetUrl, { method: 'GET', headers: { Authorization: `Bearer ${accessToken}` } });
|
||||||
|
status = resp.status;
|
||||||
|
try { respBody = await resp.json(); } catch {}
|
||||||
|
await Log.create({ type: 'outgoing', method: 'GET', path: '/external/jibit', headers: JSON.stringify({ Authorization: 'Bearer ***' }), body: JSON.stringify(reqBody).slice(0, 200000), response: JSON.stringify(respBody || {}).slice(0, 200000), status, durationMs: Date.now() - start, ip: null, userId, targetUrl });
|
||||||
|
if (status === 403 || status === 401) return { code: 'forbidden', status };
|
||||||
|
if (!resp.ok) return { matched: false, error: (respBody && (respBody.error || respBody.message)) || `jibit_http_${status}`, code: respBody && respBody.code, status };
|
||||||
|
return respBody || { matched: false };
|
||||||
|
} catch (e: any) {
|
||||||
|
await Log.create({ type: 'outgoing', method: 'GET', path: '/external/jibit', headers: JSON.stringify({ Authorization: 'Bearer ***' }), body: JSON.stringify(reqBody).slice(0, 200000), response: JSON.stringify({ error: e?.message || 'network_error' }), status: status || 0, durationMs: Date.now() - start, ip: null, userId, targetUrl });
|
||||||
|
return { matched: false, error: e?.message || 'network_error' };
|
||||||
|
}
|
||||||
|
}
|
||||||
+172
@@ -0,0 +1,172 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
import { OpenAPIHono } from '@hono/zod-openapi';
|
||||||
|
import { serve } from '@hono/node-server';
|
||||||
|
import { swaggerUI } from '@hono/swagger-ui';
|
||||||
|
import { cors } from 'hono/cors';
|
||||||
|
import { registerUserRoutes } from './routes/user.routes.js';
|
||||||
|
import { connectDatabase } from './config/database.js';
|
||||||
|
import { Log } from './models/Log.model.js';
|
||||||
|
import { User } from './models/User.model.js';
|
||||||
|
|
||||||
|
const app = new OpenAPIHono();
|
||||||
|
|
||||||
|
app.use('*', cors());
|
||||||
|
|
||||||
|
app.use('*', async (c, next) => {
|
||||||
|
const start = Date.now();
|
||||||
|
const method = c.req.method;
|
||||||
|
const url = new URL(c.req.url);
|
||||||
|
const path = url.pathname;
|
||||||
|
const headersObj: Record<string, string> = {};
|
||||||
|
c.req.raw.headers.forEach((v, k) => { headersObj[k] = v; });
|
||||||
|
let requestBody: any = null;
|
||||||
|
try { requestBody = await c.req.json(); } catch {}
|
||||||
|
|
||||||
|
await next();
|
||||||
|
|
||||||
|
const durationMs = Date.now() - start;
|
||||||
|
const status = c.res.status || 200;
|
||||||
|
let responseBody: any = null;
|
||||||
|
try { responseBody = await c.res.clone().json(); } catch {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const count = await Log.count();
|
||||||
|
if (count > 85000) {
|
||||||
|
await Log.destroy({ where: {}, order: [['createdAt', 'ASC']], limit: count - 85000 } as any);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let userId: number | null = null;
|
||||||
|
const auth = headersObj['authorization'] || headersObj['Authorization'];
|
||||||
|
const token = auth ? (auth.startsWith('Bearer ') ? auth.substring(7) : auth) : null;
|
||||||
|
if (token) {
|
||||||
|
const user = await User.findOne({ where: { token } });
|
||||||
|
if (user) userId = (user as any).id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ipHeader = headersObj['x-forwarded-for'] || headersObj['cf-connecting-ip'] || headersObj['x-real-ip'] || '';
|
||||||
|
|
||||||
|
await Log.create({
|
||||||
|
type: 'incoming',
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
headers: JSON.stringify(headersObj).slice(0, 100000),
|
||||||
|
body: requestBody ? JSON.stringify(requestBody).slice(0, 200000) : '',
|
||||||
|
response: responseBody ? JSON.stringify(responseBody).slice(0, 200000) : '',
|
||||||
|
status,
|
||||||
|
durationMs,
|
||||||
|
ip: ipHeader || null,
|
||||||
|
userId,
|
||||||
|
targetUrl: null
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
|
||||||
|
registerUserRoutes(app);
|
||||||
|
|
||||||
|
app.doc('/doc', {
|
||||||
|
openapi: '3.0.0',
|
||||||
|
info: {
|
||||||
|
version: '1.0.0',
|
||||||
|
title: 'سامانه استعلام کد ملی',
|
||||||
|
description: 'API اعتبارسنجی کد ملی و شماره موبایل'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/swagger', swaggerUI({ url: '/doc' }));
|
||||||
|
|
||||||
|
app.get('/', (c) => {
|
||||||
|
return c.html(`<!doctype html>
|
||||||
|
<html lang="fa" dir="rtl">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>پنل ادمین - استعلام کد ملی</title>
|
||||||
|
<style>
|
||||||
|
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;background:#0b1220;color:#e5e7eb;margin:0;padding:24px}
|
||||||
|
.card{max-width:760px;margin:0 auto;background:#0f172a;border:1px solid #1f2937;border-radius:12px;padding:20px}
|
||||||
|
h1{font-size:20px;margin:0 0 12px}
|
||||||
|
.row{display:flex;gap:12px;flex-wrap:wrap;margin-bottom:12px}
|
||||||
|
.col{flex:1 1 220px;min-width:220px}
|
||||||
|
label{display:block;font-size:12px;color:#93c5fd;margin-bottom:6px}
|
||||||
|
input{width:100%;padding:10px 12px;border:1px solid #334155;border-radius:8px;background:#0b1020;color:#e5e7eb}
|
||||||
|
button{padding:10px 14px;border:1px solid #334155;border-radius:8px;background:#1e293b;color:#e5e7eb;cursor:pointer}
|
||||||
|
button.primary{background:#2563eb;border-color:#2563eb}
|
||||||
|
.bar{display:flex;gap:8px;align-items:end;margin-bottom:16px}
|
||||||
|
pre{background:#0b1020;border:1px solid #1f2937;border-radius:8px;padding:12px;overflow:auto;max-height:360px}
|
||||||
|
small{color:#9ca3af}
|
||||||
|
a{color:#93c5fd;text-decoration:none}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<h1>پنل ادمین / NationalID Checker</h1>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<label>توکن (Authorization)</label>
|
||||||
|
<input id="token" placeholder="Bearer token" />
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<label>Host</label>
|
||||||
|
<input id="host" placeholder="مثال: golak" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bar">
|
||||||
|
<button class="primary" id="saveToken">ذخیره توکن</button>
|
||||||
|
<small id="tokenStatus"></small>
|
||||||
|
<div style="flex:1"></div>
|
||||||
|
<a href="/swagger" target="_blank">Swagger</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<label>شماره موبایل</label>
|
||||||
|
<input id="number" placeholder="09xxxxxxxxx" />
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<label>کد ملی</label>
|
||||||
|
<input id="national" placeholder="xxxxxxxxxx" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bar">
|
||||||
|
<button id="runQuery" class="primary">استعلام</button>
|
||||||
|
<small>نتیجه در پایین نمایش داده میشود</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<pre id="out">Ready.</pre>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
const out = (data) => { $('out').textContent = typeof data === 'string' ? data : JSON.stringify(data, null, 2); };
|
||||||
|
function loadToken(){ const t = localStorage.getItem('admin_token') || ''; $('token').value = t; $('tokenStatus').textContent = t ? 'توکن ست شد' : 'توکن وارد نشده'; }
|
||||||
|
$('saveToken').onclick = () => { localStorage.setItem('admin_token', $('token').value.trim()); loadToken(); };
|
||||||
|
$('runQuery').onclick = async () => {
|
||||||
|
const token = localStorage.getItem('admin_token') || '';
|
||||||
|
const number = $('number').value.trim();
|
||||||
|
const national = $('national').value.trim();
|
||||||
|
const host = $('host').value.trim();
|
||||||
|
if(!token){ out('توکن وارد نشده'); return; }
|
||||||
|
try{
|
||||||
|
const res = await fetch('/check_user', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': token },
|
||||||
|
body: JSON.stringify({ number, national, host })
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
out(json);
|
||||||
|
}catch(e){ out(String(e)); }
|
||||||
|
};
|
||||||
|
loadToken();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const port = parseInt(process.env.PORT || '20205', 10);
|
||||||
|
|
||||||
|
connectDatabase().then(() => {
|
||||||
|
console.log(`🚀 Server: http://localhost:${port}`);
|
||||||
|
console.log(`📖 Docs: http://localhost:${port}/swagger`);
|
||||||
|
serve({ fetch: app.fetch, port });
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { DataTypes, Model } from 'sequelize';
|
||||||
|
import { sequelize } from '../config/database.js';
|
||||||
|
|
||||||
|
export class Card extends Model {
|
||||||
|
declare id: number;
|
||||||
|
declare cardNumber: string;
|
||||||
|
declare bin?: string;
|
||||||
|
declare bank?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
Card.init(
|
||||||
|
{
|
||||||
|
id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true },
|
||||||
|
cardNumber: { type: DataTypes.STRING(16), allowNull: false, unique: true },
|
||||||
|
bin: { type: DataTypes.STRING(6), allowNull: true },
|
||||||
|
bank: { type: DataTypes.STRING(128), allowNull: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sequelize,
|
||||||
|
tableName: 'cards',
|
||||||
|
timestamps: true
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { DataTypes, Model } from 'sequelize';
|
||||||
|
import { sequelize } from '../config/database.js';
|
||||||
|
|
||||||
|
export class Log extends Model {
|
||||||
|
declare id: number;
|
||||||
|
declare type: string; // incoming | outgoing
|
||||||
|
declare method: string;
|
||||||
|
declare path: string;
|
||||||
|
declare headers: string;
|
||||||
|
declare body: string;
|
||||||
|
declare response: string;
|
||||||
|
declare status: number;
|
||||||
|
declare durationMs: number;
|
||||||
|
declare ip: string | null;
|
||||||
|
declare userId: number | null;
|
||||||
|
declare targetUrl: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.init(
|
||||||
|
{
|
||||||
|
id: { type: DataTypes.BIGINT, autoIncrement: true, primaryKey: true },
|
||||||
|
type: { type: DataTypes.STRING(16), allowNull: false, defaultValue: 'incoming' },
|
||||||
|
method: { type: DataTypes.STRING(10), allowNull: false },
|
||||||
|
path: { type: DataTypes.STRING(512), allowNull: false },
|
||||||
|
headers: { type: DataTypes.TEXT('long'), allowNull: false },
|
||||||
|
body: { type: DataTypes.TEXT('long'), allowNull: true },
|
||||||
|
response: { type: DataTypes.TEXT('long'), allowNull: true },
|
||||||
|
status: { type: DataTypes.INTEGER, allowNull: false },
|
||||||
|
durationMs: { type: DataTypes.INTEGER, allowNull: false },
|
||||||
|
ip: { type: DataTypes.STRING(64), allowNull: true },
|
||||||
|
userId: { type: DataTypes.INTEGER, allowNull: true },
|
||||||
|
targetUrl: { type: DataTypes.STRING(1024), allowNull: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sequelize,
|
||||||
|
tableName: 'logs',
|
||||||
|
timestamps: true,
|
||||||
|
indexes: [
|
||||||
|
{ fields: ['createdAt'] },
|
||||||
|
{ fields: ['path'] },
|
||||||
|
{ fields: ['type'] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { DataTypes, Model } from 'sequelize';
|
||||||
|
import { sequelize } from '../config/database.js';
|
||||||
|
|
||||||
|
export class Person extends Model {
|
||||||
|
declare id: number;
|
||||||
|
declare national: string;
|
||||||
|
declare number: string;
|
||||||
|
declare matched: boolean;
|
||||||
|
declare userId: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Person.init(
|
||||||
|
{
|
||||||
|
id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true },
|
||||||
|
national: { type: DataTypes.STRING(10), allowNull: false },
|
||||||
|
number: { type: DataTypes.STRING(11), allowNull: false },
|
||||||
|
matched: { type: DataTypes.BOOLEAN, defaultValue: false },
|
||||||
|
userId: { type: DataTypes.INTEGER, allowNull: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sequelize,
|
||||||
|
tableName: 'persons',
|
||||||
|
timestamps: true,
|
||||||
|
indexes: [
|
||||||
|
{ unique: true, fields: ['national', 'number', 'userId'] },
|
||||||
|
{ fields: ['userId'] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { DataTypes, Model } from 'sequelize';
|
||||||
|
import { sequelize } from '../config/database.js';
|
||||||
|
import { User } from './User.model.js';
|
||||||
|
|
||||||
|
// مدل استعلام
|
||||||
|
export class Query extends Model {
|
||||||
|
declare id: number;
|
||||||
|
declare nationalCode: string;
|
||||||
|
declare mobileNumber: string;
|
||||||
|
declare matched: boolean;
|
||||||
|
declare userId: number;
|
||||||
|
declare source: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
Query.init(
|
||||||
|
{
|
||||||
|
id: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
autoIncrement: true,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
nationalCode: {
|
||||||
|
type: DataTypes.STRING(10),
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
mobileNumber: {
|
||||||
|
type: DataTypes.STRING(11),
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
matched: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
userId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'users',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
source: {
|
||||||
|
type: DataTypes.STRING(50),
|
||||||
|
defaultValue: 'cache',
|
||||||
|
comment: 'cache or jibit'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sequelize,
|
||||||
|
tableName: 'queries',
|
||||||
|
timestamps: true,
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
unique: true,
|
||||||
|
fields: ['nationalCode', 'mobileNumber']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
Query.belongsTo(User, { foreignKey: 'userId' });
|
||||||
|
User.hasMany(Query, { foreignKey: 'userId' });
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { DataTypes, Model } from 'sequelize';
|
||||||
|
import { sequelize } from '../config/database.js';
|
||||||
|
|
||||||
|
export class Token extends Model {
|
||||||
|
declare id: number;
|
||||||
|
declare accessToken: string;
|
||||||
|
declare refreshToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
Token.init(
|
||||||
|
{
|
||||||
|
id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true },
|
||||||
|
accessToken: { type: DataTypes.TEXT, allowNull: false },
|
||||||
|
refreshToken: { type: DataTypes.TEXT, allowNull: false }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sequelize,
|
||||||
|
tableName: 'tokens',
|
||||||
|
timestamps: true
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { DataTypes, Model } from 'sequelize';
|
||||||
|
import { sequelize } from '../config/database.js';
|
||||||
|
|
||||||
|
// مدل کاربر (برای احراز هویت و محدودیتها)
|
||||||
|
export class User extends Model {
|
||||||
|
declare id: number;
|
||||||
|
declare username: string;
|
||||||
|
declare domain: string | null;
|
||||||
|
declare host: string | null;
|
||||||
|
declare token: string;
|
||||||
|
declare isAdmin: boolean;
|
||||||
|
declare queryCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
User.init(
|
||||||
|
{
|
||||||
|
id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true },
|
||||||
|
username: { type: DataTypes.STRING(100), allowNull: false },
|
||||||
|
domain: { type: DataTypes.STRING(255), allowNull: true },
|
||||||
|
host: { type: DataTypes.STRING(255), allowNull: true },
|
||||||
|
token: { type: DataTypes.STRING(255), allowNull: false },
|
||||||
|
isAdmin: { type: DataTypes.BOOLEAN, defaultValue: false },
|
||||||
|
queryCount: { type: DataTypes.INTEGER, defaultValue: 0 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sequelize,
|
||||||
|
tableName: 'users',
|
||||||
|
timestamps: true,
|
||||||
|
indexes: [
|
||||||
|
{ fields: ['token'] },
|
||||||
|
{ fields: ['username'] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { createRoute, OpenAPIHono } from '@hono/zod-openapi';
|
||||||
|
import { CheckUserSchema, CheckUserResponseSchema } from '../schemas/user.schema.js';
|
||||||
|
import { checkUser } from '../controllers/user.controller.js';
|
||||||
|
|
||||||
|
// Route definition
|
||||||
|
const checkUserRoute = createRoute({
|
||||||
|
method: 'post',
|
||||||
|
path: '/check_user',
|
||||||
|
tags: ['User Verification'],
|
||||||
|
summary: 'بررسی تطابق کد ملی و شماره موبایل',
|
||||||
|
description: 'اعتبارسنجی کد ملی و شماره موبایل کاربر',
|
||||||
|
request: {
|
||||||
|
body: {
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: CheckUserSchema
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: 'موفق',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: CheckUserResponseSchema
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register routes
|
||||||
|
export const registerUserRoutes = (app: OpenAPIHono) => {
|
||||||
|
// @ts-ignore: openapi handler generic typing mismatch
|
||||||
|
app.openapi(checkUserRoute, checkUser as any);
|
||||||
|
};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { z } from '@hono/zod-openapi';
|
||||||
|
|
||||||
|
// Request Schema
|
||||||
|
export const CheckUserSchema = z.object({
|
||||||
|
number: z.string().regex(/^09\d{9}$/, 'شماره موبایل نامعتبر').openapi({
|
||||||
|
example: '09123456789',
|
||||||
|
description: 'شماره موبایل'
|
||||||
|
}),
|
||||||
|
national: z.string().length(10, 'کد ملی باید 10 رقمی باشد').openapi({
|
||||||
|
example: '0123456789',
|
||||||
|
description: 'کد ملی'
|
||||||
|
})
|
||||||
|
}).openapi('CheckUserRequest');
|
||||||
|
|
||||||
|
// Response Schema
|
||||||
|
export const CheckUserResponseSchema = z.object({
|
||||||
|
matched: z.boolean().openapi({
|
||||||
|
example: true,
|
||||||
|
description: 'وضعیت تطابق اطلاعات'
|
||||||
|
})
|
||||||
|
}).openapi('CheckUserResponse');
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
export type JibitTokenResult = { ok: boolean; accessToken?: string; refreshToken?: string; error?: string; code?: string; status?: number };
|
||||||
|
|
||||||
|
const API_KEY = process.env.JIBIT_API_KEY || '';
|
||||||
|
const SECRET_KEY = process.env.JIBIT_SECRET_KEY || '';
|
||||||
|
|
||||||
|
export async function generateTokens(): Promise<JibitTokenResult> {
|
||||||
|
if (!API_KEY || !SECRET_KEY) return { ok: false, error: 'jibit_keys_missing' };
|
||||||
|
let resp: Response;
|
||||||
|
try {
|
||||||
|
resp = await fetch('https://napi.jibit.ir/ide/v1/tokens/generate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ apiKey: API_KEY, secretKey: SECRET_KEY })
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
return { ok: false, error: e?.message || 'network_error' };
|
||||||
|
}
|
||||||
|
let data: any = null;
|
||||||
|
try { data = await resp.json(); } catch {}
|
||||||
|
if (!resp.ok) return { ok: false, error: (data && (data.error || data.message)) || `jibit_http_${resp.status}`, code: data && data.code, status: resp.status };
|
||||||
|
return { ok: true, accessToken: data?.accessToken, refreshToken: data?.refreshToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshTokens(refreshToken: string): Promise<JibitTokenResult> {
|
||||||
|
if (!API_KEY || !SECRET_KEY) return { ok: false, error: 'jibit_keys_missing' };
|
||||||
|
let resp: Response;
|
||||||
|
try {
|
||||||
|
resp = await fetch('https://napi.jibit.ir/ide/v1/tokens/generate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${refreshToken}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ apiKey: API_KEY, secretKey: SECRET_KEY })
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
return { ok: false, error: e?.message || 'network_error' };
|
||||||
|
}
|
||||||
|
let data: any = null;
|
||||||
|
try { data = await resp.json(); } catch {}
|
||||||
|
if (!resp.ok) return { ok: false, error: (data && (data.error || data.message)) || `jibit_http_${resp.status}`, code: data && data.code, status: resp.status };
|
||||||
|
return { ok: true, accessToken: data?.accessToken, refreshToken: data?.refreshToken };
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { randomBytes } from 'crypto';
|
||||||
|
|
||||||
|
export function generateToken(byteLength: number = 32): string {
|
||||||
|
const buf = randomBytes(byteLength);
|
||||||
|
// base64url encode
|
||||||
|
return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
### بررسی اطلاعات کاربر
|
||||||
|
POST http://localhost:20205/check_user
|
||||||
|
Authorization: Bearer admin-token
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"number": "09372281182",
|
||||||
|
"national": "1274467144",
|
||||||
|
"host": "golak"
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
### مشاهده مستندات
|
||||||
|
GET http://localhost:20205/
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
### مشخصات OpenAPI
|
||||||
|
GET http://localhost:20205/doc
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": false,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user