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/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/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..57c2f6f 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,29 @@ 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) // Default 10 minutes + +// Configure underlying Node server timeout via Hono node server Options +const serverOptions: HonoNodeServerOptions = { + fetch: app.fetch, + port, + serverOptions: { + // 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 e0d674b..bf1b27c 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) + // Convert Buffer to 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/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 diff --git a/docker-compose.yml b/docker-compose.yml index 1fdc2be..95e87f7 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/kinboyw/share-note-server:latest 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"