Suwayomi认证方式重构

This commit is contained in:
mtvpls
2026-04-17 22:50:58 +08:00
parent 92a7a0d8a7
commit 7fb11f0ff7
6 changed files with 378 additions and 27 deletions
+3 -1
View File
@@ -246,7 +246,9 @@ export interface AdminConfig {
SuwayomiConfig?: {
Enabled: boolean; // 是否启用漫画展馆
ServerURL: string; // Suwayomi 服务地址
AuthToken?: string; // 可选认证 Token
AuthMode?: 'none' | 'basic_auth' | 'simple_login'; // 认证模式
Username?: string; // 登录用户名
Password?: string; // 登录密码
DefaultLang?: string; // 默认语言,如 zh
SourceIds?: string[]; // 限制可用源
MaxSources?: number; // 搜索时最多查询多少个源
+14 -3
View File
@@ -627,7 +627,9 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
adminConfig.SuwayomiConfig = {
Enabled: process.env.SUWAYOMI_ENABLED === 'true',
ServerURL: process.env.SUWAYOMI_URL || process.env.NEXT_PUBLIC_SUWAYOMI_URL || '',
AuthToken: process.env.SUWAYOMI_AUTH_TOKEN || '',
AuthMode: (process.env.SUWAYOMI_AUTH_MODE as 'none' | 'basic_auth' | 'simple_login' | undefined) || 'none',
Username: process.env.SUWAYOMI_USERNAME || '',
Password: process.env.SUWAYOMI_PASSWORD || '',
DefaultLang: process.env.SUWAYOMI_DEFAULT_LANG || 'zh',
SourceIds: [],
MaxSources: Number(process.env.SUWAYOMI_MAX_SOURCES || 10),
@@ -639,8 +641,17 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
if (adminConfig.SuwayomiConfig.ServerURL === undefined) {
adminConfig.SuwayomiConfig.ServerURL = '';
}
if (adminConfig.SuwayomiConfig.AuthToken === undefined) {
adminConfig.SuwayomiConfig.AuthToken = '';
if (
adminConfig.SuwayomiConfig.AuthMode !== 'basic_auth' &&
adminConfig.SuwayomiConfig.AuthMode !== 'simple_login'
) {
adminConfig.SuwayomiConfig.AuthMode = 'none';
}
if (adminConfig.SuwayomiConfig.Username === undefined) {
adminConfig.SuwayomiConfig.Username = '';
}
if (adminConfig.SuwayomiConfig.Password === undefined) {
adminConfig.SuwayomiConfig.Password = '';
}
if (adminConfig.SuwayomiConfig.DefaultLang === undefined) {
adminConfig.SuwayomiConfig.DefaultLang = 'zh';
+168 -9
View File
@@ -1,5 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { createHash } from 'crypto';
import { getConfig } from './config';
import {
MangaChapter,
@@ -17,21 +19,115 @@ interface GraphQLResponse<T> {
interface SuwayomiClientOptions {
serverUrl?: string;
token?: string;
authMode?: 'none' | 'basic_auth' | 'simple_login';
username?: string;
password?: string;
}
interface ResolvedSuwayomiConfig {
serverBaseUrl: string;
serverUrl: string;
token?: string;
authMode: 'none' | 'basic_auth' | 'simple_login';
username?: string;
password?: string;
defaultLang: string;
sourceIds: string[];
maxSources: number;
}
interface SuwayomiSessionCacheEntry {
cookieHeader: string;
expiresAt: number;
}
const SUWAYOMI_SESSION_TTL_MS = 25 * 60 * 1000;
const suwayomiSessionCache = new Map<string, SuwayomiSessionCacheEntry>();
function normalizeSuwayomiAuthMode(value?: string | null): 'none' | 'basic_auth' | 'simple_login' {
if (value === 'basic_auth' || value === 'simple_login') {
return value;
}
return 'none';
}
function buildBasicAuthHeader(username: string, password: string): string {
return `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
}
function hashSimpleLoginPassword(password?: string): string {
return createHash('sha256').update(password || '').digest('hex');
}
function getSimpleLoginCacheKey(config: ResolvedSuwayomiConfig): string {
return `${config.serverBaseUrl}|${config.username || ''}|${hashSimpleLoginPassword(config.password)}`;
}
function getResponseSetCookieHeaders(response: Response): string[] {
const headers = response.headers as Headers & { getSetCookie?: () => string[] };
if (typeof headers.getSetCookie === 'function') {
return headers.getSetCookie();
}
const setCookie = response.headers.get('set-cookie');
return setCookie ? [setCookie] : [];
}
function extractCookieHeader(response: Response): string | null {
const cookies = getResponseSetCookieHeaders(response)
.map((item) => item.split(';', 1)[0]?.trim())
.filter(Boolean) as string[];
return cookies.length > 0 ? cookies.join('; ') : null;
}
export async function loginWithSimpleAuth(
config: ResolvedSuwayomiConfig,
forceRefresh = false
): Promise<string> {
if (!config.username || !config.password) {
throw new Error('Suwayomi simple_login 缺少用户名或密码');
}
const cacheKey = getSimpleLoginCacheKey(config);
const cached = suwayomiSessionCache.get(cacheKey);
if (!forceRefresh && cached && cached.expiresAt > Date.now()) {
return cached.cookieHeader;
}
const response = await fetch(
`${config.serverBaseUrl}/login.html?redirect=${encodeURIComponent('/api/graphql')}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
user: config.username,
pass: config.password,
}).toString(),
redirect: 'manual',
cache: 'no-store',
}
);
const cookieHeader = extractCookieHeader(response);
if (!cookieHeader) {
throw new Error(`Suwayomi simple_login 登录失败: ${response.status}`);
}
suwayomiSessionCache.set(cacheKey, {
cookieHeader,
expiresAt: Date.now() + SUWAYOMI_SESSION_TTL_MS,
});
return cookieHeader;
}
async function resolveSuwayomiConfig(options: SuwayomiClientOptions = {}): Promise<ResolvedSuwayomiConfig> {
let serverUrl = options.serverUrl || process.env.SUWAYOMI_URL || process.env.NEXT_PUBLIC_SUWAYOMI_URL || '';
let token = options.token || process.env.SUWAYOMI_AUTH_TOKEN || '';
let serverUrl = process.env.SUWAYOMI_URL || process.env.NEXT_PUBLIC_SUWAYOMI_URL || '';
let authMode = normalizeSuwayomiAuthMode(process.env.SUWAYOMI_AUTH_MODE);
let username = process.env.SUWAYOMI_USERNAME || '';
let password = process.env.SUWAYOMI_PASSWORD || '';
let defaultLang = process.env.SUWAYOMI_DEFAULT_LANG || 'zh';
let sourceIds: string[] = [];
let maxSources = Number(process.env.SUWAYOMI_MAX_SOURCES || 10);
@@ -40,7 +136,9 @@ async function resolveSuwayomiConfig(options: SuwayomiClientOptions = {}): Promi
const config = await getConfig();
if (config.SuwayomiConfig?.Enabled) {
serverUrl = config.SuwayomiConfig.ServerURL || serverUrl;
token = config.SuwayomiConfig.AuthToken || token;
authMode = normalizeSuwayomiAuthMode(config.SuwayomiConfig.AuthMode || authMode);
username = config.SuwayomiConfig.Username || username;
password = config.SuwayomiConfig.Password || password;
defaultLang = config.SuwayomiConfig.DefaultLang || defaultLang;
sourceIds = config.SuwayomiConfig.SourceIds || sourceIds;
maxSources = config.SuwayomiConfig.MaxSources || maxSources;
@@ -49,6 +147,19 @@ async function resolveSuwayomiConfig(options: SuwayomiClientOptions = {}): Promi
// 配置读取失败时回退到环境变量
}
if (options.serverUrl !== undefined) {
serverUrl = options.serverUrl;
}
if (options.authMode !== undefined) {
authMode = normalizeSuwayomiAuthMode(options.authMode);
}
if (options.username !== undefined) {
username = options.username;
}
if (options.password !== undefined) {
password = options.password;
}
if (!serverUrl) {
throw new Error('Suwayomi 未配置,请先在管理面板或环境变量中设置服务地址');
}
@@ -58,7 +169,9 @@ async function resolveSuwayomiConfig(options: SuwayomiClientOptions = {}): Promi
return {
serverBaseUrl: normalizedBaseUrl,
serverUrl: normalizedBaseUrl + '/api/graphql',
token: token || undefined,
authMode,
username: username || undefined,
password: password || undefined,
defaultLang,
sourceIds,
maxSources,
@@ -75,6 +188,54 @@ export function buildSuwayomiImageProxyUrl(pathOrUrl: string): string {
return `/api/manga/image?path=${encodeURIComponent(pathOrUrl)}`;
}
async function getSuwayomiRequestHeaders(
resolved: ResolvedSuwayomiConfig,
forceSimpleLoginRefresh = false
): Promise<HeadersInit | undefined> {
if (resolved.authMode === 'basic_auth') {
if (!resolved.username || !resolved.password) {
throw new Error('Suwayomi basic_auth 缺少用户名或密码');
}
return {
Authorization: buildBasicAuthHeader(resolved.username, resolved.password),
};
}
if (resolved.authMode === 'simple_login') {
return {
Cookie: await loginWithSimpleAuth(resolved, forceSimpleLoginRefresh),
};
}
return undefined;
}
async function suwayomiFetch(
resolved: ResolvedSuwayomiConfig,
input: string,
init: RequestInit = {}
): Promise<Response> {
const execute = async (forceSimpleLoginRefresh: boolean) => {
const authHeaders = await getSuwayomiRequestHeaders(resolved, forceSimpleLoginRefresh);
return fetch(input, {
...init,
headers: {
...(authHeaders || {}),
...(init.headers || {}),
},
cache: 'no-store',
});
};
let response = await execute(false);
if (response.status === 401 && resolved.authMode === 'simple_login') {
response = await execute(true);
}
return response;
}
function normalizeMangaStatus(status?: string): string | undefined {
if (!status) return undefined;
@@ -109,14 +270,12 @@ export class SuwayomiClient {
private async graphqlRequest<T>(query: string, variables?: Record<string, any>, operationName?: string): Promise<T> {
const resolved = await resolveSuwayomiConfig(this.options);
const response = await fetch(resolved.serverUrl, {
const response = await suwayomiFetch(resolved, resolved.serverUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(resolved.token ? { Authorization: `Bearer ${resolved.token}` } : {}),
},
body: JSON.stringify({ query, variables, operationName }),
cache: 'no-store',
});
if (!response.ok) {