diff --git a/.trae/.ignore b/.trae/.ignore new file mode 100644 index 0000000..e69de29 diff --git a/bun.lockb b/bun.lockb index 4288996..771d03b 100644 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 0000000..d9e3812 --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": [ + "src/*" + ], + "db/*": [ + "src/db/*" + ], + "utils/*": [ + "src/utils/*" + ] + }, + "module": "commonjs", + "target": "es6", + "allowSyntheticDefaultImports": true + }, + "include": [ + "src/**/*", + "jsconfig.json" + ] +} \ No newline at end of file diff --git a/package.json b/package.json index 73ab3df..05a95a7 100644 --- a/package.json +++ b/package.json @@ -19,10 +19,12 @@ "koa": "^3.0.0", "log4js": "^6.9.1", "module-alias": "^2.2.3", + "path-to-regexp": "^8.2.0", "sqlite3": "^5.1.7" }, "_moduleAliases": { "@": "./src", - "db": "./src/db" + "db": "./src/db", + "utils": "./src/utils" } } \ No newline at end of file diff --git a/public/aa.txt b/public/aa.txt new file mode 100644 index 0000000..6a12326 --- /dev/null +++ b/public/aa.txt @@ -0,0 +1 @@ +sada \ No newline at end of file diff --git a/src/controllers/userController.mjs b/src/controllers/userController.mjs deleted file mode 100644 index 5b91a7b..0000000 --- a/src/controllers/userController.mjs +++ /dev/null @@ -1,23 +0,0 @@ -import db from "../db/index.js" - -// 创建用户 -export async function createUser(userData) { - const [id] = await db("users").insert(userData) - return id -} - -// 查询所有用户 -export async function getUsers() { - return db("users").select("*") -} - -// 更新用户 -export async function updateUser(id, updates) { - updates.updated_at = new Date() - return db("users").where("id", id).update(updates) -} - -// 删除用户 -export async function deleteUser(id) { - return db("users").where("id", id).del() -} diff --git a/src/db/migrations/20250616065041_create_users_table.mjs b/src/db/migrations/20250616065041_create_users_table.mjs index c1f4ca3..56f7418 100644 --- a/src/db/migrations/20250616065041_create_users_table.mjs +++ b/src/db/migrations/20250616065041_create_users_table.mjs @@ -5,7 +5,7 @@ export const up = async knex => { return knex.schema.createTable("users", function (table) { table.increments("id").primary() // 自增主键 - table.string("name", 100).notNullable() // 字符串字段(最大长度100) + table.string("username", 100).notNullable() // 字符串字段(最大长度100) table.string("email", 100).unique().notNullable() // 唯一邮箱 table.integer("age").unsigned() // 无符号整数 table.timestamp("created_at").defaultTo(knex.fn.now()) // 创建时间 diff --git a/src/db/seeds/20250616071157_users_seed.mjs b/src/db/seeds/20250616071157_users_seed.mjs index 521b6b2..9cb6728 100644 --- a/src/db/seeds/20250616071157_users_seed.mjs +++ b/src/db/seeds/20250616071157_users_seed.mjs @@ -1,7 +1,7 @@ export const seed = async knex => { // 检查表是否存在 const tables = await knex.raw(` - SELECT name FROM sqlite_master WHERE type='table' AND name='users' + SELECT name FROM sqlite_master WHERE type='table' AND username='users' `) if (tables.length === 0) { @@ -13,7 +13,7 @@ export const seed = async knex => { // Inserts seed entries await knex("users").insert([ - { name: "Alice", email: "alice@example.com" }, - { name: "Bob", email: "bob@example.com" }, + { username: "Alice", email: "alice@example.com" }, + { username: "Bob", email: "bob@example.com" }, ]) } diff --git a/src/main.js b/src/main.js index f38d0ae..bdc064a 100644 --- a/src/main.js +++ b/src/main.js @@ -1,38 +1,71 @@ -import "./logger" -import "module-alias/register" -import Koa from "koa" -import os from "os" -import LoadPlugins from "./plugins/install" -import UserModel from "./db/models/UserModel" +import './logger.js'; +import Koa from 'koa'; +import os from 'os'; import log4js from "log4js" +import LoadPlugins from "./plugins/install.js" +import Router from './utils/router.js'; const logger = log4js.getLogger() -const app = new Koa() +const app = new Koa(); LoadPlugins(app) -app.use(async ctx => { - ctx.body = await UserModel.findAll() -}) -app.on("error", err => { - logger.error("server error", err) -}) +const router = new Router({ prefix: '/api' }); + +// 基础路由 +router.get('/hello', (ctx) => { + ctx.body = 'Hello World'; +}); + +// 参数路由 +router.get('/user/:id', (ctx) => { + ctx.body = `User ID: ${ctx.params.id}`; +}); + +// 路由组 +router.group('/v1', (v1) => { + v1.use((ctx, next) => { + ctx.set('X-API-Version', 'v1'); + return next(); + }); + v1.get('/status', (ctx) => ctx.body = 'OK'); +}); -const server = app.listen(3000, () => { - const port = server.address().port - const getLocalIP = () => { - const interfaces = os.networkInterfaces() - for (const name of Object.keys(interfaces)) { - for (const iface of interfaces[name]) { - if (iface.family === "IPv4" && !iface.internal) { - return iface.address - } - } +app.use(router.middleware()); + +// 错误处理中间件 +app.use(async (ctx, next) => { + try { + await next(); + if (ctx.status === 404) { + ctx.status = 404; + ctx.body = { success: false, error: 'Resource not found' }; + } + } catch (err) { + ctx.status = err.statusCode || 500; + ctx.body = { success: false, error: err.message || 'Internal server error' }; + } +}); + +const PORT = process.env.PORT || 3000; + +const server = app.listen(PORT, () => { + const port = server.address().port + const getLocalIP = () => { + const interfaces = os.networkInterfaces() + for (const name of Object.keys(interfaces)) { + for (const iface of interfaces[name]) { + if (iface.family === "IPv4" && !iface.internal) { + return iface.address } - return "localhost" + } } - const localIP = getLocalIP() - logger.trace(`服务器运行在: http://${localIP}:${port}`) + return "localhost" + } + const localIP = getLocalIP() + logger.trace(`服务器运行在: http://${localIP}:${port}`) }) + +export default app; diff --git a/src/plugins/ResponseTime/index.js b/src/plugins/ResponseTime/index.js index de0ad8d..a8e7443 100644 --- a/src/plugins/ResponseTime/index.js +++ b/src/plugins/ResponseTime/index.js @@ -1,13 +1,30 @@ -import log4js from "log4js" +import log4js from "log4js"; -const logger = log4js.getLogger() +// ANSI颜色代码常量定义 +const COLORS = { + REQ: '\x1b[36m', // 青色 - 请求标记 + RES: '\x1b[32m', // 绿色 - 响应标记 + TIME: '\x1b[33m', // 黄色 - 响应时间 + METHOD: '\x1b[1m', // 加粗 - HTTP方法 + RESET: '\x1b[0m' // 重置颜色 +}; +const logger = log4js.getLogger(); + +/** + * 响应时间记录中间件 + * 为请求和响应添加彩色日志标记,并记录处理时间 + * @param {Object} ctx - Koa上下文对象 + * @param {Function} next - Koa中间件链函数 + */ export default async (ctx, next) => { - logger.debug("::in:: %s %s", ctx.method, ctx.path) - const start = Date.now() - await next() - const ms = Date.now() - start - ctx.set("X-Response-Time", `${ms}ms`) - const rt = ctx.response.get("X-Response-Time") - logger.debug(`::out:: takes ${rt} for ${ctx.method} ${ctx.url}`) + // 彩色请求日志:青色标记 + 加粗方法名 + logger.debug(`${COLORS.REQ}::req::${COLORS.RESET} ${COLORS.METHOD}%s${COLORS.RESET} %s`, ctx.method, ctx.path); + const start = Date.now(); + await next(); + const ms = Date.now() - start; + ctx.set("X-Response-Time", `${ms}ms`); + // 彩色响应日志:绿色标记 + 黄色响应时间 + 加粗方法名 + logger.debug(`${COLORS.RES}::res::${COLORS.RESET} takes ${COLORS.TIME}%s${COLORS.RESET} for ${COLORS.METHOD}%s${COLORS.RESET} %s`, + ctx.response.get("X-Response-Time"), ctx.method, ctx.url); } diff --git a/src/plugins/Send/index.js b/src/plugins/Send/index.js new file mode 100644 index 0000000..1502d3f --- /dev/null +++ b/src/plugins/Send/index.js @@ -0,0 +1,185 @@ +/** + * koa-send@5.0.1 转换为ES Module版本 + * 静态资源服务中间件 + */ +import fs from 'fs'; +import { promisify } from 'util'; +import logger from 'log4js'; +import resolvePath from './resolve-path.js'; +import createError from 'http-errors'; +import assert from 'assert'; +import { normalize, basename, extname, resolve, parse, sep } from 'path'; +import { fileURLToPath } from 'url'; +import path from "path" + +// 转换为ES Module格式 +const log = logger.getLogger('koa-send'); +const stat = promisify(fs.stat); +const access = promisify(fs.access); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +/** + * 检查文件是否存在 + * @param {string} path - 文件路径 + * @returns {Promise} 文件是否存在 + */ +async function exists(path) { + try { + await access(path); + return true; + } catch (e) { + return false; + } +} + +/** + * 发送文件给客户端 + * @param {Context} ctx - Koa上下文对象 + * @param {String} path - 文件路径 + * @param {Object} [opts] - 配置选项 + * @returns {Promise} - 异步Promise + */ +async function send(ctx, path, opts = {}) { + assert(ctx, 'koa context required'); + assert(path, 'pathname required'); + + // 移除硬编码的public目录,要求必须通过opts.root配置 + const root = opts.root; + if (!root) { + throw new Error('Static root directory must be configured via opts.root'); + } + const trailingSlash = path[path.length - 1] === '/'; + path = path.substr(parse(path).root.length); + const index = opts.index || 'index.html'; + const maxage = opts.maxage || opts.maxAge || 0; + const immutable = opts.immutable || false; + const hidden = opts.hidden || false; + const format = opts.format !== false; + const extensions = Array.isArray(opts.extensions) ? opts.extensions : false; + const brotli = opts.brotli !== false; + const gzip = opts.gzip !== false; + const setHeaders = opts.setHeaders; + + if (setHeaders && typeof setHeaders !== 'function') { + throw new TypeError('option setHeaders must be function'); + } + + // 解码路径 + path = decode(path); + if (path === -1) return ctx.throw(400, 'failed to decode'); + + // 索引文件支持 + if (index && trailingSlash) path += index; + + path = resolvePath(root, path); + + // 隐藏文件支持 + if (!hidden && isHidden(root, path)) return; + + let encodingExt = ''; + // 尝试提供压缩文件 + if (ctx.acceptsEncodings('br', 'identity') === 'br' && brotli && (await exists(path + '.br'))) { + path = path + '.br'; + ctx.set('Content-Encoding', 'br'); + ctx.res.removeHeader('Content-Length'); + encodingExt = '.br'; + } else if (ctx.acceptsEncodings('gzip', 'identity') === 'gzip' && gzip && (await exists(path + '.gz'))) { + path = path + '.gz'; + ctx.set('Content-Encoding', 'gzip'); + ctx.res.removeHeader('Content-Length'); + encodingExt = '.gz'; + } + + // 尝试添加文件扩展名 + if (extensions && !/\./.exec(basename(path))) { + const list = [].concat(extensions); + for (let i = 0; i < list.length; i++) { + let ext = list[i]; + if (typeof ext !== 'string') { + throw new TypeError('option extensions must be array of strings or false'); + } + if (!/^\./.exec(ext)) ext = `.${ext}`; + if (await exists(`${path}${ext}`)) { + path = `${path}${ext}`; + break; + } + } + } + + // 获取文件状态 + let stats; + try { + stats = await stat(path); + + // 处理目录 + if (stats.isDirectory()) { + if (format && index) { + path += `/${index}`; + stats = await stat(path); + } else { + return; + } + } + } catch (err) { + const notfound = ['ENOENT', 'ENAMETOOLONG', 'ENOTDIR']; + if (notfound.includes(err.code)) { + throw createError(404, err); + } + err.status = 500; + throw err; + } + + if (setHeaders) setHeaders(ctx.res, path, stats); + + // 设置响应头 + ctx.set('Content-Length', stats.size); + if (!ctx.response.get('Last-Modified')) ctx.set('Last-Modified', stats.mtime.toUTCString()); + if (!ctx.response.get('Cache-Control')) { + const directives = [`max-age=${(maxage / 1000) | 0}`]; + if (immutable) directives.push('immutable'); + ctx.set('Cache-Control', directives.join(',')); + } + if (!ctx.type) ctx.type = type(path, encodingExt); + ctx.body = fs.createReadStream(path); + + return path; +} + +/** + * 检查是否为隐藏文件 + * @param {string} root - 根目录 + * @param {string} path - 文件路径 + * @returns {boolean} 是否为隐藏文件 + */ +function isHidden(root, path) { + path = path.substr(root.length).split(sep); + for (let i = 0; i < path.length; i++) { + if (path[i][0] === '.') return true; + } + return false; +} + +/** + * 获取文件类型 + * @param {string} file - 文件路径 + * @param {string} ext - 编码扩展名 + * @returns {string} 文件MIME类型 + */ +function type(file, ext) { + return ext !== '' ? extname(basename(file, ext)) : extname(file); +} + +/** + * 解码URL路径 + * @param {string} path - 需要解码的路径 + * @returns {string|number} 解码后的路径或错误代码 + */ +function decode(path) { + try { + return decodeURIComponent(path); + } catch (err) { + return -1; + } +} + +export default send; diff --git a/src/plugins/Send/resolve-path.js b/src/plugins/Send/resolve-path.js new file mode 100644 index 0000000..9c6dce6 --- /dev/null +++ b/src/plugins/Send/resolve-path.js @@ -0,0 +1,74 @@ +/*! + * resolve-path + * Copyright(c) 2014 Jonathan Ong + * Copyright(c) 2015-2018 Douglas Christopher Wilson + * MIT Licensed + */ + +/** + * ES Module 转换版本 + * 路径解析工具,防止路径遍历攻击 + */ +import createError from 'http-errors'; +import { join, normalize, resolve, sep } from 'path'; +import pathIsAbsolute from 'path-is-absolute'; + +/** + * 模块变量 + * @private + */ +const UP_PATH_REGEXP = /(?:^|[\/])\.\.(?:[\/]|$)/; + +/** + * 解析相对路径到根路径 + * @param {string} rootPath - 根目录路径 + * @param {string} relativePath - 相对路径 + * @returns {string} 解析后的绝对路径 + * @public + */ +function resolvePath(rootPath, relativePath) { + let path = relativePath; + let root = rootPath; + + // root是可选的,类似于root.resolve + if (arguments.length === 1) { + path = rootPath; + root = process.cwd(); + } + + if (root == null) { + throw new TypeError('argument rootPath is required'); + } + + if (typeof root !== 'string') { + throw new TypeError('argument rootPath must be a string'); + } + + if (path == null) { + throw new TypeError('argument relativePath is required'); + } + + if (typeof path !== 'string') { + throw new TypeError('argument relativePath must be a string'); + } + + // 包含NULL字节是恶意的 + if (path.indexOf('\0') !== -1) { + throw createError(400, 'Malicious Path'); + } + + // 路径绝不能是绝对路径 + if (pathIsAbsolute.posix(path) || pathIsAbsolute.win32(path)) { + throw createError(400, 'Malicious Path'); + } + + // 路径超出根目录 + if (UP_PATH_REGEXP.test(normalize('.' + sep + path))) { + throw createError(403); + } + + // 拼接相对路径 + return normalize(join(resolve(root), path)); +} + +export default resolvePath; diff --git a/src/plugins/install.js b/src/plugins/install.js index 6eb43a1..7d3daf0 100644 --- a/src/plugins/install.js +++ b/src/plugins/install.js @@ -1,5 +1,21 @@ import ResponseTime from "./ResponseTime"; +import Send from "./Send/index.js"; +import { resolve } from 'path'; +import { fileURLToPath } from 'url'; +import path from "path" + + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const publicPath = resolve(__dirname, '../../public'); export default (app)=>{ app.use(ResponseTime) + app.use(async (ctx, next) => { + try { + await Send(ctx, ctx.path, { root: publicPath }); + } catch (err) { + if (err.status !== 404) throw err; + } + await next(); + }) } \ No newline at end of file diff --git a/src/utils/router.js b/src/utils/router.js new file mode 100644 index 0000000..d302aa8 --- /dev/null +++ b/src/utils/router.js @@ -0,0 +1,110 @@ +import { pathToRegexp } from 'path-to-regexp'; + +class Router { + /** + * 初始化路由实例 + * @param {Object} options - 路由配置 + * @param {string} options.prefix - 全局路由前缀 + */ + constructor(options = {}) { + this.prefix = options.prefix || ''; + this.routes = { get: [], post: [], put: [], delete: [] }; + this.middlewares = []; + } + + /** + * 注册中间件 + * @param {Function} middleware - 中间件函数 + */ + use(middleware) { + this.middlewares.push(middleware); + } + + /** + * 注册GET路由 + * @param {string} path - 路由路径 + * @param {Function} handler - 处理函数 + */ + get(path, handler) { + this._registerRoute('get', path, handler); + } + + /** + * 注册POST路由 + * @param {string} path - 路由路径 + * @param {Function} handler - 处理函数 + */ + post(path, handler) { + this._registerRoute('post', path, handler); + } + + /** + * 创建路由组 + * @param {string} prefix - 组内路由前缀 + * @param {Function} callback - 组路由注册回调 + */ + group(prefix, callback) { + const groupRouter = new Router({ prefix: this.prefix + prefix }); + callback(groupRouter); + // 合并组路由到当前路由 + Object.keys(groupRouter.routes).forEach(method => { + this.routes[method].push(...groupRouter.routes[method]); + }); + this.middlewares.push(...groupRouter.middlewares); + } + + /** + * 生成Koa中间件 + * @returns {Function} Koa中间件函数 + */ + middleware() { + return async (ctx, next) => { + // 执行全局中间件 + for (const middleware of this.middlewares) { + await middleware(ctx, next); + } + + const { method, path } = ctx; + const route = this._matchRoute(method.toLowerCase(), path); + + if (route) { + ctx.params = route.params; + await route.handler(ctx, next); + } else { + await next(); + } + }; + } + + /** + * 内部路由注册方法 + * @private + */ + _registerRoute(method, path, handler) { + const fullPath = this.prefix + path; + const keys = []; + const regexp = pathToRegexp(fullPath, keys).regexp; + this.routes[method].push({ path: fullPath, regexp, keys, handler }); + } + + /** + * 匹配路由 + * @private + */ + _matchRoute(method, currentPath) { + const routes = this.routes[method] || []; + for (const route of routes) { + const match = route.regexp.exec(currentPath); + if (match) { + const params = {}; + for (let i = 1; i < match.length; i++) { + params[route.keys[i - 1].name] = match[i] || ''; + } + return { ...route, params }; + } + } + return null; + } +} + +export default Router; \ No newline at end of file