Suwayomi认证方式重构
This commit is contained in:
+94
-11
@@ -10127,7 +10127,9 @@ const SuwayomiConfigComponent = ({
|
||||
const { isLoading, withLoading } = useLoadingState();
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [serverURL, setServerURL] = useState('');
|
||||
const [authToken, setAuthToken] = useState('');
|
||||
const [authMode, setAuthMode] = useState<'none' | 'basic_auth' | 'simple_login'>('none');
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [defaultLang, setDefaultLang] = useState('zh');
|
||||
const [sourceIds, setSourceIds] = useState('');
|
||||
const [maxSources, setMaxSources] = useState(10);
|
||||
@@ -10136,7 +10138,9 @@ const SuwayomiConfigComponent = ({
|
||||
if (config?.SuwayomiConfig) {
|
||||
setEnabled(config.SuwayomiConfig.Enabled || false);
|
||||
setServerURL(config.SuwayomiConfig.ServerURL || '');
|
||||
setAuthToken(config.SuwayomiConfig.AuthToken || '');
|
||||
setAuthMode(config.SuwayomiConfig.AuthMode || 'none');
|
||||
setUsername(config.SuwayomiConfig.Username || '');
|
||||
setPassword(config.SuwayomiConfig.Password || '');
|
||||
setDefaultLang(config.SuwayomiConfig.DefaultLang || 'zh');
|
||||
setSourceIds((config.SuwayomiConfig.SourceIds || []).join(','));
|
||||
setMaxSources(config.SuwayomiConfig.MaxSources || 10);
|
||||
@@ -10146,7 +10150,9 @@ const SuwayomiConfigComponent = ({
|
||||
const buildConfig = () => ({
|
||||
Enabled: enabled,
|
||||
ServerURL: serverURL,
|
||||
AuthToken: authToken,
|
||||
AuthMode: authMode,
|
||||
Username: authMode === 'none' ? '' : username,
|
||||
Password: authMode === 'none' ? '' : password,
|
||||
DefaultLang: defaultLang || 'zh',
|
||||
SourceIds: sourceIds.split(',').map((item) => item.trim()).filter(Boolean),
|
||||
MaxSources: Math.max(1, maxSources || 10),
|
||||
@@ -10180,6 +10186,34 @@ const SuwayomiConfigComponent = ({
|
||||
});
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
await withLoading('testSuwayomi', async () => {
|
||||
try {
|
||||
const response = await fetch('/api/admin/suwayomi', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
ServerURL: serverURL,
|
||||
AuthMode: authMode,
|
||||
Username: username,
|
||||
Password: password,
|
||||
DefaultLang: defaultLang,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.message || data.error || '测试连接失败');
|
||||
}
|
||||
|
||||
showSuccess(data.message || '连接成功', showAlert);
|
||||
} catch (error) {
|
||||
showError(error instanceof Error ? error.message : '测试连接失败', showAlert);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<div className='bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4'>
|
||||
@@ -10188,6 +10222,7 @@ const SuwayomiConfigComponent = ({
|
||||
</h3>
|
||||
<div className='text-sm text-blue-800 dark:text-blue-200 space-y-1'>
|
||||
<p>• 漫画展馆通过 Suwayomi Server 的 GraphQL 接口搜索、拉取章节与阅读页。</p>
|
||||
<p>• 认证仅支持 basic_auth 与 simple_login;未开启认证时请选择“无认证”。</p>
|
||||
<p>• 可限制默认语言、可用源白名单,以及单次搜索最多查询的源数量。</p>
|
||||
<p>• 保存后漫画模块会优先使用这里的配置,环境变量只作为兜底。</p>
|
||||
</div>
|
||||
@@ -10222,16 +10257,57 @@ const SuwayomiConfigComponent = ({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>认证 Token</label>
|
||||
<input
|
||||
type='password'
|
||||
value={authToken}
|
||||
onChange={(e) => setAuthToken(e.target.value)}
|
||||
placeholder='可选,若 Suwayomi 开启认证请填写'
|
||||
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
|
||||
/>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>认证方式</label>
|
||||
<div className='grid grid-cols-1 gap-2 md:grid-cols-3'>
|
||||
{[
|
||||
{ value: 'none', label: '无认证' },
|
||||
{ value: 'basic_auth', label: 'basic_auth' },
|
||||
{ value: 'simple_login', label: 'simple_login' },
|
||||
].map((item) => (
|
||||
<button
|
||||
key={item.value}
|
||||
type='button'
|
||||
onClick={() => setAuthMode(item.value as 'none' | 'basic_auth' | 'simple_login')}
|
||||
className={`rounded-lg border px-3 py-2 text-sm transition-colors ${
|
||||
authMode === item.value
|
||||
? 'border-blue-500 bg-blue-50 text-blue-700 dark:border-blue-400 dark:bg-blue-900/30 dark:text-blue-200'
|
||||
: 'border-gray-300 text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||
basic_auth 使用 Basic Authorization 头;simple_login 会向 /login.html 提交表单并复用返回 Cookie。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{authMode !== 'none' && (
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>用户名</label>
|
||||
<input
|
||||
type='text'
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder='登录用户名'
|
||||
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>密码</label>
|
||||
<input
|
||||
type='password'
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder='登录密码'
|
||||
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>默认语言</label>
|
||||
@@ -10267,6 +10343,13 @@ const SuwayomiConfigComponent = ({
|
||||
</div>
|
||||
|
||||
<div className='flex gap-3'>
|
||||
<button
|
||||
onClick={handleTest}
|
||||
disabled={!serverURL || isLoading('testSuwayomi')}
|
||||
className={buttonStyles.primary}
|
||||
>
|
||||
{isLoading('testSuwayomi') ? '测试中...' : '测试连接'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isLoading('saveSuwayomi')}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
import { SuwayomiClient } from '@/lib/suwayomi.client';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const username = authInfo.username;
|
||||
if (username !== process.env.USERNAME) {
|
||||
const userInfo = await db.getUserInfoV2(username);
|
||||
if (!userInfo || userInfo.role !== 'admin' || userInfo.banned) {
|
||||
return NextResponse.json({ error: '权限不足' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const {
|
||||
ServerURL,
|
||||
AuthMode,
|
||||
Username,
|
||||
Password,
|
||||
DefaultLang,
|
||||
} = body as {
|
||||
ServerURL?: string;
|
||||
AuthMode?: 'none' | 'basic_auth' | 'simple_login';
|
||||
Username?: string;
|
||||
Password?: string;
|
||||
DefaultLang?: string;
|
||||
};
|
||||
|
||||
if (!ServerURL?.trim()) {
|
||||
return NextResponse.json({ success: false, message: '请先填写 Suwayomi 服务地址' }, { status: 400 });
|
||||
}
|
||||
|
||||
if ((AuthMode === 'basic_auth' || AuthMode === 'simple_login') && (!Username?.trim() || !Password)) {
|
||||
return NextResponse.json({ success: false, message: '当前认证方式需要填写用户名和密码' }, { status: 400 });
|
||||
}
|
||||
|
||||
const client = new SuwayomiClient({
|
||||
serverUrl: ServerURL.trim(),
|
||||
authMode: AuthMode || 'none',
|
||||
username: Username?.trim(),
|
||||
password: Password,
|
||||
});
|
||||
|
||||
const sources = await client.getSources((DefaultLang || 'zh').trim() || 'zh');
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `连接成功,当前语言下检测到 ${sources.length} 个源`,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '测试连接失败',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthorizedUsername } from '../_utils';
|
||||
import { getSuwayomiConfig } from '@/lib/suwayomi.client';
|
||||
import { getSuwayomiConfig, loginWithSimpleAuth } from '@/lib/suwayomi.client';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
@@ -34,11 +34,38 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const config = await getSuwayomiConfig();
|
||||
const upstreamUrl = resolveUpstreamUrl(config.serverBaseUrl, pathOrUrl);
|
||||
const response = await fetch(upstreamUrl, {
|
||||
headers: config.token ? { Authorization: `Bearer ${config.token}` } : undefined,
|
||||
const buildHeaders = async (forceRelogin: boolean) => {
|
||||
if (config.authMode === 'basic_auth') {
|
||||
if (!config.username || !config.password) {
|
||||
throw new Error('Suwayomi basic_auth 缺少用户名或密码');
|
||||
}
|
||||
|
||||
return {
|
||||
Authorization: `Basic ${Buffer.from(`${config.username}:${config.password}`).toString('base64')}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (config.authMode === 'simple_login') {
|
||||
return {
|
||||
Cookie: await loginWithSimpleAuth(config, forceRelogin),
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
let response = await fetch(upstreamUrl, {
|
||||
headers: await buildHeaders(false),
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (response.status === 401 && config.authMode === 'simple_login') {
|
||||
response = await fetch(upstreamUrl, {
|
||||
headers: await buildHeaders(true),
|
||||
cache: 'no-store',
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `Suwayomi 图片请求失败: ${response.status}` },
|
||||
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user