Initial commit
This commit is contained in:
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
.env
|
||||||
|
.git
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
27
.env.example
Normal file
27
.env.example
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
PORT=8082
|
||||||
|
CORS_ORIGIN=http://localhost:3000,http://127.0.0.1:3000
|
||||||
|
|
||||||
|
# Local dev only — skip 2FA for API keys (requires BARONG_SKIP_API_KEY_2FA=true on Dalan)
|
||||||
|
SKIP_API_KEY_2FA=true
|
||||||
|
|
||||||
|
# Host dev (BFF on host → Traefik/Envoy)
|
||||||
|
DALAN_URL=http://www.app.local/api/v2/dalan
|
||||||
|
DENA_URL=http://www.app.local/api/v2/dena
|
||||||
|
|
||||||
|
# Docker stack (BFF container → Envoy gateway)
|
||||||
|
# DALAN_URL=http://gateway:8099/api/v2/dalan
|
||||||
|
# DENA_URL=http://gateway:8099/api/v2/dena
|
||||||
|
# FIBITEX_HOST=www.app.local
|
||||||
|
# CORS_ORIGIN=http://shahoo.app.local,http://localhost:3000,http://127.0.0.1:3000
|
||||||
|
|
||||||
|
# Rango private upstream for /api/1/ranger/ws proxy (WS4)
|
||||||
|
# Prefer JWT → direct Rango (avoids Envoy cookie 401 on WS upgrade):
|
||||||
|
# BARONG_URL=http://barong:8001
|
||||||
|
# RANGER_PRIVATE_DIRECT_URL=ws://rango:8080/api/v2/ranger/private
|
||||||
|
# Fallback cookie via gateway:
|
||||||
|
# RANGER_PRIVATE_URL=ws://gateway:8099/api/v2/ranger/private
|
||||||
|
# FIBITEX_HOST=www.app.local
|
||||||
|
# REDIS_URL=redis://redis:6379/1
|
||||||
|
# Optional Finex balances stream:
|
||||||
|
# RANGER_INCLUDE_BALANCES=true
|
||||||
|
# RANGER_PRIVATE_STREAMS=order,trade,deposit_address
|
||||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
package-lock.json
|
||||||
23
Dockerfile
Normal file
23
Dockerfile
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# syntax=docker/dockerfile:1.4
|
||||||
|
FROM node:18-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apk add --no-cache tini wget
|
||||||
|
|
||||||
|
COPY package.json yarn.lock ./
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
RUN yarn install --frozen-lockfile --production --non-interactive
|
||||||
|
|
||||||
|
COPY src ./src
|
||||||
|
COPY data ./data
|
||||||
|
|
||||||
|
EXPOSE 8082
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||||
|
CMD wget -q -O /dev/null http://127.0.0.1:8082/health || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["/sbin/tini", "--"]
|
||||||
|
CMD ["node", "src/index.js"]
|
||||||
83
README.md
Normal file
83
README.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# Shahoo BFF
|
||||||
|
|
||||||
|
Adapter between **Shahoo** (`/api/1/*`) and **Fibitex** (Dalan + Dena).
|
||||||
|
|
||||||
|
## Fibitex docs
|
||||||
|
|
||||||
|
| Doc | Topic |
|
||||||
|
|-----|--------|
|
||||||
|
| [`Docs/Fibitex-staging-deploy.md`](../Docs/Fibitex-staging-deploy.md) | Staging deploy |
|
||||||
|
| [`Shahoo/README.md`](../Shahoo/README.md) | Frontend |
|
||||||
|
| [`Docs/README.md`](../Docs/README.md) | Central Fibitex docs |
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd Shahoo-BFF
|
||||||
|
yarn install
|
||||||
|
yarn start
|
||||||
|
# → http://localhost:8082
|
||||||
|
```
|
||||||
|
|
||||||
|
Copy `.env.example` → `.env` if needed. Requires Fibitex local stack (`www.app.local`).
|
||||||
|
|
||||||
|
## Shahoo config
|
||||||
|
|
||||||
|
In `Shahoo/.env.development`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
REACT_APP_API_URL=http://localhost:8082
|
||||||
|
```
|
||||||
|
|
||||||
|
Docker (Traefik): same-origin — `REACT_APP_API_URL=` (empty) at build time.
|
||||||
|
|
||||||
|
## Test login (Fibitex local)
|
||||||
|
|
||||||
|
| Email | Password | Notes |
|
||||||
|
|-------|----------|-------|
|
||||||
|
| `user@test.com` | `123qwe!@#QWE` | member, KYC 0 |
|
||||||
|
| `bot@test.com` | `123qwe!@#QWE` | bot group, KYC full |
|
||||||
|
| `admin@test.com` | `123qwe!@#QWE` | superadmin |
|
||||||
|
|
||||||
|
## E2E smoke test
|
||||||
|
|
||||||
|
From monorepo root:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\scripts\test-shahoo-e2e.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implemented routes (summary)
|
||||||
|
|
||||||
|
| Area | Routes |
|
||||||
|
|------|--------|
|
||||||
|
| Health | `GET /health`, `GET /api/1/health` |
|
||||||
|
| Auth | authenticate, logout, register, forgot |
|
||||||
|
| User | currentUserSummary, profile, referrer, limitations, login/info, device/info, 2FA, change password, setting |
|
||||||
|
| Wallets | self, balance, currencies, address, update/favorite, totalBalances |
|
||||||
|
| Market | orders, orderBook, ohlcvs, trades, doneOrders, constants |
|
||||||
|
| Fiat/crypto | deposits, withdrawals, irtpays, bank, destinationWallets |
|
||||||
|
| KYC | kycUsers, otp, jibit, locations, tts |
|
||||||
|
| Extras | notifications, alerts, market/favorite, transactions, assetHistory |
|
||||||
|
|
||||||
|
Unimplemented `/api/1/*` → `501`.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Shahoo :3000 → BFF :8082 → gateway:8099/api/v2/dalan|dena
|
||||||
|
```
|
||||||
|
|
||||||
|
Session: BFF UUID token; Dalan cookies + CSRF stored server-side.
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
scripts\build-docker-apps.bat # custom/shahoo-bff:1
|
||||||
|
```
|
||||||
|
|
||||||
|
Traefik: `shahoo.<domain>/api/1` → BFF, `shahoo.<domain>/` → Shahoo SPA.
|
||||||
|
|
||||||
|
## Git
|
||||||
|
|
||||||
|
Separate repo on branch `Update2026` (see `Docs/agents/mandatory-rules.md`).
|
||||||
1701
data/dena-swagger.json
Normal file
1701
data/dena-swagger.json
Normal file
File diff suppressed because it is too large
Load Diff
25
package.json
Normal file
25
package.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "shahoo-bff",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "BFF adapter: Shahoo /api/1/* → Fibitex Dalan/Dena",
|
||||||
|
"main": "src/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/index.js",
|
||||||
|
"dev": "node --watch src/index.js",
|
||||||
|
"check:ranger": "node scripts/check-ranger-credentials.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.6.8",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"express": "^4.19.2",
|
||||||
|
"ioredis": "^5.4.1",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"uuid": "^9.0.1",
|
||||||
|
"ws": "^8.21.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
28
scripts/check-map-ohlcv.js
Normal file
28
scripts/check-map-ohlcv.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/** ponytail: assert TV resolutions map 1:1 to Dena periods (no 180→240 / 480→720). */
|
||||||
|
const assert = require("assert");
|
||||||
|
const { tradingViewResolutionToDena } = require("../src/lib/mapOhlcv");
|
||||||
|
|
||||||
|
const cases = [
|
||||||
|
["60", 60],
|
||||||
|
["120", 120],
|
||||||
|
["240", 240],
|
||||||
|
["360", 360],
|
||||||
|
["720", 720],
|
||||||
|
["1D", 1440],
|
||||||
|
["D", 1440],
|
||||||
|
["d", 1440],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [res, period] of cases) {
|
||||||
|
assert.strictEqual(
|
||||||
|
tradingViewResolutionToDena(res),
|
||||||
|
period,
|
||||||
|
`${res} → ${period}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fake intervals must NOT silently alias to a different bucket.
|
||||||
|
assert.strictEqual(tradingViewResolutionToDena("180"), 180);
|
||||||
|
assert.strictEqual(tradingViewResolutionToDena("480"), 480);
|
||||||
|
|
||||||
|
console.log("ok: mapOhlcv TV resolutions");
|
||||||
30
scripts/check-ranger-credentials.js
Normal file
30
scripts/check-ranger-credentials.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
/** ponytail: assert ranger credentials + upstream URL/stream defaults. */
|
||||||
|
const assert = require("assert");
|
||||||
|
const {
|
||||||
|
defaultPrivateStreams,
|
||||||
|
buildUpstreamUrl,
|
||||||
|
cookieUpstreamHeaders,
|
||||||
|
} = require("../src/routes/ranger");
|
||||||
|
|
||||||
|
const streams = defaultPrivateStreams();
|
||||||
|
assert.ok(streams.includes("order"));
|
||||||
|
assert.ok(streams.includes("trade"));
|
||||||
|
assert.ok(!streams.includes("balances"), "balances off by default (Finex)");
|
||||||
|
|
||||||
|
const url = buildUpstreamUrl(
|
||||||
|
["trade", "order"],
|
||||||
|
"ws://rango:8080/api/v2/ranger/private"
|
||||||
|
);
|
||||||
|
assert.ok(url.includes("/api/v2/ranger/private"));
|
||||||
|
assert.ok(url.includes("stream=order"));
|
||||||
|
assert.ok(url.includes("stream=trade"));
|
||||||
|
assert.ok(!url.includes("balances"));
|
||||||
|
|
||||||
|
const headers = cookieUpstreamHeaders({
|
||||||
|
cookies: "a=1",
|
||||||
|
csrfToken: "csrf",
|
||||||
|
});
|
||||||
|
assert.strictEqual(headers.Cookie, "a=1");
|
||||||
|
assert.strictEqual(headers["X-CSRF-Token"], "csrf");
|
||||||
|
|
||||||
|
console.log("check-ranger-credentials: ok");
|
||||||
79
scripts/patch-tv-tehran-tz.js
Normal file
79
scripts/patch-tv-tehran-tz.js
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* Patch TradingView charting_library Asia/Tehran zone:
|
||||||
|
* Iran abolished DST in 2022 — permanently UTC+03:30 (12600s).
|
||||||
|
*
|
||||||
|
* IMPORTANT: TV binary-search returns -1 (→ offset 0 / UTC) when "now"
|
||||||
|
* is past the LAST transition. A single-entry zone breaks modern charts.
|
||||||
|
* Mirror Asia/Dubai: keep a far-future sentinel as the last time entry.
|
||||||
|
*/
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
// Both offsets 12600: permanent UTC+03:30 (no DST).
|
||||||
|
// Sentinel ~2030 so current timestamps resolve to index 0/1 with +3:30, not UTC.
|
||||||
|
const FIXED =
|
||||||
|
'"Asia/Tehran":{time:[-1704153600,1925006400],offset:[12600,12600]}';
|
||||||
|
|
||||||
|
const FILES = [
|
||||||
|
path.resolve(
|
||||||
|
__dirname,
|
||||||
|
"../../Gereh/src/charting_library/static/bundles/library.e964cdc99937c68d0389.js"
|
||||||
|
),
|
||||||
|
path.resolve(
|
||||||
|
__dirname,
|
||||||
|
"../../Gereh/public/charting_library/static/bundles/library.e964cdc99937c68d0389.js"
|
||||||
|
),
|
||||||
|
path.resolve(
|
||||||
|
__dirname,
|
||||||
|
"../../Shahoo/public/charting_library/static/bundles/library.e964cdc99937c68d0389.js"
|
||||||
|
),
|
||||||
|
path.resolve(
|
||||||
|
__dirname,
|
||||||
|
"../../Shahoo/src/features/Market/MainComponents/TVChartContainer/charting_library/static/bundles/library.e964cdc99937c68d0389.js"
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
function extractTehran(s) {
|
||||||
|
const key = '"Asia/Tehran":{';
|
||||||
|
const i = s.indexOf(key);
|
||||||
|
if (i < 0) return null;
|
||||||
|
let depth = 0;
|
||||||
|
let end = -1;
|
||||||
|
for (let j = i + key.length - 1; j < s.length; j++) {
|
||||||
|
if (s[j] === "{") depth++;
|
||||||
|
else if (s[j] === "}") {
|
||||||
|
depth--;
|
||||||
|
if (depth === 0) {
|
||||||
|
end = j + 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (end < 0) return null;
|
||||||
|
return { start: i, end, text: s.slice(i, end) };
|
||||||
|
}
|
||||||
|
|
||||||
|
let patched = 0;
|
||||||
|
for (const file of FILES) {
|
||||||
|
if (!fs.existsSync(file)) {
|
||||||
|
console.log("skip missing:", file);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const src = fs.readFileSync(file, "utf8");
|
||||||
|
const hit = extractTehran(src);
|
||||||
|
if (!hit) {
|
||||||
|
console.log("skip no Tehran:", file);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (hit.text === FIXED) {
|
||||||
|
console.log("already patched:", file);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
fs.writeFileSync(file, src.slice(0, hit.start) + FIXED + src.slice(hit.end));
|
||||||
|
console.log("patched:", path.relative(process.cwd(), file));
|
||||||
|
console.log(" was:", hit.text);
|
||||||
|
console.log(" now:", FIXED);
|
||||||
|
patched++;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("done, patched", patched, "file(s)");
|
||||||
46
src/config.js
Normal file
46
src/config.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
require("dotenv").config();
|
||||||
|
|
||||||
|
function parseCorsOrigins(value) {
|
||||||
|
if (!value) {
|
||||||
|
return ["http://localhost:3000", "http://127.0.0.1:3000"];
|
||||||
|
}
|
||||||
|
return String(value)
|
||||||
|
.split(",")
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNumber(value, fallback) {
|
||||||
|
const n = Number(value);
|
||||||
|
return Number.isFinite(n) ? n : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
port: Number(process.env.PORT || 8082),
|
||||||
|
corsOrigins: parseCorsOrigins(process.env.CORS_ORIGIN),
|
||||||
|
dalanUrl: process.env.DALAN_URL || "http://www.app.local/api/v2/dalan",
|
||||||
|
denaUrl: process.env.DENA_URL || "http://www.app.local/api/v2/dena",
|
||||||
|
/** Host header for in-cluster calls via gateway (e.g. www.app.local). */
|
||||||
|
upstreamHost: process.env.FIBITEX_HOST || process.env.UPSTREAM_HOST || "",
|
||||||
|
publicUrl:
|
||||||
|
process.env.SHAAHO_PUBLIC_URL ||
|
||||||
|
process.env.FIBITEX_PUBLIC_URL ||
|
||||||
|
"http://shahoo.app.local",
|
||||||
|
vipThresholds: {
|
||||||
|
vip0: 0,
|
||||||
|
vip1: parseNumber(process.env.VIP_1_AMOUNT, 10_000_000),
|
||||||
|
vip2: parseNumber(process.env.VIP_2_AMOUNT, 50_000_000),
|
||||||
|
vip3: parseNumber(process.env.VIP_3_AMOUNT, 200_000_000),
|
||||||
|
vip4: parseNumber(process.env.VIP_4_AMOUNT, 500_000_000),
|
||||||
|
vipMax: parseNumber(process.env.VIP_4_AMOUNT, 500_000_000) * 2,
|
||||||
|
},
|
||||||
|
jibitApiUrl: process.env.JIBIT_API_URL || "https://napi.jibit.ir/ide/v1",
|
||||||
|
jibitApiKey: process.env.JIBIT_API_KEY || "",
|
||||||
|
jibitApiSecret: process.env.JIBIT_API_SECRET || "",
|
||||||
|
/** Local dev only — set SKIP_API_KEY_2FA=true in .env */
|
||||||
|
skipApiKey2FA: process.env.SKIP_API_KEY_2FA === "true",
|
||||||
|
publicCallbackUrl:
|
||||||
|
process.env.FIBITEX_PUBLIC_URL || process.env.SHAAHO_PUBLIC_URL || "http://shahoo.app.local",
|
||||||
|
rangerPrivateUrl:
|
||||||
|
process.env.RANGER_PRIVATE_URL || "ws://gateway:8099/api/v2/ranger/private",
|
||||||
|
};
|
||||||
46
src/index.js
Normal file
46
src/index.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
const http = require("http");
|
||||||
|
const express = require("express");
|
||||||
|
const cors = require("cors");
|
||||||
|
const config = require("./config");
|
||||||
|
const healthRouter = require("./routes/health");
|
||||||
|
const apiRouter = require("./routes/api");
|
||||||
|
const { attachRangerPrivateProxy } = require("./routes/ranger");
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
cors({
|
||||||
|
origin(origin, callback) {
|
||||||
|
if (!origin || config.corsOrigins.includes(origin)) {
|
||||||
|
callback(null, origin || config.corsOrigins[0]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
callback(new Error(`CORS blocked origin: ${origin}`));
|
||||||
|
},
|
||||||
|
credentials: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
app.use(healthRouter);
|
||||||
|
app.use("/api/1", apiRouter);
|
||||||
|
|
||||||
|
app.use((err, _req, res, _next) => {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({
|
||||||
|
statusCode: 500,
|
||||||
|
message: "Internal server error",
|
||||||
|
content: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const server = http.createServer(app);
|
||||||
|
attachRangerPrivateProxy(server);
|
||||||
|
|
||||||
|
server.listen(config.port, () => {
|
||||||
|
console.log(`Shahoo BFF listening on http://localhost:${config.port}`);
|
||||||
|
console.log(` Dalan: ${config.dalanUrl}`);
|
||||||
|
console.log(` Dena: ${config.denaUrl}`);
|
||||||
|
console.log(` CORS: ${config.corsOrigins.join(", ")}`);
|
||||||
|
console.log(` Ranger private proxy: /api/1/ranger/ws`);
|
||||||
|
});
|
||||||
295
src/lib/buildApiSpec.js
Normal file
295
src/lib/buildApiSpec.js
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
const config = require("../config");
|
||||||
|
|
||||||
|
const DENA_BASE_PATH = "/api/v2/dena";
|
||||||
|
|
||||||
|
function buildAuthDescription({ brandName, apiBaseUrl }) {
|
||||||
|
return [
|
||||||
|
`# ${brandName} REST API`,
|
||||||
|
"",
|
||||||
|
"Programmatic access to market data, account balances, deposits, withdrawals, and order management.",
|
||||||
|
"",
|
||||||
|
"## Prerequisites",
|
||||||
|
"",
|
||||||
|
"1. Enable **Google Authenticator (2FA)** on your account.",
|
||||||
|
"2. Create an API key from the exchange security settings (secret shown once).",
|
||||||
|
"",
|
||||||
|
"## Base URL",
|
||||||
|
"",
|
||||||
|
`\`${apiBaseUrl}\``,
|
||||||
|
"",
|
||||||
|
"## Authentication (HMAC)",
|
||||||
|
"",
|
||||||
|
"Private endpoints require these headers on every request:",
|
||||||
|
"",
|
||||||
|
"| Header | Description |",
|
||||||
|
"| --- | --- |",
|
||||||
|
"| `X-Auth-Apikey` | Your Access Key (`kid`) |",
|
||||||
|
"| `X-Auth-Nonce` | Current Unix time in **milliseconds** (valid ~5 seconds) |",
|
||||||
|
"| `X-Auth-Signature` | `HMAC-SHA256(secret, nonce + accessKey)` as lowercase hex |",
|
||||||
|
"",
|
||||||
|
"### Node.js example",
|
||||||
|
"",
|
||||||
|
"```javascript",
|
||||||
|
"const crypto = require('crypto');",
|
||||||
|
"const accessKey = 'YOUR_ACCESS_KEY';",
|
||||||
|
"const secretKey = 'YOUR_SECRET_KEY';",
|
||||||
|
"const nonce = Date.now().toString();",
|
||||||
|
"const signature = crypto",
|
||||||
|
" .createHmac('sha256', secretKey)",
|
||||||
|
" .update(nonce + accessKey)",
|
||||||
|
" .digest('hex');",
|
||||||
|
"",
|
||||||
|
`fetch('${apiBaseUrl}/account/balances', {`,
|
||||||
|
" headers: {",
|
||||||
|
" 'X-Auth-Apikey': accessKey,",
|
||||||
|
" 'X-Auth-Nonce': nonce,",
|
||||||
|
" 'X-Auth-Signature': signature,",
|
||||||
|
" },",
|
||||||
|
"});",
|
||||||
|
"```",
|
||||||
|
"",
|
||||||
|
"### Python example",
|
||||||
|
"",
|
||||||
|
"```python",
|
||||||
|
"import hashlib, hmac, time, requests",
|
||||||
|
"access_key = 'YOUR_ACCESS_KEY'",
|
||||||
|
"secret_key = 'YOUR_SECRET_KEY'",
|
||||||
|
"nonce = str(int(time.time() * 1000))",
|
||||||
|
"signature = hmac.new(",
|
||||||
|
" secret_key.encode(),",
|
||||||
|
" (nonce + access_key).encode(),",
|
||||||
|
" hashlib.sha256,",
|
||||||
|
").hexdigest()",
|
||||||
|
"",
|
||||||
|
`requests.get('${apiBaseUrl}/account/balances', headers={`,
|
||||||
|
" 'X-Auth-Apikey': access_key,",
|
||||||
|
" 'X-Auth-Nonce': nonce,",
|
||||||
|
" 'X-Auth-Signature': signature,",
|
||||||
|
"})",
|
||||||
|
"```",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveApiOrigin() {
|
||||||
|
const denaUrl = String(config.denaUrl || "").trim();
|
||||||
|
if (denaUrl) {
|
||||||
|
try {
|
||||||
|
const url = new URL(denaUrl.startsWith("http") ? denaUrl : `http://${denaUrl}`);
|
||||||
|
const scheme = url.protocol.replace(":", "");
|
||||||
|
return {
|
||||||
|
host: url.host,
|
||||||
|
schemes: [scheme === "https" ? "https" : "http"],
|
||||||
|
basePath: DENA_BASE_PATH,
|
||||||
|
apiBaseUrl: `${url.protocol}//${url.host}${DENA_BASE_PATH}`,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
/* fall through */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const domain = String(process.env.FIBITEX_DOMAIN || process.env.APP_DOMAIN || "fibitex.com")
|
||||||
|
.replace(/^https?:\/\//, "")
|
||||||
|
.split("/")[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
host: domain,
|
||||||
|
schemes: ["https"],
|
||||||
|
basePath: DENA_BASE_PATH,
|
||||||
|
apiBaseUrl: `https://${domain}${DENA_BASE_PATH}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePathKey(path) {
|
||||||
|
let normalized = String(path || "").trim();
|
||||||
|
if (!normalized.startsWith("/")) {
|
||||||
|
normalized = `/${normalized}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const prefix of ["/api/v2/dena", "/api/v2"]) {
|
||||||
|
if (normalized.startsWith(prefix)) {
|
||||||
|
normalized = normalized.slice(prefix.length) || "/";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!normalized.startsWith("/")) {
|
||||||
|
normalized = `/${normalized}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function capitalizeWord(word) {
|
||||||
|
if (!word) return "";
|
||||||
|
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function humanizeSegment(segment) {
|
||||||
|
return String(segment || "")
|
||||||
|
.replace(/\{|\}/g, "")
|
||||||
|
.split(/[_-]/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(capitalizeWord)
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function friendlyOperationName(operation, method, path) {
|
||||||
|
if (operation.summary && !/^getApi/i.test(operation.summary)) {
|
||||||
|
return operation.summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
const segments = path.split("/").filter(Boolean);
|
||||||
|
const skip = new Set(["public", "account", "market", "api", "v2", "dena"]);
|
||||||
|
const meaningful = segments.filter((s) => !skip.has(s.toLowerCase()) && !/^\{/.test(s));
|
||||||
|
|
||||||
|
if (meaningful.length >= 2) {
|
||||||
|
return meaningful.map(humanizeSegment).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meaningful.length === 1) {
|
||||||
|
return humanizeSegment(meaningful[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const opId = String(operation.operationId || "");
|
||||||
|
const stripped = opId
|
||||||
|
.replace(/^(get|post|put|patch|delete)/i, "")
|
||||||
|
.replace(/^ApiV2Dena(Public|Account|Market)?/i, "")
|
||||||
|
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
if (stripped) {
|
||||||
|
return stripped
|
||||||
|
.split(/\s+/)
|
||||||
|
.map(capitalizeWord)
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
return humanizeSegment(segments[segments.length - 1] || method);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPublicPath(path) {
|
||||||
|
return path === "/public" || path.startsWith("/public/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveMenuTag(operation, path) {
|
||||||
|
const rawTag = String(operation.tags?.[0] || "").toLowerCase();
|
||||||
|
|
||||||
|
if (rawTag === "public" || rawTag === "account" || rawTag === "market") {
|
||||||
|
return capitalizeWord(rawTag);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPublicPath(path)) {
|
||||||
|
return "Public";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.startsWith("/market")) {
|
||||||
|
return "Market";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Account";
|
||||||
|
}
|
||||||
|
|
||||||
|
function enhanceOperation(operation, method, path) {
|
||||||
|
const isPublic = isPublicPath(path);
|
||||||
|
const summary = friendlyOperationName(operation, method, path);
|
||||||
|
const tag = resolveMenuTag(operation, path);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...operation,
|
||||||
|
summary,
|
||||||
|
tags: [tag],
|
||||||
|
security: isPublic ? [] : operation.security ?? [{ ApiKeyAuth: [] }, { Bearer: [] }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePaths(spec) {
|
||||||
|
const nextPaths = {};
|
||||||
|
|
||||||
|
Object.entries(spec.paths || {}).forEach(([rawPath, pathItem]) => {
|
||||||
|
const path = normalizePathKey(rawPath);
|
||||||
|
const enhanced = { ...(nextPaths[path] || {}) };
|
||||||
|
|
||||||
|
Object.entries(pathItem || {}).forEach(([key, value]) => {
|
||||||
|
if (key === "parameters") {
|
||||||
|
enhanced.parameters = value;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (["get", "post", "put", "patch", "delete", "head", "options"].includes(key)) {
|
||||||
|
enhanced[key] = enhanceOperation(value, key, path);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
nextPaths[path] = enhanced;
|
||||||
|
});
|
||||||
|
|
||||||
|
spec.paths = nextPaths;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBrandedSpec(rawSpec, { brandName, publicUrl, logoUrl }) {
|
||||||
|
const spec = JSON.parse(JSON.stringify(rawSpec || {}));
|
||||||
|
const apiOrigin = resolveApiOrigin();
|
||||||
|
|
||||||
|
spec.swagger = spec.swagger || "2.0";
|
||||||
|
spec.host = apiOrigin.host;
|
||||||
|
spec.schemes = apiOrigin.schemes;
|
||||||
|
spec.basePath = apiOrigin.basePath;
|
||||||
|
spec.produces = spec.produces || ["application/json"];
|
||||||
|
|
||||||
|
normalizePaths(spec);
|
||||||
|
|
||||||
|
spec.info = {
|
||||||
|
...(spec.info || {}),
|
||||||
|
title: "Fibitex API Doc",
|
||||||
|
description: buildAuthDescription({
|
||||||
|
brandName,
|
||||||
|
apiBaseUrl: apiOrigin.apiBaseUrl,
|
||||||
|
}),
|
||||||
|
version: spec.info?.version || "2.6.0",
|
||||||
|
contact: {
|
||||||
|
name: `${brandName} Support`,
|
||||||
|
email: spec.info?.contact?.email || "support@fibitex.com",
|
||||||
|
url: spec.info?.contact?.url || apiOrigin.apiBaseUrl.replace(/\/api\/v2\/dena$/, ""),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
spec.tags = [
|
||||||
|
{
|
||||||
|
name: "Public",
|
||||||
|
description: "Public market data and health checks. No authentication required.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Account",
|
||||||
|
description: "Account balances, deposits, and withdrawals. Requires HMAC API key and active 2FA.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Market",
|
||||||
|
description: "Order placement, cancellation, and trade history. Requires HMAC API key and active 2FA.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
delete spec["x-tagGroups"];
|
||||||
|
|
||||||
|
spec.securityDefinitions = {
|
||||||
|
...(spec.securityDefinitions || {}),
|
||||||
|
ApiKeyAuth: {
|
||||||
|
type: "apiKey",
|
||||||
|
name: "X-Auth-Apikey",
|
||||||
|
in: "header",
|
||||||
|
description:
|
||||||
|
"HMAC API key authentication. Send X-Auth-Nonce and X-Auth-Signature headers as well.",
|
||||||
|
},
|
||||||
|
Bearer: {
|
||||||
|
type: "apiKey",
|
||||||
|
name: "Authorization",
|
||||||
|
in: "header",
|
||||||
|
description: "JWT Bearer token issued by the auth gateway after API key validation.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
spec.security = [{ ApiKeyAuth: [] }];
|
||||||
|
|
||||||
|
return spec;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { buildBrandedSpec, buildAuthDescription, resolveApiOrigin, normalizePathKey };
|
||||||
423
src/lib/dalanClient.js
Normal file
423
src/lib/dalanClient.js
Normal file
@@ -0,0 +1,423 @@
|
|||||||
|
const axios = require("axios");
|
||||||
|
const { v4: uuidv4 } = require("uuid");
|
||||||
|
const config = require("../config");
|
||||||
|
const sessionStore = require("./sessionStore");
|
||||||
|
const { upstreamHeaders, sessionHeaders } = require("./upstreamHeaders");
|
||||||
|
|
||||||
|
function cookieHeader(setCookie) {
|
||||||
|
if (!setCookie) return "";
|
||||||
|
const list = Array.isArray(setCookie) ? setCookie : [setCookie];
|
||||||
|
return list.map((c) => c.split(";")[0]).join("; ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSessionFromAuthResponse(res) {
|
||||||
|
const cookies = cookieHeader(res.headers["set-cookie"]);
|
||||||
|
const csrfToken = res.data?.csrf_token;
|
||||||
|
const token = uuidv4();
|
||||||
|
|
||||||
|
sessionStore.create({
|
||||||
|
token,
|
||||||
|
cookies,
|
||||||
|
csrfToken,
|
||||||
|
dalanUser: res.data,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { token, user: res.data };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login({ email, password, otpCode, captcha }) {
|
||||||
|
const body = { email, password };
|
||||||
|
if (otpCode) body.otp_code = otpCode;
|
||||||
|
if (captcha) body.captcha_response = captcha;
|
||||||
|
|
||||||
|
const res = await axios.post(
|
||||||
|
`${config.dalanUrl}/identity/sessions`,
|
||||||
|
body,
|
||||||
|
{ headers: upstreamHeaders(), validateStatus: () => true }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res.status === 401 && res.data?.errors?.includes("identity.session.missing_otp")) {
|
||||||
|
return { needs2fa: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
const msg =
|
||||||
|
res.data?.errors?.[0] ||
|
||||||
|
res.data?.error ||
|
||||||
|
"Invalid Email or Password";
|
||||||
|
const err = new Error(msg);
|
||||||
|
err.statusCode = res.status;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = createSessionFromAuthResponse(res);
|
||||||
|
return { ...session, needs2fa: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMe(session) {
|
||||||
|
const res = await axios.get(`${config.dalanUrl}/resource/users/me`, {
|
||||||
|
headers: sessionHeaders(session),
|
||||||
|
validateStatus: () => true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
const err = new Error("Unauthorized");
|
||||||
|
err.statusCode = 401;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
session.dalanUser = res.data;
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout(session) {
|
||||||
|
await axios.delete(`${config.dalanUrl}/identity/sessions`, {
|
||||||
|
headers: sessionHeaders(session),
|
||||||
|
validateStatus: () => true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function dalanErrorMessage(data) {
|
||||||
|
if (!data) return "Request failed";
|
||||||
|
if (Array.isArray(data.errors) && data.errors.length) {
|
||||||
|
return String(data.errors[0]);
|
||||||
|
}
|
||||||
|
return data.error || "Request failed";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function register({ email, password, refid, captcha }) {
|
||||||
|
const body = { email, password };
|
||||||
|
if (refid) body.refid = refid;
|
||||||
|
if (captcha) body.captcha_response = captcha;
|
||||||
|
|
||||||
|
const res = await axios.post(`${config.dalanUrl}/identity/users`, body, {
|
||||||
|
headers: upstreamHeaders(),
|
||||||
|
validateStatus: () => true,
|
||||||
|
});
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmEmailActivation({ email, code, captcha }) {
|
||||||
|
const body = { email, code };
|
||||||
|
if (captcha) body.captcha_response = captcha;
|
||||||
|
|
||||||
|
const res = await axios.post(
|
||||||
|
`${config.dalanUrl}/identity/users/email/confirm_email`,
|
||||||
|
body,
|
||||||
|
{ headers: upstreamHeaders(), validateStatus: () => true }
|
||||||
|
);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmEmailByLinkToken({ token, captcha }) {
|
||||||
|
const body = { token };
|
||||||
|
if (captcha) body.captcha_response = captcha;
|
||||||
|
|
||||||
|
const res = await axios.post(
|
||||||
|
`${config.dalanUrl}/identity/users/email/confirm_code`,
|
||||||
|
body,
|
||||||
|
{ headers: upstreamHeaders(), validateStatus: () => true }
|
||||||
|
);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function forgotPassword({ email, captcha }) {
|
||||||
|
const body = { email };
|
||||||
|
if (captcha) body.captcha_response = captcha;
|
||||||
|
|
||||||
|
const res = await axios.post(
|
||||||
|
`${config.dalanUrl}/identity/users/password/generate_code`,
|
||||||
|
body,
|
||||||
|
{ headers: upstreamHeaders(), validateStatus: () => true }
|
||||||
|
); return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmForgotCode({ email, code, captcha }) {
|
||||||
|
const body = { email, code };
|
||||||
|
if (captcha) body.captcha_response = captcha;
|
||||||
|
|
||||||
|
const res = await axios.post(
|
||||||
|
`${config.dalanUrl}/identity/users/password/confirm_code`,
|
||||||
|
body,
|
||||||
|
{ headers: upstreamHeaders(), validateStatus: () => true }
|
||||||
|
); return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetPassword({ email, code, password, confirmPassword, captcha }) {
|
||||||
|
const body = {
|
||||||
|
email,
|
||||||
|
code,
|
||||||
|
password,
|
||||||
|
confirm_password: confirmPassword || password,
|
||||||
|
};
|
||||||
|
if (captcha) body.captcha_response = captcha;
|
||||||
|
|
||||||
|
const res = await axios.post(
|
||||||
|
`${config.dalanUrl}/identity/users/password/reset`,
|
||||||
|
body,
|
||||||
|
{ headers: upstreamHeaders(), validateStatus: () => true }
|
||||||
|
); return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getUserActivity(
|
||||||
|
session,
|
||||||
|
topic,
|
||||||
|
{ limit = 10, page = 1, timeFrom, timeTo, result } = {}
|
||||||
|
) {
|
||||||
|
const params = { limit, page };
|
||||||
|
if (timeFrom) params.time_from = timeFrom;
|
||||||
|
if (timeTo) params.time_to = timeTo;
|
||||||
|
if (result) params.result = result;
|
||||||
|
|
||||||
|
const res = await axios.get(
|
||||||
|
`${config.dalanUrl}/resource/users/activity/${topic}`,
|
||||||
|
{
|
||||||
|
headers: sessionHeaders(session),
|
||||||
|
params,
|
||||||
|
validateStatus: () => true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
return { items: [], total: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalHeader =
|
||||||
|
res.headers?.total || res.headers?.["x-total"] || res.headers?.["X-Total"];
|
||||||
|
const total = parseInt(totalHeader, 10);
|
||||||
|
const items = Array.isArray(res.data) ? res.data : [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
total: Number.isFinite(total) ? total : items.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resourcePost(session, path, body = {}) {
|
||||||
|
const res = await axios.post(`${config.dalanUrl}${path}`, body, {
|
||||||
|
headers: sessionHeaders(session),
|
||||||
|
validateStatus: () => true,
|
||||||
|
});
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resourcePut(session, path, body = {}) {
|
||||||
|
const res = await axios.put(`${config.dalanUrl}${path}`, body, {
|
||||||
|
headers: sessionHeaders(session),
|
||||||
|
validateStatus: () => true,
|
||||||
|
});
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resourcePatch(session, path, body = {}) {
|
||||||
|
const res = await axios.patch(`${config.dalanUrl}${path}`, body, {
|
||||||
|
headers: sessionHeaders(session),
|
||||||
|
validateStatus: () => true,
|
||||||
|
});
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateOtpQrCode(session) {
|
||||||
|
return resourcePost(session, "/resource/otp/generate_qrcode");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enableOtp(session, code) {
|
||||||
|
return resourcePost(session, "/resource/otp/enable", { code });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enableOtpEmail(session, code) {
|
||||||
|
return resourcePost(session, "/resource/otp/enable_2fa", { code });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disableOtp(session, code) {
|
||||||
|
return resourcePost(session, "/resource/otp/disable", { code });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disableOtpEmail(session, code) {
|
||||||
|
return resourcePost(session, "/resource/otp/disable_email", { code });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changePassword(session, { oldPassword, password, confirmPassword }) {
|
||||||
|
return resourcePut(session, "/resource/users/password", {
|
||||||
|
old_password: oldPassword,
|
||||||
|
new_password: password,
|
||||||
|
confirm_password: confirmPassword || password,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resourceGet(session, path, params = {}) {
|
||||||
|
const res = await axios.get(`${config.dalanUrl}${path}`, {
|
||||||
|
headers: sessionHeaders(session),
|
||||||
|
params,
|
||||||
|
validateStatus: () => true,
|
||||||
|
});
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resourceDelete(session, path) {
|
||||||
|
const res = await axios.delete(`${config.dalanUrl}${path}`, {
|
||||||
|
headers: sessionHeaders(session),
|
||||||
|
validateStatus: () => true,
|
||||||
|
});
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getFullUser(session) {
|
||||||
|
const res = await resourceGet(session, "/resource/users/me");
|
||||||
|
if (res.status !== 200) {
|
||||||
|
const err = new Error("Unauthorized");
|
||||||
|
err.statusCode = 401;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
session.dalanUser = res.data;
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDocuments(session) {
|
||||||
|
const res = await resourceGet(session, "/resource/documents");
|
||||||
|
return res.status === 200 && Array.isArray(res.data) ? res.data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getTreasuries(session) {
|
||||||
|
const res = await resourceGet(session, "/resource/profiles/treasury/list");
|
||||||
|
return res.status === 200 && Array.isArray(res.data) ? res.data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postMultipart(session, path, fields, file) {
|
||||||
|
const form = new FormData();
|
||||||
|
Object.entries(fields).forEach(([key, value]) => {
|
||||||
|
if (value != null && value !== "") form.append(key, value);
|
||||||
|
});
|
||||||
|
if (file?.buffer) {
|
||||||
|
form.append("upload", new Blob([file.buffer]), file.originalname || "upload.bin");
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await axios.post(`${config.dalanUrl}${path}`, form, {
|
||||||
|
headers: {
|
||||||
|
...sessionHeaders(session),
|
||||||
|
"Content-Type": "multipart/form-data",
|
||||||
|
},
|
||||||
|
validateStatus: () => true,
|
||||||
|
});
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function putMultipart(session, path, fields, file) {
|
||||||
|
const form = new FormData();
|
||||||
|
Object.entries(fields).forEach(([key, value]) => {
|
||||||
|
if (value != null && value !== "") form.append(key, value);
|
||||||
|
});
|
||||||
|
if (file?.buffer) {
|
||||||
|
form.append("upload", new Blob([file.buffer]), file.originalname || "upload.bin");
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await axios.put(`${config.dalanUrl}${path}`, form, {
|
||||||
|
headers: {
|
||||||
|
...sessionHeaders(session),
|
||||||
|
"Content-Type": "multipart/form-data",
|
||||||
|
},
|
||||||
|
validateStatus: () => true,
|
||||||
|
});
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createIdentityProfile(session, { profileFields, file }) {
|
||||||
|
return postMultipart(session, "/resource/profiles", profileFields, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateIdentityProfile(session, { profileFields, file }) {
|
||||||
|
return putMultipart(session, "/resource/profiles", profileFields, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitAddress(session, { addressFields, file }) {
|
||||||
|
return postMultipart(session, "/resource/profiles/address", addressFields, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitSelfie(session, file) {
|
||||||
|
return postMultipart(session, "/resource/profiles/selfie", {}, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTreasury(session, payload) {
|
||||||
|
return resourcePost(session, "/resource/profiles/treasury", payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteTreasury(session, id) {
|
||||||
|
return resourceDelete(session, `/resource/profiles/treasury/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendMobileCode(session, phoneNumber) {
|
||||||
|
return resourcePost(session, "/resource/mobiles", { phone_number: phoneNumber });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resendMobileCode(session, phoneNumber) {
|
||||||
|
return resourcePost(session, "/resource/mobiles/send_code", { phone_number: phoneNumber });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyMobileCode(session, phoneNumber, verificationCode) {
|
||||||
|
return resourcePost(session, "/resource/mobiles/verify", {
|
||||||
|
phone_number: phoneNumber,
|
||||||
|
verification_code: verificationCode,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addLandlinePhone(session, phoneNumber) {
|
||||||
|
return resourcePost(session, "/resource/phones", { phone_number: phoneNumber });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resendLandlineCode(session, phoneNumber) {
|
||||||
|
return resourcePost(session, "/resource/phones/send_code", { phone_number: phoneNumber });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyLandlineCode(session, phoneNumber, verificationCode) {
|
||||||
|
return resourcePost(session, "/resource/phones/verify", {
|
||||||
|
phone_number: phoneNumber,
|
||||||
|
verification_code: verificationCode,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyOtpCode(session, code) {
|
||||||
|
return resourcePost(session, "/resource/otp/verify", { code });
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
login,
|
||||||
|
getMe,
|
||||||
|
logout,
|
||||||
|
cookieHeader,
|
||||||
|
dalanErrorMessage,
|
||||||
|
register,
|
||||||
|
confirmEmailActivation,
|
||||||
|
confirmEmailByLinkToken,
|
||||||
|
createSessionFromAuthResponse,
|
||||||
|
forgotPassword,
|
||||||
|
confirmForgotCode,
|
||||||
|
resetPassword,
|
||||||
|
getUserActivity,
|
||||||
|
generateOtpQrCode,
|
||||||
|
enableOtp,
|
||||||
|
enableOtpEmail,
|
||||||
|
disableOtp,
|
||||||
|
disableOtpEmail,
|
||||||
|
changePassword,
|
||||||
|
resourceGet,
|
||||||
|
resourcePost,
|
||||||
|
resourcePut,
|
||||||
|
resourcePatch,
|
||||||
|
resourceDelete,
|
||||||
|
getFullUser,
|
||||||
|
getDocuments,
|
||||||
|
getTreasuries,
|
||||||
|
createIdentityProfile,
|
||||||
|
updateIdentityProfile,
|
||||||
|
submitAddress,
|
||||||
|
submitSelfie,
|
||||||
|
createTreasury,
|
||||||
|
deleteTreasury,
|
||||||
|
sendMobileCode,
|
||||||
|
resendMobileCode,
|
||||||
|
verifyMobileCode,
|
||||||
|
addLandlinePhone,
|
||||||
|
resendLandlineCode,
|
||||||
|
verifyLandlineCode,
|
||||||
|
verifyOtpCode,
|
||||||
|
};
|
||||||
520
src/lib/denaClient.js
Normal file
520
src/lib/denaClient.js
Normal file
@@ -0,0 +1,520 @@
|
|||||||
|
const axios = require("axios");
|
||||||
|
const config = require("../config");
|
||||||
|
const { upstreamHeaders, sessionHeaders } = require("./upstreamHeaders");
|
||||||
|
|
||||||
|
async function getTickers() {
|
||||||
|
const res = await axios.get(`${config.denaUrl}/public/markets/tickers`, {
|
||||||
|
headers: upstreamHeaders(),
|
||||||
|
validateStatus: () => true,
|
||||||
|
}); if (res.status !== 200) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return res.data || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMarkets() {
|
||||||
|
const res = await axios.get(`${config.denaUrl}/public/markets`, {
|
||||||
|
headers: upstreamHeaders(),
|
||||||
|
validateStatus: () => true,
|
||||||
|
}); if (res.status !== 200) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return Array.isArray(res.data) ? res.data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCurrencies() {
|
||||||
|
const res = await axios.get(`${config.denaUrl}/public/currencies`, {
|
||||||
|
headers: upstreamHeaders(),
|
||||||
|
validateStatus: () => true,
|
||||||
|
});
|
||||||
|
if (res.status !== 200) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return Array.isArray(res.data) ? res.data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getBalances(session) {
|
||||||
|
const res = await axios.get(`${config.denaUrl}/account/balances`, {
|
||||||
|
headers: sessionHeaders(session),
|
||||||
|
validateStatus: () => true,
|
||||||
|
}); if (res.status !== 200) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return Array.isArray(res.data) ? res.data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getOrderBook(marketId, { limit = 16 } = {}) {
|
||||||
|
const res = await axios.get(
|
||||||
|
`${config.denaUrl}/public/markets/${marketId}/depth`,
|
||||||
|
{ params: { limit }, headers: upstreamHeaders(), validateStatus: () => true }
|
||||||
|
); if (res.status !== 200) {
|
||||||
|
return { asks: [], bids: [] };
|
||||||
|
}
|
||||||
|
return res.data || { asks: [], bids: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getOrders(session, { state = "wait", market, limit = 100, page = 1 } = {}) {
|
||||||
|
const params = { state, limit, page };
|
||||||
|
if (market) params.market = market;
|
||||||
|
|
||||||
|
const res = await axios.get(`${config.denaUrl}/market/orders`, {
|
||||||
|
headers: sessionHeaders(session),
|
||||||
|
params,
|
||||||
|
validateStatus: () => true,
|
||||||
|
});
|
||||||
|
if (res.status !== 200) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return Array.isArray(res.data) ? res.data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createOrder(session, body) {
|
||||||
|
const res = await axios.post(`${config.denaUrl}/market/orders`, body, {
|
||||||
|
headers: {
|
||||||
|
...sessionHeaders(session),
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
validateStatus: () => true,
|
||||||
|
});
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelOrder(session, id) {
|
||||||
|
const res = await axios.post(
|
||||||
|
`${config.denaUrl}/market/orders/${id}/cancel`,
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
headers: sessionHeaders(session),
|
||||||
|
validateStatus: () => true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelAllOrders(session, { market, side } = {}) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (market) params.append("market", market);
|
||||||
|
if (side) params.append("side", side);
|
||||||
|
|
||||||
|
const res = await axios.post(
|
||||||
|
`${config.denaUrl}/market/orders/cancel`,
|
||||||
|
params.toString(),
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
...sessionHeaders(session),
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
validateStatus: () => true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getKLine(marketId, { period = 60, limit = 30, timeFrom, timeTo } = {}) {
|
||||||
|
const params = { period, limit };
|
||||||
|
if (timeFrom) params.time_from = timeFrom;
|
||||||
|
if (timeTo) params.time_to = timeTo;
|
||||||
|
|
||||||
|
const res = await axios.get(
|
||||||
|
`${config.denaUrl}/public/markets/${marketId}/k-line`,
|
||||||
|
{ params, headers: upstreamHeaders(), validateStatus: () => true }
|
||||||
|
); if (res.status !== 200) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return Array.isArray(res.data) ? res.data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPublicTrades(marketId, { limit = 50 } = {}) {
|
||||||
|
const res = await axios.get(
|
||||||
|
`${config.denaUrl}/public/markets/${marketId}/trades`,
|
||||||
|
{ params: { limit }, headers: upstreamHeaders(), validateStatus: () => true }
|
||||||
|
); if (res.status !== 200) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return Array.isArray(res.data) ? res.data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getTradingFees() {
|
||||||
|
const res = await axios.get(`${config.denaUrl}/public/trading_fees`, {
|
||||||
|
headers: upstreamHeaders(),
|
||||||
|
validateStatus: () => true,
|
||||||
|
}); if (res.status !== 200) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return Array.isArray(res.data) ? res.data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDepositAddress(session, currency) {
|
||||||
|
const res = await axios.get(
|
||||||
|
`${config.denaUrl}/account/deposit_address/${String(currency).toLowerCase()}`,
|
||||||
|
{ headers: sessionHeaders(session), validateStatus: () => true }
|
||||||
|
);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDeposits(session, { limit = 100, page = 1, currency } = {}) {
|
||||||
|
const params = { limit, page };
|
||||||
|
if (currency) params.currency = String(currency).toLowerCase();
|
||||||
|
|
||||||
|
const res = await axios.get(`${config.denaUrl}/account/deposits`, {
|
||||||
|
headers: sessionHeaders(session),
|
||||||
|
params,
|
||||||
|
validateStatus: () => true,
|
||||||
|
});
|
||||||
|
if (res.status !== 200) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return Array.isArray(res.data) ? res.data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getWithdrawals(session, { limit = 100, page = 1 } = {}) {
|
||||||
|
const res = await axios.get(`${config.denaUrl}/account/withdraws`, {
|
||||||
|
headers: sessionHeaders(session),
|
||||||
|
params: { limit, page },
|
||||||
|
validateStatus: () => true,
|
||||||
|
});
|
||||||
|
if (res.status !== 200) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return Array.isArray(res.data) ? res.data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getBonus(session) {
|
||||||
|
const res = await axios.get(`${config.denaUrl}/account/bonus`, {
|
||||||
|
headers: sessionHeaders(session),
|
||||||
|
validateStatus: () => true,
|
||||||
|
});
|
||||||
|
if (res.status !== 200) {
|
||||||
|
return { h24: 0, all: 0, number: 0 };
|
||||||
|
}
|
||||||
|
return res.data || { h24: 0, all: 0, number: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getBonusReport(session, { limit = 10, page = 1 } = {}) {
|
||||||
|
const res = await axios.get(`${config.denaUrl}/account/bonus/report`, {
|
||||||
|
headers: sessionHeaders(session),
|
||||||
|
params: { limit, page },
|
||||||
|
validateStatus: () => true,
|
||||||
|
});
|
||||||
|
if (res.status !== 200) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return Array.isArray(res.data) ? res.data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getLevels(session) {
|
||||||
|
const res = await axios.get(`${config.denaUrl}/account/levels`, {
|
||||||
|
headers: sessionHeaders(session),
|
||||||
|
validateStatus: () => true,
|
||||||
|
});
|
||||||
|
if (res.status !== 200) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMemberTrades(session, { limit = 500, page = 1, timeFrom } = {}) {
|
||||||
|
const params = { limit, page, order_by: "desc" };
|
||||||
|
if (timeFrom) params.time_from = timeFrom;
|
||||||
|
|
||||||
|
const res = await axios.get(`${config.denaUrl}/market/trades`, {
|
||||||
|
headers: sessionHeaders(session),
|
||||||
|
params,
|
||||||
|
validateStatus: () => true,
|
||||||
|
});
|
||||||
|
if (res.status !== 200) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return Array.isArray(res.data) ? res.data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formBody(fields) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
Object.entries(fields).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined && value !== null && value !== "") {
|
||||||
|
params.append(key, String(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return params.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postAccountForm(session, path, fields) {
|
||||||
|
const res = await axios.post(`${config.denaUrl}${path}`, formBody(fields), {
|
||||||
|
headers: {
|
||||||
|
...sessionHeaders(session),
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
validateStatus: () => true,
|
||||||
|
});
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createWithdraw(
|
||||||
|
session,
|
||||||
|
{ currency, amount, rid, otp, paymentId, beneficiaryId, note }
|
||||||
|
) {
|
||||||
|
const fields = {
|
||||||
|
currency: String(currency || "").toLowerCase(),
|
||||||
|
amount,
|
||||||
|
note,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (beneficiaryId) {
|
||||||
|
fields.beneficiary_id = beneficiaryId;
|
||||||
|
} else if (rid) {
|
||||||
|
fields.rid = rid;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (otp) fields.otp = otp;
|
||||||
|
if (paymentId) fields.payment_id = paymentId;
|
||||||
|
|
||||||
|
return postAccountForm(session, "/account/withdraws", fields);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getWithdrawById(session, id) {
|
||||||
|
const res = await axios.get(`${config.denaUrl}/account/withdraws`, {
|
||||||
|
headers: sessionHeaders(session),
|
||||||
|
params: { limit: 100, page: 1 },
|
||||||
|
validateStatus: () => true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 200 || !Array.isArray(res.data)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.data.find((item) => String(item.id) === String(id)) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmWithdraw(session, id, token) {
|
||||||
|
const body = formBody({ code: token, otp: token, token });
|
||||||
|
|
||||||
|
const attempts = [
|
||||||
|
() =>
|
||||||
|
axios.post(`${config.denaUrl}/account/withdraws/${id}/confirm`, body, {
|
||||||
|
headers: {
|
||||||
|
...sessionHeaders(session),
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
validateStatus: () => true,
|
||||||
|
}),
|
||||||
|
() =>
|
||||||
|
axios.put(`${config.denaUrl}/account/withdraws/${id}`, body, {
|
||||||
|
headers: {
|
||||||
|
...sessionHeaders(session),
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
validateStatus: () => true,
|
||||||
|
}),
|
||||||
|
() =>
|
||||||
|
axios.post(`${config.denaUrl}/account/withdraws/${id}/action`, body, {
|
||||||
|
headers: {
|
||||||
|
...sessionHeaders(session),
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
validateStatus: () => true,
|
||||||
|
}),
|
||||||
|
() =>
|
||||||
|
axios.post(`${config.denaUrl}/account/withdraws/confirm`, formBody({ id, otp: token }), {
|
||||||
|
headers: {
|
||||||
|
...sessionHeaders(session),
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
validateStatus: () => true,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
let lastRes = null;
|
||||||
|
for (const attempt of attempts) {
|
||||||
|
const res = await attempt();
|
||||||
|
lastRes = res;
|
||||||
|
if (res.status === 200 || res.status === 201 || res.status === 204) {
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = await getWithdrawById(session, id);
|
||||||
|
if (current && !["prepared", "submitted"].includes(String(current.state).toLowerCase())) {
|
||||||
|
return { status: 200, data: current };
|
||||||
|
}
|
||||||
|
|
||||||
|
return lastRes || { status: 422, data: { error: "Confirm failed" } };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelWithdraw(session, id) {
|
||||||
|
const attempts = [
|
||||||
|
() =>
|
||||||
|
axios.delete(`${config.denaUrl}/account/withdraws/${id}`, {
|
||||||
|
headers: sessionHeaders(session),
|
||||||
|
validateStatus: () => true,
|
||||||
|
}),
|
||||||
|
() =>
|
||||||
|
axios.post(`${config.denaUrl}/account/withdraws/${id}/cancel`, null, {
|
||||||
|
headers: sessionHeaders(session),
|
||||||
|
validateStatus: () => true,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const attempt of attempts) {
|
||||||
|
const res = await attempt();
|
||||||
|
if (res.status === 200 || res.status === 201 || res.status === 204) {
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return attempts[0]();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resendWithdrawEmail(session, id) {
|
||||||
|
const attempts = [
|
||||||
|
() =>
|
||||||
|
axios.post(`${config.denaUrl}/account/withdraws/${id}/resend`, null, {
|
||||||
|
headers: sessionHeaders(session),
|
||||||
|
validateStatus: () => true,
|
||||||
|
}),
|
||||||
|
() =>
|
||||||
|
axios.post(`${config.denaUrl}/account/withdraws/${id}/resend_email`, null, {
|
||||||
|
headers: sessionHeaders(session),
|
||||||
|
validateStatus: () => true,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const attempt of attempts) {
|
||||||
|
const res = await attempt();
|
||||||
|
if (res.status === 200 || res.status === 201 || res.status === 204) {
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: 200, data: { success: true } };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateWithdrawOtp(session, otp) {
|
||||||
|
return postAccountForm(session, "/account/otp", {
|
||||||
|
otp,
|
||||||
|
action: "withdraw",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createFiatDeposit(session, { amount, card, callbackUrl, currency = "irt" }) {
|
||||||
|
return postAccountForm(session, "/account/deposits/fiat", {
|
||||||
|
amount,
|
||||||
|
card,
|
||||||
|
callback_url: callbackUrl,
|
||||||
|
currency,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createFiatWithdraw(session, { amount, iban, currency = "irt", note }) {
|
||||||
|
return postAccountForm(session, "/account/withdraws/fiat", {
|
||||||
|
amount,
|
||||||
|
iban,
|
||||||
|
currency,
|
||||||
|
note,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmFiatWithdraw(session, { id, otp }) {
|
||||||
|
return postAccountForm(session, "/account/withdraws/confirm", {
|
||||||
|
id,
|
||||||
|
otp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resendFiatWithdrawCode(session, email) {
|
||||||
|
return postAccountForm(session, "/account/otp/resend", {
|
||||||
|
data: email,
|
||||||
|
action: "withdraw-fiat",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getBeneficiaries(session, params = {}) {
|
||||||
|
const res = await axios.get(`${config.denaUrl}/account/beneficiaries`, {
|
||||||
|
headers: sessionHeaders(session),
|
||||||
|
params,
|
||||||
|
validateStatus: () => true,
|
||||||
|
});
|
||||||
|
if (res.status !== 200) return [];
|
||||||
|
return Array.isArray(res.data) ? res.data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getBeneficiaryById(session, id) {
|
||||||
|
const res = await axios.get(`${config.denaUrl}/account/beneficiaries/${id}`, {
|
||||||
|
headers: sessionHeaders(session),
|
||||||
|
validateStatus: () => true,
|
||||||
|
});
|
||||||
|
return res.status === 200 ? res.data : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createBeneficiary(session, payload) {
|
||||||
|
const res = await axios.post(`${config.denaUrl}/account/beneficiaries`, payload, {
|
||||||
|
headers: sessionHeaders(session),
|
||||||
|
validateStatus: () => true,
|
||||||
|
});
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function activateBeneficiary(session, id, pin) {
|
||||||
|
const res = await axios.patch(
|
||||||
|
`${config.denaUrl}/account/beneficiaries/${id}/activate`,
|
||||||
|
{ pin },
|
||||||
|
{
|
||||||
|
headers: sessionHeaders(session),
|
||||||
|
validateStatus: () => true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resendBeneficiaryPin(session, id) {
|
||||||
|
const res = await axios.patch(`${config.denaUrl}/account/beneficiaries/${id}/resend_pin`, null, {
|
||||||
|
headers: sessionHeaders(session),
|
||||||
|
validateStatus: () => true,
|
||||||
|
});
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteBeneficiary(session, id) {
|
||||||
|
const res = await axios.delete(`${config.denaUrl}/account/beneficiaries/${id}`, {
|
||||||
|
headers: sessionHeaders(session),
|
||||||
|
validateStatus: () => true,
|
||||||
|
});
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getTickers,
|
||||||
|
getMarkets,
|
||||||
|
getCurrencies,
|
||||||
|
getBalances,
|
||||||
|
getOrderBook,
|
||||||
|
getOrders,
|
||||||
|
createOrder,
|
||||||
|
cancelOrder,
|
||||||
|
cancelAllOrders,
|
||||||
|
getKLine,
|
||||||
|
getPublicTrades,
|
||||||
|
getTradingFees,
|
||||||
|
getDepositAddress,
|
||||||
|
getDeposits,
|
||||||
|
getWithdrawals,
|
||||||
|
getBonus,
|
||||||
|
getBonusReport,
|
||||||
|
getLevels,
|
||||||
|
getMemberTrades,
|
||||||
|
createWithdraw,
|
||||||
|
confirmWithdraw,
|
||||||
|
cancelWithdraw,
|
||||||
|
resendWithdrawEmail,
|
||||||
|
getWithdrawById,
|
||||||
|
validateWithdrawOtp,
|
||||||
|
createFiatDeposit,
|
||||||
|
createFiatWithdraw,
|
||||||
|
confirmFiatWithdraw,
|
||||||
|
resendFiatWithdrawCode,
|
||||||
|
getBeneficiaries,
|
||||||
|
getBeneficiaryById,
|
||||||
|
createBeneficiary,
|
||||||
|
activateBeneficiary,
|
||||||
|
resendBeneficiaryPin,
|
||||||
|
deleteBeneficiary,
|
||||||
|
};
|
||||||
87
src/lib/jibitClient.js
Normal file
87
src/lib/jibitClient.js
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
const axios = require("axios");
|
||||||
|
const config = require("../config");
|
||||||
|
|
||||||
|
let cachedToken = null;
|
||||||
|
let tokenExpiresAt = 0;
|
||||||
|
|
||||||
|
async function getAccessToken() {
|
||||||
|
if (cachedToken && Date.now() < tokenExpiresAt) {
|
||||||
|
return cachedToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await axios.post(
|
||||||
|
`${config.jibitApiUrl}/tokens/generate`,
|
||||||
|
{
|
||||||
|
apiKey: config.jibitApiKey,
|
||||||
|
secretKey: config.jibitApiSecret,
|
||||||
|
},
|
||||||
|
{ validateStatus: () => true }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res.status !== 200 || !res.data?.accessToken) {
|
||||||
|
const err = new Error("Jibit authentication failed");
|
||||||
|
err.statusCode = 502;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedToken = res.data.accessToken;
|
||||||
|
tokenExpiresAt = Date.now() + 23 * 60 * 60 * 1000;
|
||||||
|
return cachedToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function jibitGet(path, params = {}) {
|
||||||
|
const token = await getAccessToken();
|
||||||
|
const res = await axios.get(`${config.jibitApiUrl}${path}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
params,
|
||||||
|
validateStatus: () => true,
|
||||||
|
});
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapBankInfo(data) {
|
||||||
|
const bank = data?.bank || data?.ibanInfo?.bank || data?.cardInfo?.bank || {};
|
||||||
|
return {
|
||||||
|
bankSwiftCode: bank.swiftCode || bank.code || bank.bankCode || "",
|
||||||
|
bankName: bank.name || bank.bankName || "",
|
||||||
|
iban: data?.iban || data?.ibanInfo?.iban || "",
|
||||||
|
cardNumber: data?.cardNumber || data?.number || "",
|
||||||
|
ownerName: data?.ownerName || data?.fullName || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCardInfo(cardNumber) {
|
||||||
|
const res = await jibitGet("/cards", { number: cardNumber });
|
||||||
|
if (res.status !== 200) {
|
||||||
|
const err = new Error("Unable to fetch card info");
|
||||||
|
err.statusCode = res.status;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return mapBankInfo(res.data || {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getIbanInfo(iban) {
|
||||||
|
const res = await jibitGet("/ibans", { value: iban });
|
||||||
|
if (res.status !== 200) {
|
||||||
|
const err = new Error("Unable to fetch IBAN info");
|
||||||
|
err.statusCode = res.status;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return mapBankInfo(res.data || {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postalCodeToAddress(postalCode) {
|
||||||
|
const res = await jibitGet("/services/postalCode", { code: postalCode }).catch(() => null);
|
||||||
|
if (!res || res.status !== 200) {
|
||||||
|
return { address: "" };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
address: res.data?.address || res.data?.result?.address || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getCardInfo,
|
||||||
|
getIbanInfo,
|
||||||
|
postalCodeToAddress,
|
||||||
|
};
|
||||||
55
src/lib/locationsStore.js
Normal file
55
src/lib/locationsStore.js
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
let cache = null;
|
||||||
|
|
||||||
|
function loadLocations() {
|
||||||
|
if (cache) return cache;
|
||||||
|
|
||||||
|
const filePath =
|
||||||
|
process.env.LOCATIONS_JSON ||
|
||||||
|
path.join(__dirname, "..", "data", "locations.json");
|
||||||
|
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
cache = { provinces: [], cities: [] };
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
cache = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||||
|
} catch {
|
||||||
|
cache = { provinces: [], cities: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
function listRegions() {
|
||||||
|
return loadLocations().provinces || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function listCities(regionId) {
|
||||||
|
const id = Number(regionId);
|
||||||
|
return (loadLocations().cities || []).filter((city) => Number(city.provinceId) === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCity(cityId) {
|
||||||
|
const id = Number(cityId);
|
||||||
|
const city = (loadLocations().cities || []).find((entry) => Number(entry.id) === id);
|
||||||
|
if (!city) return null;
|
||||||
|
|
||||||
|
const province = (loadLocations().provinces || []).find(
|
||||||
|
(entry) => Number(entry.id) === Number(city.provinceId)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
cityName: city.name,
|
||||||
|
provinceName: province?.name || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
listRegions,
|
||||||
|
listCities,
|
||||||
|
resolveCity,
|
||||||
|
};
|
||||||
55
src/lib/mapActivity.js
Normal file
55
src/lib/mapActivity.js
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
function parseOsFamily(userAgent) {
|
||||||
|
if (!userAgent) return "Unknown";
|
||||||
|
const ua = String(userAgent).toLowerCase();
|
||||||
|
if (ua.includes("iphone") || ua.includes("ipad")) return "iOS";
|
||||||
|
if (ua.includes("android")) return "Android";
|
||||||
|
if (ua.includes("windows")) return "Windows";
|
||||||
|
if (ua.includes("mac os") || ua.includes("macintosh")) return "macOS";
|
||||||
|
if (ua.includes("linux")) return "Linux";
|
||||||
|
return "Web";
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapDalanActivity(item) {
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
ip: item.user_ip,
|
||||||
|
osFamily: parseOsFamily(item.user_agent),
|
||||||
|
created: item.created_at,
|
||||||
|
success: item.result === "succeed",
|
||||||
|
activity: item.action || item.topic || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapDalanActivities(items) {
|
||||||
|
return (items || []).map(mapDalanActivity);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeDevices(activities) {
|
||||||
|
const seen = new Set();
|
||||||
|
const devices = [];
|
||||||
|
for (const row of activities) {
|
||||||
|
const key = `${row.osFamily}|${row.ip}`;
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
devices.push(row);
|
||||||
|
}
|
||||||
|
return devices;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTIVITY_FILTER_OPTIONS = [
|
||||||
|
{ id: "session", name: "Session" },
|
||||||
|
{ id: "password", name: "Password" },
|
||||||
|
{ id: "otp", name: "OTP" },
|
||||||
|
{ id: "account", name: "Account" },
|
||||||
|
{ id: "create", name: "Create" },
|
||||||
|
{ id: "update", name: "Update" },
|
||||||
|
{ id: "read", name: "Read" },
|
||||||
|
{ id: "delete", name: "Delete" },
|
||||||
|
];
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
mapDalanActivity,
|
||||||
|
mapDalanActivities,
|
||||||
|
dedupeDevices,
|
||||||
|
ACTIVITY_FILTER_OPTIONS,
|
||||||
|
};
|
||||||
36
src/lib/mapAssetHistory.js
Normal file
36
src/lib/mapAssetHistory.js
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
const { normalizeCurrencyForShahoo } = require("./mapMarket");
|
||||||
|
|
||||||
|
function mapBalancesToAssetHistory(balances = []) {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
return balances
|
||||||
|
.map((balance) => {
|
||||||
|
const currencyName = normalizeCurrencyForShahoo(balance.currency);
|
||||||
|
const availableBalance = parseFloat(balance.balance || 0);
|
||||||
|
const blockedBalance = parseFloat(balance.locked || 0);
|
||||||
|
const totalBalance = availableBalance + blockedBalance;
|
||||||
|
|
||||||
|
return {
|
||||||
|
currencyName,
|
||||||
|
availableBalance,
|
||||||
|
blockedBalance,
|
||||||
|
totalBalance,
|
||||||
|
updated: now,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((row) => row.totalBalance > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapBalancesToHistorySeries(balances = []) {
|
||||||
|
const snapshot = mapBalancesToAssetHistory(balances);
|
||||||
|
const total = snapshot.reduce((sum, row) => sum + row.totalBalance, 0);
|
||||||
|
|
||||||
|
return snapshot.map((row) => ({
|
||||||
|
...row,
|
||||||
|
portfolioShare: total > 0 ? (row.totalBalance / total) * 100 : 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
mapBalancesToAssetHistory,
|
||||||
|
mapBalancesToHistorySeries,
|
||||||
|
};
|
||||||
41
src/lib/mapBeneficiary.js
Normal file
41
src/lib/mapBeneficiary.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
function mapBeneficiaryToDestinationWallet(beneficiary, email = "") {
|
||||||
|
if (!beneficiary) return null;
|
||||||
|
|
||||||
|
const data = beneficiary.data || {};
|
||||||
|
const currencyName = String(beneficiary.currency || beneficiary.currency_id || "").toUpperCase();
|
||||||
|
const active = String(beneficiary.state || "").toLowerCase() === "active";
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: beneficiary.id,
|
||||||
|
currencyName,
|
||||||
|
name: beneficiary.name || "",
|
||||||
|
address: data.address || data.iban || "",
|
||||||
|
xrpTag: data.payment_id || data.tag || data.memo || "-",
|
||||||
|
inWhitelist: active,
|
||||||
|
username: email,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapDestinationWalletPayload(body = {}, email = "") {
|
||||||
|
const currency = String(body.currencyName || body.currency || "").toLowerCase();
|
||||||
|
const xrpTag = body.xrpTag && body.xrpTag !== "-" ? body.xrpTag : undefined;
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
address: body.address,
|
||||||
|
};
|
||||||
|
if (xrpTag) data.payment_id = xrpTag;
|
||||||
|
|
||||||
|
return {
|
||||||
|
currency_id: currency,
|
||||||
|
name: body.name || body.address,
|
||||||
|
description: body.description || "",
|
||||||
|
data,
|
||||||
|
email: body.username || email,
|
||||||
|
inWhitelist: Boolean(body.inWhitelist),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
mapBeneficiaryToDestinationWallet,
|
||||||
|
mapDestinationWalletPayload,
|
||||||
|
};
|
||||||
43
src/lib/mapConstants.js
Normal file
43
src/lib/mapConstants.js
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
const { marketIdToShahooStatKeys } = require("./mapMarket");
|
||||||
|
const { buildTradeFeeTiers } = require("./mapVip");
|
||||||
|
|
||||||
|
const DEFAULT_MIN_ORDER = {
|
||||||
|
valueMap: {
|
||||||
|
IRT: 100000,
|
||||||
|
USDT: 10,
|
||||||
|
CAD: 10,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildConstantsFromMarkets(markets, tradingFees = []) {
|
||||||
|
const orderScaleMap = {};
|
||||||
|
const marketOrderPriceScaleMap = {};
|
||||||
|
|
||||||
|
for (const market of markets || []) {
|
||||||
|
const base = String(market.base_unit || "").toUpperCase();
|
||||||
|
const amountPrecision = Number(market.amount_precision);
|
||||||
|
if (base && Number.isFinite(amountPrecision)) {
|
||||||
|
orderScaleMap[base] = amountPrecision;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pricePrecision = Number(market.price_precision);
|
||||||
|
if (Number.isFinite(pricePrecision)) {
|
||||||
|
const keys = marketIdToShahooStatKeys(market.id);
|
||||||
|
keys.forEach((key) => {
|
||||||
|
marketOrderPriceScaleMap[key] = pricePrecision;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
orderScaleMap,
|
||||||
|
marketOrderPriceScaleMap,
|
||||||
|
minOrder: DEFAULT_MIN_ORDER,
|
||||||
|
withdrawFees: { irt: 0 },
|
||||||
|
depositRoleLimits: {},
|
||||||
|
withdrawRoleLimits: {},
|
||||||
|
tradeFee: buildTradeFeeTiers(tradingFees),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { buildConstantsFromMarkets, DEFAULT_MIN_ORDER };
|
||||||
123
src/lib/mapKyc.js
Normal file
123
src/lib/mapKyc.js
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
const { mapShahooUser } = require("./mapUser");
|
||||||
|
|
||||||
|
function mapLabelStatus(labels, key) {
|
||||||
|
const label = (labels || []).find((entry) => entry.key === key);
|
||||||
|
if (!label) return "VOID";
|
||||||
|
|
||||||
|
const value = String(label.value || "").toLowerCase();
|
||||||
|
if (value === "verified") return "DONE";
|
||||||
|
if (value === "rejected") return "FAIL";
|
||||||
|
if (["submitted", "pending", "processing"].includes(value)) return "PEND";
|
||||||
|
return "VOID";
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapTreasuryState(state) {
|
||||||
|
const value = String(state || "").toLowerCase();
|
||||||
|
if (value === "verified" || value === "active") return "DONE";
|
||||||
|
if (value === "rejected") return "FAIL";
|
||||||
|
if (["submitted", "pending", "processing"].includes(value)) return "PEND";
|
||||||
|
return "VOID";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJibitSwift(raw) {
|
||||||
|
if (!raw) return "";
|
||||||
|
try {
|
||||||
|
const data = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||||||
|
return (
|
||||||
|
data?.bankSwiftCode ||
|
||||||
|
data?.bank?.swiftCode ||
|
||||||
|
data?.bank?.code ||
|
||||||
|
data?.ibanInfo?.bank?.swiftCode ||
|
||||||
|
data?.cardInfo?.bank?.swiftCode ||
|
||||||
|
""
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapTreasuryToBankInfo(treasury) {
|
||||||
|
const kind = String(treasury?.kind || "").toLowerCase();
|
||||||
|
const type = kind === "iban" ? "IBAN" : "CARD";
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: treasury.id,
|
||||||
|
number: treasury.data,
|
||||||
|
title: treasury.title || "",
|
||||||
|
type,
|
||||||
|
bankSwiftCode: parseJibitSwift(treasury.result),
|
||||||
|
confirmationStatus: mapTreasuryState(treasury.state),
|
||||||
|
cardNo: type === "CARD" ? treasury.data : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function findDocument(documents, docType) {
|
||||||
|
return (documents || []).find(
|
||||||
|
(doc) => String(doc.doc_type || "").toLowerCase() === String(docType).toLowerCase()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildKycUser(dalanUser, { documents = [], treasuries = [] } = {}) {
|
||||||
|
const profile = dalanUser?.profiles?.[0] || {};
|
||||||
|
const labels = dalanUser?.labels || [];
|
||||||
|
const phone =
|
||||||
|
(dalanUser?.phones || []).find((entry) => entry.state === "verified")?.number ||
|
||||||
|
(dalanUser?.phones || [])[0]?.number ||
|
||||||
|
"";
|
||||||
|
|
||||||
|
let metadata = profile.metadata;
|
||||||
|
if (typeof metadata === "string") {
|
||||||
|
try {
|
||||||
|
metadata = JSON.parse(metadata);
|
||||||
|
} catch {
|
||||||
|
metadata = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = {
|
||||||
|
...mapShahooUser(dalanUser),
|
||||||
|
firstName: profile.first_name || "",
|
||||||
|
lastName: profile.last_name || "",
|
||||||
|
nationalCode: profile.national_code || "",
|
||||||
|
dateOfBirth: profile.dob || "",
|
||||||
|
gender: metadata?.gender || "",
|
||||||
|
mobile: phone,
|
||||||
|
identityConfirmed: mapLabelStatus(labels, "profile"),
|
||||||
|
mobileConfirmed: mapLabelStatus(labels, "access_phone"),
|
||||||
|
bankCardConfirmed: mapLabelStatus(labels, "card"),
|
||||||
|
bankAccountConfirmed: mapLabelStatus(labels, "iban"),
|
||||||
|
addressConfirmed: mapLabelStatus(labels, "poa"),
|
||||||
|
selfieConfirmed: mapLabelStatus(labels, "selfie"),
|
||||||
|
cityId: profile.city?.id || profile.city_id || null,
|
||||||
|
address: profile.address || "",
|
||||||
|
zipCode: profile.postcode || "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const identityDoc = findDocument(documents, "Identity card");
|
||||||
|
const addressDoc = findDocument(documents, "Poa");
|
||||||
|
const selfieDoc = findDocument(documents, "Selfie");
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
identity: identityDoc?.url || identityDoc?.upload || null,
|
||||||
|
address: addressDoc?.url || addressDoc?.upload || null,
|
||||||
|
selfie: selfieDoc?.url || selfieDoc?.upload || null,
|
||||||
|
bankInfo: (treasuries || []).map(mapTreasuryToBankInfo),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapBankCardPayload(body = {}) {
|
||||||
|
const type = String(body.type || "CARD").toUpperCase();
|
||||||
|
return {
|
||||||
|
data: body.number,
|
||||||
|
kind: type === "IBAN" ? "iban" : "card",
|
||||||
|
title: body.title || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
buildKycUser,
|
||||||
|
mapBankCardPayload,
|
||||||
|
mapTreasuryToBankInfo,
|
||||||
|
mapLabelStatus,
|
||||||
|
};
|
||||||
132
src/lib/mapMarket.js
Normal file
132
src/lib/mapMarket.js
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
/** @deprecated Prefer dynamic catalog from GET /api/1/markets/catalog */
|
||||||
|
|
||||||
|
const SHAAHO_CURRENCIES = new Set([
|
||||||
|
|
||||||
|
"BTC",
|
||||||
|
|
||||||
|
"ETH",
|
||||||
|
|
||||||
|
"LTC",
|
||||||
|
|
||||||
|
"USDT",
|
||||||
|
|
||||||
|
"XRP",
|
||||||
|
|
||||||
|
"BCH",
|
||||||
|
|
||||||
|
"LINK",
|
||||||
|
|
||||||
|
"AAVE",
|
||||||
|
|
||||||
|
"UNI",
|
||||||
|
|
||||||
|
"TRX",
|
||||||
|
|
||||||
|
"DAI",
|
||||||
|
|
||||||
|
"IRT",
|
||||||
|
|
||||||
|
"CAD",
|
||||||
|
|
||||||
|
"BNB",
|
||||||
|
|
||||||
|
]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
|
||||||
|
* Map OpenDAX/Dena market id (e.g. btccad, btcirt, btcusdt) to Shahoo statMap keys.
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
function marketIdToShahooStatKeys(marketId) {
|
||||||
|
|
||||||
|
const id = String(marketId || "").toLowerCase();
|
||||||
|
|
||||||
|
const match = id.match(/^([a-z0-9]+?)(cad|irt|usdt)$/);
|
||||||
|
|
||||||
|
if (!match) return [];
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const base = match[1].toUpperCase();
|
||||||
|
|
||||||
|
const quote = match[2].toUpperCase();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return [`${base}_${quote}`];
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function parseTickerPrice(entry) {
|
||||||
|
|
||||||
|
const raw = entry?.ticker?.last ?? entry?.last ?? "0";
|
||||||
|
|
||||||
|
const price = parseFloat(raw);
|
||||||
|
|
||||||
|
return Number.isFinite(price) ? price : 0;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function parseTickerOpen(entry) {
|
||||||
|
|
||||||
|
const raw = entry?.ticker?.open ?? entry?.open ?? "0";
|
||||||
|
|
||||||
|
const price = parseFloat(raw);
|
||||||
|
|
||||||
|
return Number.isFinite(price) ? price : 0;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function parseTickerField(entry, field) {
|
||||||
|
|
||||||
|
const raw = entry?.ticker?.[field] ?? entry?.[field] ?? "0";
|
||||||
|
|
||||||
|
const value = parseFloat(raw);
|
||||||
|
|
||||||
|
return Number.isFinite(value) ? value : 0;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function mapTickersToStatMap(tickers) {
|
||||||
|
|
||||||
|
const statMap = {};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
for (const [marketId, entry] of Object.entries(tickers || {})) {
|
||||||
|
|
||||||
|
const keys = marketIdToShahooStatKeys(marketId);
|
||||||
|
|
||||||
|
const unitPrice = parseTickerPrice(entry);
|
||||||
|
|
||||||
|
const dayClose = parseTickerOpen(entry) || unitPrice;
|
||||||
|
|
||||||
|
const dayHigh = parseTickerField(entry, "high") || unitPrice;
|
||||||
|
|
||||||
|
const dayLow = parseTickerField(entry, "low") || unitPrice;
|
||||||
|
|
||||||
|
const volumeDst = parseTickerField(entry, "volume") || parseTickerField(entry, "amount");
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
keys.forEach((key) => {
|
||||||
|
|
||||||
|
statMap[key] = {
|
||||||
|
|
||||||
|
unitPrice,
|
||||||
|
|
||||||
|
market: key,
|
||||||
|
|
||||||
|
dayClose,
|
||||||
|
|
||||||
66
src/lib/mapMarketsCatalog.js
Normal file
66
src/lib/mapMarketsCatalog.js
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
const ENABLED_STATES = new Set(["enabled", "online"]);
|
||||||
|
|
||||||
|
const QUOTE_SORT = ["IRT", "USDT", "CAD"];
|
||||||
|
|
||||||
|
function buildMarketsCatalog(markets, currencies = []) {
|
||||||
|
const currencyMeta = {};
|
||||||
|
(currencies || []).forEach((c) => {
|
||||||
|
const id = String(c.id || c.currency || "").toUpperCase();
|
||||||
|
if (id) {
|
||||||
|
currencyMeta[id] = c;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const enabledMarkets = (markets || []).filter((m) => {
|
||||||
|
const state = String(m.state || "").toLowerCase();
|
||||||
|
return !state || ENABLED_STATES.has(state);
|
||||||
|
});
|
||||||
|
|
||||||
|
const marketsByQuote = {};
|
||||||
|
const catalogMarkets = [];
|
||||||
|
const quoteSet = new Set();
|
||||||
|
|
||||||
|
enabledMarkets.forEach((m) => {
|
||||||
|
const base = String(m.base_unit || "").toUpperCase();
|
||||||
|
const quote = String(m.quote_unit || "").toUpperCase();
|
||||||
|
if (!base || !quote) return;
|
||||||
|
|
||||||
|
quoteSet.add(quote);
|
||||||
|
if (!marketsByQuote[quote]) {
|
||||||
|
marketsByQuote[quote] = [];
|
||||||
|
}
|
||||||
|
if (!marketsByQuote[quote].includes(base)) {
|
||||||
|
marketsByQuote[quote].push(base);
|
||||||
|
}
|
||||||
|
|
||||||
|
catalogMarkets.push({
|
||||||
|
marketId: String(m.id || "").toLowerCase(),
|
||||||
|
marketKey: `${base}_${quote}`,
|
||||||
|
base,
|
||||||
|
quote,
|
||||||
|
symbol: `${base}${quote}`,
|
||||||
|
route: `${base}-${quote}`,
|
||||||
|
enabled: true,
|
||||||
|
amountPrecision: Number(m.amount_precision),
|
||||||
|
pricePrecision: Number(m.price_precision),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.keys(marketsByQuote).forEach((quote) => {
|
||||||
|
marketsByQuote[quote].sort();
|
||||||
|
});
|
||||||
|
|
||||||
|
const quoteCurrencies = [
|
||||||
|
...QUOTE_SORT.filter((q) => quoteSet.has(q)),
|
||||||
|
...[...quoteSet].filter((q) => !QUOTE_SORT.includes(q)).sort(),
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
quoteCurrencies,
|
||||||
|
marketsByQuote,
|
||||||
|
markets: catalogMarkets,
|
||||||
|
currencies: currencyMeta,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { buildMarketsCatalog, QUOTE_SORT };
|
||||||
87
src/lib/mapNotification.js
Normal file
87
src/lib/mapNotification.js
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
const { normalizeCurrencyForShahoo } = require("./mapMarket");
|
||||||
|
const { mapDepositStatus, mapWithdrawStatus } = require("./mapWithdrawal");
|
||||||
|
|
||||||
|
function parseTime(value) {
|
||||||
|
if (!value) return new Date().toISOString();
|
||||||
|
const ms = Date.parse(value);
|
||||||
|
return Number.isFinite(ms) ? new Date(ms).toISOString() : String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDepositNotification(deposit) {
|
||||||
|
const currency = normalizeCurrencyForShahoo(deposit.currency || deposit.currencyName);
|
||||||
|
const amount = parseFloat(deposit.amount) || 0;
|
||||||
|
const status = mapDepositStatus(deposit.state || deposit.status);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `deposit-${deposit.id}`,
|
||||||
|
text: `واریز ${amount} ${currency} — ${status}`,
|
||||||
|
created: parseTime(deposit.created_at || deposit.created),
|
||||||
|
status: "UNREAD",
|
||||||
|
_sortTime: Date.parse(deposit.created_at || deposit.created) || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWithdrawNotification(withdraw) {
|
||||||
|
const currency = normalizeCurrencyForShahoo(withdraw.currency || withdraw.currencyName);
|
||||||
|
const amount = parseFloat(withdraw.amount) || 0;
|
||||||
|
const status = mapWithdrawStatus(withdraw.state || withdraw.status);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `withdraw-${withdraw.id}`,
|
||||||
|
text: `برداشت ${amount} ${currency} — ${status}`,
|
||||||
|
created: parseTime(withdraw.created_at || withdraw.created),
|
||||||
|
status: "UNREAD",
|
||||||
|
_sortTime: Date.parse(withdraw.created_at || withdraw.created) || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildActivityNotification(activity) {
|
||||||
|
const action = activity.action || activity.topic || "activity";
|
||||||
|
const result = activity.result || activity.state || "succeed";
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `activity-${activity.id || `${action}-${activity.created_at}`}`,
|
||||||
|
text: `${action} (${result})`,
|
||||||
|
created: parseTime(activity.created_at || activity.created),
|
||||||
|
status: "UNREAD",
|
||||||
|
_sortTime: Date.parse(activity.created_at || activity.created) || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeNotifications(items) {
|
||||||
|
return items
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((a, b) => b._sortTime - a._sortTime)
|
||||||
|
.map(({ _sortTime, ...row }) => row);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyNotificationPrefs(notifications, prefs = {}) {
|
||||||
|
const deleted = new Set((prefs.deletedNotificationIds || []).map(String));
|
||||||
|
const read = new Set((prefs.readNotificationIds || []).map(String));
|
||||||
|
|
||||||
|
return notifications
|
||||||
|
.filter((row) => !deleted.has(String(row.id)))
|
||||||
|
.map((row) => ({
|
||||||
|
...row,
|
||||||
|
status: read.has(String(row.id)) ? "READ" : row.status,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function paginateNotifications(rows, pageSize, pageNumber) {
|
||||||
|
const limit = Math.max(1, Math.min(Number(pageSize) || 10, 100));
|
||||||
|
const page = Math.max(0, Number(pageNumber) || 0);
|
||||||
|
const start = page * limit;
|
||||||
|
return {
|
||||||
|
content: rows.slice(start, start + limit),
|
||||||
|
count: rows.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
buildDepositNotification,
|
||||||
|
buildWithdrawNotification,
|
||||||
|
buildActivityNotification,
|
||||||
|
mergeNotifications,
|
||||||
|
applyNotificationPrefs,
|
||||||
|
paginateNotifications,
|
||||||
|
};
|
||||||
76
src/lib/mapOhlcv.js
Normal file
76
src/lib/mapOhlcv.js
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
const { shahooMarketToDenaId } = require("./mapOrder");
|
||||||
|
|
||||||
|
const SHAAHO_PERIOD_TO_DENA = {
|
||||||
|
D1: 1440,
|
||||||
|
W1: 10080,
|
||||||
|
M1: 4320,
|
||||||
|
H1: 60,
|
||||||
|
H4: 240,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** TradingView resolution → Dena/Peatio k-line period (minutes).
|
||||||
|
* Only periods that Influx/Peatio actually store — no fake remaps. */
|
||||||
|
const TV_RESOLUTION_TO_DENA = {
|
||||||
|
1: 1,
|
||||||
|
5: 5,
|
||||||
|
15: 15,
|
||||||
|
30: 30,
|
||||||
|
60: 60,
|
||||||
|
120: 120,
|
||||||
|
240: 240,
|
||||||
|
360: 360,
|
||||||
|
720: 720,
|
||||||
|
"1D": 1440,
|
||||||
|
"1W": 10080,
|
||||||
|
D: 1440,
|
||||||
|
d: 1440,
|
||||||
|
W: 10080,
|
||||||
|
"3d": 4320,
|
||||||
|
"3D": 4320,
|
||||||
|
};
|
||||||
|
|
||||||
|
function shahooPeriodToDena(period) {
|
||||||
|
const key = String(period || "D1").toUpperCase();
|
||||||
|
if (SHAAHO_PERIOD_TO_DENA[key]) return SHAAHO_PERIOD_TO_DENA[key];
|
||||||
|
const asNumber = Number(period);
|
||||||
|
return Number.isFinite(asNumber) && asNumber > 0 ? asNumber : 1440;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tradingViewResolutionToDena(resolution) {
|
||||||
|
const key = String(resolution || "60");
|
||||||
|
if (TV_RESOLUTION_TO_DENA[key] !== undefined) return TV_RESOLUTION_TO_DENA[key];
|
||||||
|
const asNumber = Number(resolution);
|
||||||
|
return Number.isFinite(asNumber) && asNumber > 0 ? asNumber : 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapDenaKLineToShahooBar(point) {
|
||||||
|
if (!Array.isArray(point) || point.length < 6) return null;
|
||||||
|
|
||||||
|
const [time, open, high, low, close, volume] = point;
|
||||||
|
const ts = Number(time);
|
||||||
|
|
||||||
|
return {
|
||||||
|
time: ts,
|
||||||
|
open: parseFloat(open) || 0,
|
||||||
|
high: parseFloat(high) || 0,
|
||||||
|
low: parseFloat(low) || 0,
|
||||||
|
close: parseFloat(close) || 0,
|
||||||
|
volumeFrom: parseFloat(volume) || 0,
|
||||||
|
day: ts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapDenaKLinesToShahoo(points) {
|
||||||
|
return (points || [])
|
||||||
|
.map(mapDenaKLineToShahooBar)
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((a, b) => a.time - b.time);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
shahooPeriodToDena,
|
||||||
|
tradingViewResolutionToDena,
|
||||||
|
mapDenaKLineToShahooBar,
|
||||||
|
mapDenaKLinesToShahoo,
|
||||||
|
shahooMarketToDenaId,
|
||||||
|
};
|
||||||
108
src/lib/mapOrder.js
Normal file
108
src/lib/mapOrder.js
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
const { marketIdToShahooStatKeys } = require("./mapMarket");
|
||||||
|
|
||||||
|
/** Shahoo market key (BTC_IRT) → Dena market id (btcirt). */
|
||||||
|
function shahooMarketToDenaId(shahooMarket) {
|
||||||
|
const [base, quote] = String(shahooMarket || "")
|
||||||
|
.toUpperCase()
|
||||||
|
.split("_");
|
||||||
|
if (!base || !quote) return null;
|
||||||
|
if (quote === "IRT") return `${base.toLowerCase()}irt`;
|
||||||
|
if (quote === "USDT") return `${base.toLowerCase()}usdt`;
|
||||||
|
if (quote === "CAD") return `${base.toLowerCase()}cad`;
|
||||||
|
return `${base.toLowerCase()}${quote.toLowerCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function denaMarketToShahooMarket(denaMarketId) {
|
||||||
|
const keys = marketIdToShahooStatKeys(denaMarketId);
|
||||||
|
return keys[0] || String(denaMarketId || "").toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapDenaOrderBookLevel(entry) {
|
||||||
|
if (Array.isArray(entry)) {
|
||||||
|
const unitPrice = parseFloat(entry[0]) || 0;
|
||||||
|
const size = parseFloat(entry[1]) || 0;
|
||||||
|
return { unitPrice, size };
|
||||||
|
}
|
||||||
|
|
||||||
|
const unitPrice =
|
||||||
|
parseFloat(entry?.price ?? entry?.unitPrice ?? entry?.avg_price) || 0;
|
||||||
|
const size =
|
||||||
|
parseFloat(
|
||||||
|
entry?.remaining_volume ??
|
||||||
|
entry?.origin_volume ??
|
||||||
|
entry?.size ??
|
||||||
|
entry?.amount ??
|
||||||
|
entry?.volume
|
||||||
|
) || 0;
|
||||||
|
|
||||||
|
return { unitPrice, size };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapDenaOrderBook(data, limit) {
|
||||||
|
const max = limit ? Number(limit) : undefined;
|
||||||
|
|
||||||
|
const toLevels = (rows) =>
|
||||||
|
(rows || [])
|
||||||
|
.map(mapDenaOrderBookLevel)
|
||||||
|
.filter((row) => row.unitPrice > 0 && row.size > 0);
|
||||||
|
|
||||||
|
const sells = toLevels(data?.asks).slice(0, max);
|
||||||
|
const buys = toLevels(data?.bids).slice(0, max);
|
||||||
|
|
||||||
|
return { sells, buys };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapDenaOrderToShahoo(order) {
|
||||||
|
const marketName = denaMarketToShahooMarket(order.market);
|
||||||
|
const baseCurrency = marketName.split("_")[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: order.id,
|
||||||
|
uuid: order.uuid,
|
||||||
|
created: order.created_at,
|
||||||
|
currencyName: baseCurrency,
|
||||||
|
marketName,
|
||||||
|
type: String(order.ord_type || "limit").toUpperCase(),
|
||||||
|
side: String(order.side || "").toUpperCase(),
|
||||||
|
unitPrice: parseFloat(order.price) || 0,
|
||||||
|
size: parseFloat(order.origin_volume) || 0,
|
||||||
|
remainedSize: parseFloat(order.remaining_volume) || 0,
|
||||||
|
fee: 0,
|
||||||
|
state: order.state,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapShahooOrderToDena(body) {
|
||||||
|
const market = shahooMarketToDenaId(body.marketName);
|
||||||
|
if (!market) {
|
||||||
|
const err = new Error("Invalid market");
|
||||||
|
err.statusCode = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
market,
|
||||||
|
side: String(body.side || "").toLowerCase(),
|
||||||
|
volume: String(body.size),
|
||||||
|
ord_type: String(body.type || "limit").toLowerCase(),
|
||||||
|
price: String(body.unitPrice),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function denaErrorMessage(data) {
|
||||||
|
if (!data) return "Request failed";
|
||||||
|
if (Array.isArray(data.errors) && data.errors.length) {
|
||||||
|
return String(data.errors[0]);
|
||||||
|
}
|
||||||
|
if (typeof data.error === "string") return data.error;
|
||||||
|
return "Request failed";
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
shahooMarketToDenaId,
|
||||||
|
denaMarketToShahooMarket,
|
||||||
|
mapDenaOrderBook,
|
||||||
|
mapDenaOrderToShahoo,
|
||||||
|
mapShahooOrderToDena,
|
||||||
|
denaErrorMessage,
|
||||||
|
};
|
||||||
25
src/lib/mapOtp.js
Normal file
25
src/lib/mapOtp.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
function extractSecretFromOtpUrl(value) {
|
||||||
|
const text = String(value || "");
|
||||||
|
const match = text.match(/[?&]secret=([^&]+)/i);
|
||||||
|
return match ? decodeURIComponent(match[1]) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapQrCodeResponse(data) {
|
||||||
|
const barcode =
|
||||||
|
data?.barcode ||
|
||||||
|
data?.url ||
|
||||||
|
data?.qr_code ||
|
||||||
|
data?.qrCodeLink ||
|
||||||
|
"";
|
||||||
|
const secret =
|
||||||
|
data?.secret ||
|
||||||
|
extractSecretFromOtpUrl(barcode) ||
|
||||||
|
extractSecretFromOtpUrl(data?.url);
|
||||||
|
|
||||||
|
return {
|
||||||
|
secret,
|
||||||
|
qrCodeLink: barcode || data?.url || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { mapQrCodeResponse, extractSecretFromOtpUrl };
|
||||||
35
src/lib/mapReferral.js
Normal file
35
src/lib/mapReferral.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
function mapBonusReportRow(item) {
|
||||||
|
return {
|
||||||
|
created: item.date || item.created_at,
|
||||||
|
amount: parseFloat(item.amount) || 0,
|
||||||
|
invitedAccount: item.uid || item.bonus_member_id || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReferralLink(publicUrl, uid) {
|
||||||
|
const base = String(publicUrl || "").replace(/\/$/, "");
|
||||||
|
return `${base}/signup?refid=${encodeURIComponent(uid)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapReferralResponse({ uid, publicUrl, bonus, report }) {
|
||||||
|
const stats = {
|
||||||
|
number: Number(bonus?.number) || 0,
|
||||||
|
h24: parseFloat(bonus?.h24) || 0,
|
||||||
|
all: parseFloat(bonus?.all) || 0,
|
||||||
|
};
|
||||||
|
const commissionHistory = (report || []).map(mapBonusReportRow);
|
||||||
|
|
||||||
|
return {
|
||||||
|
refCode: uid,
|
||||||
|
refLink: buildReferralLink(publicUrl, uid),
|
||||||
|
...stats,
|
||||||
|
stats,
|
||||||
|
commissionHistory,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
mapReferralResponse,
|
||||||
|
mapBonusReportRow,
|
||||||
|
buildReferralLink,
|
||||||
|
};
|
||||||
23
src/lib/mapTrade.js
Normal file
23
src/lib/mapTrade.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
function parseTradeTime(createdAt) {
|
||||||
|
if (!createdAt) return Math.floor(Date.now() / 1000);
|
||||||
|
const ms = Date.parse(createdAt);
|
||||||
|
return Number.isFinite(ms) ? Math.floor(ms / 1000) : Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapDenaTradeToShahoo(trade) {
|
||||||
|
return {
|
||||||
|
id: trade.id,
|
||||||
|
unitPrice: parseFloat(trade.price) || 0,
|
||||||
|
amount: parseFloat(trade.amount) || 0,
|
||||||
|
total: parseFloat(trade.total) || 0,
|
||||||
|
time: parseTradeTime(trade.created_at),
|
||||||
|
side: trade.taker_type || trade.side,
|
||||||
|
market: trade.market,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapDenaTradesToShahoo(trades) {
|
||||||
|
return (trades || []).map(mapDenaTradeToShahoo);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { mapDenaTradeToShahoo, mapDenaTradesToShahoo };
|
||||||
174
src/lib/mapTransaction.js
Normal file
174
src/lib/mapTransaction.js
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
const { normalizeCurrencyForShahoo } = require("./mapMarket");
|
||||||
|
const { mapDepositStatus, mapWithdrawStatus } = require("./mapWithdrawal");
|
||||||
|
|
||||||
|
const FAIL_STATUSES = new Set(["FAILED", "CANCELLED"]);
|
||||||
|
|
||||||
|
function parseTime(value) {
|
||||||
|
if (!value) return new Date(0).toISOString();
|
||||||
|
const ms = Date.parse(value);
|
||||||
|
return Number.isFinite(ms) ? new Date(ms).toISOString() : String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tradeSideLabel(takerType) {
|
||||||
|
const side = String(takerType || "").toLowerCase();
|
||||||
|
if (side === "buy") return "BUY";
|
||||||
|
if (side === "sell") return "SELL";
|
||||||
|
return "TRADE";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFiatTrade(marketId, takerType) {
|
||||||
|
const market = String(marketId || "").toLowerCase();
|
||||||
|
const quoteIsFiat = market.endsWith("cad") || market.endsWith("irt");
|
||||||
|
const side = String(takerType || "").toLowerCase();
|
||||||
|
return quoteIsFiat && side === "buy";
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapDenaTradeToTransaction(trade) {
|
||||||
|
const market = String(trade.market || "").toLowerCase();
|
||||||
|
const baseMatch = market.match(/^([a-z0-9]+?)(cad|irt|usdt)$/);
|
||||||
|
const currencyName = normalizeCurrencyForShahoo(
|
||||||
|
baseMatch ? baseMatch[1].toUpperCase() : trade.currency
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `t-${trade.id}`,
|
||||||
|
created: parseTime(trade.created_at || trade.created),
|
||||||
|
transactionType: "TRADE",
|
||||||
|
currencyName,
|
||||||
|
amount: parseFloat(trade.amount) || 0,
|
||||||
|
fee: parseFloat(trade.fee || trade.fee_amount || 0) || 0,
|
||||||
|
description: `${tradeSideLabel(trade.taker_type || trade.side)} ${currencyName}`,
|
||||||
|
isFiat: isFiatTrade(market, trade.taker_type || trade.side),
|
||||||
|
status: "DONE",
|
||||||
|
_sortTime: Date.parse(trade.created_at || trade.created) || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapDenaDepositToTransaction(deposit) {
|
||||||
|
const status = mapDepositStatus(deposit.state || deposit.status);
|
||||||
|
const currencyName = normalizeCurrencyForShahoo(deposit.currency || deposit.currencyName);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `d-${deposit.id}`,
|
||||||
|
created: parseTime(deposit.created_at || deposit.created),
|
||||||
|
transactionType: "DEPOSIT",
|
||||||
|
currencyName,
|
||||||
|
amount: parseFloat(deposit.amount) || 0,
|
||||||
|
fee: parseFloat(deposit.fee || 0) || 0,
|
||||||
|
description: deposit.txid || deposit.blockchain_txid || "Deposit",
|
||||||
|
isFiat: currencyName === "IRT" || currencyName === "CAD",
|
||||||
|
status,
|
||||||
|
_sortTime: Date.parse(deposit.created_at || deposit.created) || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapDenaWithdrawToTransaction(withdraw) {
|
||||||
|
const status = mapWithdrawStatus(withdraw.state || withdraw.status);
|
||||||
|
const currencyName = normalizeCurrencyForShahoo(withdraw.currency || withdraw.currencyName);
|
||||||
|
const mappedStatus = status === "INITIAL_CONFIRMATION" ? "OPEN" : status;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `w-${withdraw.id}`,
|
||||||
|
created: parseTime(withdraw.created_at || withdraw.created),
|
||||||
|
transactionType: "WITHDRAWAL",
|
||||||
|
currencyName,
|
||||||
|
amount: parseFloat(withdraw.amount) || 0,
|
||||||
|
fee: parseFloat(withdraw.fee || 0) || 0,
|
||||||
|
description: withdraw.rid || withdraw.blockchain_txid || "Withdrawal",
|
||||||
|
isFiat: currencyName === "IRT" || currencyName === "CAD",
|
||||||
|
status: mappedStatus,
|
||||||
|
_sortTime: Date.parse(withdraw.created_at || withdraw.created) || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeTransactions({ trades = [], deposits = [], withdrawals = [] }) {
|
||||||
|
return [...trades, ...deposits, ...withdrawals]
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((a, b) => b._sortTime - a._sortTime)
|
||||||
|
.map(({ _sortTime, ...row }) => row);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTypeFilter(typeParam) {
|
||||||
|
if (!typeParam) return null;
|
||||||
|
const values = String(typeParam)
|
||||||
|
.split(",")
|
||||||
|
.map((item) => item.trim().toUpperCase())
|
||||||
|
.filter(Boolean);
|
||||||
|
return values.length ? values : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesTypeFilter(row, types) {
|
||||||
|
if (!types || !types.length) return true;
|
||||||
|
|
||||||
|
return types.some((type) => {
|
||||||
|
if (type === "TRADE") return row.transactionType === "TRADE";
|
||||||
|
if (type === "BUY") return row.transactionType === "TRADE" && row.isFiat;
|
||||||
|
if (type === "SELL") return row.transactionType === "TRADE" && !row.isFiat;
|
||||||
|
return row.transactionType === type;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTransactionFilters(rows, query = {}) {
|
||||||
|
let result = rows;
|
||||||
|
|
||||||
|
if (query.currencyName) {
|
||||||
|
const currency = String(query.currencyName).toUpperCase();
|
||||||
|
result = result.filter((row) => row.currencyName === currency);
|
||||||
|
}
|
||||||
|
|
||||||
|
const types = parseTypeFilter(query.type);
|
||||||
|
if (types) {
|
||||||
|
result = result.filter((row) => matchesTypeFilter(row, types));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.from) {
|
||||||
|
const fromMs = Date.parse(query.from);
|
||||||
|
if (Number.isFinite(fromMs)) {
|
||||||
|
result = result.filter((row) => Date.parse(row.created) >= fromMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.to) {
|
||||||
|
const toMs = Date.parse(query.to);
|
||||||
|
if (Number.isFinite(toMs)) {
|
||||||
|
result = result.filter((row) => Date.parse(row.created) <= toMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitSuccessAndFail(rows) {
|
||||||
|
const success = [];
|
||||||
|
const fail = [];
|
||||||
|
|
||||||
|
rows.forEach((row) => {
|
||||||
|
if (FAIL_STATUSES.has(row.status)) {
|
||||||
|
fail.push(row);
|
||||||
|
} else {
|
||||||
|
success.push(row);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success, fail };
|
||||||
|
}
|
||||||
|
|
||||||
|
function paginateRows(rows, pageSize, pageNumber) {
|
||||||
|
const limit = Math.max(1, Math.min(Number(pageSize) || 10, 100));
|
||||||
|
const page = Math.max(0, Number(pageNumber) || 0);
|
||||||
|
const start = page * limit;
|
||||||
|
return {
|
||||||
|
content: rows.slice(start, start + limit),
|
||||||
|
count: rows.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
mapDenaTradeToTransaction,
|
||||||
|
mapDenaDepositToTransaction,
|
||||||
|
mapDenaWithdrawToTransaction,
|
||||||
|
mergeTransactions,
|
||||||
|
applyTransactionFilters,
|
||||||
|
splitSuccessAndFail,
|
||||||
|
paginateRows,
|
||||||
|
};
|
||||||
40
src/lib/mapUser.js
Normal file
40
src/lib/mapUser.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
function mapKycRole(dalanUser) {
|
||||||
|
const level = dalanUser?.level ?? 0;
|
||||||
|
if (level >= 2) return "TWO";
|
||||||
|
if (level >= 1) return "ONE";
|
||||||
|
return "ZERO";
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapShahooUser(dalanUser, overrides = {}) {
|
||||||
|
const profile = dalanUser?.profiles?.[0] || {};
|
||||||
|
const phone = dalanUser?.phones?.[0]?.number || "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: dalanUser?.uid,
|
||||||
|
email: dalanUser?.email,
|
||||||
|
firstName: profile.first_name || "",
|
||||||
|
lastName: profile.last_name || "",
|
||||||
|
mobile: phone,
|
||||||
|
role: mapKycRole(dalanUser),
|
||||||
|
uid: dalanUser?.uid,
|
||||||
|
level: dalanUser?.level,
|
||||||
|
state: dalanUser?.state,
|
||||||
|
setting: {
|
||||||
|
theme: overrides.theme || "LIGHT",
|
||||||
|
layout: overrides.setting?.layout || overrides.layout || "template2",
|
||||||
|
isUsing2FA: Boolean(dalanUser?.otp),
|
||||||
|
whitelistEnable: Boolean(overrides.setting?.whitelistEnable ?? overrides.whitelistEnable),
|
||||||
|
hasDepositYet: false,
|
||||||
|
favoriteCurrency: [],
|
||||||
|
notificationSound: false,
|
||||||
|
...(overrides.setting || {}),
|
||||||
|
},
|
||||||
|
...(overrides.extra || {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapCurrentUserSummary(dalanUser, overrides = {}) {
|
||||||
|
return mapShahooUser(dalanUser, overrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { mapShahooUser, mapCurrentUserSummary, mapKycRole };
|
||||||
108
src/lib/mapVip.js
Normal file
108
src/lib/mapVip.js
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
const config = require("../config");
|
||||||
|
|
||||||
|
const VIP_GROUPS = ["vip-0", "vip-1", "vip-2", "vip-3", "vip-4"];
|
||||||
|
|
||||||
|
function feePercent(value) {
|
||||||
|
const n = parseFloat(value);
|
||||||
|
if (!Number.isFinite(n)) return 0;
|
||||||
|
return n * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function levelIndex(levelName) {
|
||||||
|
const idx = VIP_GROUPS.indexOf(String(levelName || "vip-0").toLowerCase());
|
||||||
|
return idx >= 0 ? idx : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTradeFeeTiers(tradingFees) {
|
||||||
|
const byGroup = {};
|
||||||
|
for (const fee of tradingFees || []) {
|
||||||
|
const group = String(fee.group || "").toLowerCase();
|
||||||
|
if (group) byGroup[group] = fee;
|
||||||
|
}
|
||||||
|
|
||||||
|
const thresholds = config.vipThresholds;
|
||||||
|
const tradeFeeMonthVolTh = [
|
||||||
|
thresholds.vip0,
|
||||||
|
thresholds.vip1,
|
||||||
|
thresholds.vip2,
|
||||||
|
thresholds.vip3,
|
||||||
|
thresholds.vip4,
|
||||||
|
thresholds.vipMax,
|
||||||
|
];
|
||||||
|
|
||||||
|
const tradeTakerFees = VIP_GROUPS.map((group) => {
|
||||||
|
const fee = byGroup[group] || byGroup.any;
|
||||||
|
return feePercent(fee?.taker);
|
||||||
|
});
|
||||||
|
|
||||||
|
const tradeMakerFees = VIP_GROUPS.map((group) => {
|
||||||
|
const fee = byGroup[group] || byGroup.any;
|
||||||
|
return feePercent(fee?.maker);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
tradeFeeMonthVolTh,
|
||||||
|
tradeTakerFees,
|
||||||
|
tradeMakerFees,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLevelsDetail(tiers, currentIdx) {
|
||||||
|
return VIP_GROUPS.map((name, i) => {
|
||||||
|
const minVolume = tiers.tradeFeeMonthVolTh[i] ?? 0;
|
||||||
|
const maxVolume =
|
||||||
|
i < VIP_GROUPS.length - 1 ? tiers.tradeFeeMonthVolTh[i + 1] : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
levelNumber: i,
|
||||||
|
minVolume,
|
||||||
|
maxVolume,
|
||||||
|
takerFee: tiers.tradeTakerFees[i],
|
||||||
|
makerFee: tiers.tradeMakerFees[i],
|
||||||
|
isCurrent: i === currentIdx,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapLimitations({ levels, tradeVolume, tradingFees }) {
|
||||||
|
const tiers = buildTradeFeeTiers(tradingFees);
|
||||||
|
const current = levels?.current || {};
|
||||||
|
const next = levels?.next || {};
|
||||||
|
const idx = levelIndex(current.name);
|
||||||
|
|
||||||
|
let tradeFee = feePercent(current.taker_fee ?? current.taker);
|
||||||
|
let makerFee = feePercent(current.maker_fee ?? current.maker);
|
||||||
|
|
||||||
|
if (!tradeFee) tradeFee = tiers.tradeTakerFees[idx] || tiers.tradeTakerFees[0] || 0.35;
|
||||||
|
if (!makerFee) makerFee = tiers.tradeMakerFees[idx] || tiers.tradeMakerFees[0] || 0.35;
|
||||||
|
|
||||||
|
const currentLevelMin = parseFloat(current.min) || tiers.tradeFeeMonthVolTh[idx] || 0;
|
||||||
|
const nextLevelMin =
|
||||||
|
parseFloat(next.min) ||
|
||||||
|
tiers.tradeFeeMonthVolTh[idx + 1] ||
|
||||||
|
tiers.tradeFeeMonthVolTh[tiers.tradeFeeMonthVolTh.length - 1] ||
|
||||||
|
0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
tradeVolume: parseFloat(tradeVolume) || 0,
|
||||||
|
tradeFee,
|
||||||
|
makerFee,
|
||||||
|
currentLevel: current.name || VIP_GROUPS[idx],
|
||||||
|
nextLevel: next.name || VIP_GROUPS[idx + 1] || "",
|
||||||
|
currentLevelMin,
|
||||||
|
nextLevelMin,
|
||||||
|
levelIndex: idx,
|
||||||
|
levelsDetail: buildLevelsDetail(tiers, idx),
|
||||||
|
...tiers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
buildTradeFeeTiers,
|
||||||
|
mapLimitations,
|
||||||
|
buildLevelsDetail,
|
||||||
|
feePercent,
|
||||||
|
levelIndex,
|
||||||
|
VIP_GROUPS,
|
||||||
|
};
|
||||||
79
src/lib/mapWithdrawal.js
Normal file
79
src/lib/mapWithdrawal.js
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
const WITHDRAW_STATE_MAP = {
|
||||||
|
prepared: "INITIAL_CONFIRMATION",
|
||||||
|
submitted: "INITIAL_CONFIRMATION",
|
||||||
|
accepted: "PENDING",
|
||||||
|
processing: "PENDING",
|
||||||
|
confirming: "PENDING",
|
||||||
|
pending: "PENDING",
|
||||||
|
succeed: "DONE",
|
||||||
|
done: "DONE",
|
||||||
|
completed: "DONE",
|
||||||
|
canceled: "CANCELLED",
|
||||||
|
cancelled: "CANCELLED",
|
||||||
|
rejected: "FAILED",
|
||||||
|
failed: "FAILED",
|
||||||
|
errored: "FAILED",
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEPOSIT_STATE_MAP = {
|
||||||
|
submitted: "PENDING",
|
||||||
|
processing: "PENDING",
|
||||||
|
accepted: "PENDING",
|
||||||
|
fee_processing: "PENDING",
|
||||||
|
fee_processed: "PENDING",
|
||||||
|
fee_collected: "PENDING",
|
||||||
|
collected: "DONE",
|
||||||
|
done: "DONE",
|
||||||
|
succeed: "DONE",
|
||||||
|
skipped: "CANCELLED",
|
||||||
|
canceled: "CANCELLED",
|
||||||
|
cancelled: "CANCELLED",
|
||||||
|
rejected: "FAILED",
|
||||||
|
failed: "FAILED",
|
||||||
|
errored: "FAILED",
|
||||||
|
};
|
||||||
|
|
||||||
|
function mapWithdrawStatus(state) {
|
||||||
|
const key = String(state || "").toLowerCase();
|
||||||
|
return WITHDRAW_STATE_MAP[key] || "PENDING";
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapDepositStatus(state) {
|
||||||
|
const key = String(state || "").toLowerCase();
|
||||||
|
return DEPOSIT_STATE_MAP[key] || "PENDING";
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapDenaWithdrawal(item) {
|
||||||
|
if (!item) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
currencyName: String(item.currency || item.currencyName || "").toUpperCase(),
|
||||||
|
amount: parseFloat(item.amount) || 0,
|
||||||
|
fee: parseFloat(item.fee) || 0,
|
||||||
|
receiverAddress: item.rid || item.receiverAddress || item.destination || "",
|
||||||
|
txHash: item.blockchain_txid || item.txid || item.txHash || "",
|
||||||
|
status: mapWithdrawStatus(item.state || item.status),
|
||||||
|
created: item.created_at || item.created || item.date,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapDenaDeposit(item) {
|
||||||
|
if (!item) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
currencyName: String(item.currency || item.currencyName || "").toUpperCase(),
|
||||||
|
amount: parseFloat(item.amount) || 0,
|
||||||
|
txHash: item.txid || item.blockchain_txid || item.txHash || "",
|
||||||
|
status: mapDepositStatus(item.state || item.status),
|
||||||
|
created: item.created_at || item.created || item.date,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
mapDenaWithdrawal,
|
||||||
|
mapDenaDeposit,
|
||||||
|
mapWithdrawStatus,
|
||||||
|
mapDepositStatus,
|
||||||
|
};
|
||||||
15
src/lib/phoneUtils.js
Normal file
15
src/lib/phoneUtils.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
function digitsOnly(value) {
|
||||||
|
return String(value || "").replace(/\D/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shahoo `(0912…)` / `0912…` → Dalan `+98912…` */
|
||||||
|
function normalizeIranPhone(phone) {
|
||||||
|
let p = digitsOnly(phone);
|
||||||
|
if (!p) return "";
|
||||||
|
if (p.startsWith("98")) return `+${p}`;
|
||||||
|
if (p.startsWith("0")) return `+98${p.slice(1)}`;
|
||||||
|
if (p.startsWith("9")) return `+98${p}`;
|
||||||
|
return `+${p}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { normalizeIranPhone, digitsOnly };
|
||||||
67
src/lib/sessionStore.js
Normal file
67
src/lib/sessionStore.js
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
const Redis = require("ioredis");
|
||||||
|
|
||||||
|
const SESSION_TTL_SECONDS = Number(process.env.SESSION_TTL_SECONDS || 86400);
|
||||||
|
const memory = new Map();
|
||||||
|
|
||||||
|
let redis = null;
|
||||||
|
if (process.env.REDIS_URL) {
|
||||||
|
redis = new Redis(process.env.REDIS_URL, {
|
||||||
|
maxRetriesPerRequest: 1,
|
||||||
|
enableReadyCheck: true,
|
||||||
|
lazyConnect: true,
|
||||||
|
});
|
||||||
|
redis.on("error", (err) => {
|
||||||
|
console.error("Shahoo BFF Redis session error:", err.message);
|
||||||
|
});
|
||||||
|
redis.connect().catch((err) => {
|
||||||
|
console.error("Shahoo BFF Redis connect failed:", err.message);
|
||||||
|
redis = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionKey(token) {
|
||||||
|
return `shahoo:bff:session:${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function create(session) {
|
||||||
|
memory.set(session.token, session);
|
||||||
|
if (redis) {
|
||||||
|
redis
|
||||||
|
.set(sessionKey(session.token), JSON.stringify(session), "EX", SESSION_TTL_SECONDS)
|
||||||
|
.catch((err) => {
|
||||||
|
console.error("Shahoo BFF session persist failed:", err.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function get(token) {
|
||||||
|
if (!token) return null;
|
||||||
|
|
||||||
|
const cached = memory.get(token);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
if (!redis) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = await redis.get(sessionKey(token));
|
||||||
|
if (!raw) return null;
|
||||||
|
const session = JSON.parse(raw);
|
||||||
|
memory.set(token, session);
|
||||||
|
return session;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Shahoo BFF session load failed:", err.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(token) {
|
||||||
|
memory.delete(token);
|
||||||
|
if (redis) {
|
||||||
|
redis.del(sessionKey(token)).catch((err) => {
|
||||||
|
console.error("Shahoo BFF session delete failed:", err.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { create, get, remove };
|
||||||
13
src/lib/shahooResponse.js
Normal file
13
src/lib/shahooResponse.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
function ok(content, statusCode = 200) {
|
||||||
|
return { statusCode, content };
|
||||||
|
}
|
||||||
|
|
||||||
|
function okPaged(content, count, statusCode = 200) {
|
||||||
|
return { statusCode, content, count };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(message, statusCode = 400) {
|
||||||
|
return { statusCode, message, content: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { ok, okPaged, fail };
|
||||||
22
src/lib/upstreamHeaders.js
Normal file
22
src/lib/upstreamHeaders.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
const config = require("../config");
|
||||||
|
|
||||||
|
function upstreamHeaders(extra = {}) {
|
||||||
|
const headers = { ...extra };
|
||||||
|
if (config.upstreamHost) {
|
||||||
|
headers.Host = config.upstreamHost;
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionHeaders(session, extra = {}) {
|
||||||
|
const headers = upstreamHeaders(extra);
|
||||||
|
if (session?.cookies) {
|
||||||
|
headers.Cookie = session.cookies;
|
||||||
|
}
|
||||||
|
if (session?.csrfToken) {
|
||||||
|
headers["X-CSRF-Token"] = session.csrfToken;
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { upstreamHeaders, sessionHeaders };
|
||||||
114
src/lib/userPrefs.js
Normal file
114
src/lib/userPrefs.js
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
const dalanClient = require("./dalanClient");
|
||||||
|
|
||||||
|
const DEFAULTS = {
|
||||||
|
favoriteCurrencies: [],
|
||||||
|
favoriteMarkets: [],
|
||||||
|
priceAlerts: [],
|
||||||
|
hiddenTransactionIds: [],
|
||||||
|
readNotificationIds: [],
|
||||||
|
deletedNotificationIds: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseDalanData(raw) {
|
||||||
|
if (!raw) return {};
|
||||||
|
if (typeof raw === "object") return raw;
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCurrencyCode(value) {
|
||||||
|
return String(value || "").toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractShahooPrefs(data) {
|
||||||
|
const root = parseDalanData(data);
|
||||||
|
const shahoo = root.shahoo && typeof root.shahoo === "object" ? root.shahoo : root;
|
||||||
|
|
||||||
|
return {
|
||||||
|
favoriteCurrencies: Array.isArray(shahoo.favoriteCurrencies)
|
||||||
|
? shahoo.favoriteCurrencies.map(normalizeCurrencyCode)
|
||||||
|
: [],
|
||||||
|
favoriteMarkets: Array.isArray(shahoo.favoriteMarkets)
|
||||||
|
? shahoo.favoriteMarkets.map(normalizeCurrencyCode)
|
||||||
|
: [],
|
||||||
|
priceAlerts: Array.isArray(shahoo.priceAlerts) ? shahoo.priceAlerts : [],
|
||||||
|
hiddenTransactionIds: Array.isArray(shahoo.hiddenTransactionIds)
|
||||||
|
? shahoo.hiddenTransactionIds.map(String)
|
||||||
|
: [],
|
||||||
|
readNotificationIds: Array.isArray(shahoo.readNotificationIds)
|
||||||
|
? shahoo.readNotificationIds.map(String)
|
||||||
|
: [],
|
||||||
|
deletedNotificationIds: Array.isArray(shahoo.deletedNotificationIds)
|
||||||
|
? shahoo.deletedNotificationIds.map(String)
|
||||||
|
: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPrefs(session) {
|
||||||
|
if (session.shahooPrefs) {
|
||||||
|
return session.shahooPrefs;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session.dalanUser) {
|
||||||
|
try {
|
||||||
|
await dalanClient.getMe(session);
|
||||||
|
} catch {
|
||||||
|
session.shahooPrefs = { ...DEFAULTS };
|
||||||
|
return session.shahooPrefs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
session.shahooPrefs = extractShahooPrefs(session.dalanUser?.data);
|
||||||
|
return session.shahooPrefs;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePrefs(session, prefs) {
|
||||||
|
session.shahooPrefs = prefs;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = session.dalanUser || (await dalanClient.getMe(session));
|
||||||
|
const root = parseDalanData(user.data);
|
||||||
|
root.shahoo = prefs;
|
||||||
|
|
||||||
|
const payload =
|
||||||
|
typeof user.data === "string" ? { data: JSON.stringify(root) } : { data: root };
|
||||||
|
|
||||||
|
const result = await dalanClient.resourcePut(session, "/resource/users/me", payload);
|
||||||
|
if (result.status === 200 && result.data) {
|
||||||
|
session.dalanUser = result.data;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* session-only fallback */
|
||||||
|
}
|
||||||
|
|
||||||
|
return prefs;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updatePrefs(session, patch) {
|
||||||
|
const current = await getPrefs(session);
|
||||||
|
const next = {
|
||||||
|
...current,
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
return savePrefs(session, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextAlertId(alerts) {
|
||||||
|
const maxId = (alerts || []).reduce((max, row) => {
|
||||||
|
const id = Number(row.id) || 0;
|
||||||
|
return id > max ? id : max;
|
||||||
|
}, 0);
|
||||||
|
return maxId + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
DEFAULTS,
|
||||||
|
normalizeCurrencyCode,
|
||||||
|
getPrefs,
|
||||||
|
savePrefs,
|
||||||
|
updatePrefs,
|
||||||
|
nextAlertId,
|
||||||
|
};
|
||||||
37
src/middleware/auth.js
Normal file
37
src/middleware/auth.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
const sessionStore = require("../lib/sessionStore");
|
||||||
|
|
||||||
|
function extractToken(req) {
|
||||||
|
const auth = String(req.headers.authorization || "").trim();
|
||||||
|
if (!auth) return "";
|
||||||
|
if (auth.toLowerCase().startsWith("bearer ")) {
|
||||||
|
return auth.slice(7).trim();
|
||||||
|
}
|
||||||
|
return auth;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireSession(req, res, next) {
|
||||||
|
const token = extractToken(req);
|
||||||
|
sessionStore
|
||||||
|
.get(token)
|
||||||
|
.then((session) => {
|
||||||
|
if (!session) {
|
||||||
|
return res.status(401).json({
|
||||||
|
statusCode: 401,
|
||||||
|
message: "Unauthorized",
|
||||||
|
content: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
req.session = session;
|
||||||
|
req.bffToken = token;
|
||||||
|
next();
|
||||||
|
})
|
||||||
|
.catch(() =>
|
||||||
|
res.status(401).json({
|
||||||
|
statusCode: 401,
|
||||||
|
message: "Unauthorized",
|
||||||
|
content: null,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { extractToken, requireSession };
|
||||||
8
src/middleware/upload.js
Normal file
8
src/middleware/upload.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
const multer = require("multer");
|
||||||
|
|
||||||
|
const upload = multer({
|
||||||
|
storage: multer.memoryStorage(),
|
||||||
|
limits: { fileSize: 10 * 1024 * 1024 },
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = { upload };
|
||||||
94
src/routes/alerts.js
Normal file
94
src/routes/alerts.js
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
const express = require("express");
|
||||||
|
const { getPrefs, updatePrefs, nextAlertId } = require("../lib/userPrefs");
|
||||||
|
const { ok, okPaged, fail } = require("../lib/shahooResponse");
|
||||||
|
const { requireSession } = require("../middleware/auth");
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
function parsePage(pageSize, pageNumber) {
|
||||||
|
const limit = Math.max(1, Math.min(Number(pageSize) || 10, 100));
|
||||||
|
const page = Math.max(0, Number(pageNumber) || 0);
|
||||||
|
return { limit, page };
|
||||||
|
}
|
||||||
|
|
||||||
|
function alertSide(crossType) {
|
||||||
|
return String(crossType || "").toUpperCase() === "DOWN" ? "sell" : "buy";
|
||||||
|
}
|
||||||
|
|
||||||
|
router.post("/", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { crossType, marketName, price } = req.body || {};
|
||||||
|
if (!marketName || price == null || !crossType) {
|
||||||
|
return res.status(422).json(fail("marketName, crossType and price are required", 422));
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefs = await getPrefs(req.session);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const alert = {
|
||||||
|
id: nextAlertId(prefs.priceAlerts),
|
||||||
|
marketName: String(marketName).toUpperCase(),
|
||||||
|
crossType: String(crossType).toUpperCase(),
|
||||||
|
price: parseFloat(price) || 0,
|
||||||
|
side: alertSide(crossType),
|
||||||
|
favorite: false,
|
||||||
|
updated: now,
|
||||||
|
created: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
await updatePrefs(req.session, {
|
||||||
|
priceAlerts: [...(prefs.priceAlerts || []), alert],
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
statusCode: 200,
|
||||||
|
message: "Alert created",
|
||||||
|
content: alert,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json(fail(err.message || "Request failed", 500));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/self/:pageSize/:pageNumber", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const prefs = await getPrefs(req.session);
|
||||||
|
const alerts = [...(prefs.priceAlerts || [])].sort(
|
||||||
|
(a, b) => Date.parse(b.updated || b.created) - Date.parse(a.updated || a.created)
|
||||||
|
);
|
||||||
|
const { limit, page } = parsePage(req.params.pageSize, req.params.pageNumber);
|
||||||
|
const start = page * limit;
|
||||||
|
const content = alerts.slice(start, start + limit);
|
||||||
|
return res.json(okPaged(content, alerts.length));
|
||||||
|
} catch {
|
||||||
|
return res.json(okPaged([], 0));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete("/:id", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const prefs = await getPrefs(req.session);
|
||||||
|
const nextAlerts = (prefs.priceAlerts || []).filter((row) => Number(row.id) !== id);
|
||||||
|
await updatePrefs(req.session, { priceAlerts: nextAlerts });
|
||||||
|
return res.json({ statusCode: 200, message: "Alert deleted", content: null });
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json(fail(err.message || "Request failed", 500));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put("/delete", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const ids = (Array.isArray(req.body) ? req.body : [])
|
||||||
|
.map((row) => Number(row?.id))
|
||||||
|
.filter((id) => Number.isFinite(id));
|
||||||
|
const prefs = await getPrefs(req.session);
|
||||||
|
const idSet = new Set(ids);
|
||||||
|
const nextAlerts = (prefs.priceAlerts || []).filter((row) => !idSet.has(Number(row.id)));
|
||||||
|
await updatePrefs(req.session, { priceAlerts: nextAlerts });
|
||||||
|
return res.json({ statusCode: 200, message: "Alerts deleted", content: null });
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json(fail(err.message || "Request failed", 500));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
69
src/routes/api.js
Normal file
69
src/routes/api.js
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
const express = require("express");
|
||||||
|
const usersRouter = require("./users");
|
||||||
|
const ohlcvsRouter = require("./ohlcvs");
|
||||||
|
const constantsRouter = require("./constants");
|
||||||
|
const walletsRouter = require("./wallets");
|
||||||
|
const ordersRouter = require("./orders");
|
||||||
|
const doneOrdersRouter = require("./doneOrders");
|
||||||
|
const tradesRouter = require("./trades");
|
||||||
|
const destinationWalletsRouter = require("./destinationWallets");
|
||||||
|
const withdrawalsRouter = require("./withdrawals");
|
||||||
|
const depositsRouter = require("./deposits");
|
||||||
|
const notificationsRouter = require("./notifications");
|
||||||
|
const kycUsersRouter = require("./kycUsers");
|
||||||
|
const otpRouter = require("./otp");
|
||||||
|
const jibitRouter = require("./jibit");
|
||||||
|
const bankRouter = require("./bank");
|
||||||
|
const locationsRouter = require("./locations");
|
||||||
|
const ttsRouter = require("./tts");
|
||||||
|
const irtpaysRouter = require("./irtpays");
|
||||||
|
const marketRouter = require("./market");
|
||||||
|
const marketsRouter = require("./markets");
|
||||||
|
const alertsRouter = require("./alerts");
|
||||||
|
const transactionsRouter = require("./transactions");
|
||||||
|
const assetHistoryRouter = require("./assetHistory");
|
||||||
|
const apiKeysRouter = require("./apiKeys");
|
||||||
|
const docsRouter = require("./docs");
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get("/health", (_req, res) => {
|
||||||
|
res.json({ status: "ok", service: "shahoo-bff" });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.use("/docs", docsRouter);
|
||||||
|
router.use("/users/apiKeys", apiKeysRouter);
|
||||||
|
router.use("/users", usersRouter);
|
||||||
|
router.use("/users", kycUsersRouter);
|
||||||
|
router.use("/users/notifications", notificationsRouter);
|
||||||
|
router.use("/otp", otpRouter);
|
||||||
|
router.use("/jibit", jibitRouter);
|
||||||
|
router.use("/bank", bankRouter);
|
||||||
|
router.use("/locations", locationsRouter);
|
||||||
|
router.use("/tts", ttsRouter);
|
||||||
|
router.use("/irtpays", irtpaysRouter);
|
||||||
|
router.use("/ohlcvs", ohlcvsRouter);
|
||||||
|
router.use("/constants", constantsRouter);
|
||||||
|
router.use("/wallets", walletsRouter);
|
||||||
|
router.use("/wallet/destination", destinationWalletsRouter);
|
||||||
|
router.use("/withdrawals", withdrawalsRouter);
|
||||||
|
router.use("/deposits", depositsRouter);
|
||||||
|
router.use("/orders", ordersRouter);
|
||||||
|
router.use("/doneOrders", doneOrdersRouter);
|
||||||
|
router.use("/trades", tradesRouter);
|
||||||
|
router.use("/market", marketRouter);
|
||||||
|
router.use("/markets", marketsRouter);
|
||||||
|
router.use("/alert", alertsRouter);
|
||||||
|
router.use("/transactions", transactionsRouter);
|
||||||
|
router.use("/assetHistory", assetHistoryRouter);
|
||||||
|
router.use("/ranger", require("./ranger").router);
|
||||||
|
|
||||||
|
router.use((_req, res) => {
|
||||||
|
res.status(501).json({
|
||||||
|
statusCode: 501,
|
||||||
|
message: "BFF endpoint not implemented yet",
|
||||||
|
content: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
150
src/routes/apiKeys.js
Normal file
150
src/routes/apiKeys.js
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
const express = require("express");
|
||||||
|
const dalanClient = require("../lib/dalanClient");
|
||||||
|
const config = require("../config");
|
||||||
|
const { ok, okPaged, fail } = require("../lib/shahooResponse");
|
||||||
|
const { requireSession } = require("../middleware/auth");
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
function mapApiKey(item) {
|
||||||
|
if (!item) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
let secret = item.secret;
|
||||||
|
if (secret && typeof secret === "object") {
|
||||||
|
secret = secret.data?.value || secret.value || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kid: item.kid,
|
||||||
|
algorithm: item.algorithm,
|
||||||
|
scope: item.scope || [],
|
||||||
|
state: item.state,
|
||||||
|
secret: secret || undefined,
|
||||||
|
created_at: item.created_at,
|
||||||
|
updated_at: item.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function dalanRouteError(res, result, fallback = "Request failed") {
|
||||||
|
const code = result.status >= 400 ? result.status : 422;
|
||||||
|
return res
|
||||||
|
.status(code)
|
||||||
|
.json(fail(dalanClient.dalanErrorMessage(result.data) || fallback, code));
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get("/", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const pageSize = Math.max(1, Math.min(Number(req.query.pageSize) || 10, 100));
|
||||||
|
const pageNumber = Math.max(0, Number(req.query.pageNumber) || 0);
|
||||||
|
const page = pageNumber + 1;
|
||||||
|
|
||||||
|
const result = await dalanClient.resourceGet(req.session, "/resource/api_keys", {
|
||||||
|
page,
|
||||||
|
limit: pageSize,
|
||||||
|
ordering: req.query.ordering || "desc",
|
||||||
|
order_by: req.query.order_by || "created_at",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.status !== 200) {
|
||||||
|
return dalanRouteError(res, result, "Unable to list API keys");
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalHeader =
|
||||||
|
result.headers?.total || result.headers?.["x-total"] || result.headers?.["X-Total"];
|
||||||
|
const total = parseInt(totalHeader, 10);
|
||||||
|
const content = (Array.isArray(result.data) ? result.data : []).map(mapApiKey);
|
||||||
|
|
||||||
|
return res.json(
|
||||||
|
okPaged(content, Number.isFinite(total) ? total : content.length)
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json(fail(err.message || "Request failed", 500));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const totpCode = req.body?.totp || req.body?.totp_code;
|
||||||
|
if (!config.skipApiKey2FA && !totpCode) {
|
||||||
|
return res.status(422).json(fail("TOTP code is required", 422));
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = { algorithm: "HS256" };
|
||||||
|
if (totpCode) {
|
||||||
|
body.totp_code = String(totpCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await dalanClient.resourcePost(req.session, "/resource/api_keys", body);
|
||||||
|
|
||||||
|
if (result.status !== 201 && result.status !== 200) {
|
||||||
|
return dalanRouteError(res, result, "Unable to create API key");
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(201).json(ok(mapApiKey(result.data), 201));
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json(fail(err.message || "Request failed", 500));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.patch("/:kid", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const totpCode = req.body?.totp || req.body?.totp_code;
|
||||||
|
if (!config.skipApiKey2FA && !totpCode) {
|
||||||
|
return res.status(422).json(fail("TOTP code is required", 422));
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = {};
|
||||||
|
if (totpCode) {
|
||||||
|
body.totp_code = String(totpCode);
|
||||||
|
}
|
||||||
|
if (req.body?.state) {
|
||||||
|
body.state = req.body.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await dalanClient.resourcePatch(
|
||||||
|
req.session,
|
||||||
|
`/resource/api_keys/${encodeURIComponent(req.params.kid)}`,
|
||||||
|
body
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.status !== 200) {
|
||||||
|
return dalanRouteError(res, result, "Unable to update API key");
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json(ok(mapApiKey(result.data)));
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json(fail(err.message || "Request failed", 500));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete("/:kid", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const totpCode =
|
||||||
|
req.body?.totp || req.body?.totp_code || req.query?.totp || req.query?.totp_code;
|
||||||
|
if (!config.skipApiKey2FA && !totpCode) {
|
||||||
|
return res.status(422).json(fail("TOTP code is required", 422));
|
||||||
|
}
|
||||||
|
|
||||||
|
const kid = encodeURIComponent(req.params.kid);
|
||||||
|
const query = totpCode
|
||||||
|
? `?totp_code=${encodeURIComponent(String(totpCode))}`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const result = await dalanClient.resourceDelete(
|
||||||
|
req.session,
|
||||||
|
`/resource/api_keys/${kid}${query}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.status !== 204 && result.status !== 200) {
|
||||||
|
return dalanRouteError(res, result, "Unable to delete API key");
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json(ok(null));
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json(fail(err.message || "Request failed", 500));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
40
src/routes/assetHistory.js
Normal file
40
src/routes/assetHistory.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
const express = require("express");
|
||||||
|
const denaClient = require("../lib/denaClient");
|
||||||
|
const {
|
||||||
|
mapBalancesToAssetHistory,
|
||||||
|
mapBalancesToHistorySeries,
|
||||||
|
} = require("../lib/mapAssetHistory");
|
||||||
|
const { ok, okPaged } = require("../lib/shahooResponse");
|
||||||
|
const { requireSession } = require("../middleware/auth");
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
function parsePage(pageSize, pageNumber) {
|
||||||
|
const limit = Math.max(1, Math.min(Number(pageSize) || 10, 100));
|
||||||
|
const page = Math.max(0, Number(pageNumber) || 0);
|
||||||
|
return { limit, page };
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get("/user/self", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const balances = await denaClient.getBalances(req.session);
|
||||||
|
return res.json(ok(mapBalancesToAssetHistory(balances)));
|
||||||
|
} catch {
|
||||||
|
return res.json(ok([]));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/:pageSize/:pageNumber", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const balances = await denaClient.getBalances(req.session);
|
||||||
|
const rows = mapBalancesToHistorySeries(balances);
|
||||||
|
const { limit, page } = parsePage(req.params.pageSize, req.params.pageNumber);
|
||||||
|
const start = page * limit;
|
||||||
|
const content = rows.slice(start, start + limit);
|
||||||
|
return res.json(okPaged(content, rows.length));
|
||||||
|
} catch {
|
||||||
|
return res.json(okPaged([], 0));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
59
src/routes/bank.js
Normal file
59
src/routes/bank.js
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
const express = require("express");
|
||||||
|
const dalanClient = require("../lib/dalanClient");
|
||||||
|
const { buildKycUser, mapBankCardPayload } = require("../lib/mapKyc");
|
||||||
|
const { ok, fail } = require("../lib/shahooResponse");
|
||||||
|
const { requireSession } = require("../middleware/auth");
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
function dalanRouteError(res, result, fallback) {
|
||||||
|
const code = result.status >= 400 ? result.status : 422;
|
||||||
|
return res.status(code).json(fail(dalanClient.dalanErrorMessage(result.data) || fallback, code));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBankCards(session) {
|
||||||
|
const [dalanUser, documents, treasuries] = await Promise.all([
|
||||||
|
dalanClient.getFullUser(session),
|
||||||
|
dalanClient.getDocuments(session),
|
||||||
|
dalanClient.getTreasuries(session),
|
||||||
|
]);
|
||||||
|
return buildKycUser(dalanUser, { documents, treasuries }).bankInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get("/self", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const cards = await loadBankCards(req.session);
|
||||||
|
return res.json(ok(cards));
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json(fail(err.message || "Request failed", 500));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/createCardIban", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const payload = mapBankCardPayload(req.body || {});
|
||||||
|
const result = await dalanClient.createTreasury(req.session, payload);
|
||||||
|
if (result.status >= 200 && result.status < 300) {
|
||||||
|
const cards = await loadBankCards(req.session);
|
||||||
|
const created = cards.find((item) => item.number === payload.data) || result.data;
|
||||||
|
return res.json(ok(created, 200));
|
||||||
|
}
|
||||||
|
return dalanRouteError(res, result, "Unable to add bank card");
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json(fail(err.message || "Request failed", 500));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete("/delete/:id", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await dalanClient.deleteTreasury(req.session, req.params.id);
|
||||||
|
if (result.status === 204 || result.status === 200) {
|
||||||
|
return res.json(ok({ message: "Deleted" }));
|
||||||
|
}
|
||||||
|
return dalanRouteError(res, result, "Unable to delete bank card");
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json(fail(err.message || "Request failed", 500));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
20
src/routes/constants.js
Normal file
20
src/routes/constants.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
const express = require("express");
|
||||||
|
const denaClient = require("../lib/denaClient");
|
||||||
|
const { buildConstantsFromMarkets } = require("../lib/mapConstants");
|
||||||
|
const { ok } = require("../lib/shahooResponse");
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get("/", async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const [markets, tradingFees] = await Promise.all([
|
||||||
|
denaClient.getMarkets(),
|
||||||
|
denaClient.getTradingFees(),
|
||||||
|
]);
|
||||||
|
return res.json(ok(buildConstantsFromMarkets(markets, tradingFees)));
|
||||||
|
} catch {
|
||||||
|
return res.json(ok(buildConstantsFromMarkets([], [])));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
76
src/routes/deposits.js
Normal file
76
src/routes/deposits.js
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
const express = require("express");
|
||||||
|
const denaClient = require("../lib/denaClient");
|
||||||
|
const { mapDenaDeposit } = require("../lib/mapWithdrawal");
|
||||||
|
const { okPaged } = require("../lib/shahooResponse");
|
||||||
|
const { requireSession } = require("../middleware/auth");
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
function parsePage(pageSize, pageNumber) {
|
||||||
|
const limit = Math.max(1, Math.min(Number(pageSize) || 10, 100));
|
||||||
|
const page = Math.max(1, Number(pageNumber) + 1);
|
||||||
|
return { limit, page };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listDeposits(session, { currency, limit, page }) {
|
||||||
|
const params = { limit, page };
|
||||||
|
if (currency) {
|
||||||
|
params.currency = String(currency).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
const deposits = await denaClient.getDeposits(session, params);
|
||||||
|
return deposits.map(mapDenaDeposit).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get("/self/:currencyName/:pageSize/:pageNumber", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { limit, page } = parsePage(req.params.pageSize, req.params.pageNumber);
|
||||||
|
const content = await listDeposits(req.session, {
|
||||||
|
currency: req.params.currencyName,
|
||||||
|
limit,
|
||||||
|
page,
|
||||||
|
});
|
||||||
|
return res.json(okPaged(content, content.length));
|
||||||
|
} catch {
|
||||||
|
return res.json(okPaged([], 0));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/self/:pageSize/:pageNumber", requireSession, async (req, res) => {
|
||||||
|
if (!/^\d+$/.test(String(req.params.pageSize))) {
|
||||||
|
return res.json(okPaged([], 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { limit, page } = parsePage(req.params.pageSize, req.params.pageNumber);
|
||||||
|
const content = await listDeposits(req.session, { limit, page });
|
||||||
|
return res.json(okPaged(content, content.length));
|
||||||
|
} catch {
|
||||||
|
return res.json(okPaged([], 0));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/syncWallet/:currencyName/:pageSize/:pageNumber",
|
||||||
|
requireSession,
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const currency = String(req.params.currencyName || "").toLowerCase();
|
||||||
|
const { limit, page } = parsePage(req.params.pageSize, req.params.pageNumber);
|
||||||
|
|
||||||
|
await denaClient.getDepositAddress(req.session, currency).catch(() => null);
|
||||||
|
await denaClient.getBalances(req.session).catch(() => null);
|
||||||
|
|
||||||
|
const content = await listDeposits(req.session, {
|
||||||
|
currency: req.params.currencyName,
|
||||||
|
limit,
|
||||||
|
page,
|
||||||
|
});
|
||||||
|
return res.json(okPaged(content, content.length));
|
||||||
|
} catch {
|
||||||
|
return res.json(okPaged([], 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
175
src/routes/destinationWallets.js
Normal file
175
src/routes/destinationWallets.js
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
const express = require("express");
|
||||||
|
const denaClient = require("../lib/denaClient");
|
||||||
|
const dalanClient = require("../lib/dalanClient");
|
||||||
|
const {
|
||||||
|
mapBeneficiaryToDestinationWallet,
|
||||||
|
mapDestinationWalletPayload,
|
||||||
|
} = require("../lib/mapBeneficiary");
|
||||||
|
const { ok, okPaged, fail } = require("../lib/shahooResponse");
|
||||||
|
const { denaErrorMessage } = require("../lib/mapOrder");
|
||||||
|
const { requireSession } = require("../middleware/auth");
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
function parsePage(pageSize, pageNumber) {
|
||||||
|
const limit = Math.max(1, Math.min(Number(pageSize) || 10, 100));
|
||||||
|
const page = Math.max(1, Number(pageNumber) + 1);
|
||||||
|
return { limit, page };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listDestinationWallets(session, email) {
|
||||||
|
const rows = await denaClient.getBeneficiaries(session);
|
||||||
|
return rows.map((row) => mapBeneficiaryToDestinationWallet(row, email)).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get("/self/:pageSize/:pageNumber", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const dalanUser = await dalanClient.getMe(req.session);
|
||||||
|
const rows = await listDestinationWallets(req.session, dalanUser.email);
|
||||||
|
const { limit, page } = parsePage(req.params.pageSize, req.params.pageNumber);
|
||||||
|
const start = (page - 1) * limit;
|
||||||
|
const content = rows.slice(start, start + limit);
|
||||||
|
return res.json(okPaged(content, rows.length));
|
||||||
|
} catch {
|
||||||
|
return res.json(okPaged([], 0));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/self", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const dalanUser = await dalanClient.getMe(req.session);
|
||||||
|
const rows = await listDestinationWallets(req.session, dalanUser.email);
|
||||||
|
return res.json(ok(rows));
|
||||||
|
} catch {
|
||||||
|
return res.json(ok([]));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/:id", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const dalanUser = await dalanClient.getMe(req.session);
|
||||||
|
const row = await denaClient.getBeneficiaryById(req.session, req.params.id);
|
||||||
|
return res.json(ok(mapBeneficiaryToDestinationWallet(row, dalanUser.email)));
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(404).json(fail(err.message || "Not found", 404));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const dalanUser = await dalanClient.getMe(req.session);
|
||||||
|
const mapped = mapDestinationWalletPayload(req.body || {}, dalanUser.email);
|
||||||
|
const payload = {
|
||||||
|
currency: mapped.currency_id,
|
||||||
|
name: mapped.name,
|
||||||
|
description: mapped.description,
|
||||||
|
data: mapped.data,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await denaClient.createBeneficiary(req.session, payload);
|
||||||
|
if (result.status === 201 || result.status === 200) {
|
||||||
|
const wallet = mapBeneficiaryToDestinationWallet(result.data, dalanUser.email);
|
||||||
|
if (mapped.inWhitelist) {
|
||||||
|
wallet.inWhitelist = true;
|
||||||
|
}
|
||||||
|
return res.json(ok(wallet));
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = result.status >= 400 ? result.status : 422;
|
||||||
|
return res.status(code).json(fail(denaErrorMessage(result.data), code));
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json(fail(err.message || "Request failed", 500));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete("/delete/:id", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await denaClient.deleteBeneficiary(req.session, req.params.id);
|
||||||
|
if (result.status === 200 || result.status === 204) {
|
||||||
|
return res.json(ok({ message: "Deleted" }));
|
||||||
|
}
|
||||||
|
const code = result.status >= 400 ? result.status : 422;
|
||||||
|
return res.status(code).json(fail(denaErrorMessage(result.data), code));
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json(fail(err.message || "Request failed", 500));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put("/delete", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const ids = Array.isArray(req.body) ? req.body.map((row) => row.id) : [];
|
||||||
|
await Promise.all(ids.map((id) => denaClient.deleteBeneficiary(req.session, id)));
|
||||||
|
return res.json(ok({ message: "Deleted" }));
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json(fail(err.message || "Request failed", 500));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put("/update", requireSession, async (req, res) => {
|
||||||
|
return res.status(501).json(fail("Update destination wallet is not supported upstream", 501));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/validate", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const entries = Array.isArray(req.body) ? req.body : [req.body];
|
||||||
|
const activated = [];
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const beneficiary = await denaClient.getBeneficiaries(req.session);
|
||||||
|
const match = beneficiary.find(
|
||||||
|
(row) =>
|
||||||
|
String(row.id) === String(entry.id) ||
|
||||||
|
String(row.data?.address) === String(entry.address)
|
||||||
|
);
|
||||||
|
if (!match) continue;
|
||||||
|
|
||||||
|
const result = await denaClient.activateBeneficiary(
|
||||||
|
req.session,
|
||||||
|
match.id,
|
||||||
|
entry.token
|
||||||
|
);
|
||||||
|
if (result.status === 200) {
|
||||||
|
activated.push(mapBeneficiaryToDestinationWallet(result.data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json(ok(activated[0] || { success: true }));
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(422).json(fail(err.message || "Invalid token", 422));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put("/whitelist", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const ids = Array.isArray(req.body) ? req.body.map((row) => row.id) : [];
|
||||||
|
const beneficiaries = await denaClient.getBeneficiaries(req.session, { state: "pending" });
|
||||||
|
const updated = [];
|
||||||
|
|
||||||
|
for (const id of ids) {
|
||||||
|
const match = beneficiaries.find((row) => String(row.id) === String(id));
|
||||||
|
if (!match) continue;
|
||||||
|
await denaClient.resendBeneficiaryPin(req.session, match.id);
|
||||||
|
updated.push(mapBeneficiaryToDestinationWallet(match));
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json(ok(updated));
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json(fail(err.message || "Request failed", 500));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put("/resend", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const beneficiaries = await denaClient.getBeneficiaries(req.session, { state: "pending" });
|
||||||
|
const pending = beneficiaries[0];
|
||||||
|
if (!pending) {
|
||||||
|
return res.json(ok({ message: "No pending wallet" }));
|
||||||
|
}
|
||||||
|
await denaClient.resendBeneficiaryPin(req.session, pending.id);
|
||||||
|
return res.json(ok({ message: "Verification email resent" }));
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json(fail(err.message || "Request failed", 500));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
95
src/routes/docs.js
Normal file
95
src/routes/docs.js
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
const express = require("express");
|
||||||
|
const axios = require("axios");
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const config = require("../config");
|
||||||
|
const { upstreamHeaders } = require("../lib/upstreamHeaders");
|
||||||
|
const { buildBrandedSpec } = require("../lib/buildApiSpec");
|
||||||
|
const { ok, fail } = require("../lib/shahooResponse");
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
function resolveDenaSwaggerUrl() {
|
||||||
|
const denaUrl = String(config.denaUrl || "").replace(/\/+$/, "");
|
||||||
|
// Grape swagger is mounted at /api/v2/swagger, not under /api/v2/dena/swagger.
|
||||||
|
if (denaUrl.endsWith("/api/v2/dena")) {
|
||||||
|
return `${denaUrl.slice(0, -"/dena".length)}/swagger`;
|
||||||
|
}
|
||||||
|
return `${denaUrl}/swagger`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function brandContext(req) {
|
||||||
|
const domain =
|
||||||
|
process.env.FIBITEX_DOMAIN ||
|
||||||
|
process.env.APP_DOMAIN ||
|
||||||
|
String(config.publicUrl || "")
|
||||||
|
.replace(/^https?:\/\//, "")
|
||||||
|
.split("/")[0] ||
|
||||||
|
"fibitex.com";
|
||||||
|
const brandName =
|
||||||
|
process.env.FIBITEX_BRAND_NAME ||
|
||||||
|
domain.split(".")[0].charAt(0).toUpperCase() + domain.split(".")[0].slice(1);
|
||||||
|
const publicUrl =
|
||||||
|
config.publicUrl?.startsWith("http")
|
||||||
|
? config.publicUrl.replace(/\/+$/, "")
|
||||||
|
: `https://${domain}`;
|
||||||
|
|
||||||
|
return { domain, brandName, publicUrl };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchDenaSwagger() {
|
||||||
|
const swaggerUrls = [
|
||||||
|
resolveDenaSwaggerUrl(),
|
||||||
|
`${String(config.denaUrl || "").replace(/\/+$/, "")}/swagger`,
|
||||||
|
].filter((url, index, list) => url && list.indexOf(url) === index);
|
||||||
|
|
||||||
|
for (const swaggerUrl of swaggerUrls) {
|
||||||
|
const res = await axios.get(swaggerUrl, {
|
||||||
|
headers: upstreamHeaders(),
|
||||||
|
validateStatus: () => true,
|
||||||
|
timeout: 15000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 200 && res.data) {
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackPaths = [
|
||||||
|
process.env.DENA_SWAGGER_PATH,
|
||||||
|
path.resolve(__dirname, "../../data/dena-swagger.json"),
|
||||||
|
path.resolve(__dirname, "../../../Dena/docs/api/swagger/user_api.json"),
|
||||||
|
path.resolve(__dirname, "../../../Dena/docs/api/swagger.json"),
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
for (const fallbackPath of fallbackPaths) {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(fallbackPath)) {
|
||||||
|
return JSON.parse(fs.readFileSync(fallbackPath, "utf8"));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* try next */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const err = new Error("Unable to load OpenAPI specification");
|
||||||
|
err.statusCode = 503;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get("/swagger", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const ctx = brandContext(req);
|
||||||
|
const raw = await fetchDenaSwagger();
|
||||||
|
const spec = buildBrandedSpec(raw, {
|
||||||
|
...ctx,
|
||||||
|
logoUrl: `${ctx.publicUrl}/static/icons/Logo/Logo-icon.svg`,
|
||||||
|
});
|
||||||
|
return res.json(ok(spec));
|
||||||
|
} catch (err) {
|
||||||
|
const code = err.statusCode || 503;
|
||||||
|
return res.status(code).json(fail(err.message || "Swagger unavailable", code));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
48
src/routes/doneOrders.js
Normal file
48
src/routes/doneOrders.js
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
const express = require("express");
|
||||||
|
const denaClient = require("../lib/denaClient");
|
||||||
|
const { mapDenaOrderToShahoo, denaErrorMessage } = require("../lib/mapOrder");
|
||||||
|
const { ok, okPaged } = require("../lib/shahooResponse");
|
||||||
|
const { requireSession } = require("../middleware/auth");
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
function parsePagination(pageSize, pageNumber) {
|
||||||
|
const limit = Math.max(1, Math.min(Number(pageSize) || 10, 100));
|
||||||
|
const page = Math.max(1, Number(pageNumber) + 1);
|
||||||
|
return { limit, page };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchDoneOrders(session, pageSize, pageNumber) {
|
||||||
|
const { limit, page } = parsePagination(pageSize, pageNumber);
|
||||||
|
const orders = await denaClient.getOrders(session, { state: "done", limit, page });
|
||||||
|
const content = orders.map(mapDenaOrderToShahoo);
|
||||||
|
return { content, count: content.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get("/self/all/:pageSize/:pageNumber", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { content, count } = await fetchDoneOrders(
|
||||||
|
req.session,
|
||||||
|
req.params.pageSize,
|
||||||
|
req.params.pageNumber
|
||||||
|
);
|
||||||
|
return res.json(okPaged(content, count));
|
||||||
|
} catch {
|
||||||
|
return res.json(okPaged([], 0));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/self/done/:pageSize/:pageNumber", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { content, count } = await fetchDoneOrders(
|
||||||
|
req.session,
|
||||||
|
req.params.pageSize,
|
||||||
|
req.params.pageNumber
|
||||||
|
);
|
||||||
|
return res.json(okPaged(content, count));
|
||||||
|
} catch {
|
||||||
|
return res.json(okPaged([], 0));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
9
src/routes/health.js
Normal file
9
src/routes/health.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
const express = require("express");
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get("/health", (_req, res) => {
|
||||||
|
res.json({ status: "ok", service: "shahoo-bff" });
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
130
src/routes/irtpays.js
Normal file
130
src/routes/irtpays.js
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
const express = require("express");
|
||||||
|
const config = require("../config");
|
||||||
|
const denaClient = require("../lib/denaClient");
|
||||||
|
const dalanClient = require("../lib/dalanClient");
|
||||||
|
const { mapDenaWithdrawal } = require("../lib/mapWithdrawal");
|
||||||
|
const { denaErrorMessage } = require("../lib/mapOrder");
|
||||||
|
const { ok, fail } = require("../lib/shahooResponse");
|
||||||
|
const { requireSession } = require("../middleware/auth");
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.post("/vandar/deposit", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const amount = req.body?.amount;
|
||||||
|
const card = req.body?.payerCardNo;
|
||||||
|
const callbackUrl = `${config.publicCallbackUrl}/app/form/irtpay/success`;
|
||||||
|
|
||||||
|
const result = await denaClient.createFiatDeposit(req.session, {
|
||||||
|
amount,
|
||||||
|
card,
|
||||||
|
callbackUrl,
|
||||||
|
currency: "irt",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.status === 200 || result.status === 201) {
|
||||||
|
const token = result.data?.response;
|
||||||
|
const redirectUrl = token ? `https://ipg.vandar.io/v3/${token}` : "";
|
||||||
|
return res.json(ok(redirectUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = result.status >= 400 ? result.status : 422;
|
||||||
|
return res.status(code).json(fail(denaErrorMessage(result.data), code));
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json(fail(err.message || "Deposit failed", 500));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/vandar/withdraw/create", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { iban, amount, totp } = req.body || {};
|
||||||
|
|
||||||
|
if (totp) {
|
||||||
|
const otpResult = await denaClient.validateWithdrawOtp(req.session, totp);
|
||||||
|
if (otpResult.status !== 200) {
|
||||||
|
const code = otpResult.status >= 400 ? otpResult.status : 422;
|
||||||
|
return res.status(code).json(fail(denaErrorMessage(otpResult.data), code));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await denaClient.createFiatWithdraw(req.session, {
|
||||||
|
iban,
|
||||||
|
amount,
|
||||||
|
currency: "irt",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.status === 200 || result.status === 201) {
|
||||||
|
return res.json(ok(mapDenaWithdrawal(result.data)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = result.status >= 400 ? result.status : 422;
|
||||||
|
return res.status(code).json(fail(denaErrorMessage(result.data), code));
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json(fail(err.message || "Withdraw failed", 500));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/:id/confirm/:token", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await denaClient.confirmFiatWithdraw(req.session, {
|
||||||
|
id: req.params.id,
|
||||||
|
otp: req.params.token,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.status === 200 || result.status === 201) {
|
||||||
|
const mapped =
|
||||||
|
mapDenaWithdrawal(result.data) ||
|
||||||
|
mapDenaWithdrawal(await denaClient.getWithdrawById(req.session, req.params.id));
|
||||||
|
return res.json(ok(mapped));
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmResult = await denaClient.confirmWithdraw(
|
||||||
|
req.session,
|
||||||
|
req.params.id,
|
||||||
|
req.params.token
|
||||||
|
);
|
||||||
|
if (confirmResult.status === 200 || confirmResult.status === 201) {
|
||||||
|
const mapped =
|
||||||
|
mapDenaWithdrawal(confirmResult.data) ||
|
||||||
|
mapDenaWithdrawal(await denaClient.getWithdrawById(req.session, req.params.id));
|
||||||
|
return res.json(ok(mapped));
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = confirmResult.status >= 400 ? confirmResult.status : 422;
|
||||||
|
return res.status(code).json(fail(denaErrorMessage(confirmResult.data), code));
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json(fail(err.message || "Confirm failed", 500));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/resendMail/:id", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const dalanUser = await dalanClient.getMe(req.session);
|
||||||
|
const result = await denaClient.resendFiatWithdrawCode(req.session, dalanUser.email);
|
||||||
|
if (result.status === 200 || result.status === 201) {
|
||||||
|
return res.json(ok({ message: "Confirmation email resent" }));
|
||||||
|
}
|
||||||
|
const fallback = await denaClient.resendWithdrawEmail(req.session, req.params.id);
|
||||||
|
if (fallback.status === 200) {
|
||||||
|
return res.json(ok({ message: "Confirmation email resent" }));
|
||||||
|
}
|
||||||
|
return res.json(ok({ message: "Confirmation email resent" }));
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json(fail(err.message || "Resend failed", 500));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete("/cancel/:id", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await denaClient.cancelWithdraw(req.session, req.params.id);
|
||||||
|
if (result.status === 200 || result.status === 204) {
|
||||||
|
return res.json(ok({ message: "Withdrawal cancelled" }));
|
||||||
|
}
|
||||||
|
const code = result.status >= 400 ? result.status : 422;
|
||||||
|
return res.status(code).json(fail(denaErrorMessage(result.data), code));
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json(fail(err.message || "Cancel failed", 500));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
51
src/routes/jibit.js
Normal file
51
src/routes/jibit.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
const express = require("express");
|
||||||
|
const jibitClient = require("../lib/jibitClient");
|
||||||
|
const { ok, fail } = require("../lib/shahooResponse");
|
||||||
|
const { requireSession } = require("../middleware/auth");
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.post("/kyc/info/card", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const cardNumber = req.body?.cardNumber;
|
||||||
|
const content = await jibitClient.getCardInfo(cardNumber);
|
||||||
|
return res.json(ok(content));
|
||||||
|
} catch (err) {
|
||||||
|
const code = err.statusCode || 502;
|
||||||
|
return res.status(code).json(fail(err.message || "Jibit request failed", code));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/kyc/info/iban", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const iban = req.body?.iban;
|
||||||
|
const content = await jibitClient.getIbanInfo(iban);
|
||||||
|
return res.json(ok(content));
|
||||||
|
} catch (err) {
|
||||||
|
const code = err.statusCode || 502;
|
||||||
|
return res.status(code).json(fail(err.message || "Jibit request failed", code));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/kyc/postalCodeToAddress", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const content = await jibitClient.postalCodeToAddress(req.body?.postalCode);
|
||||||
|
return res.json(ok(content));
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(502).json(fail(err.message || "Jibit request failed", 502));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/kyc/card/to/iban", requireSession, async (_req, res) => {
|
||||||
|
return res.status(501).json(fail("Not implemented", 501));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/kyc/mobile/nationalCode", requireSession, async (_req, res) => {
|
||||||
|
return res.status(501).json(fail("Not implemented", 501));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/kyc/name/similarity", requireSession, async (_req, res) => {
|
||||||
|
return res.status(501).json(fail("Not implemented", 501));
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
108
src/routes/kycUsers.js
Normal file
108
src/routes/kycUsers.js
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
const express = require("express");
|
||||||
|
const dalanClient = require("../lib/dalanClient");
|
||||||
|
const { buildKycUser } = require("../lib/mapKyc");
|
||||||
|
const { resolveCity } = require("../lib/locationsStore");
|
||||||
|
const { upload } = require("../middleware/upload");
|
||||||
|
const { ok, fail } = require("../lib/shahooResponse");
|
||||||
|
const { requireSession } = require("../middleware/auth");
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
function parseFormData(req) {
|
||||||
|
const raw = req.body?.form_data;
|
||||||
|
if (!raw) return {};
|
||||||
|
try {
|
||||||
|
return typeof raw === "string" ? JSON.parse(raw) : raw;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dalanRouteError(res, result, fallback) {
|
||||||
|
const code = result.status >= 400 ? result.status : 422;
|
||||||
|
return res.status(code).json(fail(dalanClient.dalanErrorMessage(result.data) || fallback, code));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProfileContent(session) {
|
||||||
|
const [dalanUser, documents, treasuries] = await Promise.all([
|
||||||
|
dalanClient.getFullUser(session),
|
||||||
|
dalanClient.getDocuments(session),
|
||||||
|
dalanClient.getTreasuries(session),
|
||||||
|
]);
|
||||||
|
return buildKycUser(dalanUser, { documents, treasuries });
|
||||||
|
}
|
||||||
|
|
||||||
|
router.post("/identity", requireSession, upload.single("file"), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const form = parseFormData(req);
|
||||||
|
const profileFields = {
|
||||||
|
first_name: form.firstName,
|
||||||
|
last_name: form.lastName,
|
||||||
|
dob: form.dateOfBirth,
|
||||||
|
national_code: form.nationalCode,
|
||||||
|
metadata: JSON.stringify({ gender: form.gender || "" }),
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = await dalanClient.createIdentityProfile(req.session, {
|
||||||
|
profileFields,
|
||||||
|
file: req.file,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.status === 409) {
|
||||||
|
result = await dalanClient.updateIdentityProfile(req.session, {
|
||||||
|
profileFields,
|
||||||
|
file: req.file,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status >= 200 && result.status < 300) {
|
||||||
|
return res.json(ok(await loadProfileContent(req.session)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return dalanRouteError(res, result, "Unable to submit identity");
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json(fail(err.message || "Request failed", 500));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/address", requireSession, upload.single("file"), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const form = parseFormData(req);
|
||||||
|
const location = resolveCity(form.cityId);
|
||||||
|
if (!location) {
|
||||||
|
return res.status(422).json(fail("City not found", 422));
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await dalanClient.submitAddress(req.session, {
|
||||||
|
addressFields: {
|
||||||
|
city: location.cityName,
|
||||||
|
province: location.provinceName,
|
||||||
|
address: form.address,
|
||||||
|
postcode: form.zipCode,
|
||||||
|
},
|
||||||
|
file: req.file,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.status >= 200 && result.status < 300) {
|
||||||
|
return res.json(ok(await loadProfileContent(req.session)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return dalanRouteError(res, result, "Unable to submit address");
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json(fail(err.message || "Request failed", 500));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/uploadSelfie", requireSession, upload.single("file"), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await dalanClient.submitSelfie(req.session, req.file);
|
||||||
|
if (result.status >= 200 && result.status < 300) {
|
||||||
|
return res.json(ok(await loadProfileContent(req.session)));
|
||||||
|
}
|
||||||
|
return dalanRouteError(res, result, "Unable to upload selfie");
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json(fail(err.message || "Request failed", 500));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
16
src/routes/locations.js
Normal file
16
src/routes/locations.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
const express = require("express");
|
||||||
|
const { listRegions, listCities } = require("../lib/locationsStore");
|
||||||
|
const { ok } = require("../lib/shahooResponse");
|
||||||
|
const { requireSession } = require("../middleware/auth");
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get("/regions", requireSession, async (_req, res) => {
|
||||||
|
return res.json(ok(listRegions()));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/cities/:regionId", requireSession, async (req, res) => {
|
||||||
|
return res.json(ok(listCities(req.params.regionId)));
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
23
src/routes/market.js
Normal file
23
src/routes/market.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
const express = require("express");
|
||||||
|
const { getPrefs } = require("../lib/userPrefs");
|
||||||
|
const { ok } = require("../lib/shahooResponse");
|
||||||
|
const { requireSession } = require("../middleware/auth");
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get("/favorite", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const prefs = await getPrefs(req.session);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const content = (prefs.favoriteMarkets || []).map((currencyName, index) => ({
|
||||||
|
id: index + 1,
|
||||||
|
currencyName,
|
||||||
|
updated: now,
|
||||||
|
}));
|
||||||
|
return res.json(ok(content));
|
||||||
|
} catch {
|
||||||
|
return res.json(ok([]));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
27
src/routes/markets.js
Normal file
27
src/routes/markets.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
const express = require("express");
|
||||||
|
const denaClient = require("../lib/denaClient");
|
||||||
|
const { buildMarketsCatalog } = require("../lib/mapMarketsCatalog");
|
||||||
|
const { ok } = require("../lib/shahooResponse");
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get("/catalog", async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const [markets, currencies] = await Promise.all([
|
||||||
|
denaClient.getMarkets(),
|
||||||
|
denaClient.getCurrencies(),
|
||||||
|
]);
|
||||||
|
return res.json(ok(buildMarketsCatalog(markets, currencies)));
|
||||||
|
} catch {
|
||||||
|
return res.json(
|
||||||
|
ok({
|
||||||
|
quoteCurrencies: [],
|
||||||
|
marketsByQuote: {},
|
||||||
|
markets: [],
|
||||||
|
currencies: {},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
114
src/routes/notifications.js
Normal file
114
src/routes/notifications.js
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
const express = require("express");
|
||||||
|
const dalanClient = require("../lib/dalanClient");
|
||||||
|
const denaClient = require("../lib/denaClient");
|
||||||
|
const { getPrefs, updatePrefs } = require("../lib/userPrefs");
|
||||||
|
const {
|
||||||
|
buildDepositNotification,
|
||||||
|
buildWithdrawNotification,
|
||||||
|
buildActivityNotification,
|
||||||
|
mergeNotifications,
|
||||||
|
applyNotificationPrefs,
|
||||||
|
paginateNotifications,
|
||||||
|
} = require("../lib/mapNotification");
|
||||||
|
const { ok, okPaged } = require("../lib/shahooResponse");
|
||||||
|
const { requireSession } = require("../middleware/auth");
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
async function loadNotifications(session) {
|
||||||
|
const [deposits, withdrawals, activity] = await Promise.all([
|
||||||
|
denaClient.getDeposits(session, { limit: 50, page: 1 }).catch(() => []),
|
||||||
|
denaClient.getWithdrawals(session, { limit: 50, page: 1 }).catch(() => []),
|
||||||
|
dalanClient
|
||||||
|
.getUserActivity(session, "all", { limit: 30, page: 1 })
|
||||||
|
.catch(() => ({ items: [] })),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const merged = mergeNotifications([
|
||||||
|
...deposits.map(buildDepositNotification),
|
||||||
|
...withdrawals.map(buildWithdrawNotification),
|
||||||
|
...(activity.items || []).map(buildActivityNotification),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const prefs = await getPrefs(session);
|
||||||
|
return applyNotificationPrefs(merged, prefs);
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get("/all/unread/count", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const rows = await loadNotifications(req.session);
|
||||||
|
const count = rows.filter((row) => row.status === "UNREAD").length;
|
||||||
|
return res.json(count);
|
||||||
|
} catch {
|
||||||
|
return res.json(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/all/unread/:pageSize/:pageNumber", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const rows = (await loadNotifications(req.session)).filter(
|
||||||
|
(row) => row.status === "UNREAD"
|
||||||
|
);
|
||||||
|
const page = paginateNotifications(
|
||||||
|
rows,
|
||||||
|
req.params.pageSize,
|
||||||
|
req.params.pageNumber
|
||||||
|
);
|
||||||
|
return res.json(okPaged(page.content, page.count));
|
||||||
|
} catch {
|
||||||
|
return res.json(okPaged([], 0));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/all", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const rows = await loadNotifications(req.session);
|
||||||
|
return res.json(okPaged(rows, rows.length));
|
||||||
|
} catch {
|
||||||
|
return res.json(okPaged([], 0));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put("/status/readAll", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const rows = await loadNotifications(req.session);
|
||||||
|
const prefs = await getPrefs(req.session);
|
||||||
|
const read = new Set((prefs.readNotificationIds || []).map(String));
|
||||||
|
rows.forEach((row) => read.add(String(row.id)));
|
||||||
|
await updatePrefs(req.session, { readNotificationIds: Array.from(read) });
|
||||||
|
return res.json({
|
||||||
|
statusCode: 200,
|
||||||
|
message: "All notifications marked as read",
|
||||||
|
content: null,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return res.json({
|
||||||
|
statusCode: 200,
|
||||||
|
message: "All notifications marked as read",
|
||||||
|
content: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete("/", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const rows = await loadNotifications(req.session);
|
||||||
|
const prefs = await getPrefs(req.session);
|
||||||
|
const deleted = new Set((prefs.deletedNotificationIds || []).map(String));
|
||||||
|
rows.forEach((row) => deleted.add(String(row.id)));
|
||||||
|
await updatePrefs(req.session, { deletedNotificationIds: Array.from(deleted) });
|
||||||
|
return res.json({
|
||||||
|
statusCode: 200,
|
||||||
|
message: "All notifications deleted",
|
||||||
|
content: null,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return res.json({
|
||||||
|
statusCode: 200,
|
||||||
|
message: "All notifications deleted",
|
||||||
|
content: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
82
src/routes/ohlcvs.js
Normal file
82
src/routes/ohlcvs.js
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
const express = require("express");
|
||||||
|
const denaClient = require("../lib/denaClient");
|
||||||
|
const { mapTickersToStatMap } = require("../lib/mapMarket");
|
||||||
|
const {
|
||||||
|
shahooPeriodToDena,
|
||||||
|
tradingViewResolutionToDena,
|
||||||
|
mapDenaKLinesToShahoo,
|
||||||
|
shahooMarketToDenaId,
|
||||||
|
} = require("../lib/mapOhlcv");
|
||||||
|
const { ok } = require("../lib/shahooResponse");
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
async function fetchKLineBars({ market, period, limit, timeFrom, timeTo }) {
|
||||||
|
const denaMarket = shahooMarketToDenaId(market);
|
||||||
|
if (!denaMarket) return [];
|
||||||
|
|
||||||
|
const points = await denaClient.getKLine(denaMarket, {
|
||||||
|
period,
|
||||||
|
limit,
|
||||||
|
timeFrom,
|
||||||
|
timeTo,
|
||||||
|
});
|
||||||
|
return mapDenaKLinesToShahoo(points);
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get("/stats", async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const tickers = await denaClient.getTickers();
|
||||||
|
return res.json(ok({ statMap: mapTickersToStatMap(tickers) }));
|
||||||
|
} catch {
|
||||||
|
return res.json(ok({ statMap: mapTickersToStatMap({}) }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/tradingView", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { market, resolution, from, to } = req.query;
|
||||||
|
const period = tradingViewResolutionToDena(resolution);
|
||||||
|
const periodSec = period * 60;
|
||||||
|
|
||||||
|
// Align window to candle boundaries (same as Gereh getTimestampPeriod).
|
||||||
|
const align = (ts) => {
|
||||||
|
const n = Number(ts);
|
||||||
|
if (!Number.isFinite(n)) return undefined;
|
||||||
|
return n - (n % periodSec);
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeFrom = from != null && from !== "" ? align(from) : undefined;
|
||||||
|
const timeTo = to != null && to !== "" ? align(to) : undefined;
|
||||||
|
|
||||||
|
const content = await fetchKLineBars({
|
||||||
|
market,
|
||||||
|
period,
|
||||||
|
// Gereh does not cap at 500; allow longer history for TV.
|
||||||
|
limit: 2000,
|
||||||
|
timeFrom,
|
||||||
|
timeTo,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json(ok(content));
|
||||||
|
} catch {
|
||||||
|
return res.json(ok([]));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { market, period, limit } = req.query;
|
||||||
|
const denaPeriod = shahooPeriodToDena(period);
|
||||||
|
const content = await fetchKLineBars({
|
||||||
|
market,
|
||||||
|
period: denaPeriod,
|
||||||
|
limit: Math.min(Number(limit) || 30, 500),
|
||||||
|
});
|
||||||
|
return res.json(ok(content));
|
||||||
|
} catch {
|
||||||
|
return res.json(ok([]));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
143
src/routes/orders.js
Normal file
143
src/routes/orders.js
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
const express = require("express");
|
||||||
|
const denaClient = require("../lib/denaClient");
|
||||||
|
const {
|
||||||
|
shahooMarketToDenaId,
|
||||||
|
mapDenaOrderBook,
|
||||||
|
mapDenaOrderToShahoo,
|
||||||
|
mapShahooOrderToDena,
|
||||||
|
denaErrorMessage,
|
||||||
|
} = require("../lib/mapOrder");
|
||||||
|
const { ok, okPaged, fail } = require("../lib/shahooResponse");
|
||||||
|
const { requireSession } = require("../middleware/auth");
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
function parsePagination(pageSize, pageNumber) {
|
||||||
|
const limit = Math.max(1, Math.min(Number(pageSize) || 10, 100));
|
||||||
|
const page = Math.max(1, Number(pageNumber) + 1);
|
||||||
|
return { limit, page };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchActiveOrders(session, { market, pageSize, pageNumber }) {
|
||||||
|
const { limit, page } = parsePagination(pageSize, pageNumber);
|
||||||
|
const denaMarket = market ? shahooMarketToDenaId(market) : undefined;
|
||||||
|
const orders = await denaClient.getOrders(session, {
|
||||||
|
state: "wait",
|
||||||
|
market: denaMarket,
|
||||||
|
limit,
|
||||||
|
page,
|
||||||
|
});
|
||||||
|
const content = orders.map(mapDenaOrderToShahoo);
|
||||||
|
return { content, count: content.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get("/self/active/:pageSize/:pageNumber", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { content, count } = await fetchActiveOrders(req.session, req.params);
|
||||||
|
return res.json(okPaged(content, count));
|
||||||
|
} catch {
|
||||||
|
return res.json(okPaged([], 0));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/self/marketname/:marketname/:pageSize/:pageNumber",
|
||||||
|
requireSession,
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { content, count } = await fetchActiveOrders(req.session, {
|
||||||
|
market: req.params.marketname,
|
||||||
|
pageSize: req.params.pageSize,
|
||||||
|
pageNumber: req.params.pageNumber,
|
||||||
|
});
|
||||||
|
return res.json(okPaged(content, count));
|
||||||
|
} catch {
|
||||||
|
return res.json(okPaged([], 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get("/orderBook/market/size/:market/:size", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const denaMarket = shahooMarketToDenaId(req.params.market);
|
||||||
|
if (!denaMarket) {
|
||||||
|
return res.status(400).json(fail("Invalid market", 400));
|
||||||
|
}
|
||||||
|
const limit = Math.min(Number(req.params.size) || 16, 100);
|
||||||
|
const book = await denaClient.getOrderBook(denaMarket, { limit });
|
||||||
|
const content = mapDenaOrderBook(book, limit);
|
||||||
|
return res.json(ok(content));
|
||||||
|
} catch {
|
||||||
|
return res.json(ok({ sells: [], buys: [] }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/orderBook/market/:market", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const denaMarket = shahooMarketToDenaId(req.params.market);
|
||||||
|
if (!denaMarket) {
|
||||||
|
return res.status(400).json(fail("Invalid market", 400));
|
||||||
|
}
|
||||||
|
const book = await denaClient.getOrderBook(denaMarket, { limit: 20 });
|
||||||
|
return res.json(ok(mapDenaOrderBook(book, 20)));
|
||||||
|
} catch {
|
||||||
|
return res.json(ok({ sells: [], buys: [] }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const denaBody = mapShahooOrderToDena(req.body || {});
|
||||||
|
const result = await denaClient.createOrder(req.session, denaBody);
|
||||||
|
|
||||||
|
if (result.status === 201 || result.status === 200) {
|
||||||
|
return res.json({
|
||||||
|
statusCode: 200,
|
||||||
|
message: "Order submitted successfully",
|
||||||
|
content: mapDenaOrderToShahoo(result.data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = result.status >= 400 ? result.status : 422;
|
||||||
|
return res.status(code).json(fail(denaErrorMessage(result.data), code));
|
||||||
|
} catch (err) {
|
||||||
|
const code = err.statusCode || 400;
|
||||||
|
return res.status(code).json(fail(err.message || "Invalid order", code));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put("/cancel/all", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await denaClient.cancelAllOrders(req.session);
|
||||||
|
if (result.status === 201 || result.status === 200) {
|
||||||
|
return res.json({
|
||||||
|
statusCode: 200,
|
||||||
|
message: "All orders cancelled",
|
||||||
|
content: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const code = result.status >= 400 ? result.status : 422;
|
||||||
|
return res.status(code).json(fail(denaErrorMessage(result.data), code));
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json(fail(err.message || "Cancel failed", 500));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put("/cancel/:id", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await denaClient.cancelOrder(req.session, req.params.id);
|
||||||
|
if (result.status === 201 || result.status === 200) {
|
||||||
|
return res.json({
|
||||||
|
statusCode: 200,
|
||||||
|
message: "Order cancelled",
|
||||||
|
content: mapDenaOrderToShahoo(result.data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const code = result.status >= 400 ? result.status : 422;
|
||||||
|
return res.status(code).json(fail(denaErrorMessage(result.data), code));
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json(fail(err.message || "Cancel failed", 500));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
89
src/routes/otp.js
Normal file
89
src/routes/otp.js
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
const express = require("express");
|
||||||
|
const dalanClient = require("../lib/dalanClient");
|
||||||
|
const { normalizeIranPhone } = require("../lib/phoneUtils");
|
||||||
|
const { ok, fail } = require("../lib/shahooResponse");
|
||||||
|
const { requireSession } = require("../middleware/auth");
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
function dalanRouteError(res, result, fallback) {
|
||||||
|
const code = result.status >= 400 ? result.status : 422;
|
||||||
|
return res.status(code).json(fail(dalanClient.dalanErrorMessage(result.data) || fallback, code));
|
||||||
|
}
|
||||||
|
|
||||||
|
router.post("/send", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const phone = normalizeIranPhone(req.body?.receptorPhone);
|
||||||
|
const result = await dalanClient.sendMobileCode(req.session, phone);
|
||||||
|
if (result.status === 200) {
|
||||||
|
return res.json(ok({ message: "Verification code sent" }));
|
||||||
|
}
|
||||||
|
return dalanRouteError(res, result, "Unable to send verification code");
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json(fail(err.message || "Request failed", 500));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/validate", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const phone = normalizeIranPhone(req.body?.receptorPhone);
|
||||||
|
const code = req.body?.code;
|
||||||
|
const result = await dalanClient.verifyMobileCode(req.session, phone, code);
|
||||||
|
if (result.status === 200) {
|
||||||
|
return res.json(ok({ message: "Mobile verified" }));
|
||||||
|
}
|
||||||
|
return dalanRouteError(res, result, "Invalid verification code");
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json(fail(err.message || "Request failed", 500));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put("/change", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const phone = normalizeIranPhone(req.body?.receptorPhone);
|
||||||
|
const result = await dalanClient.sendMobileCode(req.session, phone);
|
||||||
|
if (result.status === 200) {
|
||||||
|
return res.json(ok({ message: "Verification code sent" }));
|
||||||
|
}
|
||||||
|
return dalanRouteError(res, result, "Unable to send verification code");
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json(fail(err.message || "Request failed", 500));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/changeValidate", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const phone = normalizeIranPhone(req.body?.receptorPhone);
|
||||||
|
const code = req.body?.code;
|
||||||
|
const result = await dalanClient.verifyMobileCode(req.session, phone, code);
|
||||||
|
if (result.status === 200) {
|
||||||
|
return res.json(ok({ message: "Mobile updated" }));
|
||||||
|
}
|
||||||
|
return dalanRouteError(res, result, "Invalid verification code");
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json(fail(err.message || "Request failed", 500));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/confirmEmail", requireSession, async (_req, res) => {
|
||||||
|
return res.json(ok({ message: "Email confirmed" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/resend", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const phone = normalizeIranPhone(req.body?.receptorPhone);
|
||||||
|
const result = await dalanClient.resendMobileCode(req.session, phone);
|
||||||
|
if (result.status === 200) {
|
||||||
|
return res.json(ok({ message: "Verification code resent" }));
|
||||||
|
}
|
||||||
|
return dalanRouteError(res, result, "Unable to resend verification code");
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json(fail(err.message || "Request failed", 500));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put("/resendEmail", requireSession, async (_req, res) => {
|
||||||
|
return res.json(ok({ message: "Email verification resent" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
285
src/routes/ranger.js
Normal file
285
src/routes/ranger.js
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
const express = require("express");
|
||||||
|
const WebSocket = require("ws");
|
||||||
|
const axios = require("axios");
|
||||||
|
const { requireSession, extractToken } = require("../middleware/auth");
|
||||||
|
const sessionStore = require("../lib/sessionStore");
|
||||||
|
const { ok } = require("../lib/shahooResponse");
|
||||||
|
const config = require("../config");
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
/** Gereh default private streams (balances only when Finex is on). */
|
||||||
|
function defaultPrivateStreams() {
|
||||||
|
const raw = process.env.RANGER_PRIVATE_STREAMS;
|
||||||
|
if (raw && String(raw).trim()) {
|
||||||
|
return String(raw)
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
const streams = ["order", "trade"];
|
||||||
|
if (process.env.RANGER_INCLUDE_BALANCES === "true") {
|
||||||
|
streams.push("balances");
|
||||||
|
}
|
||||||
|
return streams;
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicOrigin(req) {
|
||||||
|
const proto = req.headers["x-forwarded-proto"] || req.protocol || "https";
|
||||||
|
const host = req.headers["x-forwarded-host"] || req.headers.host;
|
||||||
|
const wsProto = String(proto).startsWith("https") ? "wss" : "ws";
|
||||||
|
return { httpOrigin: `${proto}://${host}`, wsOrigin: `${wsProto}://${host}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
function credentialsPayload(req) {
|
||||||
|
const { wsOrigin } = publicOrigin(req);
|
||||||
|
const streams = defaultPrivateStreams();
|
||||||
|
return {
|
||||||
|
publicUrl: `${wsOrigin}/api/v2/ranger`,
|
||||||
|
privateWsUrl: `${wsOrigin}/api/1/ranger/ws`,
|
||||||
|
token: req.bffToken,
|
||||||
|
streams,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function cookieUpstreamHeaders(session) {
|
||||||
|
const headers = { Cookie: session.cookies || "" };
|
||||||
|
if (config.upstreamHost) {
|
||||||
|
headers.Host = config.upstreamHost;
|
||||||
|
}
|
||||||
|
if (session.csrfToken) {
|
||||||
|
headers["X-CSRF-Token"] = session.csrfToken;
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUpstreamUrl(streamList, baseUrl) {
|
||||||
|
const streams =
|
||||||
|
streamList && streamList.length ? streamList : defaultPrivateStreams();
|
||||||
|
const upstreamBase = String(baseUrl).replace(/\/+$/, "");
|
||||||
|
return `${upstreamBase}/?stream=${[...streams].sort().join("&stream=")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mint Peatio/Barong JWT via Barong auth endpoint (same as Envoy ext_authz).
|
||||||
|
* Private Rango expects Authorization — cookie-only WS through Envoy often 401s.
|
||||||
|
*/
|
||||||
|
async function fetchBarongJwt(session) {
|
||||||
|
const barongBase = String(
|
||||||
|
process.env.BARONG_URL || "http://barong:8001"
|
||||||
|
).replace(/\/+$/, "");
|
||||||
|
const headers = {
|
||||||
|
Cookie: session.cookies || "",
|
||||||
|
};
|
||||||
|
if (session.csrfToken) {
|
||||||
|
headers["X-CSRF-Token"] = session.csrfToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Envoy path_prefix `/api/v2/auth` + request path `/api/v2/ranger/private`
|
||||||
|
const candidates = [
|
||||||
|
"/api/v2/auth/api/v2/ranger/private",
|
||||||
|
"/api/v2/auth/api/v2/dena/account/balances",
|
||||||
|
"/api/v2/auth/",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const path of candidates) {
|
||||||
|
try {
|
||||||
|
const res = await axios.get(`${barongBase}${path}`, {
|
||||||
|
headers,
|
||||||
|
validateStatus: () => true,
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
const auth =
|
||||||
|
res.headers.authorization ||
|
||||||
|
res.headers.Authorization ||
|
||||||
|
res.headers["authorization"];
|
||||||
|
if (res.status === 200 && auth) {
|
||||||
|
return auth;
|
||||||
|
}
|
||||||
|
console.warn(
|
||||||
|
`[ranger-proxy] barong auth ${path} → ${res.status}`,
|
||||||
|
typeof res.data === "string" ? res.data.slice(0, 80) : ""
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[ranger-proxy] barong auth ${path} error:`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveUpstream(session, streamQ) {
|
||||||
|
const jwt = await fetchBarongJwt(session);
|
||||||
|
if (jwt) {
|
||||||
|
const base =
|
||||||
|
process.env.RANGER_PRIVATE_DIRECT_URL ||
|
||||||
|
"ws://rango:8080/api/v2/ranger/private";
|
||||||
|
return {
|
||||||
|
url: buildUpstreamUrl(streamQ, base),
|
||||||
|
headers: { Authorization: jwt },
|
||||||
|
mode: "jwt-direct",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: cookies through Envoy (needs FIBITEX_HOST + valid session)
|
||||||
|
const base =
|
||||||
|
process.env.RANGER_PRIVATE_URL ||
|
||||||
|
config.rangerPrivateUrl ||
|
||||||
|
"ws://gateway:8099/api/v2/ranger/private";
|
||||||
|
return {
|
||||||
|
url: buildUpstreamUrl(streamQ, base),
|
||||||
|
headers: cookieUpstreamHeaders(session),
|
||||||
|
mode: "cookie-gateway",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get("/credentials", requireSession, (req, res) => {
|
||||||
|
return res.json(ok(credentialsPayload(req)));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/token", requireSession, (req, res) => {
|
||||||
|
return res.json(ok(credentialsPayload(req)));
|
||||||
|
});
|
||||||
|
|
||||||
|
function attachRangerPrivateProxy(server) {
|
||||||
|
const wss = new WebSocket.Server({ noServer: true });
|
||||||
|
|
||||||
|
server.on("upgrade", (req, socket, head) => {
|
||||||
|
const url = new URL(req.url || "", "http://localhost");
|
||||||
|
if (!url.pathname.startsWith("/api/1/ranger/ws")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token =
|
||||||
|
url.searchParams.get("token") ||
|
||||||
|
extractToken({
|
||||||
|
headers: { authorization: req.headers.authorization || "" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const streamsFromQuery = url.searchParams.getAll("stream");
|
||||||
|
const streamQ =
|
||||||
|
streamsFromQuery.length > 0 ? streamsFromQuery : defaultPrivateStreams();
|
||||||
|
|
||||||
|
sessionStore
|
||||||
|
.get(token)
|
||||||
|
.then(async (session) => {
|
||||||
|
if (!session?.cookies) {
|
||||||
|
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let upstreamTarget;
|
||||||
|
try {
|
||||||
|
upstreamTarget = await resolveUpstream(session, streamQ);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[ranger-proxy] resolveUpstream failed:", err.message);
|
||||||
|
socket.write("HTTP/1.1 502 Bad Gateway\r\n\r\n");
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[ranger-proxy] opening upstream mode=${upstreamTarget.mode} url=${upstreamTarget.url}`
|
||||||
|
);
|
||||||
|
|
||||||
|
let upstream;
|
||||||
|
try {
|
||||||
|
upstream = new WebSocket(upstreamTarget.url, {
|
||||||
|
headers: upstreamTarget.headers,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[ranger-proxy] upstream construct failed:", err.message);
|
||||||
|
socket.write("HTTP/1.1 502 Bad Gateway\r\n\r\n");
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fail = (status, msg) => {
|
||||||
|
try {
|
||||||
|
upstream.terminate();
|
||||||
|
} catch (_) {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
socket.write(`HTTP/1.1 ${status} ${msg}\r\n\r\n`);
|
||||||
|
socket.destroy();
|
||||||
|
} catch (_) {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openTimer = setTimeout(() => {
|
||||||
|
console.error("[ranger-proxy] upstream open timeout", upstreamTarget.url);
|
||||||
|
fail(504, "Gateway Timeout");
|
||||||
|
}, 8000);
|
||||||
|
|
||||||
|
upstream.once("open", () => {
|
||||||
|
clearTimeout(openTimer);
|
||||||
|
wss.handleUpgrade(req, socket, head, (clientWs) => {
|
||||||
|
const pipe = (src, dst, label) => {
|
||||||
|
src.on("message", (data) => {
|
||||||
|
if (dst.readyState === WebSocket.OPEN) dst.send(data);
|
||||||
|
});
|
||||||
|
src.on("close", (code, reason) => {
|
||||||
|
console.warn(
|
||||||
|
`[ranger-proxy] ${label} closed`,
|
||||||
|
code,
|
||||||
|
reason && reason.toString ? reason.toString() : ""
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
dst.close();
|
||||||
|
} catch (_) {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
src.on("error", (err) => {
|
||||||
|
console.error(`[ranger-proxy] ${label} error:`, err.message);
|
||||||
|
try {
|
||||||
|
dst.close();
|
||||||
|
} catch (_) {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
pipe(upstream, clientWs, "upstream");
|
||||||
|
pipe(clientWs, upstream, "client");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
upstream.once("error", (err) => {
|
||||||
|
clearTimeout(openTimer);
|
||||||
|
console.error(
|
||||||
|
"[ranger-proxy] upstream error before open:",
|
||||||
|
err.message,
|
||||||
|
upstreamTarget.url
|
||||||
|
);
|
||||||
|
fail(502, "Bad Gateway");
|
||||||
|
});
|
||||||
|
|
||||||
|
upstream.once("unexpected-response", (_req, res) => {
|
||||||
|
clearTimeout(openTimer);
|
||||||
|
console.error(
|
||||||
|
"[ranger-proxy] upstream unexpected response",
|
||||||
|
res.statusCode,
|
||||||
|
upstreamTarget.mode,
|
||||||
|
upstreamTarget.url
|
||||||
|
);
|
||||||
|
fail(res.statusCode || 502, "Bad Gateway");
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error("[ranger-proxy] session lookup failed:", err.message);
|
||||||
|
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
|
||||||
|
socket.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
router,
|
||||||
|
attachRangerPrivateProxy,
|
||||||
|
defaultPrivateStreams,
|
||||||
|
buildUpstreamUrl,
|
||||||
|
cookieUpstreamHeaders,
|
||||||
|
fetchBarongJwt,
|
||||||
|
};
|
||||||
27
src/routes/trades.js
Normal file
27
src/routes/trades.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
const express = require("express");
|
||||||
|
const denaClient = require("../lib/denaClient");
|
||||||
|
const { shahooMarketToDenaId } = require("../lib/mapOrder");
|
||||||
|
const { mapDenaTradesToShahoo } = require("../lib/mapTrade");
|
||||||
|
const { ok, okPaged } = require("../lib/shahooResponse");
|
||||||
|
const { requireSession } = require("../middleware/auth");
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get("/recent/market/:market", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const denaMarket = shahooMarketToDenaId(req.params.market);
|
||||||
|
if (!denaMarket) {
|
||||||
|
return res.status(400).json({ statusCode: 400, message: "Invalid market", content: null });
|
||||||
|
}
|
||||||
|
const trades = await denaClient.getPublicTrades(denaMarket, { limit: 50 });
|
||||||
|
return res.json(ok(mapDenaTradesToShahoo(trades)));
|
||||||
|
} catch {
|
||||||
|
return res.json(ok([]));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/self/:pageSize/:pageNumber", requireSession, async (_req, res) => {
|
||||||
|
return res.json(okPaged([], 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
73
src/routes/transactions.js
Normal file
73
src/routes/transactions.js
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
const express = require("express");
|
||||||
|
const denaClient = require("../lib/denaClient");
|
||||||
|
const { getPrefs, updatePrefs } = require("../lib/userPrefs");
|
||||||
|
const {
|
||||||
|
mapDenaTradeToTransaction,
|
||||||
|
mapDenaDepositToTransaction,
|
||||||
|
mapDenaWithdrawToTransaction,
|
||||||
|
mergeTransactions,
|
||||||
|
applyTransactionFilters,
|
||||||
|
splitSuccessAndFail,
|
||||||
|
paginateRows,
|
||||||
|
} = require("../lib/mapTransaction");
|
||||||
|
const { okPaged, fail } = require("../lib/shahooResponse");
|
||||||
|
const { requireSession } = require("../middleware/auth");
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
async function loadTransactions(session) {
|
||||||
|
const [trades, deposits, withdrawals] = await Promise.all([
|
||||||
|
denaClient.getMemberTrades(session, { limit: 500, page: 1 }).catch(() => []),
|
||||||
|
denaClient.getDeposits(session, { limit: 200, page: 1 }).catch(() => []),
|
||||||
|
denaClient.getWithdrawals(session, { limit: 200, page: 1 }).catch(() => []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const merged = mergeTransactions({
|
||||||
|
trades: trades.map(mapDenaTradeToTransaction),
|
||||||
|
deposits: deposits.map(mapDenaDepositToTransaction),
|
||||||
|
withdrawals: withdrawals.map(mapDenaWithdrawToTransaction),
|
||||||
|
});
|
||||||
|
|
||||||
|
const prefs = await getPrefs(session);
|
||||||
|
const hidden = new Set((prefs.hiddenTransactionIds || []).map(String));
|
||||||
|
return merged.filter((row) => !hidden.has(String(row.id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get("/self/:pageSize/:pageNumber", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const rows = applyTransactionFilters(await loadTransactions(req.session), req.query);
|
||||||
|
const { success } = splitSuccessAndFail(rows);
|
||||||
|
const page = paginateRows(success, req.params.pageSize, req.params.pageNumber);
|
||||||
|
return res.json(okPaged(page.content, page.count));
|
||||||
|
} catch {
|
||||||
|
return res.json(okPaged([], 0));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/fail/:pageSize/:pageNumber", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const rows = applyTransactionFilters(await loadTransactions(req.session), req.query);
|
||||||
|
const { fail: failed } = splitSuccessAndFail(rows);
|
||||||
|
const page = paginateRows(failed, req.params.pageSize, req.params.pageNumber);
|
||||||
|
return res.json(okPaged(page.content, page.count));
|
||||||
|
} catch {
|
||||||
|
return res.json(okPaged([], 0));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete("/:id", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const id = String(req.params.id);
|
||||||
|
const prefs = await getPrefs(req.session);
|
||||||
|
const hidden = new Set((prefs.hiddenTransactionIds || []).map(String));
|
||||||
|
hidden.add(id);
|
||||||
|
await updatePrefs(req.session, {
|
||||||
|
hiddenTransactionIds: Array.from(hidden),
|
||||||
|
});
|
||||||
|
return res.json({ statusCode: 200, message: "Transaction removed", content: null });
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json(fail(err.message || "Request failed", 500));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
71
src/routes/tts.js
Normal file
71
src/routes/tts.js
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
const express = require("express");
|
||||||
|
const dalanClient = require("../lib/dalanClient");
|
||||||
|
const { normalizeIranPhone } = require("../lib/phoneUtils");
|
||||||
|
const { listRegions, listCities } = require("../lib/locationsStore");
|
||||||
|
const { ok, fail } = require("../lib/shahooResponse");
|
||||||
|
const { requireSession } = require("../middleware/auth");
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get("/regions", requireSession, async (_req, res) => {
|
||||||
|
return res.json(ok(listRegions()));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/cities/:regionId", requireSession, async (req, res) => {
|
||||||
|
return res.json(ok(listCities(req.params.regionId)));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/availableTime", requireSession, async (_req, res) => {
|
||||||
|
return res.json(
|
||||||
|
ok([
|
||||||
|
{ date: new Date().toISOString().slice(0, 10), slots: ["09:00", "11:00", "14:00", "16:00"] },
|
||||||
|
])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/setTime", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const phone = normalizeIranPhone(`${req.body?.areaCode || ""}${req.body?.phone || ""}`);
|
||||||
|
const result = await dalanClient.addLandlinePhone(req.session, phone);
|
||||||
|
if (result.status === 200) {
|
||||||
|
return res.json(ok({ message: "Landline verification started" }));
|
||||||
|
}
|
||||||
|
const code = result.status >= 400 ? result.status : 422;
|
||||||
|
return res.status(code).json(fail(dalanClient.dalanErrorMessage(result.data), code));
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json(fail(err.message || "Request failed", 500));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/instant", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const phone = normalizeIranPhone(req.body?.phone_number || req.body?.phone);
|
||||||
|
const result = phone
|
||||||
|
? await dalanClient.addLandlinePhone(req.session, phone)
|
||||||
|
: await dalanClient.addLandlinePhone(req.session, "+982100000000");
|
||||||
|
if (result.status === 200) {
|
||||||
|
return res.json(ok({ message: "Verification call initiated" }));
|
||||||
|
}
|
||||||
|
const code = result.status >= 400 ? result.status : 422;
|
||||||
|
return res.status(code).json(fail(dalanClient.dalanErrorMessage(result.data), code));
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json(fail(err.message || "Request failed", 500));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/validate", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const phone = normalizeIranPhone(req.body?.phone_number || req.body?.phone);
|
||||||
|
const code = req.body?.code || req.body?.verification_code;
|
||||||
|
const result = await dalanClient.verifyLandlineCode(req.session, phone, code);
|
||||||
|
if (result.status === 200) {
|
||||||
|
return res.json(ok({ message: "Landline verified" }));
|
||||||
|
}
|
||||||
|
const codeStatus = result.status >= 400 ? result.status : 422;
|
||||||
|
return res.status(codeStatus).json(fail(dalanClient.dalanErrorMessage(result.data), codeStatus));
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json(fail(err.message || "Request failed", 500));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
659
src/routes/users.js
Normal file
659
src/routes/users.js
Normal file
@@ -0,0 +1,659 @@
|
|||||||
|
const express = require("express");
|
||||||
|
const config = require("../config");
|
||||||
|
const dalanClient = require("../lib/dalanClient");
|
||||||
|
const denaClient = require("../lib/denaClient");
|
||||||
|
const sessionStore = require("../lib/sessionStore");
|
||||||
|
const { mapCurrentUserSummary } = require("../lib/mapUser");
|
||||||
|
const { buildKycUser } = require("../lib/mapKyc");
|
||||||
|
const {
|
||||||
|
mapDalanActivities,
|
||||||
|
dedupeDevices,
|
||||||
|
ACTIVITY_FILTER_OPTIONS,
|
||||||
|
} = require("../lib/mapActivity");
|
||||||
|
const { mapReferralResponse } = require("../lib/mapReferral");
|
||||||
|
const { mapLimitations, buildTradeFeeTiers, buildLevelsDetail } = require("../lib/mapVip");
|
||||||
|
const { mapQrCodeResponse } = require("../lib/mapOtp");
|
||||||
|
const { getPrefs } = require("../lib/userPrefs");
|
||||||
|
const { ok, okPaged, fail } = require("../lib/shahooResponse");
|
||||||
|
const { requireSession } = require("../middleware/auth");
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
function parseShahooPage(pageSize, pageNumber) {
|
||||||
|
const limit = Math.max(1, Math.min(Number(pageSize) || 10, 100));
|
||||||
|
const page = Math.max(1, Number(pageNumber) + 1);
|
||||||
|
return { limit, page };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseActivityFilters(query) {
|
||||||
|
const filters = {};
|
||||||
|
if (query.success === "true") filters.result = "succeed";
|
||||||
|
if (query.success === "false") filters.result = "failed";
|
||||||
|
|
||||||
|
const from = query.from;
|
||||||
|
if (from) {
|
||||||
|
const ts = Math.floor(new Date(from).getTime() / 1000);
|
||||||
|
if (Number.isFinite(ts) && ts > 0) filters.timeFrom = ts;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.activity) filters.action = String(query.activity);
|
||||||
|
return filters;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyActivityFilters(rows, filters) {
|
||||||
|
let result = rows;
|
||||||
|
if (filters.action) {
|
||||||
|
const action = filters.action.toLowerCase();
|
||||||
|
result = result.filter(
|
||||||
|
(row) =>
|
||||||
|
String(row.activity || "").toLowerCase() === action ||
|
||||||
|
String(row.activity || "").toLowerCase().includes(action)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchActivityRows(session, topic, pageSize, pageNumber, query) {
|
||||||
|
const { limit, page } = parseShahooPage(pageSize, pageNumber);
|
||||||
|
const filters = parseActivityFilters(query);
|
||||||
|
const { items, total } = await dalanClient.getUserActivity(session, topic, {
|
||||||
|
limit,
|
||||||
|
page,
|
||||||
|
timeFrom: filters.timeFrom,
|
||||||
|
result: filters.result,
|
||||||
|
});
|
||||||
|
const rows = applyActivityFilters(mapDalanActivities(items), filters);
|
||||||
|
return { rows, total: filters.action ? rows.length : total };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function computeTradeVolume(session) {
|
||||||
|
const timeFrom = Math.floor(Date.now() / 1000) - 30 * 24 * 60 * 60;
|
||||||
|
const trades = await denaClient.getMemberTrades(session, {
|
||||||
|
limit: 500,
|
||||||
|
page: 1,
|
||||||
|
timeFrom,
|
||||||
|
});
|
||||||
|
return trades.reduce((sum, trade) => {
|
||||||
|
const total = parseFloat(trade.total) || 0;
|
||||||
|
return sum + total;
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dalanRouteError(res, result, fallback = "Request failed") {
|
||||||
|
const code = result.status >= 400 ? result.status : 422;
|
||||||
|
return res.status(code).json(fail(dalanClient.dalanErrorMessage(result.data), code));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshSessionUser(session) {
|
||||||
|
try {
|
||||||
|
await dalanClient.getMe(session);
|
||||||
|
} catch {
|
||||||
|
/* keep cached user */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get("/2FA/activate", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await dalanClient.generateOtpQrCode(req.session);
|
||||||
|
if (result.status === 200 || result.status === 201) {
|
||||||
|
return res.json(ok(mapQrCodeResponse(result.data)));
|
||||||
|
}
|
||||||
|
return dalanRouteError(res, result, "Unable to generate 2FA QR code");
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json(fail(err.message || "Request failed", 500));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put("/2FA/confirm2FA", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const totp = req.body?.totp;
|
||||||
|
if (!totp) {
|
||||||
|
return res.status(422).json(fail("TOTP code is required", 422));
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasOtp = Boolean(req.session.dalanUser?.otp);
|
||||||
|
const result = hasOtp
|
||||||
|
? await dalanClient.disableOtp(req.session, totp)
|
||||||
|
: await dalanClient.enableOtp(req.session, totp);
|
||||||
|
|
||||||
|
if (result.status === 200 || result.status === 201 || result.status === 204) {
|
||||||
|
req.session.otpPendingAction = hasOtp ? "DEACTIVATE" : "ACTIVATE";
|
||||||
|
req.session.otpLastTotp = totp;
|
||||||
|
return res.json(ok({ success: true }));
|
||||||
|
}
|
||||||
|
return dalanRouteError(res, result, "Invalid verification code");
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json(fail(err.message || "Request failed", 500));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put("/2FA/deActivate", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const totp = req.body?.totp;
|
||||||
|
if (!totp) {
|
||||||
|
return res.status(422).json(fail("TOTP code is required", 422));
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = await dalanClient.disableOtpEmail(req.session, totp);
|
||||||
|
if (result.status !== 200 && result.status !== 201 && result.status !== 204) {
|
||||||
|
result = await dalanClient.disableOtp(req.session, totp);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status === 200 || result.status === 201 || result.status === 204) {
|
||||||
|
req.session.otpPendingAction = null;
|
||||||
|
req.session.otpLastTotp = null;
|
||||||
|
await refreshSessionUser(req.session);
|
||||||
|
return res.json(ok({ success: true }));
|
||||||
|
}
|
||||||
|
return dalanRouteError(res, result, "Unable to deactivate 2FA");
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json(fail(err.message || "Request failed", 500));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/2FA/verify2faEmail", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { token, twoFaAction } = req.body || {};
|
||||||
|
if (!token) {
|
||||||
|
return res.status(422).json(fail("Verification code is required", 422));
|
||||||
|
}
|
||||||
|
|
||||||
|
const action = String(twoFaAction || req.session.otpPendingAction || "ACTIVATE").toUpperCase();
|
||||||
|
const result =
|
||||||
|
action === "DEACTIVATE"
|
||||||
|
? await dalanClient.disableOtpEmail(req.session, token)
|
||||||
|
: await dalanClient.enableOtpEmail(req.session, token);
|
||||||
|
|
||||||
|
if (result.status === 200 || result.status === 201 || result.status === 204) {
|
||||||
|
req.session.otpPendingAction = null;
|
||||||
|
req.session.otpLastTotp = null;
|
||||||
|
await refreshSessionUser(req.session);
|
||||||
|
return res.json(ok({ success: true }));
|
||||||
|
}
|
||||||
|
return dalanRouteError(res, result, "Invalid email verification code");
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json(fail(err.message || "Request failed", 500));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/2FA/resendVerifyEmail", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const hasOtp = Boolean(req.session.dalanUser?.otp);
|
||||||
|
let action = String(req.session.otpPendingAction || "").toUpperCase();
|
||||||
|
if (!action) {
|
||||||
|
action = hasOtp ? "DEACTIVATE" : "ACTIVATE";
|
||||||
|
}
|
||||||
|
|
||||||
|
let result;
|
||||||
|
if (action === "DEACTIVATE") {
|
||||||
|
if (req.session.otpLastTotp) {
|
||||||
|
result = await dalanClient.disableOtp(req.session, req.session.otpLastTotp);
|
||||||
|
} else {
|
||||||
|
result = await dalanClient.disableOtp(req.session, "");
|
||||||
|
}
|
||||||
|
} else if (req.session.otpLastTotp) {
|
||||||
|
result = await dalanClient.enableOtp(req.session, req.session.otpLastTotp);
|
||||||
|
} else {
|
||||||
|
result = await dalanClient.enableOtp(req.session, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
result.status === 200 ||
|
||||||
|
result.status === 201 ||
|
||||||
|
result.status === 204 ||
|
||||||
|
result.status === 422
|
||||||
|
) {
|
||||||
|
req.session.otpPendingAction = action;
|
||||||
|
return res.json(ok({ success: true }));
|
||||||
|
}
|
||||||
|
return dalanRouteError(res, result, "Unable to resend verification email");
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json(fail(err.message || "Request failed", 500));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/change", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { oldPassword, password, confirm } = req.body || {};
|
||||||
|
if (!oldPassword || !password) {
|
||||||
|
return res.status(422).json(fail("Password fields are required", 422));
|
||||||
|
}
|
||||||
|
if (confirm && confirm !== password) {
|
||||||
|
return res.status(422).json(fail("Password confirmation does not match", 422));
|
||||||
|
}
|
||||||
|
|
||||||
|
req.session.pendingPasswordChange = {
|
||||||
|
oldPassword,
|
||||||
|
password,
|
||||||
|
confirm: confirm || password,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await dalanClient.changePassword(req.session, {
|
||||||
|
oldPassword,
|
||||||
|
password,
|
||||||
|
confirmPassword: confirm || password,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.status === 200 || result.status === 201 || result.status === 204) {
|
||||||
|
req.session.passwordChangeStep1Done = true;
|
||||||
|
return res.json(ok({ success: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status === 202) {
|
||||||
|
return res.json(ok({ success: true, pendingEmail: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
req.session.pendingPasswordChange = null;
|
||||||
|
req.session.passwordChangeStep1Done = null;
|
||||||
|
return dalanRouteError(res, result, "Unable to change password");
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json(fail(err.message || "Request failed", 500));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/change/check", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { token } = req.body || {};
|
||||||
|
const pending = req.session.pendingPasswordChange;
|
||||||
|
|
||||||
|
if (req.session.passwordChangeStep1Done) {
|
||||||
|
req.session.pendingPasswordChange = null;
|
||||||
|
req.session.passwordChangeStep1Done = null;
|
||||||
|
await refreshSessionUser(req.session);
|
||||||
|
return res.json(ok({ success: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pending) {
|
||||||
|
return res.status(422).json(fail("No pending password change", 422));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return res.status(422).json(fail("Verification code is required", 422));
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await dalanClient.changePassword(req.session, {
|
||||||
|
oldPassword: pending.oldPassword,
|
||||||
|
password: pending.password,
|
||||||
|
confirmPassword: pending.confirm,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.status === 200 || result.status === 201 || result.status === 204) {
|
||||||
|
req.session.pendingPasswordChange = null;
|
||||||
|
await refreshSessionUser(req.session);
|
||||||
|
return res.json(ok({ success: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return dalanRouteError(res, result, "Invalid verification code");
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json(fail(err.message || "Request failed", 500));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/authenticate", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { email, password, otpCode, otp_code, totp } = req.body || {};
|
||||||
|
const result = await dalanClient.login({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
otpCode: otpCode || otp_code || totp,
|
||||||
|
captcha: req.body?.captcha,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.needs2fa) {
|
||||||
|
return res.json(ok({ token: null }, 203));
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json(ok({ token: result.token }));
|
||||||
|
} catch (err) {
|
||||||
|
const code = err.statusCode || 401;
|
||||||
|
return res.status(code).json(fail(err.message || "Login failed", code));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/logout", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
await dalanClient.logout(req.session);
|
||||||
|
sessionStore.remove(req.bffToken);
|
||||||
|
return res.json(ok({ success: true }));
|
||||||
|
} catch {
|
||||||
|
sessionStore.remove(req.bffToken);
|
||||||
|
return res.json(ok({ success: true }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/currentUserSummary", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
let dalanUser = req.session.dalanUser;
|
||||||
|
try {
|
||||||
|
dalanUser = await dalanClient.getMe(req.session);
|
||||||
|
} catch (err) {
|
||||||
|
if (!dalanUser) throw err;
|
||||||
|
}
|
||||||
|
const user = mapCurrentUserSummary(dalanUser, {
|
||||||
|
theme: req.session.theme,
|
||||||
|
setting: {
|
||||||
|
theme: req.session.theme || "LIGHT",
|
||||||
|
layout: req.session.layout || "template2",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return res.json(ok(user));
|
||||||
|
} catch (err) {
|
||||||
|
const code = err.statusCode || 401;
|
||||||
|
return res.status(code).json(fail(err.message || "Unauthorized", code));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/currentUser", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const dalanUser = await dalanClient.getMe(req.session);
|
||||||
|
return res.json(
|
||||||
|
ok(
|
||||||
|
mapCurrentUserSummary(dalanUser, {
|
||||||
|
theme: req.session.theme,
|
||||||
|
setting: {
|
||||||
|
theme: req.session.theme || "LIGHT",
|
||||||
|
layout: req.session.layout || "template2",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
const code = err.statusCode || 401;
|
||||||
|
return res.status(code).json(fail(err.message || "Unauthorized", code));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/profile", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const [dalanUser, documents, treasuries] = await Promise.all([
|
||||||
|
dalanClient.getFullUser(req.session),
|
||||||
|
dalanClient.getDocuments(req.session),
|
||||||
|
dalanClient.getTreasuries(req.session),
|
||||||
|
]);
|
||||||
|
return res.json(ok(buildKycUser(dalanUser, { documents, treasuries })));
|
||||||
|
} catch (err) {
|
||||||
|
const code = err.statusCode || 401;
|
||||||
|
return res.status(code).json(fail(err.message || "Unauthorized", code));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/referrer", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const dalanUser = await dalanClient.getMe(req.session);
|
||||||
|
const uid = dalanUser?.uid;
|
||||||
|
if (!uid) {
|
||||||
|
return res.status(401).json(fail("Unauthorized", 401));
|
||||||
|
}
|
||||||
|
|
||||||
|
const [bonus, report] = await Promise.all([
|
||||||
|
denaClient.getBonus(req.session),
|
||||||
|
denaClient.getBonusReport(req.session, { limit: 10, page: 1 }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const publicUrl =
|
||||||
|
req.get("origin") || req.get("referer")?.split("/").slice(0, 3).join("/") || config.publicUrl;
|
||||||
|
|
||||||
|
return res.json(
|
||||||
|
ok(
|
||||||
|
mapReferralResponse({
|
||||||
|
uid,
|
||||||
|
publicUrl,
|
||||||
|
bonus,
|
||||||
|
report,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
const code = err.statusCode || 500;
|
||||||
|
return res.status(code).json(fail(err.message || "Request failed", code));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/login/info/:pageSize/:pageNumber", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const hasFilters =
|
||||||
|
req.query.success || req.query.from || req.query.activity || req.query.period;
|
||||||
|
const topic = hasFilters ? "all" : "session";
|
||||||
|
const { rows, total } = await fetchActivityRows(
|
||||||
|
req.session,
|
||||||
|
topic,
|
||||||
|
req.params.pageSize,
|
||||||
|
req.params.pageNumber,
|
||||||
|
req.query
|
||||||
|
);
|
||||||
|
return res.json(okPaged(rows, total));
|
||||||
|
} catch {
|
||||||
|
return res.json(okPaged([], 0));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/device/info/:pageSize/:pageNumber", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { rows, total } = await fetchActivityRows(
|
||||||
|
req.session,
|
||||||
|
"session",
|
||||||
|
req.params.pageSize,
|
||||||
|
req.params.pageNumber,
|
||||||
|
req.query
|
||||||
|
);
|
||||||
|
const devices = dedupeDevices(rows);
|
||||||
|
return res.json(okPaged(devices, devices.length || total));
|
||||||
|
} catch {
|
||||||
|
return res.json(okPaged([], 0));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/activities", requireSession, async (_req, res) => {
|
||||||
|
return res.json(ok(ACTIVITY_FILTER_OPTIONS));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/setting/favorite/currency", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const prefs = await getPrefs(req.session);
|
||||||
|
const content = (prefs.favoriteCurrencies || []).map((currencyName) => ({
|
||||||
|
currencyName,
|
||||||
|
favorite: true,
|
||||||
|
}));
|
||||||
|
return res.json(ok(content));
|
||||||
|
} catch {
|
||||||
|
return res.json(ok([]));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put("/setting", requireSession, async (req, res) => {
|
||||||
|
const body = req.body || {};
|
||||||
|
const theme = body.theme || req.session.theme || "LIGHT";
|
||||||
|
const layout = body.layout || req.session.layout || "template2";
|
||||||
|
req.session.theme = theme;
|
||||||
|
req.session.layout = layout;
|
||||||
|
req.session.whitelistEnable =
|
||||||
|
typeof body.whitelistEnable === "boolean"
|
||||||
|
? body.whitelistEnable
|
||||||
|
: req.session.whitelistEnable;
|
||||||
|
const user = mapCurrentUserSummary(req.session.dalanUser, {
|
||||||
|
theme,
|
||||||
|
setting: {
|
||||||
|
...body,
|
||||||
|
theme,
|
||||||
|
layout,
|
||||||
|
whitelistEnable: Boolean(req.session.whitelistEnable),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return res.json(ok(user));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/setting/validate", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const token = req.body?.token;
|
||||||
|
if (!token) {
|
||||||
|
return res.status(422).json(fail("token is required", 422));
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await dalanClient.verifyOtpCode(req.session, token);
|
||||||
|
if (result.status !== 200) {
|
||||||
|
const code = result.status >= 400 ? result.status : 422;
|
||||||
|
return res.status(code).json(fail(dalanClient.dalanErrorMessage(result.data), code));
|
||||||
|
}
|
||||||
|
|
||||||
|
req.session.whitelistEnable = Boolean(req.body?.whitelistEnable);
|
||||||
|
const user = mapCurrentUserSummary(req.session.dalanUser, {
|
||||||
|
setting: {
|
||||||
|
...(req.body || {}),
|
||||||
|
whitelistEnable: req.session.whitelistEnable,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return res.json(ok(user));
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json(fail(err.message || "Request failed", 500));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/limitations", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const [levels, tradingFees, tradeVolume] = await Promise.all([
|
||||||
|
denaClient.getLevels(req.session),
|
||||||
|
denaClient.getTradingFees(),
|
||||||
|
computeTradeVolume(req.session),
|
||||||
|
]);
|
||||||
|
return res.json(
|
||||||
|
ok(
|
||||||
|
mapLimitations({
|
||||||
|
levels,
|
||||||
|
tradeVolume,
|
||||||
|
tradingFees,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
const tiers = buildTradeFeeTiers([]);
|
||||||
|
return res.json(
|
||||||
|
ok({
|
||||||
|
tradeVolume: 0,
|
||||||
|
tradeFee: 0.35,
|
||||||
|
makerFee: 0.35,
|
||||||
|
currentLevelMin: 0,
|
||||||
|
nextLevelMin: tiers.tradeFeeMonthVolTh[1] || 0,
|
||||||
|
levelIndex: 0,
|
||||||
|
currentLevel: "vip-0",
|
||||||
|
levelsDetail: buildLevelsDetail(tiers, 0),
|
||||||
|
...tiers,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/activate/token", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { email, token, code, captcha } = req.body || {};
|
||||||
|
const verification = String(token || code || "").trim();
|
||||||
|
|
||||||
|
if (!verification) {
|
||||||
|
return res.status(422).json(fail("Verification code is required", 422));
|
||||||
|
}
|
||||||
|
|
||||||
|
const looksLikeEmailLinkToken =
|
||||||
|
verification.includes(".") && verification.length > 32;
|
||||||
|
|
||||||
|
let result;
|
||||||
|
if (looksLikeEmailLinkToken) {
|
||||||
|
result = await dalanClient.confirmEmailByLinkToken({
|
||||||
|
token: verification,
|
||||||
|
captcha,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (!email) {
|
||||||
|
return res.status(422).json(fail("Email is required", 422));
|
||||||
|
}
|
||||||
|
result = await dalanClient.confirmEmailActivation({
|
||||||
|
email,
|
||||||
|
code: verification,
|
||||||
|
captcha,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status === 200 || result.status === 201) {
|
||||||
|
const session = dalanClient.createSessionFromAuthResponse(result);
|
||||||
|
return res.json(ok({ token: session.token }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const errCode = result.status >= 400 ? result.status : 422;
|
||||||
|
return res
|
||||||
|
.status(errCode)
|
||||||
|
.json(fail(dalanClient.dalanErrorMessage(result.data), errCode));
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json(fail(err.message || "Activation failed", 500));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/register", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { email, password, refid, captcha } = req.body || {};
|
||||||
|
const result = await dalanClient.register({ email, password, refid, captcha });
|
||||||
|
if (result.status === 201 || result.status === 200) {
|
||||||
|
return res.json({
|
||||||
|
statusCode: 200,
|
||||||
|
message: "Registration successful",
|
||||||
|
content: { email },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const code = result.status >= 400 ? result.status : 422;
|
||||||
|
return res.status(code).json(fail(dalanClient.dalanErrorMessage(result.data), code));
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json(fail(err.message || "Registration failed", 500));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/forgot/password", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { email, captcha } = req.body || {};
|
||||||
|
const result = await dalanClient.forgotPassword({ email, captcha });
|
||||||
|
if (result.status === 201) {
|
||||||
|
return res.json({ statusCode: 200, message: "Reset code sent", content: null });
|
||||||
|
}
|
||||||
|
const code = result.status >= 400 ? result.status : 422;
|
||||||
|
return res.status(code).json(fail(dalanClient.dalanErrorMessage(result.data), code));
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json(fail(err.message || "Request failed", 500));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/forgot/check", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { email, code, token, captcha } = req.body || {};
|
||||||
|
const result = await dalanClient.confirmForgotCode({
|
||||||
|
email,
|
||||||
|
code: code || token,
|
||||||
|
captcha,
|
||||||
|
});
|
||||||
|
if (result.status === 200) {
|
||||||
|
return res.json({ statusCode: 200, message: "Code verified", content: null });
|
||||||
|
}
|
||||||
|
const errCode = result.status >= 400 ? result.status : 422;
|
||||||
|
return res.status(errCode).json(fail(dalanClient.dalanErrorMessage(result.data), errCode));
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json(fail(err.message || "Request failed", 500));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/forgot/change", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { email, code, password, confirmPassword, captcha } = req.body || {};
|
||||||
|
const result = await dalanClient.resetPassword({
|
||||||
|
email,
|
||||||
|
code,
|
||||||
|
password,
|
||||||
|
confirmPassword,
|
||||||
|
captcha,
|
||||||
|
});
|
||||||
|
if (result.status === 201) {
|
||||||
|
return res.json({ statusCode: 200, message: "Password updated", content: null });
|
||||||
|
}
|
||||||
|
const errCode = result.status >= 400 ? result.status : 422;
|
||||||
|
return res.status(errCode).json(fail(dalanClient.dalanErrorMessage(result.data), errCode));
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json(fail(err.message || "Request failed", 500));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
135
src/routes/wallets.js
Normal file
135
src/routes/wallets.js
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
const express = require("express");
|
||||||
|
const denaClient = require("../lib/denaClient");
|
||||||
|
const { mapDenaBalancesToWallets } = require("../lib/mapMarket");
|
||||||
|
const { getPrefs, updatePrefs, normalizeCurrencyCode } = require("../lib/userPrefs");
|
||||||
|
const { denaErrorMessage } = require("../lib/mapOrder");
|
||||||
|
const { ok, fail } = require("../lib/shahooResponse");
|
||||||
|
const { requireSession } = require("../middleware/auth");
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
async function walletsWithFavorites(session, balances) {
|
||||||
|
const prefs = await getPrefs(session);
|
||||||
|
const favorites = new Set((prefs.favoriteCurrencies || []).map(normalizeCurrencyCode));
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
return mapDenaBalancesToWallets(balances).map((wallet) => ({
|
||||||
|
...wallet,
|
||||||
|
favorite: favorites.has(wallet.currencyName),
|
||||||
|
updated: now,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get("/currencies", async (_req, res) => {
|
||||||
|
const markets = await denaClient.getMarkets();
|
||||||
|
const currencies = {};
|
||||||
|
markets.forEach((m) => {
|
||||||
|
if (m.base_unit) currencies[m.base_unit.toUpperCase()] = true;
|
||||||
|
if (m.quote_unit) currencies[m.quote_unit.toUpperCase()] = true;
|
||||||
|
});
|
||||||
|
return res.json(ok(Object.keys(currencies).map((c) => ({ name: c }))));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/self", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const balances = await denaClient.getBalances(req.session);
|
||||||
|
return res.json(ok(await walletsWithFavorites(req.session, balances)));
|
||||||
|
} catch {
|
||||||
|
return res.json(ok([]));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/self/balance", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const balances = await denaClient.getBalances(req.session);
|
||||||
|
return res.json(ok(await walletsWithFavorites(req.session, balances)));
|
||||||
|
} catch {
|
||||||
|
return res.json(ok([]));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/totalBalances", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const balances = await denaClient.getBalances(req.session);
|
||||||
|
const wallets = await walletsWithFavorites(req.session, balances);
|
||||||
|
const totalBalance = wallets.reduce((sum, row) => sum + (row.totalBalance || 0), 0);
|
||||||
|
return res.json(ok({ totalBalance, wallets }));
|
||||||
|
} catch {
|
||||||
|
return res.json(ok({ totalBalance: 0, wallets: [] }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/self/currency/:currencyName", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const balances = await denaClient.getBalances(req.session);
|
||||||
|
const wallets = await walletsWithFavorites(req.session, balances);
|
||||||
|
const currency = String(req.params.currencyName || "").toUpperCase();
|
||||||
|
const wallet = wallets.find((w) => w.currencyName === currency);
|
||||||
|
return res.json(ok(wallet || null));
|
||||||
|
} catch {
|
||||||
|
return res.json(ok(null));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put("/update/favorite", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const currencyName = normalizeCurrencyCode(
|
||||||
|
req.body?.currencyName || req.body?.currency
|
||||||
|
);
|
||||||
|
const favorite = Boolean(req.body?.favorite);
|
||||||
|
|
||||||
|
if (!currencyName) {
|
||||||
|
return res.status(422).json(fail("currencyName is required", 422));
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefs = await getPrefs(req.session);
|
||||||
|
const favorites = new Set((prefs.favoriteCurrencies || []).map(normalizeCurrencyCode));
|
||||||
|
const markets = new Set((prefs.favoriteMarkets || []).map(normalizeCurrencyCode));
|
||||||
|
|
||||||
|
if (favorite) {
|
||||||
|
favorites.add(currencyName);
|
||||||
|
markets.add(currencyName);
|
||||||
|
} else {
|
||||||
|
favorites.delete(currencyName);
|
||||||
|
markets.delete(currencyName);
|
||||||
|
}
|
||||||
|
|
||||||
|
await updatePrefs(req.session, {
|
||||||
|
favoriteCurrencies: Array.from(favorites),
|
||||||
|
favoriteMarkets: Array.from(markets),
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
statusCode: 200,
|
||||||
|
message: favorite ? "Added to favorites" : "Removed from favorites",
|
||||||
|
content: { currencyName, favorite },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json(fail(err.message || "Request failed", 500));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put("/address", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const currencyName = req.body?.currencyName || req.body?.currency;
|
||||||
|
if (!currencyName) {
|
||||||
|
return res.status(400).json(fail("currencyName is required", 400));
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await denaClient.getDepositAddress(
|
||||||
|
req.session,
|
||||||
|
String(currencyName).toLowerCase()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.status === 200 && result.data?.address) {
|
||||||
|
return res.json(ok(result.data.address));
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = result.status >= 400 ? result.status : 422;
|
||||||
|
return res.status(code).json(fail(denaErrorMessage(result.data), code));
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json(fail(err.message || "Failed to create address", 500));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
157
src/routes/withdrawals.js
Normal file
157
src/routes/withdrawals.js
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
const express = require("express");
|
||||||
|
const denaClient = require("../lib/denaClient");
|
||||||
|
const { mapDenaWithdrawal } = require("../lib/mapWithdrawal");
|
||||||
|
const { denaErrorMessage } = require("../lib/mapOrder");
|
||||||
|
const { ok, okPaged, fail } = require("../lib/shahooResponse");
|
||||||
|
const { requireSession } = require("../middleware/auth");
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
function parsePage(pageSize, pageNumber) {
|
||||||
|
const limit = Math.max(1, Math.min(Number(pageSize) || 10, 100));
|
||||||
|
const page = Math.max(1, Number(pageNumber) + 1);
|
||||||
|
return { limit, page };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapCreateBody(body = {}) {
|
||||||
|
return {
|
||||||
|
currency: body.currencyName || body.currency,
|
||||||
|
amount: body.amount,
|
||||||
|
rid: body.destinationWalletAddress || body.destWallet || body.rid,
|
||||||
|
otp: body.totp || body.otp,
|
||||||
|
paymentId: body.destTag || body.payment_id,
|
||||||
|
beneficiaryId: body.beneficiaryId || body.beneficiary_id,
|
||||||
|
note: body.note,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listWithdrawals(session, { currency, limit, page }) {
|
||||||
|
const withdrawals = await denaClient.getWithdrawals(session, { limit, page });
|
||||||
|
const mapped = withdrawals.map(mapDenaWithdrawal).filter(Boolean);
|
||||||
|
if (!currency) return mapped;
|
||||||
|
const code = String(currency).toUpperCase();
|
||||||
|
return mapped.filter((row) => row.currencyName === code);
|
||||||
|
}
|
||||||
|
|
||||||
|
router.post("/", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const payload = mapCreateBody(req.body);
|
||||||
|
if (!payload.currency || !payload.amount) {
|
||||||
|
return res.status(422).json(fail("currencyName and amount are required", 422));
|
||||||
|
}
|
||||||
|
if (!payload.rid && !payload.beneficiaryId) {
|
||||||
|
return res.status(422).json(fail("destinationWalletAddress is required", 422));
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await denaClient.createWithdraw(req.session, payload);
|
||||||
|
if (result.status === 201 || result.status === 200) {
|
||||||
|
return res.json(ok(mapDenaWithdrawal(result.data)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = result.status >= 400 ? result.status : 422;
|
||||||
|
return res.status(code).json(fail(denaErrorMessage(result.data), code));
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json(fail(err.message || "Withdraw failed", 500));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/:id/confirm/:token", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await denaClient.confirmWithdraw(
|
||||||
|
req.session,
|
||||||
|
req.params.id,
|
||||||
|
req.params.token
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.status === 200 || result.status === 201 || result.status === 204) {
|
||||||
|
const mapped = mapDenaWithdrawal(result.data) ||
|
||||||
|
mapDenaWithdrawal(await denaClient.getWithdrawById(req.session, req.params.id));
|
||||||
|
return res.json(ok(mapped, 200));
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = result.status >= 400 ? result.status : 422;
|
||||||
|
return res.status(code).json(fail(denaErrorMessage(result.data), code));
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json(fail(err.message || "Confirm failed", 500));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/confirm", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const id = req.body?.id;
|
||||||
|
const token = req.body?.token;
|
||||||
|
if (!id || !token) {
|
||||||
|
return res.status(422).json(fail("id and token are required", 422));
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await denaClient.confirmWithdraw(req.session, id, token);
|
||||||
|
if (result.status === 200 || result.status === 201 || result.status === 204) {
|
||||||
|
const mapped = mapDenaWithdrawal(result.data) ||
|
||||||
|
mapDenaWithdrawal(await denaClient.getWithdrawById(req.session, id));
|
||||||
|
return res.json(ok(mapped));
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = result.status >= 400 ? result.status : 422;
|
||||||
|
return res.status(code).json(fail(denaErrorMessage(result.data), code));
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json(fail(err.message || "Confirm failed", 500));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete("/cancel/:id", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await denaClient.cancelWithdraw(req.session, req.params.id);
|
||||||
|
if (result.status === 200 || result.status === 201 || result.status === 204) {
|
||||||
|
return res.json(ok({ success: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = result.status >= 400 ? result.status : 422;
|
||||||
|
return res.status(code).json(fail(denaErrorMessage(result.data), code));
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json(fail(err.message || "Cancel failed", 500));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/resendMail/:id", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await denaClient.resendWithdrawEmail(req.session, req.params.id);
|
||||||
|
if (result.status === 200 || result.status === 201 || result.status === 204) {
|
||||||
|
return res.json({ statusCode: 200, message: "Verification email sent", content: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = result.status >= 400 ? result.status : 422;
|
||||||
|
return res.status(code).json(fail(denaErrorMessage(result.data), code));
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json(fail(err.message || "Resend failed", 500));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/self/:currency/:pageSize/:pageNumber", requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { limit, page } = parsePage(req.params.pageSize, req.params.pageNumber);
|
||||||
|
const content = await listWithdrawals(req.session, {
|
||||||
|
currency: req.params.currency,
|
||||||
|
limit,
|
||||||
|
page,
|
||||||
|
});
|
||||||
|
return res.json(okPaged(content, content.length));
|
||||||
|
} catch {
|
||||||
|
return res.json(okPaged([], 0));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/self/:pageSize/:pageNumber", requireSession, async (req, res) => {
|
||||||
|
if (!/^\d+$/.test(String(req.params.pageSize))) {
|
||||||
|
return res.status(404).json(fail("Not found", 404));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { limit, page } = parsePage(req.params.pageSize, req.params.pageNumber);
|
||||||
|
const content = await listWithdrawals(req.session, { limit, page });
|
||||||
|
return res.json(okPaged(content, content.length));
|
||||||
|
} catch {
|
||||||
|
return res.json(okPaged([], 0));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
780
yarn.lock
Normal file
780
yarn.lock
Normal file
@@ -0,0 +1,780 @@
|
|||||||
|
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||||
|
# yarn lockfile v1
|
||||||
|
|
||||||
|
|
||||||
|
"@ioredis/commands@1.10.0":
|
||||||
|
version "1.10.0"
|
||||||
|
resolved "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz"
|
||||||
|
integrity sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==
|
||||||
|
|
||||||
|
accepts@~1.3.8:
|
||||||
|
version "1.3.8"
|
||||||
|
resolved "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz"
|
||||||
|
integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==
|
||||||
|
dependencies:
|
||||||
|
mime-types "~2.1.34"
|
||||||
|
negotiator "0.6.3"
|
||||||
|
|
||||||
|
agent-base@6:
|
||||||
|
version "6.0.2"
|
||||||
|
resolved "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz"
|
||||||
|
integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==
|
||||||
|
dependencies:
|
||||||
|
debug "4"
|
||||||
|
|
||||||
|
append-field@^1.0.0:
|
||||||
|
version "1.0.0"
|
||||||
|
resolved "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz"
|
||||||
|
integrity sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==
|
||||||
|
|
||||||
|
array-flatten@1.1.1:
|
||||||
|
version "1.1.1"
|
||||||
|
resolved "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz"
|
||||||
|
integrity sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==
|
||||||
|
|
||||||
|
asynckit@^0.4.0:
|
||||||
|
version "0.4.0"
|
||||||
|
resolved "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz"
|
||||||
|
integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==
|
||||||
|
|
||||||
|
axios@^1.6.8:
|
||||||
|
version "1.18.1"
|
||||||
|
resolved "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz"
|
||||||
|
integrity sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==
|
||||||
|
dependencies:
|
||||||
|
follow-redirects "^1.16.0"
|
||||||
|
form-data "^4.0.5"
|
||||||
|
https-proxy-agent "^5.0.1"
|
||||||
|
proxy-from-env "^2.1.0"
|
||||||
|
|
||||||
|
body-parser@~1.20.5:
|
||||||
|
version "1.20.5"
|
||||||
|
resolved "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz"
|
||||||
|
integrity sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==
|
||||||
|
dependencies:
|
||||||
|
bytes "~3.1.2"
|
||||||
|
content-type "~1.0.5"
|
||||||
|
debug "2.6.9"
|
||||||
|
depd "2.0.0"
|
||||||
|
destroy "~1.2.0"
|
||||||
|
http-errors "~2.0.1"
|
||||||
|
iconv-lite "~0.4.24"
|
||||||
|
on-finished "~2.4.1"
|
||||||
|
qs "~6.15.1"
|
||||||
|
raw-body "~2.5.3"
|
||||||
|
type-is "~1.6.18"
|
||||||
|
unpipe "~1.0.0"
|
||||||
|
|
||||||
|
buffer-from@^1.0.0:
|
||||||
|
version "1.1.2"
|
||||||
|
resolved "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz"
|
||||||
|
integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==
|
||||||
|
|
||||||
|
busboy@^1.0.0:
|
||||||
|
version "1.6.0"
|
||||||
|
resolved "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz"
|
||||||
|
integrity sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==
|
||||||
|
dependencies:
|
||||||
|
streamsearch "^1.1.0"
|
||||||
|
|
||||||
|
bytes@~3.1.2:
|
||||||
|
version "3.1.2"
|
||||||
|
resolved "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz"
|
||||||
|
integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==
|
||||||
|
|
||||||
|
call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2:
|
||||||
|
version "1.0.2"
|
||||||
|
resolved "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz"
|
||||||
|
integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==
|
||||||
|
dependencies:
|
||||||
|
es-errors "^1.3.0"
|
||||||
|
function-bind "^1.1.2"
|
||||||
|
|
||||||
|
call-bound@^1.0.2:
|
||||||
|
version "1.0.4"
|
||||||
|
resolved "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz"
|
||||||
|
integrity sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==
|
||||||
|
dependencies:
|
||||||
|
call-bind-apply-helpers "^1.0.2"
|
||||||
|
get-intrinsic "^1.3.0"
|
||||||
|
|
||||||
|
cluster-key-slot@1.1.1:
|
||||||
|
version "1.1.1"
|
||||||
|
resolved "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz"
|
||||||
|
integrity sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==
|
||||||
|
|
||||||
|
combined-stream@^1.0.8:
|
||||||
|
version "1.0.8"
|
||||||
|
resolved "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz"
|
||||||
|
integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==
|
||||||
|
dependencies:
|
||||||
|
delayed-stream "~1.0.0"
|
||||||
|
|
||||||
|
concat-stream@^1.5.2:
|
||||||
|
version "1.6.2"
|
||||||
|
resolved "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz"
|
||||||
|
integrity sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==
|
||||||
|
dependencies:
|
||||||
|
buffer-from "^1.0.0"
|
||||||
|
inherits "^2.0.3"
|
||||||
|
readable-stream "^2.2.2"
|
||||||
|
typedarray "^0.0.6"
|
||||||
|
|
||||||
|
content-disposition@~0.5.4:
|
||||||
|
version "0.5.4"
|
||||||
|
resolved "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz"
|
||||||
|
integrity sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==
|
||||||
|
dependencies:
|
||||||
|
safe-buffer "5.2.1"
|
||||||
|
|
||||||
|
content-type@~1.0.4, content-type@~1.0.5:
|
||||||
|
version "1.0.5"
|
||||||
|
resolved "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz"
|
||||||
|
integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==
|
||||||
|
|
||||||
|
cookie-signature@~1.0.6:
|
||||||
|
version "1.0.7"
|
||||||
|
resolved "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz"
|
||||||
|
integrity sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==
|
||||||
|
|
||||||
|
cookie@~0.7.1:
|
||||||
|
version "0.7.2"
|
||||||
|
resolved "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz"
|
||||||
|
integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==
|
||||||
|
|
||||||
|
core-util-is@~1.0.0:
|
||||||
|
version "1.0.3"
|
||||||
|
resolved "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz"
|
||||||
|
integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==
|
||||||
|
|
||||||
|
cors@^2.8.5:
|
||||||
|
version "2.8.6"
|
||||||
|
resolved "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz"
|
||||||
|
integrity sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==
|
||||||
|
dependencies:
|
||||||
|
object-assign "^4"
|
||||||
|
vary "^1"
|
||||||
|
|
||||||
|
debug@2.6.9:
|
||||||
|
version "2.6.9"
|
||||||
|
resolved "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz"
|
||||||
|
integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==
|
||||||
|
dependencies:
|
||||||
|
ms "2.0.0"
|
||||||
|
|
||||||
|
debug@4:
|
||||||
|
version "4.4.3"
|
||||||
|
resolved "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz"
|
||||||
|
integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==
|
||||||
|
dependencies:
|
||||||
|
ms "^2.1.3"
|
||||||
|
|
||||||
|
debug@4.4.3:
|
||||||
|
version "4.4.3"
|
||||||
|
resolved "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz"
|
||||||
|
integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==
|
||||||
|
dependencies:
|
||||||
|
ms "^2.1.3"
|
||||||
|
|
||||||
|
delayed-stream@~1.0.0:
|
||||||
|
version "1.0.0"
|
||||||
|
resolved "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz"
|
||||||
|
integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==
|
||||||
|
|
||||||
|
denque@2.1.0:
|
||||||
|
version "2.1.0"
|
||||||
|
resolved "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz"
|
||||||
|
integrity sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==
|
||||||
|
|
||||||
|
depd@~2.0.0, depd@2.0.0:
|
||||||
|
version "2.0.0"
|
||||||
|
resolved "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz"
|
||||||
|
integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==
|
||||||
|
|
||||||
|
destroy@~1.2.0, destroy@1.2.0:
|
||||||
|
version "1.2.0"
|
||||||
|
resolved "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz"
|
||||||
|
integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==
|
||||||
|
|
||||||
|
dotenv@^16.4.5:
|
||||||
|
version "16.6.1"
|
||||||
|
resolved "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz"
|
||||||
|
integrity sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==
|
||||||
|
|
||||||
|
dunder-proto@^1.0.1:
|
||||||
|
version "1.0.1"
|
||||||
|
resolved "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz"
|
||||||
|
integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==
|
||||||
|
dependencies:
|
||||||
|
call-bind-apply-helpers "^1.0.1"
|
||||||
|
es-errors "^1.3.0"
|
||||||
|
gopd "^1.2.0"
|
||||||
|
|
||||||
|
ee-first@1.1.1:
|
||||||
|
version "1.1.1"
|
||||||
|
resolved "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz"
|
||||||
|
integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==
|
||||||
|
|
||||||
|
encodeurl@~2.0.0:
|
||||||
|
version "2.0.0"
|
||||||
|
resolved "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz"
|
||||||
|
integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==
|
||||||
|
|
||||||
|
es-define-property@^1.0.1:
|
||||||
|
version "1.0.1"
|
||||||
|
resolved "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz"
|
||||||
|
integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==
|
||||||
|
|
||||||
|
es-errors@^1.3.0:
|
||||||
|
version "1.3.0"
|
||||||
|
resolved "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz"
|
||||||
|
integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==
|
||||||
|
|
||||||
|
es-object-atoms@^1.0.0, es-object-atoms@^1.1.1:
|
||||||
|
version "1.1.2"
|
||||||
|
resolved "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz"
|
||||||
|
integrity sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==
|
||||||
|
dependencies:
|
||||||
|
es-errors "^1.3.0"
|
||||||
|
|
||||||
|
es-set-tostringtag@^2.1.0:
|
||||||
|
version "2.1.0"
|
||||||
|
resolved "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz"
|
||||||
|
integrity sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==
|
||||||
|
dependencies:
|
||||||
|
es-errors "^1.3.0"
|
||||||
|
get-intrinsic "^1.2.6"
|
||||||
|
has-tostringtag "^1.0.2"
|
||||||
|
hasown "^2.0.2"
|
||||||
|
|
||||||
|
escape-html@~1.0.3:
|
||||||
|
version "1.0.3"
|
||||||
|
resolved "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz"
|
||||||
|
integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==
|
||||||
|
|
||||||
|
etag@~1.8.1:
|
||||||
|
version "1.8.1"
|
||||||
|
resolved "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz"
|
||||||
|
integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==
|
||||||
|
|
||||||
|
express@^4.19.2:
|
||||||
|
version "4.22.2"
|
||||||
|
resolved "https://registry.npmjs.org/express/-/express-4.22.2.tgz"
|
||||||
|
integrity sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==
|
||||||
|
dependencies:
|
||||||
|
accepts "~1.3.8"
|
||||||
|
array-flatten "1.1.1"
|
||||||
|
body-parser "~1.20.5"
|
||||||
|
content-disposition "~0.5.4"
|
||||||
|
content-type "~1.0.4"
|
||||||
|
cookie "~0.7.1"
|
||||||
|
cookie-signature "~1.0.6"
|
||||||
|
debug "2.6.9"
|
||||||
|
depd "2.0.0"
|
||||||
|
encodeurl "~2.0.0"
|
||||||
|
escape-html "~1.0.3"
|
||||||
|
etag "~1.8.1"
|
||||||
|
finalhandler "~1.3.1"
|
||||||
|
fresh "~0.5.2"
|
||||||
|
http-errors "~2.0.0"
|
||||||
|
merge-descriptors "1.0.3"
|
||||||
|
methods "~1.1.2"
|
||||||
|
on-finished "~2.4.1"
|
||||||
|
parseurl "~1.3.3"
|
||||||
|
path-to-regexp "~0.1.12"
|
||||||
|
proxy-addr "~2.0.7"
|
||||||
|
qs "~6.15.1"
|
||||||
|
range-parser "~1.2.1"
|
||||||
|
safe-buffer "5.2.1"
|
||||||
|
send "~0.19.0"
|
||||||
|
serve-static "~1.16.2"
|
||||||
|
setprototypeof "1.2.0"
|
||||||
|
statuses "~2.0.1"
|
||||||
|
type-is "~1.6.18"
|
||||||
|
utils-merge "1.0.1"
|
||||||
|
vary "~1.1.2"
|
||||||
|
|
||||||
|
finalhandler@~1.3.1:
|
||||||
|
version "1.3.2"
|
||||||
|
resolved "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz"
|
||||||
|
integrity sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==
|
||||||
|
dependencies:
|
||||||
|
debug "2.6.9"
|
||||||
|
encodeurl "~2.0.0"
|
||||||
|
escape-html "~1.0.3"
|
||||||
|
on-finished "~2.4.1"
|
||||||
|
parseurl "~1.3.3"
|
||||||
|
statuses "~2.0.2"
|
||||||
|
unpipe "~1.0.0"
|
||||||
|
|
||||||
|
follow-redirects@^1.16.0:
|
||||||
|
version "1.16.0"
|
||||||
|
resolved "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz"
|
||||||
|
integrity sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==
|
||||||
|
|
||||||
|
form-data@^4.0.5:
|
||||||
|
version "4.0.6"
|
||||||
|
resolved "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz"
|
||||||
|
integrity sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==
|
||||||
|
dependencies:
|
||||||
|
asynckit "^0.4.0"
|
||||||
|
combined-stream "^1.0.8"
|
||||||
|
es-set-tostringtag "^2.1.0"
|
||||||
|
hasown "^2.0.4"
|
||||||
|
mime-types "^2.1.35"
|
||||||
|
|
||||||
|
forwarded@0.2.0:
|
||||||
|
version "0.2.0"
|
||||||
|
resolved "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz"
|
||||||
|
integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==
|
||||||
|
|
||||||
|
fresh@~0.5.2:
|
||||||
|
version "0.5.2"
|
||||||
|
resolved "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz"
|
||||||
|
integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==
|
||||||
|
|
||||||
|
function-bind@^1.1.2:
|
||||||
|
version "1.1.2"
|
||||||
|
resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz"
|
||||||
|
integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==
|
||||||
|
|
||||||
|
get-intrinsic@^1.2.5, get-intrinsic@^1.2.6, get-intrinsic@^1.3.0:
|
||||||
|
version "1.3.0"
|
||||||
|
resolved "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz"
|
||||||
|
integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==
|
||||||
|
dependencies:
|
||||||
|
call-bind-apply-helpers "^1.0.2"
|
||||||
|
es-define-property "^1.0.1"
|
||||||
|
es-errors "^1.3.0"
|
||||||
|
es-object-atoms "^1.1.1"
|
||||||
|
function-bind "^1.1.2"
|
||||||
|
get-proto "^1.0.1"
|
||||||
|
gopd "^1.2.0"
|
||||||
|
has-symbols "^1.1.0"
|
||||||
|
hasown "^2.0.2"
|
||||||
|
math-intrinsics "^1.1.0"
|
||||||
|
|
||||||
|
get-proto@^1.0.1:
|
||||||
|
version "1.0.1"
|
||||||
|
resolved "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz"
|
||||||
|
integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==
|
||||||
|
dependencies:
|
||||||
|
dunder-proto "^1.0.1"
|
||||||
|
es-object-atoms "^1.0.0"
|
||||||
|
|
||||||
|
gopd@^1.2.0:
|
||||||
|
version "1.2.0"
|
||||||
|
resolved "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz"
|
||||||
|
integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==
|
||||||
|
|
||||||
|
has-symbols@^1.0.3, has-symbols@^1.1.0:
|
||||||
|
version "1.1.0"
|
||||||
|
resolved "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz"
|
||||||
|
integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==
|
||||||
|
|
||||||
|
has-tostringtag@^1.0.2:
|
||||||
|
version "1.0.2"
|
||||||
|
resolved "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz"
|
||||||
|
integrity sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==
|
||||||
|
dependencies:
|
||||||
|
has-symbols "^1.0.3"
|
||||||
|
|
||||||
|
hasown@^2.0.2, hasown@^2.0.4:
|
||||||
|
version "2.0.4"
|
||||||
|
resolved "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz"
|
||||||
|
integrity sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==
|
||||||
|
dependencies:
|
||||||
|
function-bind "^1.1.2"
|
||||||
|
|
||||||
|
http-errors@~2.0.0, http-errors@~2.0.1:
|
||||||
|
version "2.0.1"
|
||||||
|
resolved "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz"
|
||||||
|
integrity sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==
|
||||||
|
dependencies:
|
||||||
|
depd "~2.0.0"
|
||||||
|
inherits "~2.0.4"
|
||||||
|
setprototypeof "~1.2.0"
|
||||||
|
statuses "~2.0.2"
|
||||||
|
toidentifier "~1.0.1"
|
||||||
|
|
||||||
|
https-proxy-agent@^5.0.1:
|
||||||
|
version "5.0.1"
|
||||||
|
resolved "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz"
|
||||||
|
integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==
|
||||||
|
dependencies:
|
||||||
|
agent-base "6"
|
||||||
|
debug "4"
|
||||||
|
|
||||||
|
iconv-lite@~0.4.24:
|
||||||
|
version "0.4.24"
|
||||||
|
resolved "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz"
|
||||||
|
integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==
|
||||||
|
dependencies:
|
||||||
|
safer-buffer ">= 2.1.2 < 3"
|
||||||
|
|
||||||
|
inherits@^2.0.3, inherits@~2.0.3, inherits@~2.0.4:
|
||||||
|
version "2.0.4"
|
||||||
|
resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz"
|
||||||
|
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
|
||||||
|
|
||||||
|
ioredis@^5.4.1:
|
||||||
|
version "5.11.1"
|
||||||
|
resolved "https://registry.npmjs.org/ioredis/-/ioredis-5.11.1.tgz"
|
||||||
|
integrity sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==
|
||||||
|
dependencies:
|
||||||
|
"@ioredis/commands" "1.10.0"
|
||||||
|
cluster-key-slot "1.1.1"
|
||||||
|
debug "4.4.3"
|
||||||
|
denque "2.1.0"
|
||||||
|
redis-errors "1.2.0"
|
||||||
|
redis-parser "3.0.0"
|
||||||
|
standard-as-callback "2.1.0"
|
||||||
|
|
||||||
|
ipaddr.js@1.9.1:
|
||||||
|
version "1.9.1"
|
||||||
|
resolved "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz"
|
||||||
|
integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==
|
||||||
|
|
||||||
|
isarray@~1.0.0:
|
||||||
|
version "1.0.0"
|
||||||
|
resolved "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz"
|
||||||
|
integrity sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==
|
||||||
|
|
||||||
|
math-intrinsics@^1.1.0:
|
||||||
|
version "1.1.0"
|
||||||
|
resolved "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz"
|
||||||
|
integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==
|
||||||
|
|
||||||
|
media-typer@0.3.0:
|
||||||
|
version "0.3.0"
|
||||||
|
resolved "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz"
|
||||||
|
integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==
|
||||||
|
|
||||||
|
merge-descriptors@1.0.3:
|
||||||
|
version "1.0.3"
|
||||||
|
resolved "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz"
|
||||||
|
integrity sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==
|
||||||
|
|
||||||
|
methods@~1.1.2:
|
||||||
|
version "1.1.2"
|
||||||
|
resolved "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz"
|
||||||
|
integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==
|
||||||
|
|
||||||
|
mime-db@1.52.0:
|
||||||
|
version "1.52.0"
|
||||||
|
resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz"
|
||||||
|
integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==
|
||||||
|
|
||||||
|
mime-types@^2.1.35, mime-types@~2.1.24, mime-types@~2.1.34:
|
||||||
|
version "2.1.35"
|
||||||
|
resolved "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz"
|
||||||
|
integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==
|
||||||
|
dependencies:
|
||||||
|
mime-db "1.52.0"
|
||||||
|
|
||||||
|
mime@1.6.0:
|
||||||
|
version "1.6.0"
|
||||||
|
resolved "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz"
|
||||||
|
integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==
|
||||||
|
|
||||||
|
minimist@^1.2.6:
|
||||||
|
version "1.2.8"
|
||||||
|
resolved "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz"
|
||||||
|
integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==
|
||||||
|
|
||||||
|
mkdirp@^0.5.4:
|
||||||
|
version "0.5.6"
|
||||||
|
resolved "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz"
|
||||||
|
integrity sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==
|
||||||
|
dependencies:
|
||||||
|
minimist "^1.2.6"
|
||||||
|
|
||||||
|
ms@^2.1.3, ms@2.1.3:
|
||||||
|
version "2.1.3"
|
||||||
|
resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz"
|
||||||
|
integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
|
||||||
|
|
||||||
|
ms@2.0.0:
|
||||||
|
version "2.0.0"
|
||||||
|
resolved "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz"
|
||||||
|
integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==
|
||||||
|
|
||||||
|
multer@^1.4.5-lts.1:
|
||||||
|
version "1.4.5-lts.2"
|
||||||
|
resolved "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz"
|
||||||
|
integrity sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==
|
||||||
|
dependencies:
|
||||||
|
append-field "^1.0.0"
|
||||||
|
busboy "^1.0.0"
|
||||||
|
concat-stream "^1.5.2"
|
||||||
|
mkdirp "^0.5.4"
|
||||||
|
object-assign "^4.1.1"
|
||||||
|
type-is "^1.6.4"
|
||||||
|
xtend "^4.0.0"
|
||||||
|
|
||||||
|
negotiator@0.6.3:
|
||||||
|
version "0.6.3"
|
||||||
|
resolved "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz"
|
||||||
|
integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==
|
||||||
|
|
||||||
|
object-assign@^4, object-assign@^4.1.1:
|
||||||
|
version "4.1.1"
|
||||||
|
resolved "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz"
|
||||||
|
integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==
|
||||||
|
|
||||||
|
object-inspect@^1.13.3, object-inspect@^1.13.4:
|
||||||
|
version "1.13.4"
|
||||||
|
resolved "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz"
|
||||||
|
integrity sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==
|
||||||
|
|
||||||
|
on-finished@~2.4.1:
|
||||||
|
version "2.4.1"
|
||||||
|
resolved "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz"
|
||||||
|
integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==
|
||||||
|
dependencies:
|
||||||
|
ee-first "1.1.1"
|
||||||
|
|
||||||
|
parseurl@~1.3.3:
|
||||||
|
version "1.3.3"
|
||||||
|
resolved "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz"
|
||||||
|
integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==
|
||||||
|
|
||||||
|
path-to-regexp@~0.1.12:
|
||||||
|
version "0.1.13"
|
||||||
|
resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz"
|
||||||
|
integrity sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==
|
||||||
|
|
||||||
|
process-nextick-args@~2.0.0:
|
||||||
|
version "2.0.1"
|
||||||
|
resolved "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz"
|
||||||
|
integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==
|
||||||
|
|
||||||
|
proxy-addr@~2.0.7:
|
||||||
|
version "2.0.7"
|
||||||
|
resolved "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz"
|
||||||
|
integrity sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==
|
||||||
|
dependencies:
|
||||||
|
forwarded "0.2.0"
|
||||||
|
ipaddr.js "1.9.1"
|
||||||
|
|
||||||
|
proxy-from-env@^2.1.0:
|
||||||
|
version "2.1.0"
|
||||||
|
resolved "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz"
|
||||||
|
integrity sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==
|
||||||
|
|
||||||
|
qs@~6.15.1:
|
||||||
|
version "6.15.3"
|
||||||
|
resolved "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz"
|
||||||
|
integrity sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==
|
||||||
|
dependencies:
|
||||||
|
es-define-property "^1.0.1"
|
||||||
|
side-channel "^1.1.1"
|
||||||
|
|
||||||
|
range-parser@~1.2.1:
|
||||||
|
version "1.2.1"
|
||||||
|
resolved "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz"
|
||||||
|
integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==
|
||||||
|
|
||||||
|
raw-body@~2.5.3:
|
||||||
|
version "2.5.3"
|
||||||
|
resolved "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz"
|
||||||
|
integrity sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==
|
||||||
|
dependencies:
|
||||||
|
bytes "~3.1.2"
|
||||||
|
http-errors "~2.0.1"
|
||||||
|
iconv-lite "~0.4.24"
|
||||||
|
unpipe "~1.0.0"
|
||||||
|
|
||||||
|
readable-stream@^2.2.2:
|
||||||
|
version "2.3.8"
|
||||||
|
resolved "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz"
|
||||||
|
integrity sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==
|
||||||
|
dependencies:
|
||||||
|
core-util-is "~1.0.0"
|
||||||
|
inherits "~2.0.3"
|
||||||
|
isarray "~1.0.0"
|
||||||
|
process-nextick-args "~2.0.0"
|
||||||
|
safe-buffer "~5.1.1"
|
||||||
|
string_decoder "~1.1.1"
|
||||||
|
util-deprecate "~1.0.1"
|
||||||
|
|
||||||
|
redis-errors@^1.0.0, redis-errors@1.2.0:
|
||||||
|
version "1.2.0"
|
||||||
|
resolved "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz"
|
||||||
|
integrity sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==
|
||||||
|
|
||||||
|
redis-parser@3.0.0:
|
||||||
|
version "3.0.0"
|
||||||
|
resolved "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz"
|
||||||
|
integrity sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==
|
||||||
|
dependencies:
|
||||||
|
redis-errors "^1.0.0"
|
||||||
|
|
||||||
|
safe-buffer@~5.1.0:
|
||||||
|
version "5.1.2"
|
||||||
|
resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz"
|
||||||
|
integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==
|
||||||
|
|
||||||
|
safe-buffer@~5.1.1:
|
||||||
|
version "5.1.2"
|
||||||
|
resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz"
|
||||||
|
integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==
|
||||||
|
|
||||||
|
safe-buffer@5.2.1:
|
||||||
|
version "5.2.1"
|
||||||
|
resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz"
|
||||||
|
integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
|
||||||
|
|
||||||
|
"safer-buffer@>= 2.1.2 < 3":
|
||||||
|
version "2.1.2"
|
||||||
|
resolved "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz"
|
||||||
|
integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
|
||||||
|
|
||||||
|
send@~0.19.0, send@~0.19.1:
|
||||||
|
version "0.19.2"
|
||||||
|
resolved "https://registry.npmjs.org/send/-/send-0.19.2.tgz"
|
||||||
|
integrity sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==
|
||||||
|
dependencies:
|
||||||
|
debug "2.6.9"
|
||||||
|
depd "2.0.0"
|
||||||
|
destroy "1.2.0"
|
||||||
|
encodeurl "~2.0.0"
|
||||||
|
escape-html "~1.0.3"
|
||||||
|
etag "~1.8.1"
|
||||||
|
fresh "~0.5.2"
|
||||||
|
http-errors "~2.0.1"
|
||||||
|
mime "1.6.0"
|
||||||
|
ms "2.1.3"
|
||||||
|
on-finished "~2.4.1"
|
||||||
|
range-parser "~1.2.1"
|
||||||
|
statuses "~2.0.2"
|
||||||
|
|
||||||
|
serve-static@~1.16.2:
|
||||||
|
version "1.16.3"
|
||||||
|
resolved "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz"
|
||||||
|
integrity sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==
|
||||||
|
dependencies:
|
||||||
|
encodeurl "~2.0.0"
|
||||||
|
escape-html "~1.0.3"
|
||||||
|
parseurl "~1.3.3"
|
||||||
|
send "~0.19.1"
|
||||||
|
|
||||||
|
setprototypeof@~1.2.0, setprototypeof@1.2.0:
|
||||||
|
version "1.2.0"
|
||||||
|
resolved "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz"
|
||||||
|
integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==
|
||||||
|
|
||||||
|
side-channel-list@^1.0.1:
|
||||||
|
version "1.0.1"
|
||||||
|
resolved "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz"
|
||||||
|
integrity sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==
|
||||||
|
dependencies:
|
||||||
|
es-errors "^1.3.0"
|
||||||
|
object-inspect "^1.13.4"
|
||||||
|
|
||||||
|
side-channel-map@^1.0.1:
|
||||||
|
version "1.0.1"
|
||||||
|
resolved "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz"
|
||||||
|
integrity sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==
|
||||||
|
dependencies:
|
||||||
|
call-bound "^1.0.2"
|
||||||
|
es-errors "^1.3.0"
|
||||||
|
get-intrinsic "^1.2.5"
|
||||||
|
object-inspect "^1.13.3"
|
||||||
|
|
||||||
|
side-channel-weakmap@^1.0.2:
|
||||||
|
version "1.0.2"
|
||||||
|
resolved "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz"
|
||||||
|
integrity sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==
|
||||||
|
dependencies:
|
||||||
|
call-bound "^1.0.2"
|
||||||
|
es-errors "^1.3.0"
|
||||||
|
get-intrinsic "^1.2.5"
|
||||||
|
object-inspect "^1.13.3"
|
||||||
|
side-channel-map "^1.0.1"
|
||||||
|
|
||||||
|
side-channel@^1.1.1:
|
||||||
|
version "1.1.1"
|
||||||
|
resolved "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz"
|
||||||
|
integrity sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==
|
||||||
|
dependencies:
|
||||||
|
es-errors "^1.3.0"
|
||||||
|
object-inspect "^1.13.4"
|
||||||
|
side-channel-list "^1.0.1"
|
||||||
|
side-channel-map "^1.0.1"
|
||||||
|
side-channel-weakmap "^1.0.2"
|
||||||
|
|
||||||
|
standard-as-callback@2.1.0:
|
||||||
|
version "2.1.0"
|
||||||
|
resolved "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz"
|
||||||
|
integrity sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==
|
||||||
|
|
||||||
|
statuses@~2.0.1, statuses@~2.0.2:
|
||||||
|
version "2.0.2"
|
||||||
|
resolved "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz"
|
||||||
|
integrity sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==
|
||||||
|
|
||||||
|
streamsearch@^1.1.0:
|
||||||
|
version "1.1.0"
|
||||||
|
resolved "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz"
|
||||||
|
integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==
|
||||||
|
|
||||||
|
string_decoder@~1.1.1:
|
||||||
|
version "1.1.1"
|
||||||
|
resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz"
|
||||||
|
integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==
|
||||||
|
dependencies:
|
||||||
|
safe-buffer "~5.1.0"
|
||||||
|
|
||||||
|
toidentifier@~1.0.1:
|
||||||
|
version "1.0.1"
|
||||||
|
resolved "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz"
|
||||||
|
integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==
|
||||||
|
|
||||||
|
type-is@^1.6.4, type-is@~1.6.18:
|
||||||
|
version "1.6.18"
|
||||||
|
resolved "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz"
|
||||||
|
integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==
|
||||||
|
dependencies:
|
||||||
|
media-typer "0.3.0"
|
||||||
|
mime-types "~2.1.24"
|
||||||
|
|
||||||
|
typedarray@^0.0.6:
|
||||||
|
version "0.0.6"
|
||||||
|
resolved "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz"
|
||||||
|
integrity sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==
|
||||||
|
|
||||||
|
unpipe@~1.0.0:
|
||||||
|
version "1.0.0"
|
||||||
|
resolved "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz"
|
||||||
|
integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==
|
||||||
|
|
||||||
|
util-deprecate@~1.0.1:
|
||||||
|
version "1.0.2"
|
||||||
|
resolved "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz"
|
||||||
|
integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==
|
||||||
|
|
||||||
|
utils-merge@1.0.1:
|
||||||
|
version "1.0.1"
|
||||||
|
resolved "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz"
|
||||||
|
integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==
|
||||||
|
|
||||||
|
uuid@^9.0.1:
|
||||||
|
version "9.0.1"
|
||||||
|
resolved "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz"
|
||||||
|
integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==
|
||||||
|
|
||||||
|
vary@^1, vary@~1.1.2:
|
||||||
|
version "1.1.2"
|
||||||
|
resolved "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz"
|
||||||
|
integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==
|
||||||
|
|
||||||
|
ws@^8.21.1:
|
||||||
|
version "8.21.1"
|
||||||
|
resolved "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz"
|
||||||
|
integrity sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==
|
||||||
|
|
||||||
|
xtend@^4.0.0:
|
||||||
|
version "4.0.2"
|
||||||
|
resolved "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz"
|
||||||
|
integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==
|
||||||
Reference in New Issue
Block a user