kt-template-online-api/src/wordpress/wordpress.service.ts

749 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, unknown>;
method?: 'GET' | 'POST' | 'DELETE';
query?: Record<string, unknown>;
};
type WordpressResponse<T> = {
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<string>('WORDPRESS_ADMIN_USERNAME');
const password = this.configService.get<string>('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<any[]>('/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<any[]>(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<T>(
path: string,
options: WordpressRequestOptions,
): Promise<WordpressResponse<T>> {
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<string, string>) {
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<string, string> = {
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<string>('ADMIN_COOKIE_SECURE') === 'true';
return {
httpOnly: true,
path: '/',
sameSite: secure ? ('none' as const) : ('lax' as const),
secure,
};
}
private getUrl(path: string, query?: Record<string, unknown>) {
const baseUrl = this.configService.get<string>('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<string, unknown>) {
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<string, unknown>) {
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<string, unknown>) {
return Object.entries(payload).reduce<Record<string, unknown>>(
(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(/<div[^>]*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;