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