import { HttpStatus, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import type { Request, Response as ExpressResponse } from 'express'; import { throwVbenError } from '@/common'; import type { WordpressArticleBodyDto, WordpressArticleListQueryDto, WordpressTermBodyDto, WordpressTermListQueryDto, } from './wordpress.dto'; export type WordpressAuthContext = { authorization?: string; cookie?: string; nonce?: string; }; export type WordpressLoginResult = { auth: { nonce: string; type: 'cookie'; }; user: any; }; type WordpressRequestOptions = { auth: WordpressAuthContext; body?: Record; method?: 'GET' | 'POST' | 'DELETE'; query?: Record; }; type WordpressResponse = { data: T; total?: number; }; const WORDPRESS_COOKIE_PREFIXES = [ 'wordpress_', 'wordpress_logged_in_', 'wp-settings-', 'wp-postpass_', 'comment_author_', ]; const WORDPRESS_AUTH_COOKIE = 'kt_wordpress_auth'; @Injectable() export class WordpressService { constructor(private readonly configService: ConfigService) {} getAuthContext(request: Request): WordpressAuthContext { const authorization = this.readHeader(request, 'x-wordpress-authorization') || this.readHeader(request, 'x-wp-authorization') || this.getForwardableAuthorization(request); const nonce = this.readHeader(request, 'x-wp-nonce') || this.readHeader(request, 'x-wordpress-nonce'); const cookie = this.readHeader(request, 'x-wordpress-cookie') || this.readCookie(request, WORDPRESS_AUTH_COOKIE) || this.getWordpressCookie(request.headers.cookie); return { authorization, cookie, nonce, }; } async checkAuth(auth: WordpressAuthContext) { const response = await this.request('/wp-json/wp/v2/users/me', { auth, query: { context: 'edit', }, }); return response.data; } async loginWithConfiguredAdmin(): Promise< WordpressLoginResult & { cookie: string } > { const username = this.configService.get('WORDPRESS_ADMIN_USERNAME'); const password = this.configService.get('WORDPRESS_ADMIN_PASSWORD'); if (!username || !password) { throwVbenError( 'WordPress 管理员账号未配置', HttpStatus.BAD_REQUEST, 'WordPressConfigError', ); } const cookie = await this.loginByPassword(username, password); const nonce = await this.fetchRestNonce(cookie); if (!nonce) { throwVbenError( 'WordPress 登录成功但未获取 REST nonce', HttpStatus.BAD_GATEWAY, 'WordPressNonceError', ); } const user = await this.checkAuth({ cookie, nonce, }); return { auth: { nonce, type: 'cookie', }, cookie, user, }; } setAuthCookie(res: ExpressResponse, cookie: string) { res.cookie(WORDPRESS_AUTH_COOKIE, cookie, { ...this.getCookieOptions(), maxAge: 7 * 24 * 60 * 60 * 1000, }); } clearAuthCookie(res: ExpressResponse) { res.clearCookie(WORDPRESS_AUTH_COOKIE, this.getCookieOptions()); res.clearCookie(WORDPRESS_AUTH_COOKIE, { ...this.getCookieOptions(), path: '/api/wordpress', }); res.clearCookie(WORDPRESS_AUTH_COOKIE, { ...this.getCookieOptions(), path: '/wordpress', }); } async articleList(query: WordpressArticleListQueryDto, auth: WordpressAuthContext) { const response = await this.request('/wp-json/wp/v2/posts', { auth, query: { ...this.getPageQuery(query), author: query.author, categories: query.categories, context: 'edit', search: query.search, status: query.status || 'any', tags: query.tags, }, }); return { list: response.data, total: response.total || 0, }; } async articleDetail(id: string | number, auth: WordpressAuthContext) { const response = await this.request(`/wp-json/wp/v2/posts/${id}`, { auth, query: { context: 'edit', }, }); return response.data; } async articleSave(body: WordpressArticleBodyDto, auth: WordpressAuthContext) { const response = await this.request('/wp-json/wp/v2/posts', { auth, body: this.getArticleBody(body), method: 'POST', }); return response.data; } async articleUpdate(body: WordpressArticleBodyDto & { id: number }, auth: WordpressAuthContext) { const response = await this.request(`/wp-json/wp/v2/posts/${body.id}`, { auth, body: this.getArticleBody(body), method: 'POST', }); return response.data; } async articleRemove(id: string | number, force: boolean, auth: WordpressAuthContext) { const response = await this.request(`/wp-json/wp/v2/posts/${id}`, { auth, method: 'DELETE', query: { force, }, }); return response.data; } async tagList(query: WordpressTermListQueryDto, auth: WordpressAuthContext) { return this.termList('/wp-json/wp/v2/tags', query, auth); } async tagDetail(id: string | number, auth: WordpressAuthContext) { return this.termDetail('/wp-json/wp/v2/tags', id, auth); } async tagSave(body: WordpressTermBodyDto, auth: WordpressAuthContext) { return this.termSave('/wp-json/wp/v2/tags', body, auth); } async tagUpdate(body: WordpressTermBodyDto & { id: number }, auth: WordpressAuthContext) { return this.termUpdate('/wp-json/wp/v2/tags', body, auth); } async tagRemove(id: string | number, force: boolean, auth: WordpressAuthContext) { return this.termRemove('/wp-json/wp/v2/tags', id, force, auth); } async categoryList(query: WordpressTermListQueryDto, auth: WordpressAuthContext) { return this.termList('/wp-json/wp/v2/categories', query, auth); } async categoryDetail(id: string | number, auth: WordpressAuthContext) { return this.termDetail('/wp-json/wp/v2/categories', id, auth); } async categorySave(body: WordpressTermBodyDto, auth: WordpressAuthContext) { return this.termSave('/wp-json/wp/v2/categories', body, auth); } async categoryUpdate(body: WordpressTermBodyDto & { id: number }, auth: WordpressAuthContext) { return this.termUpdate('/wp-json/wp/v2/categories', body, auth); } async categoryRemove(id: string | number, force: boolean, auth: WordpressAuthContext) { return this.termRemove('/wp-json/wp/v2/categories', id, force, auth); } private async termList( path: string, query: WordpressTermListQueryDto, auth: WordpressAuthContext, ) { const response = await this.request(path, { auth, query: { ...this.getPageQuery(query), context: 'edit', hide_empty: query.hide_empty, parent: query.parent, search: query.search, }, }); return { list: response.data, total: response.total || 0, }; } private async termDetail( path: string, id: string | number, auth: WordpressAuthContext, ) { const response = await this.request(`${path}/${id}`, { auth, query: { context: 'edit', }, }); return response.data; } private async termSave( path: string, body: WordpressTermBodyDto, auth: WordpressAuthContext, ) { const response = await this.request(path, { auth, body: this.getTermBody(body), method: 'POST', }); return response.data; } private async termUpdate( path: string, body: WordpressTermBodyDto & { id: number }, auth: WordpressAuthContext, ) { const response = await this.request(`${path}/${body.id}`, { auth, body: this.getTermBody(body), method: 'POST', }); return response.data; } private async termRemove( path: string, id: string | number, force: boolean, auth: WordpressAuthContext, ) { const response = await this.request(`${path}/${id}`, { auth, method: 'DELETE', query: { force, }, }); return response.data; } private async request( path: string, options: WordpressRequestOptions, ): Promise> { this.assertAuthContext(options.auth); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), this.getTimeout()); try { const urls = this.getRequestUrls(path, options.query); for (let index = 0; index < urls.length; index += 1) { const response = await fetch(urls[index], { body: options.body ? JSON.stringify(options.body) : undefined, headers: this.getHeaders(options.auth, !!options.body), method: options.method || 'GET', redirect: 'follow', signal: controller.signal, }); const data = await this.parseResponse(response); // 兼容未开启 Apache rewrite 的 WordPress:/wp-json 404 时自动回退到 ?rest_route=。 if (!response.ok && response.status === 404 && index < urls.length - 1) { continue; } if (!response.ok) { throwVbenError( this.getErrorMessage(data, response.status), response.status, data, ); } return { data: data as T, total: Number(response.headers.get('x-wp-total') || 0), }; } throwVbenError('WordPress 请求失败', HttpStatus.BAD_GATEWAY); } catch (err) { if (err instanceof Error && err.name === 'AbortError') { throwVbenError('WordPress 请求超时', HttpStatus.GATEWAY_TIMEOUT); } throw err; } finally { clearTimeout(timer); } } private async loginByPassword(username: string, password: string) { const response = await this.formRequest('/wp-login.php', { log: username, pwd: password, redirect_to: this.getUrl('/wp-admin/'), testcookie: '1', 'wp-submit': 'Log In', }); const setCookies = this.getSetCookieHeaders(response.headers); const cookie = this.toCookieHeader(setCookies); if (!cookie || !/wordpress_(?:logged_in|sec)_/i.test(cookie)) { const body = await response.text().catch(() => ''); throwVbenError( this.getLoginErrorMessage(body), HttpStatus.UNAUTHORIZED, 'WordPressLoginError', ); } return cookie; } private async fetchRestNonce(cookie: string) { const adminPaths = ['/wp-admin/', '/wp-admin/post-new.php', '/wp-admin/edit.php']; for (const path of adminPaths) { const response = await this.rawRequest(path, { headers: { Cookie: cookie, }, }); const html = await response.text().catch(() => ''); const nonce = this.extractRestNonce(html); if (nonce) return nonce; } return ''; } private async formRequest(path: string, body: Record) { const form = new URLSearchParams(body); return this.rawRequest(path, { body: form, headers: { 'Content-Type': 'application/x-www-form-urlencoded', Cookie: 'wordpress_test_cookie=WP Cookie check', }, method: 'POST', redirect: 'manual', }); } private async rawRequest(path: string, init: RequestInit = {}) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), this.getTimeout()); try { return await fetch(this.getUrl(path), { ...init, signal: controller.signal, }); } catch (err) { if (err instanceof Error && err.name === 'AbortError') { throwVbenError('WordPress 请求超时', HttpStatus.GATEWAY_TIMEOUT); } throw err; } finally { clearTimeout(timer); } } private assertAuthContext(auth: WordpressAuthContext) { const hasToken = !!auth.authorization; const hasCookieLogin = !!auth.cookie && !!auth.nonce; if (hasToken || hasCookieLogin) return; throwVbenError( '缺少 WordPress 客户端登录态', HttpStatus.UNAUTHORIZED, 'WordPressUnauthorized', ); } private getHeaders(auth: WordpressAuthContext, hasBody: boolean) { const headers: Record = { Accept: 'application/json', }; if (hasBody) { headers['Content-Type'] = 'application/json'; } if (auth.authorization) { headers.Authorization = auth.authorization; } if (auth.cookie) { headers.Cookie = auth.cookie; } if (auth.nonce) { headers['X-WP-Nonce'] = auth.nonce; } return headers; } private getCookieOptions() { const secure = this.configService.get('ADMIN_COOKIE_SECURE') === 'true'; return { httpOnly: true, path: '/', sameSite: secure ? ('none' as const) : ('lax' as const), secure, }; } private getUrl(path: string, query?: Record) { const baseUrl = this.configService.get('WORDPRESS_BASE_URL'); if (!baseUrl) { throwVbenError( 'WORDPRESS_BASE_URL 未配置', HttpStatus.BAD_REQUEST, 'WordPressConfigError', ); } const url = new URL(`${baseUrl.replace(/\/+$/g, '')}${path}`); Object.entries(query || {}).forEach(([key, value]) => { if (value === undefined || value === null || value === '') return; url.searchParams.set(key, `${value}`); }); return url.toString(); } private getRequestUrls(path: string, query?: Record) { const urls = [this.getUrl(path, query)]; if (path.startsWith('/wp-json/')) { urls.push(this.getRestRouteUrl(path, query)); } return urls; } private getRestRouteUrl(path: string, query?: Record) { const restRoute = path.replace(/^\/wp-json/, '') || '/'; const url = new URL(this.getUrl('/', query)); url.searchParams.set('rest_route', restRoute); return url.toString(); } private getTimeout() { return Number(this.configService.get('WORDPRESS_TIMEOUT_MS') || 15000); } private getPageQuery(query: WordpressPagedQueryDto) { return { order: query.order, orderby: query.orderby, page: Number(query.pageNo || 1), per_page: Number(query.pageSize || 10), }; } private getArticleBody(body: WordpressArticleBodyDto) { return this.pickDefined({ categories: this.normalizeIdList(body.categories), content: body.content, excerpt: body.excerpt, featured_media: body.featured_media, slug: body.slug, status: body.status, sticky: body.sticky, tags: this.normalizeIdList(body.tags), title: body.title, }); } private getTermBody(body: WordpressTermBodyDto) { return this.pickDefined({ description: body.description, name: body.name, parent: body.parent, slug: body.slug, }); } private normalizeIdList(value?: number[] | string) { if (Array.isArray(value)) return value; if (typeof value !== 'string') return value; return value .split(',') .map((item) => Number(item.trim())) .filter((item) => !Number.isNaN(item)); } private pickDefined(payload: Record) { return Object.entries(payload).reduce>( (acc, [key, value]) => { if (value !== undefined && value !== null && value !== '') { acc[key] = value; } return acc; }, {}, ); } private async parseResponse(response: globalThis.Response) { const text = await response.text(); if (!text) return null; try { return JSON.parse(text); } catch { return text; } } private getErrorMessage(data: any, status: number) { if (data?.message) return data.message; if (typeof data === 'string' && data) return data; return `WordPress 请求失败:${status}`; } private getLoginErrorMessage(html: string) { const match = html.match(/]*id=["']login_error["'][^>]*>([\s\S]*?)<\/div>/i); if (!match?.[1]) return 'WordPress 管理员登录失败'; return match[1].replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim(); } private getSetCookieHeaders(headers: Headers) { const getSetCookie = (headers as any).getSetCookie; if (typeof getSetCookie === 'function') { return getSetCookie.call(headers) as string[]; } const raw = (headers as any).raw?.()?.['set-cookie']; if (Array.isArray(raw)) return raw as string[]; const setCookie = headers.get('set-cookie'); if (!setCookie) return []; return this.splitSetCookieHeader(setCookie); } private splitSetCookieHeader(value: string) { return value.split(/,(?=\s*[^;,]+=)/).map((item) => item.trim()); } private toCookieHeader(setCookies: string[]) { const cookies = setCookies .map((item) => item.split(';')[0]?.trim()) .filter((item): item is string => { if (!item) return false; const [key] = item.split('='); return WORDPRESS_COOKIE_PREFIXES.some((prefix) => key.startsWith(prefix), ); }); return cookies.join('; '); } private extractRestNonce(html: string) { const patterns = [ /"nonce"\s*:\s*"([^"]+)"/i, /wpApiSettings\s*=\s*\{[\s\S]*?nonce["']?\s*:\s*["']([^"']+)/i, ]; for (const pattern of patterns) { const match = html.match(pattern); if (match?.[1]) { return match[1].replace(/\\\//g, '/'); } } return ''; } private readHeader(request: Request, name: string) { const value = request.headers[name.toLowerCase()]; return Array.isArray(value) ? value[0] : value; } private readCookie(request: Request, cookieName: string) { const cookieHeader = request.headers.cookie || ''; const cookie = cookieHeader.split(';').find((item) => { const [key] = item.trim().split('='); return key === cookieName; }); if (!cookie) return undefined; const [, ...value] = cookie.trim().split('='); try { return decodeURIComponent(value.join('=')); } catch { return value.join('='); } } private getForwardableAuthorization(request: Request) { const authorization = this.readHeader(request, 'authorization'); if (!authorization || this.isLikelyAdminAuthorization(authorization)) { return undefined; } return authorization; } private isLikelyAdminAuthorization(authorization: string) { if (!authorization.startsWith('Bearer ')) return false; const token = authorization.replace(/^Bearer\s+/i, ''); const [encodedPayload, signature, extra] = token.split('.'); if (!encodedPayload || !signature || extra) return false; try { const payload = JSON.parse( Buffer.from(encodedPayload, 'base64url').toString('utf8'), ); return payload?.type === 'access' || payload?.type === 'refresh'; } catch { return false; } } private getWordpressCookie(cookieHeader?: string) { if (!cookieHeader) return undefined; // 只透传 WordPress 登录相关 cookie,避免把本系统 admin token 泄露给 WordPress。 const cookies = cookieHeader .split(';') .map((item) => item.trim()) .filter((item) => { const [key] = item.split('='); return WORDPRESS_COOKIE_PREFIXES.some((prefix) => key.startsWith(prefix), ); }); return cookies.length ? cookies.join('; ') : undefined; } } type WordpressPagedQueryDto = WordpressArticleListQueryDto | WordpressTermListQueryDto;