diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d1b142b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +node_modules +npm-debug.log +.git +.gitignore +.env +*.md +.vscode +.idea +dist +build +coverage +.DS_Store +prisma/migrations diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 02873bf..e247d73 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -5,10 +5,14 @@ on: branches: - main workflow_dispatch: + jobs: build_and_push: name: Build and Push runs-on: ubuntu-latest + strategy: + matrix: + database: [mysql, postgres, sqlite] permissions: packages: write contents: read @@ -29,7 +33,6 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Extract metadata for Docker id: meta uses: docker/metadata-action@v5 @@ -38,14 +41,17 @@ jobs: sunwuyuan/classworks ghcr.io/ZeroCatDev/ClassworksServer tags: | - type=sha + type=sha,suffix=-${{ matrix.database }} + type=raw,value=${{ matrix.database }},enable=${{ github.ref == format('refs/heads/{0}', 'main') }} flavor: | - latest=true + latest=false - name: Build and push Docker images uses: docker/build-push-action@v5 with: context: . + build-args: | + DATABASE_TYPE=${{ matrix.database }} push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} \ No newline at end of file diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..79063c0 --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,137 @@ +## 快速开始 + +如果你想快速体验 Classworks,我们推荐使用 SQLite 版本。可以零配置运行。 + +## 部署方案 + +### MySQL 版本 + +```yaml +version: '3.8' + +services: + app: + build: + context: . + args: + DATABASE_TYPE: mysql + environment: + - NODE_ENV=production + - MYSQL_DATABASE_URL=mysql://user:password@mysql:3306/classworks + ports: + - 3000:3000 + depends_on: + mysql: + condition: service_healthy + + mysql: + image: mysql:8 + environment: + - MYSQL_DATABASE=classworks + - MYSQL_USER=user + - MYSQL_PASSWORD=password + - MYSQL_ROOT_PASSWORD=rootpassword + volumes: + - mysql_data:/var/lib/mysql + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + interval: 10s + timeout: 5s + retries: 5 + +volumes: + mysql_data: +``` + +默认配置: +- 数据库版本:MySQL 8 +- 默认端口:3306 +- 数据持久化:自动配置 + +### PostgreSQL 版本 + + +```yaml +version: '3.8' + +services: + app: + build: + context: . + args: + DATABASE_TYPE: postgres + environment: + - NODE_ENV=production + - PG_DATABASE_URL=postgresql://user:password@postgres:5432/classworks + ports: + - 3000:3000 + depends_on: + postgres: + condition: service_healthy + + postgres: + image: postgres:15-alpine + environment: + - POSTGRES_DB=classworks + - POSTGRES_USER=user + - POSTGRES_PASSWORD=password + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U user -d classworks"] + interval: 10s + timeout: 5s + retries: 5 + +volumes: + postgres_data: +``` + +默认配置: +- 数据库版本:PostgreSQL 15 Alpine +- 默认端口:5432 +- 数据持久化:自动配置 + +### SQLite 版本 + +将以下内容保存为 `docker-compose.yml`: + +```yaml +version: '3.8' + +services: + app: + build: + context: . + args: + DATABASE_TYPE: sqlite + ports: + - 3000:3000 + environment: + - NODE_ENV=production + volumes: + - sqlite_data:/data + +volumes: + sqlite_data: +``` + +## 使用说明 + +1. 选择你需要的版本,将对应的配置复制到 `docker-compose.yml` 文件中 +2. 根据需要修改环境变量(见下方环境变量配置) +3. 运行 `docker compose up -d` 启动服务 + + +## 环境变量配置 +``` +# Axiom.co 遥测配置 可选 +AXIOM_DATASET= +AXIOM_TOKEN= + +# 网站密钥 可选 +SITE_KEY= + +# 服务端口 可选 默认3000 +PORT= +``` \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 3915299..ec3bfa1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,22 @@ FROM node:alpine -LABEL author=wuyuan -RUN apk add --no-cache openssl -COPY . / -RUN npm install + +# Required build argument for database type +ARG DATABASE_TYPE + +# Set production environment +ENV NODE_ENV=production \ + DATABASE_TYPE=${DATABASE_TYPE} + +# Copy all application files +COPY . . + + +# Install dependencies and generate Prisma client +RUN npm install && \ + npx prisma migrate dev --name init && \ + npx prisma generate + +USER node EXPOSE 3000 -CMD ["sh", "-c", "npm run prisma && npm run start"] + +CMD ["npm", "start"] diff --git a/bin/www b/bin/www index 7e40943..d8e4142 100644 --- a/bin/www +++ b/bin/www @@ -5,14 +5,13 @@ */ import app from '../app.js'; -import debug from 'debug'; import { createServer } from 'http'; /** * Get port from environment and store in Express. */ -var port = normalizePort(process.env.PORT || "3030"); +var port = normalizePort(process.env.PORT || "3000"); app.set("port", port); /** @@ -83,8 +82,5 @@ function onError(error) { function onListening() { var addr = server.address(); - var bind = typeof addr === 'string' - ? 'pipe ' + addr - : "port " + addr.port; - debug("Listening on " + bind); + console.log("可以在 http://localhost:"+addr.port+" 访问"); } diff --git a/data/db.db b/data/db.db new file mode 100644 index 0000000..8fbb072 Binary files /dev/null and b/data/db.db differ diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7aadc3b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,70 @@ +version: '3.8' + +services: + app-mysql: + build: + context: . + args: + DATABASE_TYPE: mysql + environment: + - NODE_ENV=production + - DATABASE_URL=mysql://user:password@mysql:3306/classworks + depends_on: + mysql: + condition: service_healthy + + app-postgres: + build: + context: . + args: + DATABASE_TYPE: postgres + environment: + - NODE_ENV=production + - DATABASE_URL=postgresql://user:password@postgres:5432/classworks + depends_on: + postgres: + condition: service_healthy + + app-sqlite: + build: + context: . + args: + DATABASE_TYPE: sqlite + environment: + - NODE_ENV=production + volumes: + - sqlite_data:/data + + mysql: + image: mysql:8 + environment: + - MYSQL_DATABASE=classworks + - MYSQL_USER=user + - MYSQL_PASSWORD=password + - MYSQL_ROOT_PASSWORD=rootpassword + volumes: + - mysql_data:/var/lib/mysql + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + interval: 10s + timeout: 5s + retries: 5 + + postgres: + image: postgres:15-alpine + environment: + - POSTGRES_DB=classworks + - POSTGRES_USER=user + - POSTGRES_PASSWORD=password + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U user -d classworks"] + interval: 10s + timeout: 5s + retries: 5 + +volumes: + mysql_data: + postgres_data: + sqlite_data: diff --git a/package.json b/package.json index ca009c0..4416e40 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,6 @@ "prisma": "prisma generate", "prisma:pull": "prisma db pull", "dev": "NODE_ENV=development nodemon node .bin/www", - "setup": "node ./scripts/setup.js", "test:rate-limit": "node ./scripts/test-rate-limit.js", "test:stress": "node ./scripts/stress-test.js", "test:distributed": "node ./scripts/distributed-test.js", diff --git a/prisma/database/mysql/schema.prisma b/prisma/database/mysql/schema.prisma new file mode 100644 index 0000000..c0d8d90 --- /dev/null +++ b/prisma/database/mysql/schema.prisma @@ -0,0 +1,36 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "mysql" + url = env("DATABASE_URL") +} + + +enum AccessType { + PUBLIC // No password required for read/write + PROTECTED // No password for read, password for write + PRIVATE // Password required for read/write +} + +model KVStore { + namespace String + key String + value Json + creatorIp String? @default("") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@id([namespace, key]) +} + +model Device { + uuid String @id + password String? + passwordHint String? + name String? + accessType AccessType @default(PUBLIC) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} diff --git a/prisma/database/postgres/schema.prisma b/prisma/database/postgres/schema.prisma new file mode 100644 index 0000000..02ce221 --- /dev/null +++ b/prisma/database/postgres/schema.prisma @@ -0,0 +1,9 @@ +generator client { + provider = "prisma-client-js" + output = "../generated/postgres/client" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} \ No newline at end of file diff --git a/prisma/database/sqlite/schema.prisma b/prisma/database/sqlite/schema.prisma new file mode 100644 index 0000000..954085a --- /dev/null +++ b/prisma/database/sqlite/schema.prisma @@ -0,0 +1,37 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "sqlite" + url = "file:../data/db.db" +} + + +enum AccessType { + PUBLIC // No password required for read/write + PROTECTED // No password for read, password for write + PRIVATE // Password required for read/write +} + +model KVStore { + namespace String + key String + value Json + creatorIp String? @default("") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@id([namespace, key]) +} + +model Device { + uuid String @id + password String? + passwordHint String? + name String? + accessType AccessType @default(PUBLIC) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + test String? +} diff --git a/prisma/schema.prisma b/prisma/schema.prisma index bc244f2..b77015f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -3,33 +3,33 @@ generator client { } datasource db { - provider = "mysql" - url = env("DATABASE_URL") + provider = "sqlite" + url = "file:../data/db.db" } enum AccessType { - PUBLIC // No password required for read/write + PUBLIC // No password required for read/write PROTECTED // No password for read, password for write - PRIVATE // Password required for read/write + PRIVATE // Password required for read/write } model KVStore { - namespace String @db.Char(36) - key String - value Json - creatorIp String? @default("") - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + namespace String + key String + value Json + creatorIp String? @default("") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@id([namespace, key]) } model Device { - uuid String @id @db.Char(36) - password String? + uuid String @id + password String? passwordHint String? - name String? - accessType AccessType @default(PUBLIC) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + name String? + accessType AccessType @default(PUBLIC) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } diff --git a/routes/kv.js b/routes/kv.js index 6efa4dd..5ab6aef 100644 --- a/routes/kv.js +++ b/routes/kv.js @@ -9,15 +9,9 @@ import { readAuthMiddleware, writeAuthMiddleware, removePasswordMiddleware, - authMiddleware, - deviceInfoMiddleware + deviceInfoMiddleware, } from "../middleware/auth.js"; -import { - DecodeAndhashPassword, - DecodeAndVerifyPassword, - hashPassword, - verifyPassword, -} from "../utils/crypto.js"; +import { hashPassword, verifyPassword } from "../utils/crypto.js"; const prisma = new PrismaClient(); @@ -333,40 +327,6 @@ router.get( return res.json(metadata); }) ); -/** - * POST /:namespace/:key - * 更新指定命名空间下的键值,如果不存在则创建 - */ -router.post( - "/:namespace/:key", - checkRestrictedUUID, - writeAuthMiddleware, - errors.catchAsync(async (req, res, next) => { - const { namespace, key } = req.params; - const value = req.body; - - if (!value || Object.keys(value).length === 0) { - // 创建并传递错误,而不是抛出 - return next(errors.createError(400, "请提供有效的JSON值")); - } - - // 获取客户端IP - const creatorIp = - req.headers["x-forwarded-for"] || - req.connection.remoteAddress || - req.socket.remoteAddress || - req.connection.socket?.remoteAddress || - ""; - - const result = await kvStore.upsert(namespace, key, value, creatorIp); - return res.status(200).json({ - namespace: result.namespace, - key: result.key, - created: result.createdAt.getTime() === result.updatedAt.getTime(), - updatedAt: result.updatedAt, - }); - }) -); /** * POST /:namespace/batch-import @@ -425,6 +385,41 @@ router.post( }); }) ); +/** + * POST /:namespace/:key + * 更新指定命名空间下的键值,如果不存在则创建 + */ +router.post( + "/:namespace/:key", + checkRestrictedUUID, + writeAuthMiddleware, + errors.catchAsync(async (req, res, next) => { + const { namespace, key } = req.params; + const value = req.body; + + if (!value || Object.keys(value).length === 0) { + // 创建并传递错误,而不是抛出 + return next(errors.createError(400, "请提供有效的JSON值")); + } + + // 获取客户端IP + const creatorIp = + req.headers["x-forwarded-for"] || + req.connection.remoteAddress || + req.socket.remoteAddress || + req.connection.socket?.remoteAddress || + ""; + + const result = await kvStore.upsert(namespace, key, value, creatorIp); + return res.status(200).json({ + namespace: result.namespace, + key: result.key, + created: result.createdAt.getTime() === result.updatedAt.getTime(), + updatedAt: result.updatedAt, + }); + }) +); + /** * DELETE /:namespace * 删除指定命名空间及其所有键值对 diff --git a/views/index.ejs b/views/index.ejs index 2b163c0..96faec2 100644 --- a/views/index.ejs +++ b/views/index.ejs @@ -86,7 +86,7 @@