From 490632c7a129a6366ffe5ee7a6b723432ecfee74 Mon Sep 17 00:00:00 2001 From: sunlei Date: Fri, 5 Jun 2026 15:22:43 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8DWordPress=E4=B8=BB?= =?UTF-8?q?=E9=A2=98=E6=BA=90=E7=AB=99=E6=8A=93=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 1 + src/wordpress/wordpress.service.ts | 159 ++++++++++++++++++++++- test/wordpress/wordpress.service.spec.ts | 60 +++++++++ 3 files changed, 219 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 9787b9b..d2e3a5c 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,7 @@ MINIO_SECRET_KEY=minioadmin MINIO_BUCKET=kt-template-online WORDPRESS_BASE_URL=http://localhost +WORDPRESS_HOST_HEADER= WORDPRESS_ADMIN_USERNAME=admin WORDPRESS_ADMIN_PASSWORD= WORDPRESS_TIMEOUT_MS=15000 diff --git a/src/wordpress/wordpress.service.ts b/src/wordpress/wordpress.service.ts index ac402b7..d5078b5 100644 --- a/src/wordpress/wordpress.service.ts +++ b/src/wordpress/wordpress.service.ts @@ -1,3 +1,6 @@ +import { request as httpRequest } from 'node:http'; +import { request as httpsRequest } from 'node:https'; +import type { IncomingHttpHeaders } from 'node:http'; import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import type { Request, Response as ExpressResponse } from 'express'; @@ -628,6 +631,13 @@ export class WordpressService { init: RequestInit = {}, timeoutMs?: number, ) { + const url = this.getUrl(path); + const hostHeader = this.getWordpressHostHeader(); + + if (hostHeader) { + return this.rawNodeRequest(url, init, timeoutMs, hostHeader); + } + const controller = new AbortController(); const timer = setTimeout( () => controller.abort(), @@ -635,7 +645,7 @@ export class WordpressService { ); try { - return await fetch(this.getUrl(path), { + return await fetch(url, { ...init, signal: controller.signal, }); @@ -654,6 +664,140 @@ export class WordpressService { } } + private async rawNodeRequest( + url: string, + init: RequestInit, + timeoutMs: number | undefined, + hostHeader: string, + redirectCount = 0, + ): Promise { + const response = await this.executeRawNodeRequest( + url, + init, + timeoutMs, + hostHeader, + ); + const shouldFollowRedirect = + (init.redirect || 'follow') !== 'manual' && + response.status >= 300 && + response.status < 400 && + !!response.headers.get('location'); + + if (!shouldFollowRedirect) { + return response; + } + + if (redirectCount >= 5) { + return response; + } + + const nextUrl = new URL(response.headers.get('location') || '', url); + + return this.rawNodeRequest( + nextUrl.toString(), + init, + timeoutMs, + hostHeader, + redirectCount + 1, + ); + } + + private async executeRawNodeRequest( + url: string, + init: RequestInit, + timeoutMs: number | undefined, + hostHeader: string, + ): Promise { + const target = new URL(url); + const request = target.protocol === 'https:' ? httpsRequest : httpRequest; + const headers = this.getRawNodeRequestHeaders(init.headers, hostHeader); + const body = this.getRawNodeRequestBody(init.body); + + if (body && !headers['content-length']) { + headers['content-length'] = `${Buffer.byteLength(body)}`; + } + + return new Promise((resolve, reject) => { + const req = request( + target, + { + headers, + method: init.method || 'GET', + }, + (res) => { + const chunks: Buffer[] = []; + + res.on('data', (chunk) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + res.on('error', reject); + res.on('end', () => { + resolve( + new Response(Buffer.concat(chunks), { + headers: this.toResponseHeaders(res.headers), + status: res.statusCode || HttpStatus.BAD_GATEWAY, + statusText: res.statusMessage, + }), + ); + }); + }, + ); + const timer = setTimeout(() => { + const error = new Error('WordPress 请求超时'); + error.name = 'AbortError'; + req.destroy(error); + }, timeoutMs || this.getTimeout()); + + req.on('error', reject); + req.on('close', () => clearTimeout(timer)); + req.end(body); + }); + } + + private getRawNodeRequestHeaders( + headersInit: HeadersInit | undefined, + hostHeader: string, + ) { + const headers = new Headers(headersInit); + const result: Record = {}; + + headers.set('Host', hostHeader); + headers.forEach((value, key) => { + result[key] = value; + }); + + return result; + } + + private getRawNodeRequestBody(body: RequestInit['body']) { + if (!body) return undefined; + if (typeof body === 'string') return body; + if (body instanceof URLSearchParams) return body.toString(); + if (body instanceof ArrayBuffer) return Buffer.from(body); + if (ArrayBuffer.isView(body)) { + return Buffer.from(body.buffer, body.byteOffset, body.byteLength); + } + + return body as any; + } + + private toResponseHeaders(headers: IncomingHttpHeaders) { + const responseHeaders = new Headers(); + + Object.entries(headers).forEach(([key, value]) => { + if (Array.isArray(value)) { + value.forEach((item) => responseHeaders.append(key, item)); + return; + } + + if (value !== undefined) { + responseHeaders.set(key, value); + } + }); + + return responseHeaders; + } + private assertAuthContext(auth?: WordpressAuthContext) { const hasToken = !!auth?.authorization; const hasCookieLogin = !!auth?.cookie && !!auth?.nonce; @@ -703,6 +847,19 @@ export class WordpressService { }; } + private getWordpressHostHeader() { + const host = this.toolsService.toTrimmedString( + this.configService.get('WORDPRESS_HOST_HEADER'), + ); + + if (!host) return ''; + + return host + .replace(/^https?:\/\//i, '') + .replace(/\/.*$/g, '') + .trim(); + } + private getUrl(path: string, query?: Record) { const baseUrl = this.configService.get('WORDPRESS_BASE_URL'); diff --git a/test/wordpress/wordpress.service.spec.ts b/test/wordpress/wordpress.service.spec.ts index 5cce7f8..cb23145 100644 --- a/test/wordpress/wordpress.service.spec.ts +++ b/test/wordpress/wordpress.service.spec.ts @@ -1,3 +1,4 @@ +import { createServer, type Server } from 'node:http'; import { ConfigService } from '@nestjs/config'; import { MarkdownService, ToolsService } from '../../src/common'; import { WordpressService } from '../../src/wordpress/wordpress.service'; @@ -179,6 +180,65 @@ describe('WordpressService theme config', () => { }); }); + it('uses configured WordPress host header for raw theme html requests', async () => { + const hosts: string[] = []; + const server = await new Promise((resolve) => { + const nextServer = createServer((request, response) => { + hosts.push(request.headers.host || ''); + + if (request.headers.host !== 'blog.kwitsukasa.top') { + response.writeHead(301, { + Location: 'http://127.0.0.1/', + }); + response.end(); + return; + } + + if (request.url?.includes('rest_route=')) { + response.writeHead(200, { + 'Content-Type': 'application/json', + }); + response.end(JSON.stringify(rootPayload)); + return; + } + + response.writeHead(200, { + 'Content-Type': 'text/html', + }); + response.end(html); + }); + + nextServer.listen(0, '127.0.0.1', () => resolve(nextServer)); + }); + + try { + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('测试服务启动失败'); + } + service = new WordpressService( + new ConfigService({ + WORDPRESS_BASE_URL: `http://127.0.0.1:${address.port}`, + WORDPRESS_HOST_HEADER: 'blog.kwitsukasa.top', + }), + markdownService, + new ToolsService(), + ); + + const config = await service.themeConfig(); + + expect(config.themeColor).toBe('#c3a1ed'); + expect(config.site.title).toBe('KwiTsukasa的小站'); + expect(hosts).toEqual([ + 'blog.kwitsukasa.top', + 'blog.kwitsukasa.top', + ]); + expect(fetchSpy).not.toHaveBeenCalled(); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + it('renders markdown article content before saving to WordPress', async () => { fetchSpy.mockImplementation(async (url: URL | RequestInfo, init?: any) => { const target = `${url}`;