feat: v1.5 安全加固与体验优化
- 后端添加 express-rate-limit 限流(登录 10次/分钟,API 200次/分钟) - H5 演示登录:自动获取 demo token,401 时自动重试 - 分类删除前查询关联记录数,提示用户影响范围 - 分类添加支持 8 色选择器,替代随机颜色 - 首页 overview card 改为日均+收入+笔数,避免重复显示支出 - 请求模块 401 处理优化:H5 自动刷新 token 并重试原请求
This commit is contained in:
@@ -24,6 +24,25 @@ onLaunch(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
// #endif
|
// #endif
|
||||||
|
|
||||||
|
// #ifdef H5
|
||||||
|
const token = uni.getStorageSync('xc:token')
|
||||||
|
if (!token) {
|
||||||
|
// 同步等待 token,避免页面首次请求因无 token 而 401
|
||||||
|
request<{ token: string; userId: number }>({
|
||||||
|
url: '/auth/demo-login',
|
||||||
|
method: 'POST'
|
||||||
|
}).then((data) => {
|
||||||
|
uni.setStorageSync('xc:token', data.token)
|
||||||
|
uni.setStorageSync('xc:userId', data.userId)
|
||||||
|
console.log('[Auth] H5 demo login success')
|
||||||
|
}).catch((e) => {
|
||||||
|
console.error('[Auth] H5 demo login failed:', e)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
console.log('[Auth] H5 token exists')
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -33,8 +33,17 @@
|
|||||||
<text class="add-title">添加自定义分类</text>
|
<text class="add-title">添加自定义分类</text>
|
||||||
<view class="add-form">
|
<view class="add-form">
|
||||||
<input class="add-input" v-model="newName" placeholder="分类名称" maxlength="10" />
|
<input class="add-input" v-model="newName" placeholder="分类名称" maxlength="10" />
|
||||||
<view class="add-btn" :class="{ disabled: !newName.trim() }" @tap="onAdd">添加</view>
|
|
||||||
</view>
|
</view>
|
||||||
|
<view class="color-picker">
|
||||||
|
<view
|
||||||
|
v-for="c in colorOptions" :key="c"
|
||||||
|
class="color-dot"
|
||||||
|
:class="{ selected: selectedColor === c }"
|
||||||
|
:style="{ background: c }"
|
||||||
|
@tap="selectedColor = c"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<view class="add-btn" :class="{ disabled: !newName.trim() }" @tap="onAdd">添加</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
@@ -49,6 +58,8 @@ import { statusBarHeight } from '@/utils/system'
|
|||||||
const catStore = useCategoryStore()
|
const catStore = useCategoryStore()
|
||||||
const tabType = ref<'expense' | 'income'>('expense')
|
const tabType = ref<'expense' | 'income'>('expense')
|
||||||
const newName = ref('')
|
const newName = ref('')
|
||||||
|
const colorOptions = ['#FF8C69', '#7BC67E', '#5B9BD5', '#FFD700', '#FF69B4', '#8B5CF6', '#F97316', '#06B6D4']
|
||||||
|
const selectedColor = ref(colorOptions[0])
|
||||||
|
|
||||||
const currentCategories = computed(() => catStore.getByType(tabType.value))
|
const currentCategories = computed(() => catStore.getByType(tabType.value))
|
||||||
|
|
||||||
@@ -58,14 +69,11 @@ async function onAdd() {
|
|||||||
const name = newName.value.trim()
|
const name = newName.value.trim()
|
||||||
if (!name) return
|
if (!name) return
|
||||||
|
|
||||||
const colors = ['#FF8C69', '#7BC67E', '#5B9BD5', '#FFD700', '#FF69B4', '#8B5CF6', '#F97316', '#06B6D4']
|
|
||||||
const color = colors[Math.floor(Math.random() * colors.length)]
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await catStore.addCategory({
|
await catStore.addCategory({
|
||||||
name,
|
name,
|
||||||
icon: name[0],
|
icon: name[0],
|
||||||
color,
|
color: selectedColor.value,
|
||||||
type: tabType.value
|
type: tabType.value
|
||||||
})
|
})
|
||||||
newName.value = ''
|
newName.value = ''
|
||||||
@@ -76,20 +84,44 @@ async function onAdd() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function onDelete(cat: any) {
|
async function onDelete(cat: any) {
|
||||||
uni.showModal({
|
try {
|
||||||
title: '删除分类',
|
// 查询关联记录数
|
||||||
content: `确定删除「${cat.name}」?该分类下的记录将变为"未分类"。`,
|
const { request: req } = await import('@/utils/request')
|
||||||
success: async (res) => {
|
const { count } = await req<{ count: number }>({ url: `/categories/${cat.id}/transaction-count` })
|
||||||
if (res.confirm) {
|
const tip = count > 0
|
||||||
try {
|
? `该分类下有 ${count} 条记录,删除后会变为"未分类"。`
|
||||||
await catStore.deleteCategory(cat.id)
|
: '删除后不可恢复。'
|
||||||
uni.showToast({ title: '已删除', icon: 'success' })
|
uni.showModal({
|
||||||
} catch {
|
title: '删除分类',
|
||||||
uni.showToast({ title: '删除失败', icon: 'none' })
|
content: `确定删除「${cat.name}」?${tip}`,
|
||||||
|
success: async (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
try {
|
||||||
|
await catStore.deleteCategory(cat.id)
|
||||||
|
uni.showToast({ title: '已删除', icon: 'success' })
|
||||||
|
} catch {
|
||||||
|
uni.showToast({ title: '删除失败', icon: 'none' })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
})
|
} catch {
|
||||||
|
// 查询失败时仍允许删除
|
||||||
|
uni.showModal({
|
||||||
|
title: '删除分类',
|
||||||
|
content: `确定删除「${cat.name}」?`,
|
||||||
|
success: async (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
try {
|
||||||
|
await catStore.deleteCategory(cat.id)
|
||||||
|
uni.showToast({ title: '已删除', icon: 'success' })
|
||||||
|
} catch {
|
||||||
|
uni.showToast({ title: '删除失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -198,6 +230,7 @@ async function onDelete(cat: any) {
|
|||||||
.add-form {
|
.add-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 16rpx;
|
gap: 16rpx;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-input {
|
.add-input {
|
||||||
@@ -209,8 +242,29 @@ async function onDelete(cat: any) {
|
|||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.color-picker {
|
||||||
|
display: flex;
|
||||||
|
gap: 20rpx;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-dot {
|
||||||
|
width: 56rpx;
|
||||||
|
height: 56rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 4rpx solid transparent;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
border-color: #2D1B1B;
|
||||||
|
transform: scale(1.15);
|
||||||
|
}
|
||||||
|
&:active { opacity: 0.7; }
|
||||||
|
}
|
||||||
|
|
||||||
.add-btn {
|
.add-btn {
|
||||||
width: 160rpx;
|
width: 100%;
|
||||||
height: 88rpx;
|
height: 88rpx;
|
||||||
background: linear-gradient(135deg, #FF8C69, #E67355);
|
background: linear-gradient(135deg, #FF8C69, #E67355);
|
||||||
border-radius: 20rpx;
|
border-radius: 20rpx;
|
||||||
|
|||||||
@@ -23,15 +23,20 @@
|
|||||||
<BudgetBar v-if="!loading" :total="budget?.amount || DEFAULT_BUDGET" :used="overview.expense" />
|
<BudgetBar v-if="!loading" :total="budget?.amount || DEFAULT_BUDGET" :used="overview.expense" />
|
||||||
<Skeleton v-else width="100%" height="16rpx" variant="text" />
|
<Skeleton v-else width="100%" height="16rpx" variant="text" />
|
||||||
<view class="mini-stats">
|
<view class="mini-stats">
|
||||||
|
<view class="mini-stat">
|
||||||
|
<text class="mini-label">日均</text>
|
||||||
|
<text class="mini-value" v-if="!loading">{{ formatAmount(overview.daily) }}</text>
|
||||||
|
<Skeleton v-else width="120rpx" height="40rpx" variant="rect" />
|
||||||
|
</view>
|
||||||
<view class="mini-stat">
|
<view class="mini-stat">
|
||||||
<text class="mini-label">收入</text>
|
<text class="mini-label">收入</text>
|
||||||
<text class="mini-value income" v-if="!loading">+{{ formatAmount(overview.income) }}</text>
|
<text class="mini-value income" v-if="!loading">+{{ formatAmount(overview.income) }}</text>
|
||||||
<Skeleton v-else width="120rpx" height="40rpx" variant="rect" />
|
<Skeleton v-else width="120rpx" height="40rpx" variant="rect" />
|
||||||
</view>
|
</view>
|
||||||
<view class="mini-stat">
|
<view class="mini-stat">
|
||||||
<text class="mini-label">支出</text>
|
<text class="mini-label">笔数</text>
|
||||||
<text class="mini-value expense" v-if="!loading">-{{ formatAmount(overview.expense) }}</text>
|
<text class="mini-value" v-if="!loading">{{ overview.count }}笔</text>
|
||||||
<Skeleton v-else width="120rpx" height="40rpx" variant="rect" />
|
<Skeleton v-else width="80rpx" height="40rpx" variant="rect" />
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
@@ -33,19 +33,29 @@ export function request<T = any>(options: RequestOptions): Promise<T> {
|
|||||||
uni.removeStorageSync('xc:userId')
|
uni.removeStorageSync('xc:userId')
|
||||||
if (!isRedirectingToLogin) {
|
if (!isRedirectingToLogin) {
|
||||||
isRedirectingToLogin = true
|
isRedirectingToLogin = true
|
||||||
uni.showToast({ title: '请重新登录', icon: 'none' })
|
// H5 环境自动获取 demo token 并重试
|
||||||
setTimeout(() => {
|
if (typeof window !== 'undefined') {
|
||||||
// #ifdef H5
|
request<{ token: string; userId: number }>({
|
||||||
window.location.reload()
|
url: '/auth/demo-login',
|
||||||
// #endif
|
method: 'POST'
|
||||||
// #ifdef MP-WEIXIN
|
}).then((data) => {
|
||||||
// 重新触发 wx.login
|
uni.setStorageSync('xc:token', data.token)
|
||||||
reLogin()
|
uni.setStorageSync('xc:userId', data.userId)
|
||||||
// #endif
|
// 重试原请求
|
||||||
isRedirectingToLogin = false
|
request(options).then(resolve).catch(reject)
|
||||||
}, 1500)
|
}).catch(() => {
|
||||||
|
uni.showToast({ title: '登录失败,请刷新页面', icon: 'none' })
|
||||||
|
reject({ code: 40100, message: '未登录' })
|
||||||
|
}).finally(() => { isRedirectingToLogin = false })
|
||||||
|
} else {
|
||||||
|
// 小程序环境重新触发 wx.login
|
||||||
|
uni.showToast({ title: '请重新登录', icon: 'none' })
|
||||||
|
setTimeout(() => { reLogin(); isRedirectingToLogin = false }, 1500)
|
||||||
|
reject({ code: 40100, message: '未登录' })
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
reject({ code: 40100, message: '未登录' })
|
||||||
}
|
}
|
||||||
reject({ code: 40100, message: '未登录' })
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
28
server/package-lock.json
generated
28
server/package-lock.json
generated
@@ -11,6 +11,7 @@
|
|||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
|
"express-rate-limit": "^8.5.2",
|
||||||
"mysql2": "^3.9.0"
|
"mysql2": "^3.9.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -1042,6 +1043,24 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/express-rate-limit": {
|
||||||
|
"version": "8.5.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz",
|
||||||
|
"integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ip-address": "^10.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/express-rate-limit"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"express": ">= 4.11"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fast-glob": {
|
"node_modules/fast-glob": {
|
||||||
"version": "3.3.3",
|
"version": "3.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
||||||
@@ -1298,6 +1317,15 @@
|
|||||||
"node": ">= 0.10"
|
"node": ">= 0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ip-address": {
|
||||||
|
"version": "10.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||||
|
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ipaddr.js": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "1.9.1",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
|
"express-rate-limit": "^8.5.2",
|
||||||
"mysql2": "^3.9.0"
|
"mysql2": "^3.9.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dotenv/config'
|
import 'dotenv/config'
|
||||||
import express from 'express'
|
import express from 'express'
|
||||||
import cors from 'cors'
|
import cors from 'cors'
|
||||||
|
import rateLimit from 'express-rate-limit'
|
||||||
import { authMiddleware } from './middleware/auth'
|
import { authMiddleware } from './middleware/auth'
|
||||||
import { initDatabase } from './db/init'
|
import { initDatabase } from './db/init'
|
||||||
import pool from './db/connection'
|
import pool from './db/connection'
|
||||||
@@ -32,13 +33,32 @@ app.use(cors({
|
|||||||
credentials: true,
|
credentials: true,
|
||||||
}))
|
}))
|
||||||
app.use(express.json({ limit: '10kb' }))
|
app.use(express.json({ limit: '10kb' }))
|
||||||
|
|
||||||
|
// 限流:登录接口 — 每分钟最多 10 次
|
||||||
|
const authLimiter = rateLimit({
|
||||||
|
windowMs: 60 * 1000,
|
||||||
|
max: 10,
|
||||||
|
message: { code: 42900, message: '请求过于频繁,请稍后再试' },
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 限流:通用 API — 每分钟最多 200 次
|
||||||
|
const apiLimiter = rateLimit({
|
||||||
|
windowMs: 60 * 1000,
|
||||||
|
max: 200,
|
||||||
|
message: { code: 42900, message: '请求过于频繁,请稍后再试' },
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
})
|
||||||
|
|
||||||
app.use(authMiddleware)
|
app.use(authMiddleware)
|
||||||
|
|
||||||
app.use('/api/auth', authRoutes)
|
app.use('/api/auth', authLimiter, authRoutes)
|
||||||
app.use('/api/transactions', transactionRoutes)
|
app.use('/api/transactions', apiLimiter, transactionRoutes)
|
||||||
app.use('/api/stats', statsRoutes)
|
app.use('/api/stats', apiLimiter, statsRoutes)
|
||||||
app.use('/api/budget', budgetRoutes)
|
app.use('/api/budget', apiLimiter, budgetRoutes)
|
||||||
app.use('/api/categories', categoryRoutes)
|
app.use('/api/categories', apiLimiter, categoryRoutes)
|
||||||
|
|
||||||
app.get('/api/health', async (_req, res) => {
|
app.get('/api/health', async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -14,6 +14,23 @@ export function signToken(userId: number): string {
|
|||||||
return Buffer.from(`${payload}:${signature}`).toString('base64')
|
return Buffer.from(`${payload}:${signature}`).toString('base64')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// H5 演示登录 — 创建/复用演示用户
|
||||||
|
router.post('/demo-login', async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const demoOpenid = 'h5_demo_user'
|
||||||
|
const [result] = await pool.query(
|
||||||
|
'INSERT INTO users (openid, session_key) VALUES (?, ?) ON DUPLICATE KEY UPDATE session_key = ?, id = LAST_INSERT_ID(id)',
|
||||||
|
[demoOpenid, 'demo', 'demo']
|
||||||
|
)
|
||||||
|
const userId = (result as any).insertId
|
||||||
|
const token = signToken(userId)
|
||||||
|
res.json({ code: 0, data: { token, userId } })
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[Auth] Demo login failed:', err.message)
|
||||||
|
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
router.post('/login', async (req: Request, res: Response) => {
|
router.post('/login', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { code } = req.body
|
const { code } = req.body
|
||||||
|
|||||||
@@ -38,6 +38,20 @@ router.post('/', async (req: AuthRequest, res: Response) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 查询分类关联的记录数
|
||||||
|
router.get('/:id/transaction-count', async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
'SELECT COUNT(*) as count FROM transactions WHERE category_id = ? AND user_id = ?',
|
||||||
|
[req.params.id, req.userId]
|
||||||
|
)
|
||||||
|
res.json({ code: 0, data: { count: (rows as any[])[0].count } })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Category] count error:', err)
|
||||||
|
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
router.delete('/:id', async (req: AuthRequest, res: Response) => {
|
router.delete('/:id', async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const [result] = await pool.query(
|
const [result] = await pool.query(
|
||||||
|
|||||||
@@ -351,7 +351,14 @@ execa@^1.0.0:
|
|||||||
signal-exit "^3.0.0"
|
signal-exit "^3.0.0"
|
||||||
strip-eof "^1.0.0"
|
strip-eof "^1.0.0"
|
||||||
|
|
||||||
express@^4.18.2:
|
express-rate-limit@^8.5.2:
|
||||||
|
version "8.5.2"
|
||||||
|
resolved "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz"
|
||||||
|
integrity sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==
|
||||||
|
dependencies:
|
||||||
|
ip-address "^10.2.0"
|
||||||
|
|
||||||
|
express@^4.18.2, "express@>= 4.11":
|
||||||
version "4.22.2"
|
version "4.22.2"
|
||||||
resolved "https://registry.npmjs.org/express/-/express-4.22.2.tgz"
|
resolved "https://registry.npmjs.org/express/-/express-4.22.2.tgz"
|
||||||
integrity sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==
|
integrity sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==
|
||||||
@@ -538,6 +545,11 @@ interpret@^1.0.0:
|
|||||||
resolved "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz"
|
resolved "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz"
|
||||||
integrity sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==
|
integrity sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==
|
||||||
|
|
||||||
|
ip-address@^10.2.0:
|
||||||
|
version "10.2.0"
|
||||||
|
resolved "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz"
|
||||||
|
integrity sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==
|
||||||
|
|
||||||
ipaddr.js@1.9.1:
|
ipaddr.js@1.9.1:
|
||||||
version "1.9.1"
|
version "1.9.1"
|
||||||
resolved "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz"
|
resolved "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz"
|
||||||
|
|||||||
Reference in New Issue
Block a user