修复请求和导入图片并发导致的数据库错误,增加pixiv-cat第三方反代支持
This commit is contained in:
1 parent
a2dc23a37f
commit
ee0549ba31
16 files changed
+404
-88
No files matched your search
@@ -12,6 +12,7 @@ from app.core.runtime_settings import (
|
||||
runtime_config_from_values,
|
||||
set_runtime_setting,
|
||||
)
|
||||
from app.core.pximg_reverse_proxy import DEFAULT_PXIMG_MIRROR_HOST, normalize_pximg_mirror_host
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -251,6 +252,10 @@ async def get_settings(
|
||||
"default_pool_id": str(runtime.proxy_default_pool_id) if runtime.proxy_default_pool_id is not None else "",
|
||||
"route_pools": {k: str(v) for k, v in runtime.proxy_route_pools.items()},
|
||||
},
|
||||
"image_proxy": {
|
||||
"use_pixiv_cat": bool(runtime.image_proxy_use_pixiv_cat),
|
||||
"pximg_mirror_host": str(runtime.image_proxy_pximg_mirror_host or DEFAULT_PXIMG_MIRROR_HOST),
|
||||
},
|
||||
"random": random_defaults,
|
||||
"security": {"hide_origin_url_in_public_json": bool(runtime.hide_origin_url_in_public_json)},
|
||||
"rate_limit": dict(runtime.rate_limit),
|
||||
@@ -345,6 +350,27 @@ async def update_settings(
|
||||
route_pools[key] = int(pool_id)
|
||||
updates.append(("proxy.route_pools", route_pools))
|
||||
|
||||
image_proxy = body.get("image_proxy")
|
||||
if image_proxy is not None:
|
||||
if not isinstance(image_proxy, dict):
|
||||
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Invalid image_proxy", status_code=400)
|
||||
|
||||
if "use_pixiv_cat" in image_proxy:
|
||||
v = _as_bool(image_proxy.get("use_pixiv_cat"))
|
||||
if v is None:
|
||||
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Invalid image_proxy.use_pixiv_cat", status_code=400)
|
||||
updates.append(("image_proxy.use_pixiv_cat", bool(v)))
|
||||
|
||||
if "pximg_mirror_host" in image_proxy:
|
||||
raw = image_proxy.get("pximg_mirror_host")
|
||||
if raw is None or raw == "":
|
||||
updates.append(("image_proxy.pximg_mirror_host", None))
|
||||
else:
|
||||
host = normalize_pximg_mirror_host(raw)
|
||||
if host is None:
|
||||
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Invalid image_proxy.pximg_mirror_host", status_code=400)
|
||||
updates.append(("image_proxy.pximg_mirror_host", str(host)))
|
||||
|
||||
random = body.get("random")
|
||||
if random is not None:
|
||||
if not isinstance(random, dict):
|
||||
|
||||
@@ -19,6 +19,8 @@ def _build_docs_html(*, base_url: str) -> str:
|
||||
|
||||
examples = {
|
||||
"img_default": u("/random"),
|
||||
"img_pixiv_cat": u("/random?pixiv_cat=1"),
|
||||
"img_pixiv_re": u("/random?pixiv_cat=1&pximg_mirror_host=re"),
|
||||
"img_redirect": u("/random?redirect=1"),
|
||||
"json_full": u("/random?format=json"),
|
||||
"json_simple": u("/random?format=simple_json"),
|
||||
@@ -236,6 +238,8 @@ def _build_docs_html(*, base_url: str) -> str:
|
||||
<h2>1) 最常用(直接出图)</h2>
|
||||
<p>默认返回图片(不是 JSON)。想要稳定 URL 可加 <span class="kbd">redirect=1</span> 跳转到本站缓存/代理路径。</p>
|
||||
<pre><code>{examples["img_default"]}
|
||||
{examples["img_pixiv_cat"]}
|
||||
{examples["img_pixiv_re"]}
|
||||
{examples["img_redirect"]}</code></pre>
|
||||
</section>
|
||||
|
||||
@@ -284,6 +288,21 @@ def _build_docs_html(*, base_url: str) -> str:
|
||||
<span class="muted">quality 会先抽样 N 张候选再按热度/分辨率打分选图。</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>pixiv_cat</code></td>
|
||||
<td>
|
||||
<code>0</code>/<code>1</code>:强制使用第三方反向代理拉取图片上游(即使全局未开启)。<br/>
|
||||
<span class="muted">上游可为 i.pixiv.cat / i.pixiv.re / i.pixiv.nl。</span><br/>
|
||||
<span class="muted">仅影响服务端拉图的上游域名,客户端仍访问本站域名。</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>pximg_mirror_host</code></td>
|
||||
<td>
|
||||
指定镜像域名(可选):<code>i.pixiv.cat</code> / <code>i.pixiv.re</code> / <code>i.pixiv.nl</code>,也支持简写 <code>cat</code>/<code>re</code>/<code>nl</code>。<br/>
|
||||
示例:<a href="{examples["img_pixiv_re"]}">{examples["img_pixiv_re"]}</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>quality_samples</code></td>
|
||||
<td>
|
||||
@@ -358,4 +377,3 @@ def _build_docs_html(*, base_url: str) -> str:
|
||||
async def docs_page(request: Request) -> HTMLResponse:
|
||||
html = _build_docs_html(base_url=str(getattr(request, "base_url", "") or "").rstrip("/"))
|
||||
return HTMLResponse(content=html, status_code=200, headers={"Cache-Control": "no-store"})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
@@ -11,6 +12,7 @@ from app.core.http_stream import stream_url
|
||||
import sqlalchemy as sa
|
||||
|
||||
from app.core.pixiv_urls import ALLOWED_IMAGE_EXTS
|
||||
from app.core.pximg_reverse_proxy import normalize_pximg_mirror_host, rewrite_pximg_to_mirror
|
||||
from app.core.request_id import get_or_create_request_id, set_request_id_header, set_request_id_on_state
|
||||
from app.core.runtime_settings import load_runtime_config
|
||||
from app.core.time import iso_utc_ms
|
||||
@@ -248,10 +250,22 @@ async def proxy_image(
|
||||
request: Request,
|
||||
image_id: int,
|
||||
ext: str,
|
||||
pixiv_cat: int = 0,
|
||||
pximg_mirror_host: str | None = None,
|
||||
):
|
||||
ext = (ext or "").lower()
|
||||
if ext not in ALLOWED_IMAGE_EXTS:
|
||||
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported ext", status_code=400)
|
||||
if pixiv_cat not in {0, 1}:
|
||||
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported pixiv_cat", status_code=400)
|
||||
|
||||
mirror_host_override: str | None = None
|
||||
if pximg_mirror_host is not None:
|
||||
raw = str(pximg_mirror_host or "").strip()
|
||||
if raw:
|
||||
mirror_host_override = normalize_pximg_mirror_host(raw)
|
||||
if mirror_host_override is None:
|
||||
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported pximg_mirror_host", status_code=400)
|
||||
|
||||
engine = request.app.state.engine
|
||||
Session = create_sessionmaker(engine)
|
||||
@@ -288,28 +302,45 @@ async def proxy_image(
|
||||
needs_hydrate = tag_row is None
|
||||
|
||||
runtime = await load_runtime_config(engine)
|
||||
use_pixiv_cat = bool(runtime.image_proxy_use_pixiv_cat) or int(pixiv_cat) == 1
|
||||
mirror_host = mirror_host_override or str(getattr(runtime, "image_proxy_pximg_mirror_host", "") or "").strip() or "i.pixiv.cat"
|
||||
|
||||
def _spawn_best_effort(coro) -> None:
|
||||
async def _run() -> None:
|
||||
try:
|
||||
await coro
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
asyncio.create_task(_run())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
source_url = rewrite_pximg_to_mirror(str(image.original_url), mirror_host=mirror_host) if use_pixiv_cat else str(image.original_url)
|
||||
proxy_uri = None
|
||||
picked = await select_proxy_uri_for_url(
|
||||
engine,
|
||||
request.app.state.settings,
|
||||
runtime,
|
||||
url=str(image.original_url),
|
||||
)
|
||||
if picked is not None:
|
||||
proxy_uri = picked.uri
|
||||
if not use_pixiv_cat:
|
||||
picked = await select_proxy_uri_for_url(
|
||||
engine,
|
||||
request.app.state.settings,
|
||||
runtime,
|
||||
url=str(image.original_url),
|
||||
)
|
||||
if picked is not None:
|
||||
proxy_uri = picked.uri
|
||||
|
||||
transport = getattr(request.app.state, "httpx_transport", None)
|
||||
now = iso_utc_ms()
|
||||
try:
|
||||
resp = await stream_url(
|
||||
image.original_url,
|
||||
source_url,
|
||||
transport=transport,
|
||||
proxy=proxy_uri,
|
||||
cache_control="public, max-age=31536000, immutable",
|
||||
range_header=request.headers.get("Range"),
|
||||
)
|
||||
if should_mark_ok:
|
||||
await mark_image_ok(engine, image_id=int(image.id), now=now)
|
||||
_spawn_best_effort(mark_image_ok(engine, image_id=int(image.id), now=now))
|
||||
if needs_hydrate:
|
||||
try:
|
||||
await enqueue_opportunistic_hydrate_metadata(engine, illust_id=int(image.illust_id), reason="image_proxy")
|
||||
@@ -323,11 +354,13 @@ async def proxy_image(
|
||||
ErrorCode.UPSTREAM_404,
|
||||
ErrorCode.UPSTREAM_RATE_LIMIT,
|
||||
}:
|
||||
await mark_image_failure(
|
||||
engine,
|
||||
image_id=int(image.id),
|
||||
now=now,
|
||||
error_code=exc.code.value,
|
||||
error_message=exc.message,
|
||||
_spawn_best_effort(
|
||||
mark_image_failure(
|
||||
engine,
|
||||
image_id=int(image.id),
|
||||
now=now,
|
||||
error_code=exc.code.value,
|
||||
error_message=exc.message,
|
||||
)
|
||||
)
|
||||
raise
|
||||
@@ -5,6 +5,7 @@ from fastapi import APIRouter, Request
|
||||
from app.core.errors import ApiError, ErrorCode
|
||||
from app.core.http_stream import stream_url
|
||||
from app.core.pixiv_urls import ALLOWED_IMAGE_EXTS
|
||||
from app.core.pximg_reverse_proxy import normalize_pximg_mirror_host, rewrite_pximg_to_mirror
|
||||
from app.core.proxy_routing import select_proxy_uri_for_url
|
||||
from app.core.runtime_settings import load_runtime_config
|
||||
from app.db.images_get_by_illust import get_image_by_illust_page
|
||||
@@ -19,6 +20,8 @@ async def legacy_multi(
|
||||
illust_id: int,
|
||||
page: int,
|
||||
ext: str,
|
||||
pixiv_cat: int = 0,
|
||||
pximg_mirror_host: str | None = None,
|
||||
):
|
||||
if int(illust_id) <= 0:
|
||||
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported illust_id", status_code=400)
|
||||
@@ -28,6 +31,16 @@ async def legacy_multi(
|
||||
ext = (ext or "").lower()
|
||||
if ext not in ALLOWED_IMAGE_EXTS:
|
||||
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported ext", status_code=400)
|
||||
if pixiv_cat not in {0, 1}:
|
||||
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported pixiv_cat", status_code=400)
|
||||
|
||||
mirror_host_override: str | None = None
|
||||
if pximg_mirror_host is not None:
|
||||
raw = str(pximg_mirror_host or "").strip()
|
||||
if raw:
|
||||
mirror_host_override = normalize_pximg_mirror_host(raw)
|
||||
if mirror_host_override is None:
|
||||
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported pximg_mirror_host", status_code=400)
|
||||
|
||||
engine = request.app.state.engine
|
||||
Session = create_sessionmaker(engine)
|
||||
@@ -38,19 +51,23 @@ async def legacy_multi(
|
||||
raise ApiError(code=ErrorCode.NOT_FOUND, message="Image not found", status_code=404)
|
||||
|
||||
runtime = await load_runtime_config(engine)
|
||||
use_pixiv_cat = bool(runtime.image_proxy_use_pixiv_cat) or int(pixiv_cat) == 1
|
||||
mirror_host = mirror_host_override or str(getattr(runtime, "image_proxy_pximg_mirror_host", "") or "").strip() or "i.pixiv.cat"
|
||||
proxy_uri = None
|
||||
picked = await select_proxy_uri_for_url(
|
||||
engine,
|
||||
request.app.state.settings,
|
||||
runtime,
|
||||
url=str(image.original_url),
|
||||
)
|
||||
if picked is not None:
|
||||
proxy_uri = picked.uri
|
||||
source_url = rewrite_pximg_to_mirror(str(image.original_url), mirror_host=mirror_host) if use_pixiv_cat else str(image.original_url)
|
||||
if not use_pixiv_cat:
|
||||
picked = await select_proxy_uri_for_url(
|
||||
engine,
|
||||
request.app.state.settings,
|
||||
runtime,
|
||||
url=str(image.original_url),
|
||||
)
|
||||
if picked is not None:
|
||||
proxy_uri = picked.uri
|
||||
|
||||
transport = getattr(request.app.state, "httpx_transport", None)
|
||||
return await stream_url(
|
||||
image.original_url,
|
||||
source_url,
|
||||
transport=transport,
|
||||
proxy=proxy_uri,
|
||||
cache_control="public, max-age=31536000, immutable",
|
||||
@@ -63,6 +80,8 @@ async def legacy_single(
|
||||
request: Request,
|
||||
illust_id: int,
|
||||
ext: str,
|
||||
pixiv_cat: int = 0,
|
||||
pximg_mirror_host: str | None = None,
|
||||
):
|
||||
if int(illust_id) <= 0:
|
||||
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported illust_id", status_code=400)
|
||||
@@ -70,6 +89,16 @@ async def legacy_single(
|
||||
ext = (ext or "").lower()
|
||||
if ext not in ALLOWED_IMAGE_EXTS:
|
||||
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported ext", status_code=400)
|
||||
if pixiv_cat not in {0, 1}:
|
||||
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported pixiv_cat", status_code=400)
|
||||
|
||||
mirror_host_override: str | None = None
|
||||
if pximg_mirror_host is not None:
|
||||
raw = str(pximg_mirror_host or "").strip()
|
||||
if raw:
|
||||
mirror_host_override = normalize_pximg_mirror_host(raw)
|
||||
if mirror_host_override is None:
|
||||
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported pximg_mirror_host", status_code=400)
|
||||
|
||||
engine = request.app.state.engine
|
||||
Session = create_sessionmaker(engine)
|
||||
@@ -80,19 +109,23 @@ async def legacy_single(
|
||||
raise ApiError(code=ErrorCode.NOT_FOUND, message="Image not found", status_code=404)
|
||||
|
||||
runtime = await load_runtime_config(engine)
|
||||
use_pixiv_cat = bool(runtime.image_proxy_use_pixiv_cat) or int(pixiv_cat) == 1
|
||||
mirror_host = mirror_host_override or str(getattr(runtime, "image_proxy_pximg_mirror_host", "") or "").strip() or "i.pixiv.cat"
|
||||
proxy_uri = None
|
||||
picked = await select_proxy_uri_for_url(
|
||||
engine,
|
||||
request.app.state.settings,
|
||||
runtime,
|
||||
url=str(image.original_url),
|
||||
)
|
||||
if picked is not None:
|
||||
proxy_uri = picked.uri
|
||||
source_url = rewrite_pximg_to_mirror(str(image.original_url), mirror_host=mirror_host) if use_pixiv_cat else str(image.original_url)
|
||||
if not use_pixiv_cat:
|
||||
picked = await select_proxy_uri_for_url(
|
||||
engine,
|
||||
request.app.state.settings,
|
||||
runtime,
|
||||
url=str(image.original_url),
|
||||
)
|
||||
if picked is not None:
|
||||
proxy_uri = picked.uri
|
||||
|
||||
transport = getattr(request.app.state, "httpx_transport", None)
|
||||
return await stream_url(
|
||||
image.original_url,
|
||||
source_url,
|
||||
transport=transport,
|
||||
proxy=proxy_uri,
|
||||
cache_control="public, max-age=31536000, immutable",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import random
|
||||
import os
|
||||
import math
|
||||
@@ -12,6 +13,7 @@ from fastapi.responses import RedirectResponse
|
||||
from app.core.errors import ApiError, ErrorCode
|
||||
from app.core.http_stream import stream_url
|
||||
from app.core.imgproxy import build_signed_processing_url, load_imgproxy_config_from_settings
|
||||
from app.core.pximg_reverse_proxy import normalize_pximg_mirror_host, rewrite_pximg_to_mirror
|
||||
from app.core.proxy_routing import select_proxy_uri_for_url
|
||||
from app.core.runtime_settings import load_runtime_config
|
||||
from app.core.time import iso_utc_ms
|
||||
@@ -120,6 +122,8 @@ async def random_image(
|
||||
orientation: str = "any",
|
||||
layout: str | None = None,
|
||||
adaptive: int = 0,
|
||||
pixiv_cat: int = 0,
|
||||
pximg_mirror_host: str | None = None,
|
||||
min_width: int = 0,
|
||||
min_height: int = 0,
|
||||
min_pixels: int = 0,
|
||||
@@ -171,6 +175,17 @@ async def random_image(
|
||||
if adaptive not in {0, 1}:
|
||||
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported adaptive", status_code=400)
|
||||
|
||||
if pixiv_cat not in {0, 1}:
|
||||
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported pixiv_cat", status_code=400)
|
||||
|
||||
mirror_host_override: str | None = None
|
||||
if pximg_mirror_host is not None:
|
||||
raw = str(pximg_mirror_host or "").strip()
|
||||
if raw:
|
||||
mirror_host_override = normalize_pximg_mirror_host(raw)
|
||||
if mirror_host_override is None:
|
||||
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported pximg_mirror_host", status_code=400)
|
||||
|
||||
layout_source = "orientation"
|
||||
raw_layout = orientation
|
||||
if layout is not None:
|
||||
@@ -332,6 +347,20 @@ async def random_image(
|
||||
engine = request.app.state.engine
|
||||
Session = create_sessionmaker(engine)
|
||||
runtime = await load_runtime_config(engine)
|
||||
use_pixiv_cat = bool(runtime.image_proxy_use_pixiv_cat) or int(pixiv_cat) == 1
|
||||
mirror_host = mirror_host_override or str(getattr(runtime, "image_proxy_pximg_mirror_host", "") or "").strip() or "i.pixiv.cat"
|
||||
|
||||
def _spawn_best_effort(coro) -> None:
|
||||
async def _run() -> None:
|
||||
try:
|
||||
await coro
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
asyncio.create_task(_run())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
random_defaults = runtime.random_defaults if isinstance(runtime.random_defaults, dict) else {}
|
||||
|
||||
@@ -690,8 +719,14 @@ async def random_image(
|
||||
pass
|
||||
|
||||
if format == "image" and redirect == 1:
|
||||
qp: list[tuple[str, str]] = []
|
||||
if int(pixiv_cat) == 1:
|
||||
qp.append(("pixiv_cat", "1"))
|
||||
if mirror_host_override is not None:
|
||||
qp.append(("pximg_mirror_host", str(mirror_host_override)))
|
||||
qs = ("?" + "&".join([f"{k}={v}" for k, v in qp])) if qp else ""
|
||||
return RedirectResponse(
|
||||
url=f"/i/{image.id}.{image.ext}",
|
||||
url=f"/i/{image.id}.{image.ext}{qs}",
|
||||
status_code=302,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
@@ -796,28 +831,32 @@ async def random_image(
|
||||
break
|
||||
image_id = int(image.id)
|
||||
origin_url = str(image.original_url)
|
||||
source_url = rewrite_pximg_to_mirror(origin_url, mirror_host=mirror_host) if use_pixiv_cat else origin_url
|
||||
illust_id_for_hydrate = int(image.illust_id)
|
||||
needs_hydrate = _needs_opportunistic_hydrate(image)
|
||||
should_mark_ok = image.last_ok_at is None or image.last_error_code is not None
|
||||
|
||||
transport = getattr(request.app.state, "httpx_transport", None)
|
||||
proxy_uri = None
|
||||
picked = await select_proxy_uri_for_url(
|
||||
engine,
|
||||
request.app.state.settings,
|
||||
runtime_stream,
|
||||
url=origin_url,
|
||||
)
|
||||
if picked is not None:
|
||||
proxy_uri = picked.uri
|
||||
if not use_pixiv_cat:
|
||||
picked = await select_proxy_uri_for_url(
|
||||
engine,
|
||||
request.app.state.settings,
|
||||
runtime_stream,
|
||||
url=origin_url,
|
||||
)
|
||||
if picked is not None:
|
||||
proxy_uri = picked.uri
|
||||
try:
|
||||
resp = await stream_url(
|
||||
origin_url,
|
||||
source_url,
|
||||
transport=transport,
|
||||
proxy=proxy_uri,
|
||||
cache_control="no-store",
|
||||
range_header=request.headers.get("Range"),
|
||||
)
|
||||
await mark_image_ok(engine, image_id=image_id, now=iso_utc_ms())
|
||||
if should_mark_ok:
|
||||
_spawn_best_effort(mark_image_ok(engine, image_id=image_id, now=iso_utc_ms()))
|
||||
if needs_hydrate:
|
||||
try:
|
||||
await enqueue_opportunistic_hydrate_metadata(
|
||||
@@ -835,12 +874,14 @@ async def random_image(
|
||||
ErrorCode.UPSTREAM_404,
|
||||
ErrorCode.UPSTREAM_RATE_LIMIT,
|
||||
}:
|
||||
await mark_image_failure(
|
||||
engine,
|
||||
image_id=image_id,
|
||||
now=iso_utc_ms(),
|
||||
error_code=exc.code.value,
|
||||
error_message=exc.message,
|
||||
_spawn_best_effort(
|
||||
mark_image_failure(
|
||||
engine,
|
||||
image_id=image_id,
|
||||
now=iso_utc_ms(),
|
||||
error_code=exc.code.value,
|
||||
error_message=exc.message,
|
||||
)
|
||||
)
|
||||
tried_ids.add(image_id)
|
||||
last_error = exc
|
||||
|
||||
@@ -12,6 +12,10 @@ from app.core.logging import get_logger
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
_DEFAULT_PIXIV_OAUTH_CLIENT_ID = "MOBrBDS8blbauoSck0ZfDbtuzpyT"
|
||||
_DEFAULT_PIXIV_OAUTH_CLIENT_SECRET = "lsACyCD94FhDUtGTXi3QzcFE2uU1hqtDaKeqrdwj"
|
||||
_DEFAULT_PIXIV_OAUTH_HASH_SECRET = "28c1fdd170a5204386cb1313c7077b34f83e4aaf4aa829ce78c231e05b0bae2c"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Settings:
|
||||
@@ -107,12 +111,16 @@ def _ensure_field_encryption_key(env: Mapping[str, str], *, app_env: str) -> str
|
||||
return generated
|
||||
|
||||
|
||||
def _load_pixiv_oauth_config(env: Mapping[str, str]) -> tuple[str, str, str]:
|
||||
# NOTE: We intentionally do NOT ship any embedded Pixiv OAuth credentials.
|
||||
# If you want token refresh / metadata hydration jobs, configure these via env.
|
||||
def _ensure_pixiv_oauth_config(env: Mapping[str, str], *, app_env: str) -> tuple[str, str, str]:
|
||||
client_id = _get(env, "PIXIV_OAUTH_CLIENT_ID", "")
|
||||
client_secret = _get(env, "PIXIV_OAUTH_CLIENT_SECRET", "")
|
||||
hash_secret = _get(env, "PIXIV_OAUTH_HASH_SECRET", "")
|
||||
|
||||
if app_env not in {"prod", "production"}:
|
||||
client_id = client_id or _DEFAULT_PIXIV_OAUTH_CLIENT_ID
|
||||
client_secret = client_secret or _DEFAULT_PIXIV_OAUTH_CLIENT_SECRET
|
||||
hash_secret = hash_secret or _DEFAULT_PIXIV_OAUTH_HASH_SECRET
|
||||
|
||||
return client_id, client_secret, hash_secret
|
||||
|
||||
|
||||
@@ -127,7 +135,9 @@ def load_settings(env: Mapping[str, str] | None = None) -> Settings:
|
||||
admin_username = _get(env, "ADMIN_USERNAME", "admin")
|
||||
admin_password = _get(env, "ADMIN_PASSWORD", "admin" if app_env != "prod" else "")
|
||||
|
||||
pixiv_oauth_client_id, pixiv_oauth_client_secret, pixiv_oauth_hash_secret = _load_pixiv_oauth_config(env)
|
||||
pixiv_oauth_client_id, pixiv_oauth_client_secret, pixiv_oauth_hash_secret = _ensure_pixiv_oauth_config(
|
||||
env, app_env=app_env
|
||||
)
|
||||
|
||||
imgproxy_base_url = _get(env, "IMGPROXY_BASE_URL", "")
|
||||
imgproxy_key = _get(env, "IMGPROXY_KEY", "")
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
|
||||
DEFAULT_PXIMG_MIRROR_HOST = "i.pixiv.cat"
|
||||
|
||||
ALLOWED_PXIMG_MIRROR_HOSTS: tuple[str, ...] = (
|
||||
"i.pixiv.cat",
|
||||
"i.pixiv.re",
|
||||
"i.pixiv.nl",
|
||||
)
|
||||
|
||||
_PXIMG_MIRROR_ALIASES: dict[str, str] = {
|
||||
# pixiv.cat
|
||||
"cat": "i.pixiv.cat",
|
||||
"pixiv.cat": "i.pixiv.cat",
|
||||
"i.pixiv.cat": "i.pixiv.cat",
|
||||
# pixiv.re (often recommended for CN mainland)
|
||||
"re": "i.pixiv.re",
|
||||
"pixiv.re": "i.pixiv.re",
|
||||
"i.pixiv.re": "i.pixiv.re",
|
||||
# pixiv.nl (often recommended for CN mainland)
|
||||
"nl": "i.pixiv.nl",
|
||||
"pixiv.nl": "i.pixiv.nl",
|
||||
"i.pixiv.nl": "i.pixiv.nl",
|
||||
}
|
||||
|
||||
|
||||
def is_pximg_image_url(url: str) -> bool:
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
except Exception:
|
||||
return False
|
||||
host = (parsed.hostname or "").strip().lower()
|
||||
if not host:
|
||||
return False
|
||||
# Pixiv original images are usually served from i.pximg.net (sometimes i-cf.pximg.net).
|
||||
if host == "i.pximg.net" or host == "i-cf.pximg.net":
|
||||
return True
|
||||
return host.endswith(".pximg.net") and host.startswith("i.")
|
||||
|
||||
|
||||
def normalize_pximg_mirror_host(value: object) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
raw = str(value or "").strip().lower()
|
||||
if not raw:
|
||||
return None
|
||||
|
||||
# Support full URL forms like "https://i.pixiv.re/...".
|
||||
if raw.startswith("http://") or raw.startswith("https://"):
|
||||
try:
|
||||
parsed = urlparse(raw)
|
||||
except Exception:
|
||||
return None
|
||||
raw = (parsed.hostname or "").strip().lower()
|
||||
if not raw:
|
||||
return None
|
||||
|
||||
raw = raw.strip().strip(".")
|
||||
raw = _PXIMG_MIRROR_ALIASES.get(raw, raw)
|
||||
if raw in ALLOWED_PXIMG_MIRROR_HOSTS:
|
||||
return raw
|
||||
return None
|
||||
|
||||
|
||||
def rewrite_pximg_to_mirror(url: str, *, mirror_host: str) -> str:
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
except Exception:
|
||||
return url
|
||||
|
||||
if not is_pximg_image_url(url):
|
||||
return url
|
||||
|
||||
mirror = normalize_pximg_mirror_host(mirror_host) or ""
|
||||
if not mirror:
|
||||
return url
|
||||
|
||||
# Preserve scheme/path/query/fragment. Replace netloc (and drop any port/userinfo if present).
|
||||
new = parsed._replace(netloc=mirror)
|
||||
return urlunparse(new)
|
||||
|
||||
|
||||
def rewrite_pximg_to_pixiv_cat(url: str) -> str:
|
||||
return rewrite_pximg_to_mirror(url, mirror_host=DEFAULT_PXIMG_MIRROR_HOST)
|
||||
@@ -8,6 +8,7 @@ from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
|
||||
from app.core.pximg_reverse_proxy import DEFAULT_PXIMG_MIRROR_HOST, normalize_pximg_mirror_host
|
||||
from app.core.logging import get_logger
|
||||
from app.core.time import iso_utc_ms
|
||||
from app.db.models.runtime_settings import RuntimeSetting
|
||||
@@ -54,6 +55,8 @@ class RuntimeConfig:
|
||||
proxy_allowlist_domains: list[str]
|
||||
proxy_route_pools: dict[str, int]
|
||||
proxy_default_pool_id: int | None
|
||||
image_proxy_use_pixiv_cat: bool
|
||||
image_proxy_pximg_mirror_host: str
|
||||
random_defaults: dict[str, Any]
|
||||
hide_origin_url_in_public_json: bool
|
||||
rate_limit: dict[str, Any]
|
||||
@@ -67,6 +70,8 @@ class RuntimeConfig:
|
||||
proxy_allowlist_domains=[],
|
||||
proxy_route_pools={},
|
||||
proxy_default_pool_id=None,
|
||||
image_proxy_use_pixiv_cat=False,
|
||||
image_proxy_pximg_mirror_host=str(DEFAULT_PXIMG_MIRROR_HOST),
|
||||
random_defaults={},
|
||||
hide_origin_url_in_public_json=True,
|
||||
rate_limit={},
|
||||
@@ -129,6 +134,12 @@ def runtime_config_from_values(values: dict[str, Any]) -> RuntimeConfig:
|
||||
candidate = 0
|
||||
proxy_default_pool_id = candidate if candidate > 0 else None
|
||||
|
||||
image_proxy_use_pixiv_cat_raw = values.get("image_proxy.use_pixiv_cat")
|
||||
image_proxy_use_pixiv_cat = _as_bool(image_proxy_use_pixiv_cat_raw)
|
||||
|
||||
mirror_host_raw = values.get("image_proxy.pximg_mirror_host")
|
||||
mirror_host = normalize_pximg_mirror_host(mirror_host_raw)
|
||||
|
||||
random_defaults_raw = values.get("random.defaults")
|
||||
random_defaults = defaults.random_defaults
|
||||
if isinstance(random_defaults_raw, dict):
|
||||
@@ -149,6 +160,10 @@ def runtime_config_from_values(values: dict[str, Any]) -> RuntimeConfig:
|
||||
proxy_allowlist_domains=proxy_allowlist_domains,
|
||||
proxy_route_pools=proxy_route_pools,
|
||||
proxy_default_pool_id=proxy_default_pool_id,
|
||||
image_proxy_use_pixiv_cat=image_proxy_use_pixiv_cat
|
||||
if image_proxy_use_pixiv_cat is not None
|
||||
else defaults.image_proxy_use_pixiv_cat,
|
||||
image_proxy_pximg_mirror_host=str(mirror_host or defaults.image_proxy_pximg_mirror_host),
|
||||
random_defaults=random_defaults,
|
||||
hide_origin_url_in_public_json=hide_origin_url
|
||||
if hide_origin_url is not None
|
||||
|
||||
@@ -7,9 +7,10 @@ from sqlalchemy import event
|
||||
from sqlalchemy.engine.url import make_url
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
||||
|
||||
SQLITE_BUSY_TIMEOUT_MS = 30_000
|
||||
SQLITE_POOL_SIZE = 5
|
||||
SQLITE_MAX_OVERFLOW = 0
|
||||
SQLITE_BUSY_TIMEOUT_MS = 5_000
|
||||
SQLITE_POOL_SIZE = 10
|
||||
SQLITE_MAX_OVERFLOW = 10
|
||||
SQLITE_POOL_TIMEOUT_S = 5
|
||||
|
||||
|
||||
def apply_sqlite_pragmas(dbapi_connection: Any) -> None:
|
||||
@@ -22,8 +23,11 @@ def apply_sqlite_pragmas(dbapi_connection: Any) -> None:
|
||||
cursor = dbapi_connection.cursor()
|
||||
try:
|
||||
cursor.execute("PRAGMA foreign_keys = ON")
|
||||
cursor.execute("PRAGMA journal_mode = WAL")
|
||||
cursor.fetchone()
|
||||
try:
|
||||
cursor.execute("PRAGMA journal_mode = WAL")
|
||||
cursor.fetchone()
|
||||
except Exception:
|
||||
pass
|
||||
cursor.execute("PRAGMA synchronous = NORMAL")
|
||||
cursor.execute("PRAGMA temp_store = MEMORY")
|
||||
cursor.execute(f"PRAGMA busy_timeout = {busy_timeout_ms}")
|
||||
@@ -54,10 +58,29 @@ def create_engine(database_url: str) -> AsyncEngine:
|
||||
kwargs["connect_args"] = {"timeout": float(busy_timeout_ms) / 1000.0}
|
||||
if _is_sqlite_file_url(database_url):
|
||||
# 限制单进程内同时打开的 SQLite 连接数,减少并发写导致的 "database is locked"。
|
||||
try:
|
||||
pool_size = int((os.environ.get("SQLITE_POOL_SIZE") or str(SQLITE_POOL_SIZE)).strip() or SQLITE_POOL_SIZE)
|
||||
except Exception:
|
||||
pool_size = int(SQLITE_POOL_SIZE)
|
||||
pool_size = max(1, min(int(pool_size), 200))
|
||||
|
||||
try:
|
||||
max_overflow = int((os.environ.get("SQLITE_MAX_OVERFLOW") or str(SQLITE_MAX_OVERFLOW)).strip() or SQLITE_MAX_OVERFLOW)
|
||||
except Exception:
|
||||
max_overflow = int(SQLITE_MAX_OVERFLOW)
|
||||
max_overflow = max(0, min(int(max_overflow), 200))
|
||||
|
||||
try:
|
||||
pool_timeout_s = float((os.environ.get("SQLITE_POOL_TIMEOUT_S") or str(SQLITE_POOL_TIMEOUT_S)).strip() or SQLITE_POOL_TIMEOUT_S)
|
||||
except Exception:
|
||||
pool_timeout_s = float(SQLITE_POOL_TIMEOUT_S)
|
||||
pool_timeout_s = float(max(0.5, min(float(pool_timeout_s), 120.0)))
|
||||
|
||||
kwargs.update(
|
||||
{
|
||||
"pool_size": int(SQLITE_POOL_SIZE),
|
||||
"max_overflow": int(SQLITE_MAX_OVERFLOW),
|
||||
"pool_size": int(pool_size),
|
||||
"max_overflow": int(max_overflow),
|
||||
"pool_timeout": float(pool_timeout_s),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
@@ -14,13 +13,6 @@ OPPORTUNISTIC_HYDRATE_REF_TYPE = "opportunistic_hydrate"
|
||||
OPPORTUNISTIC_HYDRATE_PRIORITY = -10
|
||||
|
||||
|
||||
def _pixiv_oauth_configured() -> bool:
|
||||
# Opportunistic hydration needs OAuth credentials to refresh access tokens.
|
||||
client_id = (os.environ.get("PIXIV_OAUTH_CLIENT_ID") or "").strip()
|
||||
client_secret = (os.environ.get("PIXIV_OAUTH_CLIENT_SECRET") or "").strip()
|
||||
return bool(client_id and client_secret)
|
||||
|
||||
|
||||
async def enqueue_opportunistic_hydrate_metadata(
|
||||
engine: AsyncEngine,
|
||||
*,
|
||||
@@ -29,8 +21,6 @@ async def enqueue_opportunistic_hydrate_metadata(
|
||||
) -> int | None:
|
||||
if int(illust_id) <= 0:
|
||||
return None
|
||||
if not _pixiv_oauth_configured():
|
||||
return None
|
||||
|
||||
ref_id = str(int(illust_id))
|
||||
Session = create_sessionmaker(engine)
|
||||
|
||||
@@ -211,13 +211,9 @@ def build_hydrate_metadata_handler(
|
||||
) -> Any:
|
||||
settings = load_settings()
|
||||
encryptor = FieldEncryptor.from_key(settings.field_encryption_key)
|
||||
client_id = (settings.pixiv_oauth_client_id or "").strip()
|
||||
client_secret = (settings.pixiv_oauth_client_secret or "").strip()
|
||||
if not client_id or not client_secret:
|
||||
raise ValueError("Pixiv OAuth not configured (PIXIV_OAUTH_CLIENT_ID/PIXIV_OAUTH_CLIENT_SECRET)")
|
||||
oauth_config = PixivOauthConfig(
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
client_id=settings.pixiv_oauth_client_id,
|
||||
client_secret=settings.pixiv_oauth_client_secret,
|
||||
hash_secret=(settings.pixiv_oauth_hash_secret or "").strip() or None,
|
||||
)
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ def test_load_settings_dev_auto_generates_field_encryption_key(monkeypatch: pyte
|
||||
assert s2.field_encryption_key == s1.field_encryption_key
|
||||
|
||||
|
||||
def test_load_settings_dev_pixiv_oauth_empty_by_default(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
|
||||
def test_load_settings_dev_pixiv_oauth_defaults(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setenv("APP_ENV", "dev")
|
||||
monkeypatch.delenv("PIXIV_OAUTH_CLIENT_ID", raising=False)
|
||||
@@ -43,6 +43,6 @@ def test_load_settings_dev_pixiv_oauth_empty_by_default(monkeypatch: pytest.Monk
|
||||
monkeypatch.delenv("PIXIV_OAUTH_HASH_SECRET", raising=False)
|
||||
|
||||
s = load_settings()
|
||||
assert s.pixiv_oauth_client_id == ""
|
||||
assert s.pixiv_oauth_client_secret == ""
|
||||
assert s.pixiv_oauth_hash_secret == ""
|
||||
assert s.pixiv_oauth_client_id
|
||||
assert s.pixiv_oauth_client_secret
|
||||
assert s.pixiv_oauth_hash_secret
|
||||
@@ -19,8 +19,6 @@ def test_random_opportunistically_enqueues_hydrate_metadata_once(tmp_path: Path,
|
||||
monkeypatch.setenv("DATABASE_URL", db_url)
|
||||
monkeypatch.setenv("SECRET_KEY", "secret_test")
|
||||
monkeypatch.setenv("ADMIN_USERNAME", "admin")
|
||||
monkeypatch.setenv("PIXIV_OAUTH_CLIENT_ID", "client_id_test")
|
||||
monkeypatch.setenv("PIXIV_OAUTH_CLIENT_SECRET", "client_secret_test")
|
||||
|
||||
app = create_app()
|
||||
|
||||
@@ -119,8 +117,6 @@ def test_image_proxy_opportunistically_enqueues_hydrate_metadata_once(tmp_path:
|
||||
|
||||
monkeypatch.setenv("APP_ENV", "dev")
|
||||
monkeypatch.setenv("DATABASE_URL", db_url)
|
||||
monkeypatch.setenv("PIXIV_OAUTH_CLIENT_ID", "client_id_test")
|
||||
monkeypatch.setenv("PIXIV_OAUTH_CLIENT_SECRET", "client_secret_test")
|
||||
|
||||
app = create_app()
|
||||
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=admin-change-me
|
||||
|
||||
# Pixiv OAuth (required for token refresh + metadata hydrate jobs).
|
||||
# If left blank, token refresh / hydration jobs will be disabled.
|
||||
# In dev, if left blank, the backend defaults to Pixiv Android app constants.
|
||||
PIXIV_OAUTH_CLIENT_ID=
|
||||
PIXIV_OAUTH_CLIENT_SECRET=
|
||||
PIXIV_OAUTH_HASH_SECRET=
|
||||
|
||||
@@ -27,6 +27,8 @@ describe("SettingsPage", () => {
|
||||
expect(body.settings.proxy.route_mode).toBe("pixiv_only");
|
||||
expect(body.settings.proxy.allowlist_domains).toEqual(["pixiv.net"]);
|
||||
expect(body.settings.proxy.default_pool_id).toBe("");
|
||||
expect(body.settings.image_proxy.use_pixiv_cat).toBe(false);
|
||||
expect(body.settings.image_proxy.pximg_mirror_host).toBe("i.pixiv.cat");
|
||||
expect(body.settings.random.default_attempts).toBe(3);
|
||||
expect(body.settings.random.default_r18_strict).toBe(true);
|
||||
expect(body.settings.random.fail_cooldown_ms).toBe(600000);
|
||||
@@ -52,6 +54,7 @@ describe("SettingsPage", () => {
|
||||
allowlist_domains: ["pixiv.net"],
|
||||
default_pool_id: "",
|
||||
},
|
||||
image_proxy: { use_pixiv_cat: false, pximg_mirror_host: "i.pixiv.cat" },
|
||||
random: { default_attempts: 3, default_r18_strict: true, fail_cooldown_ms: 600000, strategy: "quality", quality_samples: 5 },
|
||||
security: { hide_origin_url_in_public_json: true },
|
||||
rate_limit: {},
|
||||
|
||||
@@ -35,6 +35,8 @@ type SettingsFormValues = {
|
||||
proxy_route_mode: "pixiv_only" | "all" | "allowlist" | "off";
|
||||
proxy_allowlist_domains: string[];
|
||||
proxy_default_pool_id: number;
|
||||
image_proxy_use_pixiv_cat: boolean;
|
||||
image_proxy_pximg_mirror_host: "i.pixiv.cat" | "i.pixiv.re" | "i.pixiv.nl";
|
||||
random_default_attempts: number;
|
||||
random_default_r18_strict: boolean;
|
||||
random_fail_cooldown_ms: number;
|
||||
@@ -99,6 +101,14 @@ function asStrList(value: unknown): string[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
function asPximgMirrorHost(value: unknown, fallback: SettingsFormValues["image_proxy_pximg_mirror_host"]): SettingsFormValues["image_proxy_pximg_mirror_host"] {
|
||||
if (typeof value === "string") {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === "i.pixiv.cat" || normalized === "i.pixiv.re" || normalized === "i.pixiv.nl") return normalized;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function SettingsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [form] = Form.useForm<SettingsFormValues>();
|
||||
@@ -120,6 +130,7 @@ export function SettingsPage() {
|
||||
if (!query.data) return;
|
||||
const settings = asObject(query.data.settings);
|
||||
const proxy = asObject(settings.proxy);
|
||||
const imageProxy = asObject(settings.image_proxy);
|
||||
const random = asObject(settings.random);
|
||||
const security = asObject(settings.security);
|
||||
const rateLimit = asObject(settings.rate_limit);
|
||||
@@ -134,6 +145,8 @@ export function SettingsPage() {
|
||||
proxy_route_mode: routeMode,
|
||||
proxy_allowlist_domains: asStrList(proxy.allowlist_domains),
|
||||
proxy_default_pool_id: asInt(proxy.default_pool_id, 0),
|
||||
image_proxy_use_pixiv_cat: asBool(imageProxy.use_pixiv_cat, false),
|
||||
image_proxy_pximg_mirror_host: asPximgMirrorHost(imageProxy.pximg_mirror_host, "i.pixiv.cat"),
|
||||
random_default_attempts: asInt(random.default_attempts, 3),
|
||||
random_default_r18_strict: asBool(random.default_r18_strict, true),
|
||||
random_fail_cooldown_ms: asInt(random.fail_cooldown_ms, 600_000),
|
||||
@@ -158,6 +171,10 @@ export function SettingsPage() {
|
||||
allowlist_domains: values.proxy_allowlist_domains,
|
||||
default_pool_id: values.proxy_default_pool_id > 0 ? values.proxy_default_pool_id : "",
|
||||
},
|
||||
image_proxy: {
|
||||
use_pixiv_cat: values.image_proxy_use_pixiv_cat,
|
||||
pximg_mirror_host: values.image_proxy_pximg_mirror_host,
|
||||
},
|
||||
random: {
|
||||
default_attempts: values.random_default_attempts,
|
||||
default_r18_strict: values.random_default_r18_strict,
|
||||
@@ -270,6 +287,32 @@ export function SettingsPage() {
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Typography.Title level={5} style={{ marginTop: 12 }}>
|
||||
图片加速
|
||||
</Typography.Title>
|
||||
<Form.Item
|
||||
label="使用第三方反向代理(仅图片上游)"
|
||||
name="image_proxy_use_pixiv_cat"
|
||||
valuePropName="checked"
|
||||
extra="开启后:服务端拉取图片时会把 i.pximg.net 替换为 i.pixiv.*(客户端仍访问本站域名,不会暴露第三方域名)。"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="镜像域名"
|
||||
name="image_proxy_pximg_mirror_host"
|
||||
extra="可选 i.pixiv.cat / i.pixiv.re / i.pixiv.nl(建议就近选择更快更稳的)。"
|
||||
>
|
||||
<Select
|
||||
style={{ maxWidth: 360 }}
|
||||
options={[
|
||||
{ value: "i.pixiv.cat", label: "i.pixiv.cat(默认)" },
|
||||
{ value: "i.pixiv.re", label: "i.pixiv.re(大陆备用)" },
|
||||
{ value: "i.pixiv.nl", label: "i.pixiv.nl(大陆备用)" },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Typography.Title level={5} style={{ marginTop: 12 }}>
|
||||
随机接口设置
|
||||
</Typography.Title>
|
||||
|
||||
Reference in new issue
Block a user