create-fullstack-scaffold 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.js +1673 -696
- package/dist/cli/index.js.map +1 -1
- package/package.json +16 -10
- package/template/.husky/pre-commit +1 -1
- package/template/modules.config.ts +29 -2
- package/template/package.json +12 -12
- package/template/patches/{typescript+5.8.3.patch → typescript+5.9.3.patch} +2 -4
- package/template/playwright.config.ts +7 -1
- package/template/src/admin/App.tsx +2 -2
- package/template/src/admin/components/CaptchaModal.tsx +7 -9
- package/template/src/admin/layouts/Header.tsx +56 -22
- package/template/src/admin/layouts/Layout.tsx +14 -9
- package/template/src/admin/layouts/Sidebar.tsx +122 -105
- package/template/src/admin/pages/CategoryManagementPage.tsx +241 -0
- package/template/src/admin/pages/ContentPage.tsx +2 -0
- package/template/src/admin/pages/DashboardPage.tsx +2 -0
- package/template/src/admin/pages/DisputesPage.tsx +2 -0
- package/template/src/admin/pages/MediaTestPage.tsx +1 -1
- package/template/src/admin/pages/OrdersPage.tsx +2 -0
- package/template/src/admin/pages/PluginDashboardPage.tsx +297 -0
- package/template/src/admin/pages/PluginManagementPage.tsx +340 -0
- package/template/src/admin/pages/PluginReviewPage.tsx +255 -0
- package/template/src/admin/pages/SystemLogsPage.tsx +11 -2
- package/template/src/admin/pages/TicketsPage.tsx +2 -0
- package/template/src/admin/pages/UsersPage.tsx +3 -2
- package/template/src/admin/stores/adminStore.ts +5 -0
- package/template/src/cli/index.ts +17 -19
- package/template/src/cli/modules/auth/index.ts +65 -0
- package/template/src/cli/modules/config/index.ts +74 -77
- package/template/src/cli/modules/index.ts +28 -6
- package/template/src/cli/modules/notification/index.ts +95 -79
- package/template/src/cli/modules/plugin/index.ts +111 -0
- package/template/src/cli/modules/todo/index.ts +99 -51
- package/template/src/cli/utils/auto-command.ts +7 -25
- package/template/src/cli/utils/index.ts +3 -1
- package/template/src/client/App.tsx +36 -16
- package/template/src/client/Layout.tsx +67 -10
- package/template/src/client/components/AuthButton.tsx +25 -18
- package/template/src/client/components/BottomTabBar.tsx +107 -0
- package/template/src/client/components/Navigation.tsx +162 -52
- package/template/src/client/components/__tests__/App.test.tsx +48 -32
- package/template/src/client/components/__tests__/AuthButton.test.tsx +78 -77
- package/template/src/client/components/__tests__/Navigation.test.tsx +31 -20
- package/template/src/client/components/index.ts +1 -0
- package/template/src/client/contexts/PresetContext.tsx +10 -0
- package/template/src/client/main.tsx +63 -8
- package/template/src/client/pages/CartPage.tsx +244 -0
- package/template/src/client/pages/CategoriesPage.tsx +100 -0
- package/template/src/client/pages/ContentDetailPage.tsx +9 -14
- package/template/src/client/pages/ContentListPage.tsx +4 -11
- package/template/src/client/pages/DashboardPage.tsx +261 -0
- package/template/src/client/pages/DeveloperDashboardPage.tsx +211 -0
- package/template/src/client/pages/LoginPage.tsx +127 -0
- package/template/src/client/pages/OrdersPage.tsx +196 -0
- package/template/src/client/pages/PluginDetailPage.tsx +345 -0
- package/template/src/client/pages/PluginsPage.tsx +223 -0
- package/template/src/client/pages/ProfilePage.tsx +206 -0
- package/template/src/client/pages/PublishPage.tsx +336 -0
- package/template/src/client/pages/RegisterPage.tsx +136 -0
- package/template/src/client/pages/SearchPage.tsx +204 -0
- package/template/src/client/pages/SettingsPage.tsx +220 -0
- package/template/src/client/pages/TopicsPage.tsx +180 -0
- package/template/src/client/pages/__tests__/LoginPage.test.tsx +170 -0
- package/template/src/client/pages/__tests__/RegisterPage.test.tsx +168 -0
- package/template/src/client/preset-ui-config.ts +492 -0
- package/template/src/client/services/apiClient.ts +14 -4
- package/template/src/client/stores/__tests__/authStore.test.ts +293 -38
- package/template/src/client/stores/__tests__/todoStore.test.ts +7 -17
- package/template/src/client/stores/authStore.ts +58 -5
- package/template/src/client/stores/chatWSStore.ts +9 -0
- package/template/src/client/stores/notificationStore.ts +4 -0
- package/template/src/client/stores/pluginStore.ts +279 -0
- package/template/src/client/stores/todoStore.ts +2 -6
- package/template/src/server/core/__tests__/isr-cache.test.ts +117 -0
- package/template/src/server/core/__tests__/isr-invalidation.test.ts +72 -0
- package/template/src/server/core/__tests__/ssr-renderer.test.ts +89 -0
- package/template/src/server/core/isr-cache.ts +239 -0
- package/template/src/server/core/isr-invalidation.ts +45 -0
- package/template/src/server/core/module-loader.ts +14 -7
- package/template/src/server/core/ssr-renderer.ts +240 -0
- package/template/src/server/db/init.ts +257 -10
- package/template/src/server/db/schema/developers.ts +20 -0
- package/template/src/server/db/schema/index.ts +2 -0
- package/template/src/server/db/schema/plugins.ts +114 -0
- package/template/src/server/db/test-setup.ts +91 -0
- package/template/src/server/entries/cloudflare.ts +79 -7
- package/template/src/server/entries/node.ts +48 -5
- package/template/src/server/middleware/__tests__/captcha.test.ts +23 -13
- package/template/src/server/middleware/auth.ts +8 -1
- package/template/src/server/middleware/captcha.ts +14 -4
- package/template/src/server/middleware/rate-limit.ts +7 -2
- package/template/src/server/module-admin/module.ts +9 -5
- package/template/src/server/module-admin/routes/admin-notification-routes.ts +43 -14
- package/template/src/server/module-admin/routes/admin-routes.ts +0 -2
- package/template/src/server/module-admin/routes/client-auth-routes.ts +90 -0
- package/template/src/server/module-admin/routes/dashboard-routes.ts +79 -0
- package/template/src/server/module-admin/services/admin-service.ts +68 -11
- package/template/src/server/module-auth/__tests__/auth-service.test.ts +239 -0
- package/template/src/server/module-auth/index.ts +7 -0
- package/template/src/server/module-auth/module.ts +40 -0
- package/template/src/server/module-auth/routes/auth-routes.ts +94 -0
- package/template/src/server/module-auth/routes/profile-routes.ts +31 -0
- package/template/src/server/module-auth/services/auth-service.ts +100 -0
- package/template/src/server/module-content/module.ts +10 -4
- package/template/src/server/module-content/routes/public-content-routes.ts +2 -2
- package/template/src/server/module-content/routes/topics-routes.ts +205 -0
- package/template/src/server/module-content/services/content-service.ts +18 -2
- package/template/src/server/module-dispute/routes/dispute-routes.ts +2 -1
- package/template/src/server/module-dispute/services/dispute-service.ts +1 -1
- package/template/src/server/module-notifications/__tests__/sse-rpc.test.ts +14 -14
- package/template/src/server/module-notifications/routes/notification-routes.ts +34 -8
- package/template/src/server/module-order/__tests__/order-route.test.ts +22 -2
- package/template/src/server/module-order/module.ts +15 -0
- package/template/src/server/module-order/routes/cart-routes.ts +103 -0
- package/template/src/server/module-order/routes/orders-mock-routes.ts +67 -0
- package/template/src/server/module-order/services/order-service.ts +1 -1
- package/template/src/server/module-plugin/__tests__/plugin-query-service.test.ts +203 -0
- package/template/src/server/module-plugin/__tests__/plugin-service.test.ts +234 -0
- package/template/src/server/module-plugin/index.ts +2 -0
- package/template/src/server/module-plugin/module.ts +52 -0
- package/template/src/server/module-plugin/routes/plugin-admin-routes.ts +261 -0
- package/template/src/server/module-plugin/routes/plugin-routes.ts +354 -0
- package/template/src/server/module-plugin/services/admin-category-service.ts +99 -0
- package/template/src/server/module-plugin/services/admin-plugin-service.ts +170 -0
- package/template/src/server/module-plugin/services/admin-stats-service.ts +41 -0
- package/template/src/server/module-plugin/services/plugin-query-service.ts +360 -0
- package/template/src/server/module-plugin/services/plugin-review-service.ts +95 -0
- package/template/src/server/module-plugin/services/plugin-service.ts +163 -0
- package/template/src/server/module-ticket/services/ticket-service.ts +1 -1
- package/template/src/server/module-todos/routes/todos-routes.ts +0 -4
- package/template/src/server/route-registry.ts +16 -1
- package/template/src/server/test-utils/test-client.ts +1 -2
- package/template/src/server/utils/auth.ts +6 -0
- package/template/src/server/utils/json.ts +13 -0
- package/template/src/shared/core/module-manifest.ts +3 -6
- package/template/src/shared/modules/auth/index.ts +12 -0
- package/template/src/shared/modules/auth/schemas.ts +50 -0
- package/template/src/shared/modules/cart/index.ts +1 -0
- package/template/src/shared/modules/cart/schemas.ts +41 -0
- package/template/src/shared/modules/community/index.ts +1 -0
- package/template/src/shared/modules/community/schemas.ts +58 -0
- package/template/src/shared/modules/dashboard/index.ts +1 -0
- package/template/src/shared/modules/dashboard/schemas.ts +35 -0
- package/template/src/shared/modules/index.ts +41 -0
- package/template/src/shared/modules/order/schemas.ts +29 -0
- package/template/src/shared/modules/plugins/index.ts +48 -0
- package/template/src/shared/modules/plugins/schemas.ts +227 -0
- package/template/src/shared/schemas/index.ts +130 -0
- package/template/tests/e2e/todo.spec.ts +23 -18
- package/template/tests/e2e/visual-screenshots.spec.ts +1824 -0
- package/template/uploads/.gitkeep +0 -0
- package/template/vite.config.ts +2 -1
- package/template/vitest.setup.ts +11 -0
- package/template/wrangler.toml +4 -3
- package/template/package-lock.json +0 -14554
|
@@ -16,6 +16,9 @@ import { getAppConfig } from '../config'
|
|
|
16
16
|
import { logger } from '../utils/logger'
|
|
17
17
|
import { createApp } from '../app'
|
|
18
18
|
import { getDb, runMigrations } from '../db'
|
|
19
|
+
import { createISRCache, isISRRoute } from '@server/core/isr-cache'
|
|
20
|
+
import { renderPage } from '@server/core/ssr-renderer'
|
|
21
|
+
import { setISRCache } from '@server/core/isr-invalidation'
|
|
19
22
|
import { setRuntimeAdapter } from '@server/core/runtime'
|
|
20
23
|
import { getNodeRuntimeAdapter } from '@server/core/runtime-node'
|
|
21
24
|
|
|
@@ -41,9 +44,16 @@ const adminHtml = existsSync(adminHtmlPath)
|
|
|
41
44
|
|
|
42
45
|
const log = logger.api()
|
|
43
46
|
|
|
47
|
+
const isrCache = createISRCache()
|
|
48
|
+
setISRCache(isrCache)
|
|
49
|
+
|
|
44
50
|
const runtimeAdapter = getNodeRuntimeAdapter()
|
|
45
51
|
setRuntimeAdapter(runtimeAdapter)
|
|
46
52
|
|
|
53
|
+
runtimeAdapter.handleWS('/api/chat/ws')
|
|
54
|
+
runtimeAdapter.handleSSE('/api/notifications/stream')
|
|
55
|
+
runtimeAdapter.handleSSE('/api/admin/notifications/stream')
|
|
56
|
+
|
|
47
57
|
// 先创建基础应用
|
|
48
58
|
const baseApp = createApp()
|
|
49
59
|
|
|
@@ -104,12 +114,41 @@ app.get('/admin/*', c => {
|
|
|
104
114
|
return c.html(adminHtml)
|
|
105
115
|
})
|
|
106
116
|
|
|
107
|
-
// 其他非 API 路由返回 index.html
|
|
108
|
-
app.get('*', c => {
|
|
109
|
-
// 如果路径以 /api/ 或 /files/ 开头,让 Hono 继续处理(可能会返回 404)
|
|
117
|
+
// 其他非 API 路由返回 index.html(ISR 路由尝试缓存)
|
|
118
|
+
app.get('*', async c => {
|
|
110
119
|
if (c.req.path.startsWith('/api/') || c.req.path.startsWith('/files/')) {
|
|
111
120
|
return c.notFound()
|
|
112
121
|
}
|
|
122
|
+
|
|
123
|
+
const pathname = c.req.path
|
|
124
|
+
|
|
125
|
+
if (hasDist && isISRRoute(pathname)) {
|
|
126
|
+
const result = await isrCache.lookup(pathname)
|
|
127
|
+
|
|
128
|
+
if (result.status === 'fresh' && result.html) {
|
|
129
|
+
return c.html(result.html)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (result.status === 'stale' && result.html) {
|
|
133
|
+
renderPage(pathname)
|
|
134
|
+
.then(rendered => {
|
|
135
|
+
isrCache.store(pathname, rendered.html).catch(e => {
|
|
136
|
+
console.warn('ISR cache store (background revalidation) failed:', e)
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
.catch(e => {
|
|
140
|
+
console.warn('ISR background render failed:', e)
|
|
141
|
+
})
|
|
142
|
+
return c.html(result.html)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const rendered = await renderPage(pathname)
|
|
146
|
+
isrCache.store(pathname, rendered.html).catch(e => {
|
|
147
|
+
console.warn('ISR cache store failed:', e)
|
|
148
|
+
})
|
|
149
|
+
return c.html(rendered.html)
|
|
150
|
+
}
|
|
151
|
+
|
|
113
152
|
return c.html(indexHtml)
|
|
114
153
|
})
|
|
115
154
|
|
|
@@ -118,10 +157,14 @@ app.get('*', c => {
|
|
|
118
157
|
app.onError((err, c) => {
|
|
119
158
|
log.error({ err, path: c.req.path }, 'server error')
|
|
120
159
|
c.res.headers.set('Content-Type', 'application/json')
|
|
121
|
-
const statusCode =
|
|
160
|
+
const statusCode =
|
|
161
|
+
err instanceof Error && 'status' in err ? (err as { status: number }).status : 500
|
|
122
162
|
const message = err.message || 'Internal server error'
|
|
123
163
|
const responseStatus = statusCode || 500
|
|
124
|
-
return c.json(
|
|
164
|
+
return c.json(
|
|
165
|
+
{ success: false as const, error: message, status: responseStatus },
|
|
166
|
+
responseStatus as 500
|
|
167
|
+
)
|
|
125
168
|
})
|
|
126
169
|
|
|
127
170
|
export default app
|
|
@@ -8,9 +8,7 @@ import {
|
|
|
8
8
|
} from '../captcha'
|
|
9
9
|
|
|
10
10
|
describe('captchaMiddleware', () => {
|
|
11
|
-
beforeEach(() => {
|
|
12
|
-
vi.stubEnv('NODE_ENV', 'development')
|
|
13
|
-
})
|
|
11
|
+
beforeEach(() => {})
|
|
14
12
|
|
|
15
13
|
afterEach(() => {
|
|
16
14
|
vi.unstubAllEnvs()
|
|
@@ -28,10 +26,22 @@ describe('captchaMiddleware', () => {
|
|
|
28
26
|
expect(handler).toHaveBeenCalled()
|
|
29
27
|
})
|
|
30
28
|
|
|
31
|
-
it('should skip
|
|
29
|
+
it('should skip in development environment', async () => {
|
|
30
|
+
vi.stubEnv('NODE_ENV', 'development')
|
|
32
31
|
const app = new Hono()
|
|
33
32
|
const handler = vi.fn().mockResolvedValue(new Response('ok'))
|
|
34
33
|
app.use('*', captchaMiddleware())
|
|
34
|
+
app.get('/api/dev', handler)
|
|
35
|
+
|
|
36
|
+
const res = await app.request('/api/dev')
|
|
37
|
+
expect(res.status).toBe(200)
|
|
38
|
+
expect(handler).toHaveBeenCalled()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('should skip configured skip paths', async () => {
|
|
42
|
+
const app = new Hono()
|
|
43
|
+
const handler = vi.fn().mockResolvedValue(new Response('ok'))
|
|
44
|
+
app.use('*', captchaMiddleware({ forceEnabled: true }))
|
|
35
45
|
app.get('/api/captcha', handler)
|
|
36
46
|
app.post('/api/verify-captcha', handler)
|
|
37
47
|
app.post('/api/admin/login', handler)
|
|
@@ -54,7 +64,7 @@ describe('captchaMiddleware', () => {
|
|
|
54
64
|
it('should skip custom skip paths', async () => {
|
|
55
65
|
const app = new Hono()
|
|
56
66
|
const handler = vi.fn().mockResolvedValue(new Response('ok'))
|
|
57
|
-
app.use('*', captchaMiddleware({ skipPaths: ['/api/custom'] }))
|
|
67
|
+
app.use('*', captchaMiddleware({ forceEnabled: true, skipPaths: ['/api/custom'] }))
|
|
58
68
|
app.get('/api/custom', handler)
|
|
59
69
|
|
|
60
70
|
const res = await app.request('/api/custom')
|
|
@@ -65,7 +75,7 @@ describe('captchaMiddleware', () => {
|
|
|
65
75
|
it('should allow requests within rate limit', async () => {
|
|
66
76
|
const app = new Hono()
|
|
67
77
|
let callCount = 0
|
|
68
|
-
app.use('*', captchaMiddleware({ maxRequests: 10, windowMs: 60000 }))
|
|
78
|
+
app.use('*', captchaMiddleware({ forceEnabled: true, maxRequests: 10, windowMs: 60000 }))
|
|
69
79
|
app.get('/api/data', c => {
|
|
70
80
|
callCount++
|
|
71
81
|
return c.json({ ok: true })
|
|
@@ -82,7 +92,7 @@ describe('captchaMiddleware', () => {
|
|
|
82
92
|
|
|
83
93
|
it('should block requests exceeding rate limit', async () => {
|
|
84
94
|
const app = new Hono()
|
|
85
|
-
app.use('*', captchaMiddleware({ maxRequests: 3, windowMs: 60000 }))
|
|
95
|
+
app.use('*', captchaMiddleware({ forceEnabled: true, maxRequests: 3, windowMs: 60000 }))
|
|
86
96
|
app.get('/api/data', c => c.json({ ok: true }))
|
|
87
97
|
|
|
88
98
|
const ua = 'Mozilla/5.0 Test Browser For Rate Limit Testing'
|
|
@@ -109,7 +119,7 @@ describe('captchaMiddleware', () => {
|
|
|
109
119
|
|
|
110
120
|
it('should block suspicious requests with short User-Agent', async () => {
|
|
111
121
|
const app = new Hono()
|
|
112
|
-
app.use('*', captchaMiddleware())
|
|
122
|
+
app.use('*', captchaMiddleware({ forceEnabled: true }))
|
|
113
123
|
app.get('/api/data', c => c.json({ ok: true }))
|
|
114
124
|
|
|
115
125
|
const res = await app.request('/api/data', {
|
|
@@ -122,7 +132,7 @@ describe('captchaMiddleware', () => {
|
|
|
122
132
|
|
|
123
133
|
it('should block requests with bot in User-Agent', async () => {
|
|
124
134
|
const app = new Hono()
|
|
125
|
-
app.use('*', captchaMiddleware())
|
|
135
|
+
app.use('*', captchaMiddleware({ forceEnabled: true }))
|
|
126
136
|
app.get('/api/data', c => c.json({ ok: true }))
|
|
127
137
|
|
|
128
138
|
const res = await app.request('/api/data', {
|
|
@@ -133,7 +143,7 @@ describe('captchaMiddleware', () => {
|
|
|
133
143
|
|
|
134
144
|
it('should block requests with crawler in User-Agent', async () => {
|
|
135
145
|
const app = new Hono()
|
|
136
|
-
app.use('*', captchaMiddleware())
|
|
146
|
+
app.use('*', captchaMiddleware({ forceEnabled: true }))
|
|
137
147
|
app.get('/api/data', c => c.json({ ok: true }))
|
|
138
148
|
|
|
139
149
|
const res = await app.request('/api/data', {
|
|
@@ -144,7 +154,7 @@ describe('captchaMiddleware', () => {
|
|
|
144
154
|
|
|
145
155
|
it('should block requests with empty User-Agent', async () => {
|
|
146
156
|
const app = new Hono()
|
|
147
|
-
app.use('*', captchaMiddleware())
|
|
157
|
+
app.use('*', captchaMiddleware({ forceEnabled: true }))
|
|
148
158
|
app.get('/api/data', c => c.json({ ok: true }))
|
|
149
159
|
|
|
150
160
|
const res = await app.request('/api/data')
|
|
@@ -154,7 +164,7 @@ describe('captchaMiddleware', () => {
|
|
|
154
164
|
it('should reset rate limit after window expires', async () => {
|
|
155
165
|
vi.useFakeTimers()
|
|
156
166
|
const app = new Hono()
|
|
157
|
-
app.use('*', captchaMiddleware({ maxRequests: 2, windowMs: 1000 }))
|
|
167
|
+
app.use('*', captchaMiddleware({ forceEnabled: true, maxRequests: 2, windowMs: 1000 }))
|
|
158
168
|
app.get('/api/data', c => c.json({ ok: true }))
|
|
159
169
|
|
|
160
170
|
const ua = 'Mozilla/5.0 Window Reset Test Browser'
|
|
@@ -191,7 +201,7 @@ describe('captchaMiddleware', () => {
|
|
|
191
201
|
markCaptchaVerifiedMiddleware(sessionId)
|
|
192
202
|
|
|
193
203
|
const app = new Hono()
|
|
194
|
-
app.use('*', captchaMiddleware({ maxRequests: 1, windowMs: 60000 }))
|
|
204
|
+
app.use('*', captchaMiddleware({ forceEnabled: true, maxRequests: 1, windowMs: 60000 }))
|
|
195
205
|
app.get('/api/data', c => c.json({ ok: true }))
|
|
196
206
|
|
|
197
207
|
const res = await app.request('/api/data', {
|
|
@@ -40,7 +40,14 @@ if (secretKey === defaultSecretKey && process.env.NODE_ENV === 'production') {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
const isDevTokensEnabled = (): boolean => {
|
|
43
|
-
|
|
43
|
+
// 显式启用 dev tokens(优先级最高,即使 production 也能用)
|
|
44
|
+
if (process.env.ENABLE_DEV_TOKENS === 'true') return true
|
|
45
|
+
// 显式禁用
|
|
46
|
+
if (process.env.ENABLE_DEV_TOKENS === 'false') return false
|
|
47
|
+
// production 默认禁用
|
|
48
|
+
if (process.env.NODE_ENV === 'production') return false
|
|
49
|
+
// 开发环境默认启用
|
|
50
|
+
return true
|
|
44
51
|
}
|
|
45
52
|
|
|
46
53
|
if (isDevTokensEnabled()) {
|
|
@@ -6,6 +6,7 @@ export interface CaptchaConfig {
|
|
|
6
6
|
skipPaths?: string[]
|
|
7
7
|
maxRequests?: number
|
|
8
8
|
windowMs?: number
|
|
9
|
+
forceEnabled?: boolean
|
|
9
10
|
}
|
|
10
11
|
|
|
11
12
|
interface CaptchaSession {
|
|
@@ -40,10 +41,12 @@ export function captchaMiddleware(config: CaptchaConfig = {}) {
|
|
|
40
41
|
skipPaths = ['/api/captcha', '/api/verify-captcha', '/api/admin/login', '/api/admin/register'],
|
|
41
42
|
maxRequests = 10,
|
|
42
43
|
windowMs = 60000,
|
|
44
|
+
forceEnabled = false,
|
|
43
45
|
} = config
|
|
44
46
|
|
|
45
47
|
return async (c: Context, next: Next) => {
|
|
46
|
-
|
|
48
|
+
const skipEnvCheck = !forceEnabled && process.env.NODE_ENV === 'test'
|
|
49
|
+
if (skipEnvCheck) {
|
|
47
50
|
return next()
|
|
48
51
|
}
|
|
49
52
|
|
|
@@ -97,7 +100,7 @@ export function captchaMiddleware(config: CaptchaConfig = {}) {
|
|
|
97
100
|
)
|
|
98
101
|
}
|
|
99
102
|
|
|
100
|
-
if (isSuspiciousRequest(c)) {
|
|
103
|
+
if (isSuspiciousRequest(c, forceEnabled)) {
|
|
101
104
|
return c.json(
|
|
102
105
|
{
|
|
103
106
|
success: false as const,
|
|
@@ -136,14 +139,21 @@ function generateSessionId(): string {
|
|
|
136
139
|
return randomUUID()
|
|
137
140
|
}
|
|
138
141
|
|
|
139
|
-
function isSuspiciousRequest(c: Context): boolean {
|
|
142
|
+
function isSuspiciousRequest(c: Context, forceEnabled = false): boolean {
|
|
143
|
+
if (
|
|
144
|
+
!forceEnabled &&
|
|
145
|
+
(process.env.NODE_ENV === 'test' || process.env.NODE_ENV === 'development')
|
|
146
|
+
) {
|
|
147
|
+
return false
|
|
148
|
+
}
|
|
149
|
+
|
|
140
150
|
const userAgent = c.req.header('User-Agent') || ''
|
|
141
151
|
|
|
142
152
|
if (!userAgent || userAgent.length < 10) {
|
|
143
153
|
return true
|
|
144
154
|
}
|
|
145
155
|
|
|
146
|
-
if (userAgent.includes('bot') || userAgent.includes('crawler')) {
|
|
156
|
+
if (userAgent.toLowerCase().includes('bot') || userAgent.toLowerCase().includes('crawler')) {
|
|
147
157
|
return true
|
|
148
158
|
}
|
|
149
159
|
|
|
@@ -4,14 +4,18 @@ type RateLimitEntry = { count: number; resetAt: number }
|
|
|
4
4
|
|
|
5
5
|
const rateLimitStore = new Map<string, RateLimitEntry>()
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
let lastCleanup = Date.now()
|
|
8
|
+
|
|
9
|
+
function cleanupExpired() {
|
|
8
10
|
const now = Date.now()
|
|
11
|
+
if (now - lastCleanup < 60_000) return
|
|
12
|
+
lastCleanup = now
|
|
9
13
|
for (const [key, entry] of rateLimitStore) {
|
|
10
14
|
if (entry.resetAt <= now) {
|
|
11
15
|
rateLimitStore.delete(key)
|
|
12
16
|
}
|
|
13
17
|
}
|
|
14
|
-
}
|
|
18
|
+
}
|
|
15
19
|
|
|
16
20
|
export type RateLimitOptions = {
|
|
17
21
|
windowMs?: number
|
|
@@ -29,6 +33,7 @@ export function rateLimitMiddleware(options: RateLimitOptions = {}) {
|
|
|
29
33
|
return next()
|
|
30
34
|
}
|
|
31
35
|
|
|
36
|
+
cleanupExpired()
|
|
32
37
|
const ip =
|
|
33
38
|
c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ||
|
|
34
39
|
c.req.header('x-real-ip') ||
|
|
@@ -8,21 +8,27 @@ const adminManifest: ModuleManifest = {
|
|
|
8
8
|
dependsOn: ['permission', 'notifications'],
|
|
9
9
|
|
|
10
10
|
routes: {
|
|
11
|
+
client: {
|
|
12
|
+
importPath: './routes/client-auth-routes',
|
|
13
|
+
exportName: 'clientAuthRoutes',
|
|
14
|
+
},
|
|
11
15
|
admin: [
|
|
12
16
|
{
|
|
13
17
|
importPath: './routes/admin-routes',
|
|
14
18
|
exportName: 'adminRoutes',
|
|
15
19
|
},
|
|
16
20
|
],
|
|
17
|
-
// Note: admin-routes.ts is an aggregator that composes sub-routes:
|
|
18
|
-
// auth-routes (login/register/me), user-management-routes, admin-notification-routes,
|
|
19
|
-
// media-routes (avatar/svg), export-routes (CSV), system-routes (stats/health/activity)
|
|
20
21
|
},
|
|
21
22
|
|
|
22
23
|
sharedSchemas: {
|
|
23
24
|
path: 'admin',
|
|
24
25
|
},
|
|
25
26
|
|
|
27
|
+
clientPages: [
|
|
28
|
+
{ name: 'DashboardPage', route: '/dashboard' },
|
|
29
|
+
{ name: 'SettingsPage', route: '/settings' },
|
|
30
|
+
],
|
|
31
|
+
|
|
26
32
|
adminPages: [
|
|
27
33
|
{ name: 'LoginPage', route: '/login', isPublic: true },
|
|
28
34
|
{ name: 'RegisterPage', route: '/register', isPublic: true },
|
|
@@ -32,8 +38,6 @@ const adminManifest: ModuleManifest = {
|
|
|
32
38
|
{ name: 'MediaTestPage', route: '/test/media' },
|
|
33
39
|
],
|
|
34
40
|
|
|
35
|
-
clientStores: ['authStore'],
|
|
36
|
-
|
|
37
41
|
hasSSE: true,
|
|
38
42
|
}
|
|
39
43
|
|
|
@@ -17,6 +17,30 @@ import {
|
|
|
17
17
|
} from '@shared/modules/notifications'
|
|
18
18
|
import { SuccessSchema } from '@shared/modules/admin'
|
|
19
19
|
|
|
20
|
+
function createFallbackSSEResponse(): Response {
|
|
21
|
+
const stream = new ReadableStream({
|
|
22
|
+
start(controller) {
|
|
23
|
+
const encoder = new TextEncoder()
|
|
24
|
+
const sendPing = () => {
|
|
25
|
+
controller.enqueue(encoder.encode(`event: ping\ndata: {"timestamp":${Date.now()}}\n\n`))
|
|
26
|
+
}
|
|
27
|
+
sendPing()
|
|
28
|
+
const interval = setInterval(sendPing, 30000)
|
|
29
|
+
setTimeout(() => {
|
|
30
|
+
clearInterval(interval)
|
|
31
|
+
controller.close()
|
|
32
|
+
}, 300000)
|
|
33
|
+
},
|
|
34
|
+
})
|
|
35
|
+
return new Response(stream, {
|
|
36
|
+
headers: {
|
|
37
|
+
'Content-Type': 'text/event-stream',
|
|
38
|
+
'Cache-Control': 'no-cache',
|
|
39
|
+
Connection: 'keep-alive',
|
|
40
|
+
},
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
20
44
|
const getNotificationsRoute = createRoute({
|
|
21
45
|
method: 'get',
|
|
22
46
|
path: '/admin/notifications',
|
|
@@ -160,18 +184,18 @@ export const adminNotificationRoutes = new OpenAPIHono<{ Variables: { authUser:
|
|
|
160
184
|
type === 'warning'
|
|
161
185
|
? '警告通知'
|
|
162
186
|
: type === 'error'
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
187
|
+
? '错误通知'
|
|
188
|
+
: type === 'success'
|
|
189
|
+
? '成功通知'
|
|
190
|
+
: '系统通知',
|
|
167
191
|
message:
|
|
168
192
|
type === 'warning'
|
|
169
193
|
? '这是一条警告通知,请注意!'
|
|
170
194
|
: type === 'error'
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
195
|
+
? '这是一条错误通知,请立即处理!'
|
|
196
|
+
: type === 'success'
|
|
197
|
+
? '操作成功完成!'
|
|
198
|
+
: '这是一条普通信息通知',
|
|
175
199
|
})
|
|
176
200
|
return c.json(success(notification), 200)
|
|
177
201
|
})
|
|
@@ -187,11 +211,16 @@ export const adminNotificationRoutes = new OpenAPIHono<{ Variables: { authUser:
|
|
|
187
211
|
return stub.fetch(doRequest)
|
|
188
212
|
}
|
|
189
213
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
214
|
+
try {
|
|
215
|
+
const { getRuntimeAdapter } = await import('@server/core/runtime')
|
|
216
|
+
const adapter = getRuntimeAdapter()
|
|
217
|
+
if (adapter.handleSSERequest) {
|
|
218
|
+
const response = await adapter.handleSSERequest()
|
|
219
|
+
return response
|
|
220
|
+
}
|
|
221
|
+
} catch {
|
|
222
|
+
return createFallbackSSEResponse()
|
|
195
223
|
}
|
|
196
|
-
|
|
224
|
+
|
|
225
|
+
return createFallbackSSEResponse()
|
|
197
226
|
})
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { createRoute } from '@hono/zod-openapi'
|
|
2
|
+
import { OpenAPIHono } from '@hono/zod-openapi'
|
|
3
|
+
import { authMiddleware, type AuthUser } from '@server/middleware/auth'
|
|
4
|
+
import { strictRateLimitMiddleware } from '@server/middleware/rate-limit'
|
|
5
|
+
import { getAuthUser } from '@server/utils/auth'
|
|
6
|
+
import * as adminService from '../services/admin-service'
|
|
7
|
+
import { successResponse, errorResponse, success } from '@server/utils/route-helpers'
|
|
8
|
+
import {
|
|
9
|
+
AuthUserSchema,
|
|
10
|
+
LoginRequestSchema,
|
|
11
|
+
LoginResponseSchema,
|
|
12
|
+
RegisterRequestSchema,
|
|
13
|
+
UserSchema,
|
|
14
|
+
} from '@shared/modules/admin'
|
|
15
|
+
|
|
16
|
+
const loginRoute = createRoute({
|
|
17
|
+
method: 'post',
|
|
18
|
+
path: '/auth/login',
|
|
19
|
+
tags: ['auth'],
|
|
20
|
+
middleware: [strictRateLimitMiddleware] as const,
|
|
21
|
+
request: {
|
|
22
|
+
body: {
|
|
23
|
+
content: {
|
|
24
|
+
'application/json': {
|
|
25
|
+
schema: LoginRequestSchema,
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
responses: {
|
|
31
|
+
200: successResponse(LoginResponseSchema, 'Login successful'),
|
|
32
|
+
401: errorResponse('Invalid credentials'),
|
|
33
|
+
},
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const registerRoute = createRoute({
|
|
37
|
+
method: 'post',
|
|
38
|
+
path: '/auth/register',
|
|
39
|
+
tags: ['auth'],
|
|
40
|
+
middleware: [strictRateLimitMiddleware] as const,
|
|
41
|
+
request: {
|
|
42
|
+
body: {
|
|
43
|
+
content: {
|
|
44
|
+
'application/json': {
|
|
45
|
+
schema: RegisterRequestSchema,
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
responses: {
|
|
51
|
+
201: successResponse(UserSchema, 'User registered'),
|
|
52
|
+
400: errorResponse('User already exists'),
|
|
53
|
+
},
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
const meRoute = createRoute({
|
|
57
|
+
method: 'get',
|
|
58
|
+
path: '/auth/me',
|
|
59
|
+
tags: ['auth'],
|
|
60
|
+
security: [{ Bearer: [] }],
|
|
61
|
+
middleware: [authMiddleware()],
|
|
62
|
+
responses: {
|
|
63
|
+
200: successResponse(AuthUserSchema, 'Get current authenticated user'),
|
|
64
|
+
401: errorResponse('Unauthorized'),
|
|
65
|
+
},
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
export const clientAuthRoutes = new OpenAPIHono<{ Variables: { authUser: AuthUser } }>()
|
|
69
|
+
.openapi(loginRoute, async c => {
|
|
70
|
+
try {
|
|
71
|
+
const data = c.req.valid('json')
|
|
72
|
+
const result = await adminService.login(data)
|
|
73
|
+
return c.json(success(result), 200)
|
|
74
|
+
} catch {
|
|
75
|
+
return c.json({ success: false as const, error: 'Invalid credentials' }, 401)
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
.openapi(registerRoute, async c => {
|
|
79
|
+
try {
|
|
80
|
+
const data = c.req.valid('json')
|
|
81
|
+
const user = await adminService.register(data)
|
|
82
|
+
return c.json(success(user), 201)
|
|
83
|
+
} catch {
|
|
84
|
+
return c.json({ success: false as const, error: 'User already exists' }, 400)
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
.openapi(meRoute, async c => {
|
|
88
|
+
const user = getAuthUser(c)
|
|
89
|
+
return c.json(success(user), 200)
|
|
90
|
+
})
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { createRoute } from '@hono/zod-openapi'
|
|
2
|
+
import { OpenAPIHono } from '@hono/zod-openapi'
|
|
3
|
+
import { successResponse } from '@server/utils/route-helpers'
|
|
4
|
+
import { DashboardResponseSchema } from '@shared/schemas'
|
|
5
|
+
|
|
6
|
+
const getDashboardStatsRoute = createRoute({
|
|
7
|
+
method: 'get',
|
|
8
|
+
path: '/admin/dashboard/stats',
|
|
9
|
+
responses: {
|
|
10
|
+
200: successResponse(DashboardResponseSchema, 'Dashboard stats'),
|
|
11
|
+
},
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
export const dashboardRoutes = new OpenAPIHono().openapi(getDashboardStatsRoute, async c => {
|
|
15
|
+
return c.json({
|
|
16
|
+
success: true as const,
|
|
17
|
+
data: {
|
|
18
|
+
stats: [
|
|
19
|
+
{ label: 'Total Users', value: '12,847', trend: 12.5 },
|
|
20
|
+
{ label: 'Active Users', value: '8,234', trend: 8.2 },
|
|
21
|
+
{ label: 'Revenue', value: '$48,293', trend: -2.4 },
|
|
22
|
+
{ label: 'Conversion', value: '3.2%', trend: 4.1 },
|
|
23
|
+
],
|
|
24
|
+
revenue: [
|
|
25
|
+
{ month: 'Jan', value: 65 },
|
|
26
|
+
{ month: 'Feb', value: 45 },
|
|
27
|
+
{ month: 'Mar', value: 78 },
|
|
28
|
+
{ month: 'Apr', value: 52 },
|
|
29
|
+
{ month: 'May', value: 90 },
|
|
30
|
+
{ month: 'Jun', value: 70 },
|
|
31
|
+
],
|
|
32
|
+
userGrowth: [
|
|
33
|
+
{ month: 'Jan', value: 30 },
|
|
34
|
+
{ month: 'Feb', value: 45 },
|
|
35
|
+
{ month: 'Mar', value: 55 },
|
|
36
|
+
{ month: 'Apr', value: 60 },
|
|
37
|
+
{ month: 'May', value: 72 },
|
|
38
|
+
{ month: 'Jun', value: 85 },
|
|
39
|
+
],
|
|
40
|
+
activity: [
|
|
41
|
+
{
|
|
42
|
+
id: 1,
|
|
43
|
+
user: 'Sarah Chen',
|
|
44
|
+
action: 'Upgraded to Pro plan',
|
|
45
|
+
date: '2024-01-15',
|
|
46
|
+
status: 'Active' as const,
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
id: 2,
|
|
50
|
+
user: 'Mike Johnson',
|
|
51
|
+
action: 'Submitted support ticket',
|
|
52
|
+
date: '2024-01-14',
|
|
53
|
+
status: 'Pending' as const,
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: 3,
|
|
57
|
+
user: 'Emily Davis',
|
|
58
|
+
action: 'Cancelled subscription',
|
|
59
|
+
date: '2024-01-13',
|
|
60
|
+
status: 'Inactive' as const,
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: 4,
|
|
64
|
+
user: 'Alex Turner',
|
|
65
|
+
action: 'Registered new account',
|
|
66
|
+
date: '2024-01-12',
|
|
67
|
+
status: 'Active' as const,
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
id: 5,
|
|
71
|
+
user: 'Lisa Park',
|
|
72
|
+
action: 'Updated billing info',
|
|
73
|
+
date: '2024-01-11',
|
|
74
|
+
status: 'Active' as const,
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
})
|
|
79
|
+
})
|