Browse Source

feat: 添加视图渲染支持,更新中间件,优化用户认证和错误处理

alpha
npmrun 2 months ago
parent
commit
f7dc33873d
  1. BIN
      bun.lockb
  2. 3
      package.json
  3. 101
      public/index.html
  4. 1
      public/static/aa.txt
  5. 23
      src/controllers/Page/HtmxController.js
  6. 14
      src/controllers/Page/PageController.js
  7. 10
      src/controllers/userController.js
  8. 10
      src/main.js
  9. 7
      src/middlewares/Auth/auth.js
  10. 78
      src/middlewares/Views/index.js
  11. 24
      src/middlewares/errorHandler/index.js
  12. 19
      src/middlewares/install.js
  13. 1
      src/views/htmx/fuck.pug
  14. 26
      src/views/index.pug
  15. 12
      src/views/layouts/base.pug
  16. 17
      src/views/layouts/page.pug

BIN
bun.lockb

Binary file not shown.

3
package.json

@ -16,6 +16,8 @@
},
"dependencies": {
"bcryptjs": "^3.0.2",
"consolidate": "^1.0.4",
"get-paths": "^0.0.7",
"jsonwebtoken": "^9.0.0",
"knex": "^3.1.0",
"koa": "^3.0.0",
@ -25,6 +27,7 @@
"module-alias": "^2.2.3",
"node-cron": "^4.1.0",
"path-to-regexp": "^8.2.0",
"pug": "^3.0.3",
"sqlite3": "^5.1.7"
},
"_moduleAliases": {

101
public/index.html

@ -1,101 +0,0 @@
<!DOCTYPE html>
<html lang="zh-cn">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登录 / 注册</title>
<style>
body { background: #f5f6fa; font-family: 'Segoe UI', Arial, sans-serif; }
.container { max-width: 350px; margin: 60px auto; background: #fff; border-radius: 8px; box-shadow: 0 2px 12px #0001; padding: 32px 28px; }
h2 { text-align: center; margin-bottom: 24px; color: #333; }
.tabs { display: flex; margin-bottom: 24px; }
.tab { flex: 1; text-align: center; padding: 10px 0; cursor: pointer; border-bottom: 2px solid #eee; color: #888; font-weight: 500; }
.tab.active { color: #1976d2; border-bottom: 2px solid #1976d2; }
.form-group { margin-bottom: 18px; }
label { display: block; margin-bottom: 6px; color: #555; }
input { width: 100%; padding: 8px 10px; border: 1px solid #ccc; border-radius: 4px; font-size: 15px; }
button { width: 100%; padding: 10px; background: #1976d2; color: #fff; border: none; border-radius: 4px; font-size: 16px; cursor: pointer; margin-top: 8px; }
button:active { background: #145ea8; }
.msg { text-align: center; color: #e53935; margin-bottom: 10px; min-height: 22px; }
</style>
</head>
<body>
<div class="container">
<div class="tabs">
<div class="tab active" id="loginTab">登录</div>
<div class="tab" id="registerTab">注册</div>
</div>
<div class="msg" id="msg"></div>
<form id="loginForm">
<div class="form-group">
<label for="login-username">用户名</label>
<input type="text" id="login-username" required autocomplete="username">
</div>
<div class="form-group">
<label for="login-password">密码</label>
<input type="password" id="login-password" required autocomplete="current-password">
</div>
<button type="submit">登录</button>
</form>
<form id="registerForm" style="display:none;">
<div class="form-group">
<label for="register-username">用户名</label>
<input type="text" id="register-username" required autocomplete="username">
</div>
<div class="form-group">
<label for="register-email">邮箱</label>
<input type="email" id="register-email" required autocomplete="email">
</div>
<div class="form-group">
<label for="register-password">密码</label>
<input type="password" id="register-password" required autocomplete="new-password">
</div>
<button type="submit">注册</button>
</form>
</div>
<script>
const loginTab = document.getElementById('loginTab');
const registerTab = document.getElementById('registerTab');
const loginForm = document.getElementById('loginForm');
const registerForm = document.getElementById('registerForm');
const msg = document.getElementById('msg');
loginTab.onclick = () => {
loginTab.classList.add('active');
registerTab.classList.remove('active');
loginForm.style.display = '';
registerForm.style.display = 'none';
msg.textContent = '';
};
registerTab.onclick = () => {
registerTab.classList.add('active');
loginTab.classList.remove('active');
registerForm.style.display = '';
loginForm.style.display = 'none';
msg.textContent = '';
};
loginForm.onsubmit = async e => {
e.preventDefault();
msg.textContent = '';
const username = document.getElementById('login-username').value.trim();
const password = document.getElementById('login-password').value;
if (!username || !password) { msg.textContent = '请填写完整信息'; return; }
// TODO: 替换为实际API
msg.style.color = '#1976d2';
msg.textContent = '登录成功(示例)';
};
registerForm.onsubmit = async e => {
e.preventDefault();
msg.textContent = '';
const username = document.getElementById('register-username').value.trim();
const email = document.getElementById('register-email').value.trim();
const password = document.getElementById('register-password').value;
if (!username || !email || !password) { msg.textContent = '请填写完整信息'; return; }
// TODO: 替换为实际API
msg.style.color = '#1976d2';
msg.textContent = '注册成功(示例)';
};
</script>
</body>
</html>

1
public/static/aa.txt

@ -0,0 +1 @@
asd

23
src/controllers/Page/HtmxController.js

@ -0,0 +1,23 @@
export const Index = async ctx => {
return await ctx.render("index", { name: "bluescurry" })
}
export const Page = (name, data) => async ctx => {
return await ctx.render(name, data)
}
import Router from "utils/router.js"
export function createRoutes() {
const router = new Router()
router.post("/clicked", async ctx => {
ctx.cookies.set("token", "sadas", {
httpOnly: true,
// Setting httpOnly to false allows JavaScript to access the cookie
// This enables browsers to automatically include the cookie in requests
sameSite: "lax",
// maxAge: 86400000, // Optional: cookie expiration in milliseconds (e.g., 24 hours)
})
return await ctx.render("htmx/fuck", { title: "HTMX Clicked" })
})
return router
}

14
src/controllers/Page/PageController.js

@ -0,0 +1,14 @@
export const Index = async ctx => {
return await ctx.render("index", { name: "bluescurry" })
}
export const Page = (name, data) => async ctx => {
return await ctx.render(name, data)
}
import Router from "utils/router.js"
export function createRoutes() {
const router = new Router()
router.get("/", Index)
return router
}

10
src/controllers/userController.js

@ -26,6 +26,16 @@ export const login = async (ctx) => {
try {
const { username, email, password } = ctx.request.body
const result = await userService.login({ username, email, password })
if (result && result.token) {
ctx.cookies.set("token", result.token, {
httpOnly: true,
// Setting httpOnly to false allows JavaScript to access the cookie
// This enables browsers to automatically include the cookie in requests
sameSite: "lax",
secure: process.env.NODE_ENV === "production", // Use secure cookies in production
// maxAge: 86400000, // Optional: cookie expiration in milliseconds (e.g., 24 hours)
})
}
ctx.body = formatResponse(true, result)
} catch (err) {
ctx.body = formatResponse(false, null, err.message)

10
src/main.js

@ -9,17 +9,12 @@ import log4js from "log4js"
// 应用插件与自动路由
import LoadMiddlewares from "./middlewares/install.js"
import { autoRegisterControllers } from "utils/autoRegister.js"
import bodyParser from "koa-bodyparser"
const logger = log4js.getLogger()
const app = new Koa()
app.use(bodyParser());
// 注册插件
LoadMiddlewares(app)
// 自动注册所有 controller
autoRegisterControllers(app)
const PORT = process.env.PORT || 3000
@ -38,7 +33,10 @@ const server = app.listen(PORT, () => {
return "localhost"
}
const localIP = getLocalIP()
logger.trace(`服务器运行在: http://${localIP}:${port}`)
logger.trace(`===================【服务器地址】====================`)
logger.trace(` http://localhost:${port} (本地地址) `)
logger.trace(` http://${localIP}:${port} (本地地址) `)
logger.trace(`===================【服务器地址】====================`)
})
export default app

7
src/middlewares/Auth/auth.js

@ -17,7 +17,12 @@ function matchList(list, path) {
}
function verifyToken(ctx) {
const token = ctx.headers["authorization"]?.replace(/^Bearer\s/, "")
// 优先从 headers 获取 token
let token = ctx.headers["authorization"]?.replace(/^Bearer\s/, "")
// 如果 headers 没有,则从 cookies 获取
if (!token) {
token = ctx.cookies.get("authorization")
}
if (!token) return { ok: false }
try {
ctx.state.user = jwt.verify(token, JWT_SECRET)

78
src/middlewares/Views/index.js

@ -0,0 +1,78 @@
import { resolve } from "path"
import consolidate from "consolidate"
import send from "../Send"
import getPaths from "get-paths"
// import pretty from "pretty"
import { logger } from "@/logger"
export default viewsMiddleware
function viewsMiddleware(path, { engineSource = consolidate, extension = "html", options = {}, map } = {}) {
return function views(ctx, next) {
if (ctx.render) return next()
ctx.getRender = function (relPath, locals = {}) {
return getPaths(path, relPath, extension).then(paths => {
const suffix = paths.ext
const state = Object.assign(locals, options, ctx.state || {})
state.partials = Object.assign({}, options.partials || {})
if (isHtml(suffix) && !map) {
return send.getBody(ctx, paths.rel, { root: path })
}
const engineName = map && map[suffix] ? map[suffix] : suffix
const render = engineSource[engineName]
if (!engineName || !render) {
return Promise.reject(new Error(`Engine not found for the ".${suffix}" file extension`))
}
return render(resolve(path, paths.rel), state)
})
}
// 将 render 注入到 context 和 response 对象中
ctx.response.render = ctx.render = function (relPath, locals = {}) {
return getPaths(path, relPath, extension).then(paths => {
const suffix = paths.ext
const state = Object.assign(locals, options, ctx.state || {})
// deep copy partials
state.partials = Object.assign({}, options.partials || {})
logger.debug("render `%s` with %j", paths.rel, state)
ctx.type = "text/html"
// 如果是 html 文件,不编译直接 send 静态文件
if (isHtml(suffix) && !map) {
return send(ctx, paths.rel, {
root: path,
})
} else {
const engineName = map && map[suffix] ? map[suffix] : suffix
// 使用 engineSource 配置的渲染引擎 render
const render = engineSource[engineName]
if (!engineName || !render) return Promise.reject(new Error(`Engine not found for the ".${suffix}" file extension`))
return render(resolve(path, paths.rel), state).then(html => {
// since pug has deprecated `pretty` option
// we'll use the `pretty` package in the meanwhile
// if (locals.pretty) {
// debug("using `pretty` package to beautify HTML")
// html = pretty(html)
// }
ctx.body = html
})
}
})
}
// 中间件执行结束
return next()
}
}
function isHtml(ext) {
return ext === "html"
}

24
src/middlewares/errorHandler/index.js

@ -1,11 +1,14 @@
// src/plugins/errorHandler.js
// 错误处理中间件插件
function formatError(ctx, status, message) {
function formatError(ctx, status, message, stack) {
const accept = ctx.accepts('json', 'html', 'text');
const isDev = process.env.NODE_ENV === 'development';
if (accept === 'json') {
ctx.type = 'application/json';
ctx.body = { success: false, error: message };
ctx.body = isDev && stack
? { success: false, error: message, stack }
: { success: false, error: message };
} else if (accept === 'html') {
ctx.type = 'html';
ctx.body = `
@ -14,25 +17,38 @@ function formatError(ctx, status, message) {
<body>
<h1>${status} Error</h1>
<p>${message}</p>
${isDev && stack ? `<pre style='color:red;'>${stack}</pre>` : ''}
</body>
</html>
`;
} else {
ctx.type = 'text';
ctx.body = `${status} - ${message}`;
ctx.body = isDev && stack
? `${status} - ${message}\n${stack}`
: `${status} - ${message}`;
}
ctx.status = status;
}
export default function errorHandler() {
return async (ctx, next) => {
// 拦截 Chrome DevTools 探测请求,直接返回 204
if (ctx.path === '/.well-known/appspecific/com.chrome.devtools.json') {
ctx.status = 204;
ctx.body = '';
return;
}
try {
await next();
if (ctx.status === 404) {
formatError(ctx, 404, 'Resource not found');
}
} catch (err) {
formatError(ctx, err.statusCode || 500, err.message || err || 'Internal server error');
const isDev = process.env.NODE_ENV === 'development';
if (isDev && err.stack) {
console.error(err.stack);
}
formatError(ctx, err.statusCode || 500, err.message || err || 'Internal server error', isDev ? err.stack : undefined);
}
};
}

19
src/middlewares/install.js

@ -3,14 +3,17 @@ import Send from "./Send"
import { resolve } from "path"
import { fileURLToPath } from "url"
import path from "path"
import errorHandler from "./errorHandler"
import ErrorHandler from "./ErrorHandler"
import { auth } from "./Auth"
import bodyParser from "koa-bodyparser"
import Views from "./Views"
import { autoRegisterControllers } from "utils/autoRegister.js"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const publicPath = resolve(__dirname, "../../public")
export default app => {
app.use(errorHandler())
app.use(ErrorHandler())
app.use(ResponseTime)
app.use(
auth({
@ -21,13 +24,19 @@ export default app => {
{ pattern: "/api/v1/status", auth: "try" },
{ pattern: "/api/**/*", auth: true },
// 静态资源访问
"",
"/",
"/**/*",
{ pattern: "/", auth: "try" },
{ pattern: "/**/*", auth: "try" },
],
blackList: [],
})
)
app.use(bodyParser())
app.use(
Views(resolve(__dirname, "../views"), {
extension: "pug",
})
)
autoRegisterControllers(app)
app.use(async (ctx, next) => {
try {
await Send(ctx, ctx.path, { root: publicPath })

1
src/views/htmx/fuck.pug

@ -0,0 +1 @@
<a href="/page/htmx">#{title || '默认标题'}</a>

26
src/views/index.pug

@ -0,0 +1,26 @@
extends ./layouts/page.pug
block pageRoot
- var title = '示例页面标题'
block pageContent
.container.mt-5
.row.justify-content-center
.col-md-8.text-center
img.rounded-circle.shadow.mb-4(src='https://avatars.githubusercontent.com/u/9919?s=200&v=4', alt='Avatar', width='120', height='120')
h1.mt-3.mb-1 你的姓名
h4.text-muted 你的职位 / 头衔
p.lead.mt-3 这里是一段简短的自我介绍,突出你的专业技能、兴趣或座右铭。
button(hx-post="/clicked" hx-swap="outerHTML") Click Me
hr.my-4
.d-flex.justify-content-center.gap-4
a(href='mailto:your@email.com', target='_blank')
i.fas.fa-envelope.me-2
| 邮箱
a(href='https://github.com/your-github', target='_blank')
i.fab.fa-github.me-2
| GitHub
a(href='https://your-website.com', target='_blank')
i.fas.fa-globe.me-2
| 个人网站

12
src/views/layouts/base.pug

@ -0,0 +1,12 @@
block root
doctype html
html(lang="zh-CN")
head
title #{title || '默认标题'}
meta(charset="utf-8")
meta(name="viewport" content="width=device-width, initial-scale=1")
script(src="https://unpkg.com/htmx.org@2.0.4")
block head
body
block content
block scripts

17
src/views/layouts/page.pug

@ -0,0 +1,17 @@
extends ./base.pug
block root
block pageRoot
block head
link(href='https://cdn.bootcss.com/twitter-bootstrap/4.1.3/css/bootstrap.min.css' rel='stylesheet')
script(src='https://cdn.bootcss.com/jquery/3.3.1/jquery.min.js')
script(src='https://cdn.bootcss.com/twitter-bootstrap/4.1.3/js/bootstrap.bundle.min.js')
block pageHead
block content
block pageContent
block scripts
block pageScripts
Loading…
Cancel
Save