From 851fd0ac5fdfbbb3a4c3d131f02694adf5b9fa7e Mon Sep 17 00:00:00 2001 From: kinboy Date: Wed, 17 Dec 2025 17:31:14 +0800 Subject: [PATCH 1/3] fix: improve Docker build and HTTP server timeout configuration This commit consolidates fixes for Docker build issues and HTTP server timeout problems when serving large files. Docker build improvements: - Fix GitHub CI Actions platform compatibility issue with node:lts-alpine - Remove --build-from-source option from Dockerfile (not needed) - Add build dependencies (python3, make, g++) for better-sqlite3 compilation - Update CI workflow to only build for linux/amd64 and linux/arm64 platforms HTTP server timeout fixes: - Increase default server timeout to 10 minutes (600000ms) to handle large CSS files - Configure keepAliveTimeout, requestTimeout, and headersTimeout - Fix Buffer to ArrayBuffer conversion in sha1() helper function - Update docker-compose.yml image version These changes resolve timeout issues when serving large CSS files (>8MB) and improve Docker build reliability across different platforms. fix: timeout issues fix: github ci actions node:lts-alpine platform issue fix: remove --build-from-source options --- .github/workflows/ci.yaml | 4 +++- Dockerfile | 3 +++ app/package-lock.json | 4 ++-- app/src/index.ts | 20 +++++++++++++++++++- app/src/v1/helpers.ts | 4 +++- docker-compose.yml | 2 +- mise.toml | 2 ++ 7 files changed, 33 insertions(+), 6 deletions(-) create mode 100644 mise.toml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 59b013f..1d00601 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -42,9 +42,11 @@ jobs: context: . file: Dockerfile push: true - platforms: linux/amd64,linux/arm64,linux/arm/v7 + platforms: linux/amd64,linux/arm64 build-args: | PACKAGE_VERSION=${{ env.PACKAGE_VERSION }} tags: | ghcr.io/${{ github.repository }}:${{ env.PACKAGE_VERSION }} ghcr.io/${{ github.repository }}:latest + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/Dockerfile b/Dockerfile index 8e8b719..d024e96 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,8 @@ FROM node:lts-alpine +# 安装构建 better-sqlite3 所需的依赖 +RUN apk add --no-cache python3 make g++ + COPY app /notesx/app COPY db /notesx/db COPY userfiles /notesx/userfiles diff --git a/app/package-lock.json b/app/package-lock.json index b95ca20..62e2be6 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -1,12 +1,12 @@ { "name": "notesx-api", - "version": "1.1.0", + "version": "1.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "notesx-api", - "version": "1.1.0", + "version": "1.1.1", "dependencies": { "@hono/node-server": "^1.13.8", "better-sqlite3": "^12.2.0", diff --git a/app/src/index.ts b/app/src/index.ts index a3bb2d7..6b49cb1 100644 --- a/app/src/index.ts +++ b/app/src/index.ts @@ -1,5 +1,6 @@ import { Hono } from 'hono' import { serve } from '@hono/node-server' +import type { Options as HonoNodeServerOptions } from '@hono/node-server/dist/types' import { serveStatic } from '@hono/node-server/serve-static' import { cors } from 'hono/cors' import { etag } from 'hono/etag' @@ -136,4 +137,21 @@ process.on('SIGTERM', () => { new Cron(appInstance) -serve(app) +const port = parseInt(process.env.PORT || '3000', 10) +const serverTimeout = parseInt(process.env.SERVER_TIMEOUT || '600000', 10) // 默认 10 分钟 + +// 通过 Hono node server 的 Options 在创建时配置底层 Node server 的超时 +const serverOptions: HonoNodeServerOptions = { + fetch: app.fetch, + port, + serverOptions: { + // 整个请求生命周期超时(Node 18+ 的 requestTimeout) + requestTimeout: serverTimeout, + // 响应头发送后的超时 + headersTimeout: serverTimeout, + // keep-alive 空闲连接超时,对应响应头里的 Keep-Alive: timeout=... + keepAliveTimeout: serverTimeout + } +} + +const server = serve(serverOptions) diff --git a/app/src/v1/helpers.ts b/app/src/v1/helpers.ts index e0d674b..bb42e29 100644 --- a/app/src/v1/helpers.ts +++ b/app/src/v1/helpers.ts @@ -74,7 +74,9 @@ export async function sha256 (data: string | ArrayBuffer) { } export async function sha1 (data: string | Buffer) { - return sha('SHA-1', data) + // 将 Buffer 转换为 ArrayBuffer + const arrayBuffer = typeof data === 'string' ? data : new Uint8Array(data).buffer + return sha('SHA-1', arrayBuffer) } export async function shortHash (text: string) { diff --git a/docker-compose.yml b/docker-compose.yml index 1fdc2be..3068b83 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: notesx-server: - image: ghcr.io/note-sx/server:latest + image: ghcr.io/note-sx/server:1.0.0 container_name: notesx-server restart: always ports: diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..df7e1bb --- /dev/null +++ b/mise.toml @@ -0,0 +1,2 @@ +[tools] +node = "v22" From 2cdeeaf4e8c9ef660bda88691fbc2d16617af1e0 Mon Sep 17 00:00:00 2001 From: kinboy Date: Wed, 17 Dec 2025 23:12:26 +0800 Subject: [PATCH 2/3] feat: support multiple CSS file chunks for large CSS files Add support for splitting large CSS files into multiple chunks to avoid timeout issues when serving large CSS files (>8MB). Key changes: - Support CSS file array format: template.css?: Array<{ url: string, hash: string }> - CSS file upload supports multiple chunks with unique filenames (user UID + hash prefix) - checkCss() and checkFiles() return CSS files as arrays instead of single objects - checkFile() supports finding CSS chunks by hash and user UID prefix - WebNote.setCss() supports both array and string formats for backward compatibility - HTML template updated to allow multiple CSS link tags via placeholder Technical implementation: - CSS chunks stored with filenames: {userUID}{hashPrefix8chars} - Multiple CSS files queried by filename prefix matching (LIKE pattern) - Backward compatible: falls back to single CSS file logic if no array provided - CSS file lookup optimized for chunked files using hash + filename prefix Files modified: - app/src/v1/File.ts: CSS handling for upload, check, and note creation - app/src/v1/WebNote.ts: setCss() method to support arrays - app/src/v1/templates/note.html: removed hardcoded link tag, use placeholder - app/src/index.ts: HTTP server timeout configuration - app/src/v1/helpers.ts: Buffer to ArrayBuffer conversion fix --- Makefile | 49 ++++++++++ app/src/index.ts | 22 +++-- app/src/v1/File.ts | 169 ++++++++++++++++++++++++++------- app/src/v1/WebNote.ts | 11 ++- app/src/v1/helpers.ts | 2 +- app/src/v1/templates/note.html | 2 +- 6 files changed, 212 insertions(+), 43 deletions(-) create mode 100644 Makefile diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..41a4027 --- /dev/null +++ b/Makefile @@ -0,0 +1,49 @@ +.PHONY: build-app docker dev clean help + +# 检测包管理器 (优先使用 pnpm,否则使用 npm) +NPM := $(shell command -v pnpm >/dev/null 2>&1 && echo pnpm || echo npm) + +# 获取版本号 +VERSION := $(shell cd app && $(NPM) pkg get version 2>/dev/null | xargs || echo "1.0.0") +IMAGE_NAME := ghcr.io/kinboyw/share-note-server +DOCKER_IMAGE := $(IMAGE_NAME):$(VERSION) + +help: ## 显示帮助信息 + @echo "可用的命令:" + @echo " make build-app - 构建应用 (编译 TypeScript)" + @echo " make docker - 构建 Docker 镜像 (依赖 build-app)" + @echo " make dev - 启动开发环境 (依赖 docker)" + @echo " make clean - 清理构建产物" + @echo "" + @echo "当前版本: $(VERSION)" + @echo "Docker 镜像: $(DOCKER_IMAGE)" + +build-app: ## 构建应用 + @echo "构建应用 (使用 $(NPM))..." + cd app && $(NPM) run build + @echo "构建完成!" + +docker: build-app ## 构建 Docker 镜像 + @echo "构建 Docker 镜像: $(DOCKER_IMAGE)" + docker build \ + --build-arg PACKAGE_VERSION=$(VERSION) \ + -t $(DOCKER_IMAGE) \ + -t $(IMAGE_NAME):latest \ + . + @echo "Docker 镜像构建完成: $(DOCKER_IMAGE)" + +dev: docker ## 启动开发环境 + @echo "启动开发环境..." + @if docker compose version >/dev/null 2>&1; then \ + docker compose up -d; \ + else \ + docker-compose up -d; \ + fi + @echo "开发环境已启动!" + @echo "服务运行在 http://localhost:3000" + +clean: ## 清理构建产物 + @echo "清理构建产物..." + rm -rf app/dist + @echo "清理完成!" + diff --git a/app/src/index.ts b/app/src/index.ts index 6b49cb1..57c2f6f 100644 --- a/app/src/index.ts +++ b/app/src/index.ts @@ -138,20 +138,28 @@ process.on('SIGTERM', () => { new Cron(appInstance) const port = parseInt(process.env.PORT || '3000', 10) -const serverTimeout = parseInt(process.env.SERVER_TIMEOUT || '600000', 10) // 默认 10 分钟 +const serverTimeout = parseInt(process.env.SERVER_TIMEOUT || '600000', 10) // Default 10 minutes -// 通过 Hono node server 的 Options 在创建时配置底层 Node server 的超时 +// Configure underlying Node server timeout via Hono node server Options const serverOptions: HonoNodeServerOptions = { fetch: app.fetch, port, serverOptions: { - // 整个请求生命周期超时(Node 18+ 的 requestTimeout) - requestTimeout: serverTimeout, - // 响应头发送后的超时 - headersTimeout: serverTimeout, - // keep-alive 空闲连接超时,对应响应头里的 Keep-Alive: timeout=... + // keep-alive idle connection timeout, corresponds to Keep-Alive: timeout=... header keepAliveTimeout: serverTimeout } } const server = serve(serverOptions) + +// Manually set timeout options after creating server (compatible with different Node.js versions and type definitions) +// These options are available in Node.js 18+ but type definitions may be incomplete +if ('timeout' in server && typeof (server as any).timeout === 'number') { + (server as any).timeout = serverTimeout +} +if ('requestTimeout' in server && typeof (server as any).requestTimeout === 'number') { + (server as any).requestTimeout = serverTimeout +} +if ('headersTimeout' in server && typeof (server as any).headersTimeout === 'number') { + (server as any).headersTimeout = serverTimeout +} diff --git a/app/src/v1/File.ts b/app/src/v1/File.ts index d62fec0..22e486c 100644 --- a/app/src/v1/File.ts +++ b/app/src/v1/File.ts @@ -163,13 +163,48 @@ export default class File extends Controller { } } else if (this.extension === 'css') { /* - * CSS is special, as it uses the user's UID for the filename + * CSS files support multiple chunks + * 1. If hash is provided, check if a CSS file with the same hash already exists (avoid duplicate uploads) + * 2. If exists, use that filename + * 3. If not, generate a new filename (user UID + first 8 chars of hash as suffix) */ - this.filename = await this.getCssFilename() - await this.file.load({ - filetype: this.extension, - filename: this.filename - }) + const cssFilename = await this.getCssFilename() + + if (this.hash) { + // Check if a CSS file with the same hash already exists + const existingCss = this.app.db.prepare(` + SELECT filename, hash + FROM files + WHERE filetype = 'css' + AND filename LIKE ? + AND hash = ? + LIMIT 1 + `).get(cssFilename + '%', this.hash) as { filename: string; hash: string } | undefined + + if (existingCss) { + // CSS file with same hash exists, use it + this.filename = existingCss.filename + await this.file.load({ + filetype: this.extension, + filename: this.filename + }) + } else { + // Not found, generate new filename: user UID + first 8 chars of hash as suffix + const hashSuffix = this.hash.substring(0, 8) + this.filename = cssFilename + hashSuffix + await this.file.load({ + filetype: this.extension, + filename: this.filename + }) + } + } else { + // No hash provided, use legacy logic (single CSS file) + this.filename = cssFilename + await this.file.load({ + filetype: this.extension, + filename: this.filename + }) + } } else { /* * Other file-types, not HTML @@ -206,7 +241,33 @@ export default class File extends Controller { const template = this.post.template // Make replacements - note.setCss(this.getDisplayUrl(await this.getCssFilename(), 'css')) + // Handle CSS files: support array format css?: Array<{ url: string, hash: string }> + if (template.css && Array.isArray(template.css) && template.css.length > 0) { + const cssUrls = template.css + .map((cssItem: { url: string; hash: string }) => { + if (!cssItem?.url) return null + + const cssUrl = cssItem.url + if (cssUrl.startsWith('http://') || cssUrl.startsWith('https://')) { + return cssUrl + } else if (cssUrl.startsWith('/')) { + return this.app.baseWebUrl + cssUrl + } else { + return this.app.baseWebUrl + '/' + cssUrl + } + }) + .filter((url: string | null): url is string => url !== null) + + if (cssUrls.length > 0) { + note.setCss(cssUrls) + } else { + // Array is empty or invalid, fallback to legacy single CSS file logic + note.setCss(this.getDisplayUrl(await this.getCssFilename(), 'css')) + } + } else { + // No CSS array provided, use legacy single CSS file logic + note.setCss(this.getDisplayUrl(await this.getCssFilename(), 'css')) + } note.setWidth(template.width) note.enableMathjax(!!template.mathJax) @@ -393,37 +454,75 @@ export default class File extends Controller { return this.cssFilename } + /** + * Check all CSS files for the user + * Returns array format, each element contains url and hash + */ async checkCss () { - const file = await Mapper(this.app.db, 'files') - await file.load({ - filename: await this.getCssFilename(), - filetype: 'css' - }) + // Query all CSS files for this user (matched by filename prefix) + const cssFilename = await this.getCssFilename() + const cssFiles = this.app.db.prepare(` + SELECT filename, hash, filetype + FROM files + WHERE filetype = 'css' + AND filename LIKE ? + ORDER BY filename + `).all(cssFilename + '%') as Array<{ filename: string; hash: string; filetype: string }> + + // Convert to array format, each element contains url and hash + const cssArray = cssFiles.map(cssFile => ({ + url: this.getDisplayUrl(cssFile.filename, cssFile.filetype), + hash: cssFile.hash + })) + return { - success: !!file?.found + success: cssArray.length > 0, + css: cssArray.length > 0 ? cssArray : [] } } /** * Check to see if a file matching this exact contents is already uploaded on the server + * For CSS files, supports checking multiple chunk files by hash */ async checkFile (item?: CheckFileItem): Promise { const params: { [key: string]: string } = { filetype: item?.filetype || this.post.filetype, hash: item?.hash || this.post.hash } + if (params.filetype === 'css') { - // CSS files also need to match on salted UID - params.filename = await this.getCssFilename() - } - const file = await Mapper(this.app.db, 'files') - if (params.filetype && params.hash) { - await file.load(params) - if (file.found) { - const url = this.getDisplayUrl(file.row.filename, file.row.filetype) - return this.returnSuccessUrl(url) + // CSS files: match by hash and user UID (filename prefix) + // Since there may be multiple CSS chunks now, need to find the specific file by hash + const cssFilename = await this.getCssFilename() + if (params.hash) { + // Query if there's a matching hash in the user's CSS files + const cssFile = this.app.db.prepare(` + SELECT filename, hash, filetype + FROM files + WHERE filetype = 'css' + AND filename LIKE ? + AND hash = ? + LIMIT 1 + `).get(cssFilename + '%', params.hash) as { filename: string; hash: string; filetype: string } | undefined + + if (cssFile) { + const url = this.getDisplayUrl(cssFile.filename, cssFile.filetype) + return this.returnSuccessUrl(url) + } + } + } else { + // Non-CSS files: use original logic + const file = await Mapper(this.app.db, 'files') + if (params.filetype && params.hash) { + await file.load(params) + if (file.found) { + const url = this.getDisplayUrl(file.row.filename, file.row.filetype) + return this.returnSuccessUrl(url) + } } } + return { success: false, url: null @@ -440,20 +539,26 @@ export default class File extends Controller { result.push(file) } - // Get the info on the user's CSS (if exists) - const css = await Mapper(this.app.db, 'files') - await css.load({ - filename: await this.getCssFilename(), - filetype: 'css' - }) + // Get the info on the user's CSS files (returns array format) + const cssFilename = await this.getCssFilename() + const cssFiles = this.app.db.prepare(` + SELECT filename, hash, filetype + FROM files + WHERE filetype = 'css' + AND filename LIKE ? + ORDER BY filename + `).all(cssFilename + '%') as Array<{ filename: string; hash: string; filetype: string }> + + // Convert to array format, each element contains url and hash + const cssArray = cssFiles.map(cssFile => ({ + url: this.getDisplayUrl(cssFile.filename, cssFile.filetype), + hash: cssFile.hash + })) return { success: true, files: result, - css: css.notFound ? null : { - url: this.getDisplayUrl(await this.getCssFilename(), 'css'), - hash: css.row.hash - } + css: cssArray } } diff --git a/app/src/v1/WebNote.ts b/app/src/v1/WebNote.ts index 6c28d45..6b5b60a 100644 --- a/app/src/v1/WebNote.ts +++ b/app/src/v1/WebNote.ts @@ -62,8 +62,15 @@ export default class WebNote { }[tag] || '')) } - setCss (url: string) { - this.replace(this.placeholders.css, url) + setCss (url: string | string[]) { + if (Array.isArray(url)) { + // Multiple CSS files: generate multiple link tags + const cssLinks = url.map(cssUrl => ``).join('\n ') + this.replace(this.placeholders.css, cssLinks) + } else { + // Single CSS file: maintain backward compatibility + this.replace(this.placeholders.css, ``) + } } setWidth (width: any) { diff --git a/app/src/v1/helpers.ts b/app/src/v1/helpers.ts index bb42e29..bf1b27c 100644 --- a/app/src/v1/helpers.ts +++ b/app/src/v1/helpers.ts @@ -74,7 +74,7 @@ export async function sha256 (data: string | ArrayBuffer) { } export async function sha1 (data: string | Buffer) { - // 将 Buffer 转换为 ArrayBuffer + // Convert Buffer to ArrayBuffer const arrayBuffer = typeof data === 'string' ? data : new Uint8Array(data).buffer return sha('SHA-1', arrayBuffer) } diff --git a/app/src/v1/templates/note.html b/app/src/v1/templates/note.html index 56d6a09..27d92e6 100644 --- a/app/src/v1/templates/note.html +++ b/app/src/v1/templates/note.html @@ -10,7 +10,7 @@ - + TEMPLATE_CSS TEMPLATE_SCRIPTS From 3db520514c8c54e0143645331c75c1a868241af9 Mon Sep 17 00:00:00 2001 From: kinboy Date: Thu, 18 Dec 2025 09:55:06 +0800 Subject: [PATCH 3/3] fix: docker-compose image update --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 3068b83..95e87f7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: notesx-server: - image: ghcr.io/note-sx/server:1.0.0 + image: ghcr.io/kinboyw/share-note-server:latest container_name: notesx-server restart: always ports: