Compare commits

...

11 Commits

Author SHA1 Message Date
shiyu
1c216a7516 chore: update version to v2.2.0 in service configuration 2026-05-01 22:04:20 +08:00
shiyu
d8425f1cdd feat: add client authorization feature in TopHeader and update localization files 2026-05-01 17:59:30 +08:00
shiyu
e235845737 feat: add PikPak adapter implementation 2026-05-01 14:09:52 +08:00
shiyu
6981bb8444 feat: add /client directory to .gitignore 2026-05-01 10:53:22 +08:00
shiyu
1101273077 feat: add default language configuration 2026-04-19 16:42:19 +08:00
shiyu
398dbcf8ae feat: enhance vector database configuration handling and improve provider initialization 2026-04-10 19:40:41 +08:00
shiyu
0609cf6971 feat: add metadata options for file status checks and optimize permission filtering logic 2026-04-10 17:56:01 +08:00
dependabot[bot]
93c4d7a748 chore(deps): bump cryptography in the uv group across 1 directory (#116)
Bumps the uv group with 1 update in the / directory: [cryptography](https://github.com/pyca/cryptography).


Updates `cryptography` from 46.0.6 to 46.0.7
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/46.0.6...46.0.7)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-version: 46.0.7
  dependency-type: indirect
  dependency-group: uv
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-10 17:35:45 +08:00
dependabot[bot]
a0fe35b6e9 chore(deps): bump aiohttp in the uv group across 1 directory (#115)
---
updated-dependencies:
- dependency-name: aiohttp
  dependency-version: 3.13.4
  dependency-type: indirect
  dependency-group: uv
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-02 11:22:59 +08:00
dependabot[bot]
3efc286ef8 chore(deps): bump cryptography in the uv group across 1 directory (#114)
Bumps the uv group with 1 update in the / directory: [cryptography](https://github.com/pyca/cryptography).


Updates `cryptography` from 46.0.5 to 46.0.6
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/46.0.5...46.0.6)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-version: 46.0.6
  dependency-type: indirect
  dependency-group: uv
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-30 10:26:46 +08:00
dependabot[bot]
013c14b767 chore(deps): bump pyasn1 in the uv group across 1 directory (#113)
Bumps the uv group with 1 update in the / directory: [pyasn1](https://github.com/pyasn1/pyasn1).


Updates `pyasn1` from 0.6.2 to 0.6.3
- [Release notes](https://github.com/pyasn1/pyasn1/releases)
- [Changelog](https://github.com/pyasn1/pyasn1/blob/main/CHANGES.rst)
- [Commits](https://github.com/pyasn1/pyasn1/compare/v0.6.2...v0.6.3)

---
updated-dependencies:
- dependency-name: pyasn1
  dependency-version: 0.6.3
  dependency-type: indirect
  dependency-group: uv
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-22 11:22:58 +08:00
39 changed files with 1469 additions and 323 deletions

0
.codex Normal file
View File

4
.gitignore vendored
View File

@@ -31,4 +31,6 @@ lerna-debug.log*
*.ntvs*
*.njsproj
*.sln
*.sw?
*.sw?
/client

View File

@@ -299,23 +299,23 @@ class LocalAdapter:
return StreamingResponse(iterator(), status_code=status, headers=headers, media_type=content_type)
async def stat_file(self, root: str, rel: str):
async def stat_file(self, root: str, rel: str, include_metadata: bool = False):
fp = _safe_join(root, rel)
if not fp.exists():
raise FileNotFoundError(rel)
st = await asyncio.to_thread(fp.stat)
is_dir = fp.is_dir()
info = {
"name": fp.name,
"is_dir": fp.is_dir(),
"is_dir": is_dir,
"size": st.st_size,
"mtime": int(st.st_mtime),
"mode": stat.S_IMODE(st.st_mode),
"type": "dir" if fp.is_dir() else "file",
"type": "dir" if is_dir else "file",
"path": str(fp),
}
# exif信息
exif = None
if not fp.is_dir():
if include_metadata and not is_dir:
exif = None
mime, _ = mimetypes.guess_type(fp.name)
if mime and mime.startswith("image/"):
try:
@@ -326,7 +326,7 @@ class LocalAdapter:
exif = {str(k): str(v) for k, v in exif_data.items()}
except Exception:
exif = None
info["exif"] = exif
info["exif"] = exif
return info

View File

@@ -0,0 +1,875 @@
import asyncio
import hashlib
import mimetypes
import re
import time
from typing import Any, AsyncIterator, Dict, List, Optional, Tuple
import httpx
from fastapi import HTTPException
from fastapi.responses import Response, StreamingResponse
from models import StorageAdapter
from .base import BaseAdapter
API_BASE = "https://api-drive.mypikpak.net/drive/v1"
USER_BASE = "https://user.mypikpak.net/v1"
ANDROID_ALGORITHMS = [
"SOP04dGzk0TNO7t7t9ekDbAmx+eq0OI1ovEx",
"nVBjhYiND4hZ2NCGyV5beamIr7k6ifAsAbl",
"Ddjpt5B/Cit6EDq2a6cXgxY9lkEIOw4yC1GDF28KrA",
"VVCogcmSNIVvgV6U+AochorydiSymi68YVNGiz",
"u5ujk5sM62gpJOsB/1Gu/zsfgfZO",
"dXYIiBOAHZgzSruaQ2Nhrqc2im",
"z5jUTBSIpBN9g4qSJGlidNAutX6",
"KJE2oveZ34du/g1tiimm",
]
WEB_ALGORITHMS = [
"C9qPpZLN8ucRTaTiUMWYS9cQvWOE",
"+r6CQVxjzJV6LCV",
"F",
"pFJRC",
"9WXYIDGrwTCz2OiVlgZa90qpECPD6olt",
"/750aCr4lm/Sly/c",
"RB+DT/gZCrbV",
"",
"CyLsf7hdkIRxRm215hl",
"7xHvLi2tOYP0Y92b",
"ZGTXXxu8E/MIWaEDB+Sm/",
"1UI3",
"E7fP5Pfijd+7K+t6Tg/NhuLq0eEUVChpJSkrKxpO",
"ihtqpG6FMt65+Xk+tWUH2",
"NhXXU9rg4XXdzo7u5o",
]
PC_ALGORITHMS = [
"KHBJ07an7ROXDoK7Db",
"G6n399rSWkl7WcQmw5rpQInurc1DkLmLJqE",
"JZD1A3M4x+jBFN62hkr7VDhkkZxb9g3rWqRZqFAAb",
"fQnw/AmSlbbI91Ik15gpddGgyU7U",
"/Dv9JdPYSj3sHiWjouR95NTQff",
"yGx2zuTjbWENZqecNI+edrQgqmZKP",
"ljrbSzdHLwbqcRn",
"lSHAsqCkGDGxQqqwrVu",
"TsWXI81fD1",
"vk7hBjawK/rOSrSWajtbMk95nfgf3",
]
PLATFORM_CONFIG = {
"android": {
"client_id": "YNxT9w7GMdWvEOKa",
"client_secret": "dbw2OtmVEeuUvIptb1Coyg",
"client_version": "1.53.2",
"package_name": "com.pikcloud.pikpak",
"sdk_version": "2.0.6.206003",
"algorithms": ANDROID_ALGORITHMS,
"ua": None,
},
"web": {
"client_id": "YUMx5nI8ZU8Ap8pm",
"client_secret": "dbw2OtmVEeuUvIptb1Coyg",
"client_version": "2.0.0",
"package_name": "mypikpak.com",
"sdk_version": "8.0.3",
"algorithms": WEB_ALGORITHMS,
"ua": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/117.0.0.0 Safari/537.36",
},
"pc": {
"client_id": "YvtoWO6GNHiuCl7x",
"client_secret": "1NIH5R1IEe2pAxZE3hv3uA",
"client_version": "undefined",
"package_name": "mypikpak.com",
"sdk_version": "8.0.3",
"algorithms": PC_ALGORITHMS,
"ua": "MainWindow Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 "
"(KHTML, like Gecko) PikPak/2.6.11.4955 Chrome/100.0.4896.160 Electron/18.3.15 Safari/537.36",
},
}
def _md5_text(value: str) -> str:
return hashlib.md5(value.encode("utf-8")).hexdigest()
def _sha1_text(value: str) -> str:
return hashlib.sha1(value.encode("utf-8")).hexdigest()
def _as_bool(value: Any, default: bool = False) -> bool:
if value is None:
return default
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "on"}
return bool(value)
def _root_payload(root: str | None) -> Tuple[str, str]:
raw = (root or "").strip()
if not raw:
return "", ""
if "|" not in raw:
return raw, ""
root_id, sub_path = raw.split("|", 1)
return root_id.strip(), sub_path.strip("/")
def _split_parent_name(rel: str) -> Tuple[str, str]:
rel = (rel or "").strip("/")
if not rel:
return "", ""
if "/" not in rel:
return "", rel
parent, _, name = rel.rpartition("/")
return parent, name
def _parse_time(value: str | None) -> int:
if not value:
return 0
text = str(value).strip()
if not text:
return 0
try:
from datetime import datetime, timezone
if text.endswith("Z"):
text = text[:-1] + "+00:00"
dt = datetime.fromisoformat(text)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return int(dt.timestamp())
except Exception:
return 0
class PikPakAdapter:
def __init__(self, record: StorageAdapter):
self.record = record
cfg = record.config or {}
self.username = str(cfg.get("username") or "").strip()
self.password = str(cfg.get("password") or "")
if not self.username or not self.password:
raise ValueError("PikPak adapter requires username and password")
self.platform = str(cfg.get("platform") or "web").strip().lower()
if self.platform not in PLATFORM_CONFIG:
self.platform = "web"
platform_cfg = PLATFORM_CONFIG[self.platform]
self.client_id = str(platform_cfg["client_id"])
self.client_secret = str(platform_cfg["client_secret"])
self.client_version = str(platform_cfg["client_version"])
self.package_name = str(platform_cfg["package_name"])
self.sdk_version = str(platform_cfg["sdk_version"])
self.algorithms = list(platform_cfg["algorithms"])
self.device_id = str(cfg.get("device_id") or "").strip() or _md5_text(self.username + self.password)
self.user_id = str(cfg.get("user_id") or "").strip()
self.refresh_token = str(cfg.get("refresh_token") or "").strip()
self.access_token = str(cfg.get("access_token") or "").strip()
self.captcha_token = str(cfg.get("captcha_token") or "").strip()
self.root_id = str(cfg.get("root_id") or "").strip()
self.disable_media_link = _as_bool(cfg.get("disable_media_link"), True)
self.enable_direct_download_307 = _as_bool(cfg.get("enable_direct_download_307"), False)
self.timeout = float(cfg.get("timeout") or 30)
ua = platform_cfg.get("ua")
self.user_agent = str(ua) if ua else self._build_android_user_agent()
self._auth_lock = asyncio.Lock()
self._config_save_lock = asyncio.Lock()
self._dir_id_cache: Dict[str, str] = {}
self._children_cache: Dict[str, List[Dict[str, Any]]] = {}
def get_effective_root(self, sub_path: str | None) -> str:
return f"{self.root_id}|{(sub_path or '').strip('/')}"
def _build_android_user_agent(self) -> str:
device_sign = self._generate_device_sign(self.device_id, self.package_name)
user_id = self.user_id
return (
f"ANDROID-{self.package_name}/{self.client_version} "
"protocolVersion/200 accesstype/ "
f"clientid/{self.client_id} "
f"clientversion/{self.client_version} "
"action_type/ networktype/WIFI sessionid/ "
f"deviceid/{self.device_id} "
"providername/NONE "
f"devicesign/{device_sign} "
"refresh_token/ "
f"sdkversion/{self.sdk_version} "
f"datetime/{int(time.time() * 1000)} "
f"usrno/{user_id} "
f"appname/android-{self.package_name} "
"session_origin/ grant_type/ appid/ clientip/ "
"devicename/Xiaomi_M2004j7ac osversion/13 platformversion/10 "
"accessmode/ devicemodel/M2004J7AC "
)
@staticmethod
def _generate_device_sign(device_id: str, package_name: str) -> str:
sha1_str = _sha1_text(f"{device_id}{package_name}1appkey")
md5_str = _md5_text(sha1_str)
return f"div101.{device_id}{md5_str}"
def _captcha_sign(self) -> Tuple[str, str]:
timestamp = str(int(time.time() * 1000))
value = f"{self.client_id}{self.client_version}{self.package_name}{self.device_id}{timestamp}"
for algorithm in self.algorithms:
value = _md5_text(value + algorithm)
return timestamp, "1." + value
@staticmethod
def _action(method: str, url: str) -> str:
m = re.search(r"://[^/]+((/[^/\s?#]+)*)", url)
path = m.group(1) if m else "/"
return f"{method.upper()}:{path}"
def _download_headers(self) -> Dict[str, str]:
headers = {
"User-Agent": self.user_agent,
"X-Device-ID": self.device_id,
"X-Captcha-Token": self.captcha_token,
}
if self.access_token:
headers["Authorization"] = f"Bearer {self.access_token}"
return headers
async def _save_runtime_config(self):
cfg = dict(self.record.config or {})
changed = False
for key, value in (
("refresh_token", self.refresh_token),
("captcha_token", self.captcha_token),
("device_id", self.device_id),
):
if value and cfg.get(key) != value:
cfg[key] = value
changed = True
if not changed:
return
async with self._config_save_lock:
self.record.config = cfg
await self.record.save(update_fields=["config"])
async def _ensure_auth(self):
if self.access_token:
return
async with self._auth_lock:
if self.access_token:
return
if self.refresh_token:
try:
await self._refresh_access_token()
return
except Exception:
self.access_token = ""
if not self.username or not self.password:
raise
await self._login()
async def _login(self):
url = f"{USER_BASE}/auth/signin"
if not self.captcha_token:
await self._refresh_captcha_token(self._action("POST", url), self._login_captcha_meta())
body = {
"captcha_token": self.captcha_token,
"client_id": self.client_id,
"client_secret": self.client_secret,
"username": self.username,
"password": self.password,
}
data = await self._raw_json("POST", url, json=body, params={"client_id": self.client_id}, auth=False)
self.refresh_token = str(data.get("refresh_token") or "").strip()
self.access_token = str(data.get("access_token") or "").strip()
self.user_id = str(data.get("sub") or self.user_id).strip()
if not self.refresh_token or not self.access_token:
raise HTTPException(502, detail="PikPak login failed: missing token")
if self.platform == "android":
self.user_agent = self._build_android_user_agent()
await self._save_runtime_config()
async def _refresh_access_token(self):
url = f"{USER_BASE}/auth/token"
body = {
"client_id": self.client_id,
"client_secret": self.client_secret,
"grant_type": "refresh_token",
"refresh_token": self.refresh_token,
}
data = await self._raw_json("POST", url, json=body, params={"client_id": self.client_id}, auth=False)
self.refresh_token = str(data.get("refresh_token") or "").strip()
self.access_token = str(data.get("access_token") or "").strip()
self.user_id = str(data.get("sub") or self.user_id).strip()
if not self.refresh_token or not self.access_token:
raise HTTPException(502, detail="PikPak refresh token failed: missing token")
if self.platform == "android":
self.user_agent = self._build_android_user_agent()
await self._save_runtime_config()
def _login_captcha_meta(self) -> Dict[str, str]:
if re.match(r"\w+([-+.]\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*", self.username):
return {"email": self.username}
if 11 <= len(self.username) <= 18:
return {"phone_number": self.username}
return {"username": self.username}
async def _refresh_captcha_token(self, action: str, meta: Dict[str, str]):
url = f"{USER_BASE}/shield/captcha/init"
body = {
"action": action,
"captcha_token": self.captcha_token,
"client_id": self.client_id,
"device_id": self.device_id,
"meta": meta,
"redirect_uri": "xlaccsdk01://xbase.cloud/callback?state=harbor",
}
data = await self._raw_json("POST", url, json=body, params={"client_id": self.client_id}, auth=False)
verify_url = str(data.get("url") or "").strip()
token = str(data.get("captcha_token") or "").strip()
if token and not verify_url:
self.captcha_token = token
await self._save_runtime_config()
if verify_url:
raise HTTPException(
400,
detail=(
"PikPak requires captcha verification. Open the URL, finish verification, "
"then capture the fresh captcha_token from the successful verification request and paste it into the adapter config. URL: "
f"{verify_url}"
),
)
if not token:
raise HTTPException(502, detail="PikPak captcha refresh failed: missing captcha_token")
self.captcha_token = token
await self._save_runtime_config()
async def _refresh_captcha_token_after_login(self, method: str, url: str):
timestamp, sign = self._captcha_sign()
meta = {
"client_version": self.client_version,
"package_name": self.package_name,
"user_id": self.user_id,
"timestamp": timestamp,
"captcha_sign": sign,
}
await self._refresh_captcha_token(self._action(method, url), meta)
async def _raw_json(
self,
method: str,
url: str,
*,
json: Any | None = None,
params: Dict[str, Any] | None = None,
auth: bool = True,
retry_auth: bool = True,
retry_captcha: bool = True,
) -> Dict[str, Any]:
if auth:
await self._ensure_auth()
headers = {
"User-Agent": self.user_agent,
"X-Device-ID": self.device_id,
"X-Captcha-Token": self.captcha_token,
}
if auth and self.access_token:
headers["Authorization"] = f"Bearer {self.access_token}"
async with httpx.AsyncClient(timeout=self.timeout, follow_redirects=True) as client:
resp = await client.request(method, url, headers=headers, params=params, json=json)
payload: Dict[str, Any] = {}
try:
parsed = resp.json()
if isinstance(parsed, dict):
payload = parsed
except Exception:
resp.raise_for_status()
return {}
if auth and retry_auth and resp.status_code in {401, 403}:
async with self._auth_lock:
await self._refresh_access_token()
return await self._raw_json(
method,
url,
json=json,
params=params,
auth=auth,
retry_auth=False,
retry_captcha=retry_captcha,
)
error_code = payload.get("error_code")
error_msg = payload.get("error") or payload.get("error_description") or payload.get("message")
try:
code_int = int(error_code or 0)
except Exception:
code_int = 0
has_error = code_int != 0 or bool(error_msg and resp.status_code >= 400)
if has_error:
if auth and retry_auth and code_int in {4122, 4121, 16}:
async with self._auth_lock:
await self._refresh_access_token()
return await self._raw_json(
method,
url,
json=json,
params=params,
auth=auth,
retry_auth=False,
retry_captcha=retry_captcha,
)
if code_int == 4002 or error_msg == "captcha_invalid":
if retry_captcha:
if auth:
if self.user_id:
await self._refresh_captcha_token_after_login(method, url)
else:
await self._refresh_captcha_token(self._action(method, url), self._login_captcha_meta())
else:
await self._refresh_captcha_token(self._action(method, url), self._login_captcha_meta())
return await self._raw_json(
method,
url,
json=json,
params=params,
auth=auth,
retry_auth=retry_auth,
retry_captcha=False,
)
raise HTTPException(
400,
detail=(
"PikPak captcha_invalid. Refresh the captcha token, then retry after solving the verification page."
),
)
if auth and retry_captcha and code_int == 9:
await self._refresh_captcha_token_after_login(method, url)
return await self._raw_json(
method,
url,
json=json,
params=params,
auth=auth,
retry_auth=retry_auth,
retry_captcha=False,
)
raise HTTPException(502, detail=f"PikPak error code={error_code} msg={error_msg}")
if resp.status_code >= 400:
raise HTTPException(resp.status_code, detail=f"PikPak HTTP error: {payload or resp.text}")
return payload
async def _request(
self,
method: str,
path_or_url: str,
*,
json: Any | None = None,
params: Dict[str, Any] | None = None,
) -> Dict[str, Any]:
url = path_or_url if path_or_url.startswith("http") else API_BASE + path_or_url
return await self._raw_json(method, url, json=json, params=params, auth=True)
def _map_file_item(self, it: Dict[str, Any]) -> Dict[str, Any]:
is_dir = it.get("kind") == "drive#folder"
size = 0
if not is_dir:
try:
size = int(it.get("size") or 0)
except Exception:
size = 0
return {
"fid": it.get("id"),
"id": it.get("id"),
"name": it.get("name") or "",
"is_dir": is_dir,
"size": size,
"ctime": _parse_time(it.get("created_time")),
"mtime": _parse_time(it.get("modified_time")),
"type": "dir" if is_dir else "file",
"hash": it.get("hash") or "",
"thumbnail_link": it.get("thumbnail_link") or "",
"web_content_link": it.get("web_content_link") or "",
"medias": it.get("medias") or [],
}
async def _list_children(self, parent_id: str) -> List[Dict[str, Any]]:
if parent_id in self._children_cache:
return self._children_cache[parent_id]
items: List[Dict[str, Any]] = []
page_token = ""
while True:
params = {
"parent_id": parent_id,
"thumbnail_size": "SIZE_LARGE",
"with_audit": "true",
"limit": "100",
"filters": '{"phase":{"eq":"PHASE_TYPE_COMPLETE"},"trashed":{"eq":false}}',
"page_token": page_token,
}
data = await self._request("GET", "/files", params=params)
files = data.get("files") or []
if isinstance(files, list):
items.extend(self._map_file_item(x) for x in files if isinstance(x, dict))
page_token = str(data.get("next_page_token") or "")
if not page_token:
break
self._children_cache[parent_id] = items
return items
async def _resolve_root_id(self, root: str | None) -> str:
root_id, sub_path = _root_payload(root)
base_id = root_id or ""
if not sub_path:
return base_id
return await self._resolve_dir_id_from(base_id, sub_path)
async def _resolve_dir_id_from(self, base_id: str, rel: str) -> str:
rel = (rel or "").strip("/")
cache_key = f"{base_id}:{rel}"
if cache_key in self._dir_id_cache:
return self._dir_id_cache[cache_key]
if not rel:
self._dir_id_cache[cache_key] = base_id
return base_id
parent_id = base_id
path_so_far: List[str] = []
for seg in rel.split("/"):
if not seg:
continue
path_so_far.append(seg)
current_key = f"{base_id}:{'/'.join(path_so_far)}"
cached = self._dir_id_cache.get(current_key)
if cached is not None:
parent_id = cached
continue
children = await self._list_children(parent_id)
found = next((item for item in children if item["is_dir"] and item["name"] == seg), None)
if not found:
raise FileNotFoundError(rel)
parent_id = str(found["fid"])
self._dir_id_cache[current_key] = parent_id
return parent_id
async def _find_child(self, parent_id: str, name: str) -> Optional[Dict[str, Any]]:
children = await self._list_children(parent_id)
return next((item for item in children if item.get("name") == name), None)
async def _resolve_obj(self, root: str, rel: str) -> Dict[str, Any]:
rel = (rel or "").strip("/")
base_id = await self._resolve_root_id(root)
if not rel:
return {"fid": base_id, "id": base_id, "name": "", "is_dir": True, "size": 0, "mtime": 0, "type": "dir"}
if rel.endswith("/"):
fid = await self._resolve_dir_id_from(base_id, rel.rstrip("/"))
return {"fid": fid, "id": fid, "name": rel.rstrip("/").split("/")[-1], "is_dir": True, "size": 0, "mtime": 0, "type": "dir"}
parent_rel, name = _split_parent_name(rel)
parent_id = await self._resolve_dir_id_from(base_id, parent_rel)
item = await self._find_child(parent_id, name)
if not item:
raise FileNotFoundError(rel)
return item
async def _resolve_parent_and_obj(self, root: str, rel: str) -> Tuple[str, Dict[str, Any]]:
base_id = await self._resolve_root_id(root)
parent_rel, name = _split_parent_name(rel)
parent_id = await self._resolve_dir_id_from(base_id, parent_rel)
item = await self._find_child(parent_id, name)
if not item:
raise FileNotFoundError(rel)
return parent_id, item
def _invalidate_children_cache(self, parent_id: str):
self._children_cache.pop(parent_id, None)
def _clear_path_cache(self):
self._dir_id_cache.clear()
async def list_dir(
self,
root: str,
rel: str,
page_num: int = 1,
page_size: int = 50,
sort_by: str = "name",
sort_order: str = "asc",
) -> Tuple[List[Dict], int]:
base_id = await self._resolve_root_id(root)
target_id = await self._resolve_dir_id_from(base_id, rel)
items = list(await self._list_children(target_id))
reverse = sort_order.lower() == "desc"
def sort_key(item: Dict[str, Any]) -> Tuple:
key = (not item.get("is_dir"),)
field = sort_by.lower()
if field == "size":
key += (int(item.get("size") or 0),)
elif field == "mtime":
key += (int(item.get("mtime") or 0),)
else:
key += (str(item.get("name") or "").lower(),)
return key
items.sort(key=sort_key, reverse=reverse)
total = len(items)
start = max(page_num - 1, 0) * page_size
return items[start : start + page_size], total
async def stat_file(self, root: str, rel: str):
return await self._resolve_obj(root, rel)
async def stat_path(self, root: str, rel: str):
try:
item = await self._resolve_obj(root, rel)
return {"exists": True, "is_dir": bool(item.get("is_dir")), "path": rel, "fid": item.get("fid")}
except FileNotFoundError:
return {"exists": False, "is_dir": None, "path": rel}
async def exists(self, root: str, rel: str) -> bool:
try:
await self._resolve_obj(root, rel)
return True
except FileNotFoundError:
return False
async def _get_remote_file(self, file_id: str) -> Dict[str, Any]:
params = {"_magic": "2021", "usage": "FETCH", "thumbnail_size": "SIZE_LARGE"}
if not self.disable_media_link:
params["usage"] = "CACHE"
return await self._request("GET", f"/files/{file_id}", params=params)
async def _get_download_url(self, item: Dict[str, Any]) -> str:
file_id = str(item.get("fid") or item.get("id") or "")
if not file_id:
raise FileNotFoundError(item.get("name") or "")
data = await self._get_remote_file(file_id)
url = str(data.get("web_content_link") or "").strip()
medias = data.get("medias") or []
if not self.disable_media_link and isinstance(medias, list) and medias:
first = medias[0]
if isinstance(first, dict):
media_url = str(((first.get("link") or {}).get("url") if isinstance(first.get("link"), dict) else "") or "")
if media_url:
url = media_url
if not url:
raise HTTPException(502, detail="PikPak did not return download url")
return url
async def read_file(self, root: str, rel: str) -> bytes:
item = await self._resolve_obj(root, rel)
if item.get("is_dir"):
raise IsADirectoryError(rel)
url = await self._get_download_url(item)
async with httpx.AsyncClient(timeout=None, follow_redirects=True) as client:
resp = await client.get(url, headers=self._download_headers())
if resp.status_code == 404:
raise FileNotFoundError(rel)
resp.raise_for_status()
return resp.content
async def read_file_range(self, root: str, rel: str, start: int, end: Optional[int] = None) -> bytes:
item = await self._resolve_obj(root, rel)
if item.get("is_dir"):
raise IsADirectoryError(rel)
url = await self._get_download_url(item)
headers = self._download_headers()
headers["Range"] = f"bytes={start}-" if end is None else f"bytes={start}-{end}"
async with httpx.AsyncClient(timeout=self.timeout, follow_redirects=True) as client:
resp = await client.get(url, headers=headers)
if resp.status_code == 404:
raise FileNotFoundError(rel)
if resp.status_code == 416:
raise HTTPException(416, detail="Requested Range Not Satisfiable")
resp.raise_for_status()
return resp.content
async def stream_file(self, root: str, rel: str, range_header: str | None):
item = await self._resolve_obj(root, rel)
if item.get("is_dir"):
raise IsADirectoryError(rel)
url = await self._get_download_url(item)
file_size = int(item.get("size") or 0)
mime, _ = mimetypes.guess_type(rel)
content_type = mime or "application/octet-stream"
start = 0
end = file_size - 1 if file_size > 0 else None
status_code = 200
if range_header and range_header.startswith("bytes="):
status_code = 206
part = range_header.split("=", 1)[1]
s, _, e = part.partition("-")
if s.strip():
start = int(s)
if e.strip():
end = int(e)
elif file_size > 0:
end = file_size - 1
if file_size > 0:
if start >= file_size:
raise HTTPException(416, detail="Requested Range Not Satisfiable")
if end is None or end >= file_size:
end = file_size - 1
if start > end:
raise HTTPException(416, detail="Requested Range Not Satisfiable")
resp_headers = {"Accept-Ranges": "bytes", "Content-Type": content_type}
if file_size > 0:
if status_code == 206 and end is not None:
resp_headers["Content-Range"] = f"bytes {start}-{end}/{file_size}"
resp_headers["Content-Length"] = str(end - start + 1)
else:
resp_headers["Content-Length"] = str(file_size)
async def iterator():
headers = self._download_headers()
if status_code == 206 and end is not None:
headers["Range"] = f"bytes={start}-{end}"
async with httpx.AsyncClient(timeout=None, follow_redirects=True) as client:
async with client.stream("GET", url, headers=headers) as resp:
if resp.status_code == 404:
raise FileNotFoundError(rel)
if resp.status_code == 416:
raise HTTPException(416, detail="Requested Range Not Satisfiable")
resp.raise_for_status()
async for chunk in resp.aiter_bytes():
if chunk:
yield chunk
return StreamingResponse(iterator(), status_code=status_code, headers=resp_headers, media_type=content_type)
async def get_direct_download_response(self, root: str, rel: str):
if not self.enable_direct_download_307:
return None
item = await self._resolve_obj(root, rel)
if item.get("is_dir"):
return None
url = await self._get_download_url(item)
return Response(status_code=307, headers={"Location": url})
async def get_thumbnail(self, root: str, rel: str, size: str = "medium"):
item = await self._resolve_obj(root, rel)
url = str(item.get("thumbnail_link") or "").strip()
if not url:
return None
async with httpx.AsyncClient(timeout=self.timeout, follow_redirects=True) as client:
resp = await client.get(url, headers=self._download_headers())
if resp.status_code >= 400:
return None
return resp.content
async def mkdir(self, root: str, rel: str):
rel = (rel or "").strip("/")
if not rel:
raise HTTPException(400, detail="Cannot create root")
parent_rel, name = _split_parent_name(rel)
if not name:
raise HTTPException(400, detail="Invalid directory name")
base_id = await self._resolve_root_id(root)
parent_id = await self._resolve_dir_id_from(base_id, parent_rel)
await self._request("POST", "/files", json={"kind": "drive#folder", "parent_id": parent_id, "name": name})
self._invalidate_children_cache(parent_id)
async def delete(self, root: str, rel: str):
parent_id, item = await self._resolve_parent_and_obj(root, rel)
await self._request("POST", "/files:batchTrash", json={"ids": [item["fid"]]})
self._invalidate_children_cache(parent_id)
if item.get("is_dir"):
self._clear_path_cache()
async def move(self, root: str, src_rel: str, dst_rel: str):
src_parent_id, item = await self._resolve_parent_and_obj(root, src_rel)
base_id = await self._resolve_root_id(root)
dst_parent_rel, dst_name = _split_parent_name(dst_rel)
dst_parent_id = await self._resolve_dir_id_from(base_id, dst_parent_rel)
if src_parent_id != dst_parent_id:
await self._request("POST", "/files:batchMove", json={"ids": [item["fid"]], "to": {"parent_id": dst_parent_id}})
self._invalidate_children_cache(src_parent_id)
self._invalidate_children_cache(dst_parent_id)
if item.get("name") != dst_name:
await self._request("PATCH", f"/files/{item['fid']}", json={"name": dst_name})
self._invalidate_children_cache(dst_parent_id)
if item.get("is_dir"):
self._clear_path_cache()
async def rename(self, root: str, src_rel: str, dst_rel: str):
await self.move(root, src_rel, dst_rel)
async def copy(self, root: str, src_rel: str, dst_rel: str, overwrite: bool = False):
src_parent_id, item = await self._resolve_parent_and_obj(root, src_rel)
base_id = await self._resolve_root_id(root)
dst_parent_rel, dst_name = _split_parent_name(dst_rel)
dst_parent_id = await self._resolve_dir_id_from(base_id, dst_parent_rel)
await self._request("POST", "/files:batchCopy", json={"ids": [item["fid"]], "to": {"parent_id": dst_parent_id}})
self._invalidate_children_cache(dst_parent_id)
if item.get("name") != dst_name:
children = await self._list_children(dst_parent_id)
copied_candidates = [x for x in children if x.get("name") == item.get("name") and x.get("fid") != item.get("fid")]
copied = None
if copied_candidates:
copied_candidates.sort(key=lambda x: (int(x.get("ctime") or 0), int(x.get("mtime") or 0)), reverse=True)
copied = copied_candidates[0]
if copied:
await self._request("PATCH", f"/files/{copied['fid']}", json={"name": dst_name})
self._invalidate_children_cache(dst_parent_id)
if item.get("is_dir"):
self._clear_path_cache()
_ = src_parent_id
async def write_file(self, root: str, rel: str, data: bytes):
raise HTTPException(501, detail="PikPak upload not implemented")
async def write_file_stream(self, root: str, rel: str, data_iter: AsyncIterator[bytes]):
raise HTTPException(501, detail="PikPak upload not implemented")
async def write_upload_file(
self,
root: str,
rel: str,
file_obj,
filename: str | None,
file_size: int | None = None,
content_type: str | None = None,
):
raise HTTPException(501, detail="PikPak upload not implemented")
ADAPTER_TYPE = "pikpak"
CONFIG_SCHEMA = [
{"key": "username", "label": "PikPak 账号", "type": "string", "required": True},
{"key": "password", "label": "PikPak 密码", "type": "password", "required": True},
{"key": "platform", "label": "平台", "type": "select", "required": False, "default": "web", "options": ["web", "android", "pc"]},
{"key": "refresh_token", "label": "Refresh Token", "type": "password", "required": False},
{"key": "captcha_token", "label": "Captcha Token", "type": "password", "required": False},
{"key": "device_id", "label": "Device ID", "type": "string", "required": False},
{"key": "root_id", "label": "根目录 ID", "type": "string", "required": False, "default": ""},
{"key": "disable_media_link", "label": "禁用媒体转码链接", "type": "boolean", "required": False, "default": True},
{"key": "enable_direct_download_307", "label": "直链 307 跳转", "type": "boolean", "required": False, "default": False},
]
def ADAPTER_FACTORY(rec: StorageAdapter) -> BaseAdapter:
return PikPakAdapter(rec)

View File

@@ -376,7 +376,7 @@ class WebDAVAdapter:
return StreamingResponse(segmented_body(), status_code=status_code, headers=resp_headers, media_type=content_type)
async def stat_file(self, root: str, rel: str):
async def stat_file(self, root: str, rel: str, include_metadata: bool = False):
url = self._build_url(rel)
async with self._client() as client:
# PROPFIND 获取属性
@@ -426,9 +426,8 @@ class WebDAVAdapter:
info["mtime"] = 0
elif info["mtime"] is None:
info["mtime"] = 0
# exif信息
exif = None
if not info["is_dir"]:
if include_metadata and not info["is_dir"]:
exif = None
mime, _ = mimetypes.guess_type(info["name"])
if mime and mime.startswith("image/"):
try:
@@ -442,7 +441,7 @@ class WebDAVAdapter:
exif = {str(k): str(v) for k, v in exif_data.items()}
except Exception:
exif = None
info["exif"] = exif
info["exif"] = exif
return info
async def exists(self, root: str, rel: str) -> bool:

View File

@@ -267,19 +267,24 @@ async def get_vector_db_config(request: Request, user: User = Depends(get_curren
async def update_vector_db_config(
request: Request, payload: VectorDBConfigPayload, user: User = Depends(get_current_active_user)
):
entry = get_provider_entry(payload.type)
provider_type = str(payload.type or "").strip()
if not provider_type:
raise HTTPException(status_code=400, detail="向量数据库类型不能为空")
normalized_config = VectorDBConfigManager.normalize_config(payload.config)
entry = get_provider_entry(provider_type)
if not entry:
raise HTTPException(
status_code=400, detail=f"未知的向量数据库类型: {payload.type}")
status_code=400, detail=f"未知的向量数据库类型: {provider_type}")
if not entry.get("enabled", True):
raise HTTPException(status_code=400, detail="该向量数据库类型暂不可用")
provider_cls = get_provider_class(payload.type)
provider_cls = get_provider_class(provider_type)
if not provider_cls:
raise HTTPException(
status_code=400, detail=f"未找到类型 {payload.type} 对应的实现")
status_code=400, detail=f"未找到类型 {provider_type} 对应的实现")
test_provider = provider_cls(payload.config)
test_provider = provider_cls(normalized_config)
try:
await test_provider.initialize()
except Exception as exc:
@@ -293,7 +298,7 @@ async def update_vector_db_config(
except Exception:
pass
await VectorDBConfigManager.save_config(payload.type, payload.config)
await VectorDBConfigManager.save_config(provider_type, normalized_config)
service = VectorDBService()
await service.reload()
config_data = await service.current_provider()

View File

@@ -1,7 +1,7 @@
import asyncio
import json
from collections.abc import Iterable
from typing import Any, Dict, List, Optional, Tuple
from typing import Any, Dict, List, Optional, Tuple, TypeVar
import httpx
from tortoise.exceptions import DoesNotExist
@@ -28,16 +28,37 @@ OPENAI_EMBEDDING_DIMS = {
"text-embedding-ada-002": 1536,
}
T = TypeVar("T")
class VectorDBConfigManager:
TYPE_KEY = "VECTOR_DB_TYPE"
CONFIG_KEY = "VECTOR_DB_CONFIG"
DEFAULT_TYPE = "milvus_lite"
@classmethod
def normalize_type(cls, provider_type: Any) -> str:
normalized = str(provider_type or cls.DEFAULT_TYPE).strip()
return normalized or cls.DEFAULT_TYPE
@classmethod
def normalize_config(cls, config: Dict[str, Any] | None) -> Dict[str, Any]:
normalized: Dict[str, Any] = {}
for key, value in (config or {}).items():
normalized_key = str(key).strip()
if not normalized_key:
continue
if isinstance(value, str):
value = value.strip()
if not value:
continue
normalized[normalized_key] = value
return normalized
@classmethod
async def load_config(cls) -> Tuple[str, Dict[str, Any]]:
raw_type = await ConfigService.get(cls.TYPE_KEY, cls.DEFAULT_TYPE)
provider_type = str(raw_type or cls.DEFAULT_TYPE)
provider_type = cls.normalize_type(raw_type)
raw_config = await ConfigService.get(cls.CONFIG_KEY)
config_dict: Dict[str, Any] = {}
@@ -48,12 +69,14 @@ class VectorDBConfigManager:
config_dict = {}
elif isinstance(raw_config, dict):
config_dict = raw_config
return provider_type, config_dict
return provider_type, cls.normalize_config(config_dict)
@classmethod
async def save_config(cls, provider_type: str, config: Dict[str, Any]) -> None:
await ConfigService.set(cls.TYPE_KEY, provider_type)
await ConfigService.set(cls.CONFIG_KEY, json.dumps(config or {}))
normalized_type = cls.normalize_type(provider_type)
normalized_config = cls.normalize_config(config)
await ConfigService.set(cls.TYPE_KEY, normalized_type)
await ConfigService.set(cls.CONFIG_KEY, json.dumps(normalized_config))
@classmethod
async def get_type(cls) -> str:
@@ -413,6 +436,7 @@ class VectorDBService:
self._provider_type: Optional[str] = None
self._provider_config: Dict[str, Any] | None = None
self._lock = asyncio.Lock()
self._operation_lock = asyncio.Lock()
async def _ensure_provider(self) -> BaseVectorProvider:
if self._provider is None:
@@ -449,33 +473,38 @@ class VectorDBService:
self._provider_config = normalized_config
return provider
async def _run_provider_call(self, provider: BaseVectorProvider, method_name: str, *args, **kwargs) -> T:
method = getattr(provider, method_name)
async with self._operation_lock:
return await asyncio.to_thread(method, *args, **kwargs)
async def ensure_collection(self, collection_name: str, vector: bool = True, dim: int = DEFAULT_VECTOR_DIMENSION) -> None:
provider = await self._ensure_provider()
provider.ensure_collection(collection_name, vector, dim)
await self._run_provider_call(provider, "ensure_collection", collection_name, vector, dim)
async def upsert_vector(self, collection_name: str, data: Dict[str, Any]) -> None:
provider = await self._ensure_provider()
provider.upsert_vector(collection_name, data)
await self._run_provider_call(provider, "upsert_vector", collection_name, data)
async def delete_vector(self, collection_name: str, path: str) -> None:
provider = await self._ensure_provider()
provider.delete_vector(collection_name, path)
await self._run_provider_call(provider, "delete_vector", collection_name, path)
async def search_vectors(self, collection_name: str, query_embedding, top_k: int = 5):
provider = await self._ensure_provider()
return provider.search_vectors(collection_name, query_embedding, top_k)
return await self._run_provider_call(provider, "search_vectors", collection_name, query_embedding, top_k)
async def search_by_path(self, collection_name: str, query_path: str, top_k: int = 20):
provider = await self._ensure_provider()
return provider.search_by_path(collection_name, query_path, top_k)
return await self._run_provider_call(provider, "search_by_path", collection_name, query_path, top_k)
async def get_all_stats(self) -> Dict[str, Any]:
provider = await self._ensure_provider()
return provider.get_all_stats()
return await self._run_provider_call(provider, "get_all_stats")
async def clear_all_data(self) -> None:
provider = await self._ensure_provider()
provider.clear_all_data()
await self._run_provider_call(provider, "clear_all_data")
async def current_provider(self) -> Dict[str, Any]:
provider_type, provider_config = await VectorDBConfigManager.load_config()

View File

@@ -1,3 +1,4 @@
import asyncio
from pathlib import Path
from typing import Any, Dict, List, Optional
@@ -23,12 +24,14 @@ class MilvusLiteProvider(BaseVectorProvider):
def __init__(self, config: Dict[str, Any] | None = None):
super().__init__(config)
self.db_path = Path(self.config.get("db_path") or "data/db/milvus.db")
raw_db_path = self.config.get("db_path")
db_path = str(raw_db_path).strip() if raw_db_path is not None else ""
self.db_path = Path(db_path or "data/db/milvus.db")
self.client: MilvusClient | None = None
async def initialize(self) -> None:
try:
self.client = MilvusClient(str(self.db_path))
self.client = await asyncio.to_thread(MilvusClient, str(self.db_path))
except Exception as exc: # pragma: no cover - depends on local environment
raise RuntimeError(f"Failed to open Milvus Lite at {self.db_path}: {exc}") from exc

View File

@@ -1,3 +1,4 @@
import asyncio
from typing import Any, Dict, List, Optional
from pymilvus import CollectionSchema, DataType, FieldSchema, MilvusClient
@@ -32,11 +33,14 @@ class MilvusServerProvider(BaseVectorProvider):
self.client: MilvusClient | None = None
async def initialize(self) -> None:
uri = self.config.get("uri")
uri = str(self.config.get("uri") or "").strip()
if not uri:
raise RuntimeError("Milvus Server URI is required")
token = self.config.get("token")
if isinstance(token, str):
token = token.strip() or None
try:
self.client = MilvusClient(uri=uri, token=self.config.get("token"))
self.client = await asyncio.to_thread(MilvusClient, uri=uri, token=token)
except Exception as exc: # pragma: no cover - depends on remote availability
raise RuntimeError(f"Failed to connect to Milvus Server {uri}: {exc}") from exc

View File

@@ -1,3 +1,4 @@
import asyncio
from typing import Any, Dict, List, Optional, Sequence
from uuid import NAMESPACE_URL, uuid5
@@ -40,7 +41,7 @@ class QdrantProvider(BaseVectorProvider):
api_key = (self.config.get("api_key") or None) or None
try:
client = QdrantClient(url=url, api_key=api_key)
client.get_collections()
await asyncio.to_thread(client.get_collections)
self.client = client
except Exception as exc: # pragma: no cover - 依赖外部服务
raise RuntimeError(f"Failed to connect to Qdrant at {url}: {exc}") from exc

View File

@@ -13,6 +13,7 @@ from .types import ConfigItem
router = APIRouter(prefix="/api/config", tags=["config"])
PUBLIC_CONFIG_KEYS = [
"APP_DEFAULT_LANGUAGE",
"THEME_MODE",
"THEME_PRIMARY_COLOR",
"THEME_BORDER_RADIUS",
@@ -56,6 +57,7 @@ async def get_all_config(
configs = await ConfigService.get_all()
return success(configs)
@router.get("/public")
@audit(action=AuditAction.READ, description="获取公开配置")
async def get_public_config(

View File

@@ -10,7 +10,7 @@ from models.database import Configuration, UserAccount
load_dotenv(dotenv_path=".env")
VERSION = "v2.1.1"
VERSION = "v2.2.0"
class ConfigService:
@@ -80,6 +80,7 @@ class ConfigService:
logo=logo,
favicon=favicon,
is_initialized=user_count > 0,
default_language=await cls.get("APP_DEFAULT_LANGUAGE", "zh"),
app_domain=await cls.get("APP_DOMAIN"),
file_domain=await cls.get("FILE_DOMAIN"),
)

View File

@@ -14,6 +14,7 @@ class SystemStatus(BaseModel):
logo: str
favicon: str
is_initialized: bool
default_language: str = "zh"
app_domain: Optional[str] = None
file_domain: Optional[str] = None

View File

@@ -1,3 +1,4 @@
from dataclasses import dataclass
from typing import List, Optional
from fastapi import HTTPException
@@ -17,74 +18,169 @@ from .types import (
PERMISSION_DEFINITIONS,
)
@dataclass(slots=True)
class PermissionContext:
exists: bool
is_admin: bool
path_rules: List[PathRule]
class PermissionService:
"""权限检查服务"""
# 权限检查结果缓存(简单的内存缓存)
_cache: dict[str, tuple[bool, float]] = {}
_context_cache: dict[int, tuple[PermissionContext, float]] = {}
_cache_ttl = 300 # 5分钟缓存
@classmethod
def _now(cls) -> float:
import time
return time.time()
@classmethod
def _is_cache_valid(cls, timestamp: float) -> bool:
return cls._now() - timestamp < cls._cache_ttl
@classmethod
def _get_cached_result(cls, cache_key: str) -> Optional[bool]:
cached = cls._cache.get(cache_key)
if not cached:
return None
result, timestamp = cached
if cls._is_cache_valid(timestamp):
return result
cls._cache.pop(cache_key, None)
return None
@classmethod
def _sort_path_rules(cls, rules: List[PathRule]) -> List[PathRule]:
return sorted(
rules,
key=lambda r: (
r.priority,
PathMatcher.get_pattern_specificity(r.path_pattern, r.is_regex),
),
reverse=True,
)
@classmethod
def _match_sorted_path_rules(
cls, path: str, action: str, sorted_rules: List[PathRule]
) -> Optional[bool]:
for rule in sorted_rules:
if PathMatcher.match_pattern(path, rule.path_pattern, rule.is_regex):
if action == PathAction.READ:
return rule.can_read
if action == PathAction.WRITE:
return rule.can_write
if action == PathAction.DELETE:
return rule.can_delete
if action == PathAction.SHARE:
return rule.can_share
return False
return None
@classmethod
async def _get_permission_context(cls, user_id: int) -> PermissionContext:
cached = cls._context_cache.get(user_id)
if cached:
context, timestamp = cached
if cls._is_cache_valid(timestamp):
return context
cls._context_cache.pop(user_id, None)
user = await UserAccount.get_or_none(id=user_id)
if not user:
context = PermissionContext(exists=False, is_admin=False, path_rules=[])
cls._context_cache[user_id] = (context, cls._now())
return context
if user.is_admin:
context = PermissionContext(exists=True, is_admin=True, path_rules=[])
cls._context_cache[user_id] = (context, cls._now())
return context
user_roles = await UserRole.filter(user_id=user_id)
role_ids = [ur.role_id for ur in user_roles]
if not role_ids:
context = PermissionContext(exists=True, is_admin=False, path_rules=[])
cls._context_cache[user_id] = (context, cls._now())
return context
path_rules = await PathRule.filter(role_id__in=role_ids)
context = PermissionContext(
exists=True,
is_admin=False,
path_rules=cls._sort_path_rules(list(path_rules)),
)
cls._context_cache[user_id] = (context, cls._now())
return context
@classmethod
def _check_path_permission_with_context(
cls,
user_id: int,
normalized_path: str,
action: str,
context: PermissionContext,
) -> bool:
if not context.exists:
return False
if context.is_admin:
return True
checked_cache_keys: List[str] = []
current_path = normalized_path
while True:
cache_key = f"{user_id}:{current_path}:{action}"
cached_result = cls._get_cached_result(cache_key)
if cached_result is not None:
result = cached_result
break
checked_cache_keys.append(cache_key)
result = cls._match_sorted_path_rules(current_path, action, context.path_rules)
if result is not None:
break
parent_path = PathMatcher.get_parent_path(current_path)
if not parent_path:
result = False
break
current_path = parent_path
timestamp = cls._now()
for cache_key in checked_cache_keys:
cls._cache[cache_key] = (result, timestamp)
return result
@classmethod
async def check_path_permission(
cls, user_id: int, path: str, action: str
) -> bool:
"""
检查用户对路径的操作权限
Args:
user_id: 用户ID
path: 要检查的路径
action: 操作类型 (read/write/delete/share)
Returns:
是否有权限
"""
import time
# 检查缓存
cache_key = f"{user_id}:{path}:{action}"
if cache_key in cls._cache:
result, timestamp = cls._cache[cache_key]
if time.time() - timestamp < cls._cache_ttl:
return result
# 获取用户
user = await UserAccount.get_or_none(id=user_id)
if not user:
return False
# 超级管理员直接放行
if user.is_admin:
cls._cache[cache_key] = (True, time.time())
return True
# 获取用户所有角色
user_roles = await UserRole.filter(user_id=user_id).prefetch_related("role")
role_ids = [ur.role_id for ur in user_roles]
if not role_ids:
cls._cache[cache_key] = (False, time.time())
return False
# 获取所有角色的路径规则
path_rules = await PathRule.filter(role_id__in=role_ids).order_by("-priority")
# 规范化路径
normalized_path = PathMatcher.normalize_path(path)
cache_key = f"{user_id}:{normalized_path}:{action}"
cached_result = cls._get_cached_result(cache_key)
if cached_result is not None:
return cached_result
# 按优先级和具体程度匹配
result = cls._match_path_rules(normalized_path, action, list(path_rules))
# 如果没有匹配到规则,检查父目录(继承)
if result is None:
parent_path = PathMatcher.get_parent_path(normalized_path)
if parent_path:
result = await cls.check_path_permission(user_id, parent_path, action)
else:
result = False # 默认拒绝
cls._cache[cache_key] = (result, time.time())
context = await cls._get_permission_context(user_id)
result = cls._check_path_permission_with_context(user_id, normalized_path, action, context)
cls._cache[cache_key] = (result, cls._now())
return result
@classmethod
@@ -97,31 +193,7 @@ class PermissionService:
Returns:
True/False 表示明确的权限结果None 表示没有匹配到规则
"""
# 按优先级和具体程度排序
sorted_rules = sorted(
rules,
key=lambda r: (
r.priority,
PathMatcher.get_pattern_specificity(r.path_pattern, r.is_regex),
),
reverse=True,
)
for rule in sorted_rules:
if PathMatcher.match_pattern(path, rule.path_pattern, rule.is_regex):
# 匹配到规则,检查具体操作权限
if action == PathAction.READ:
return rule.can_read
elif action == PathAction.WRITE:
return rule.can_write
elif action == PathAction.DELETE:
return rule.can_delete
elif action == PathAction.SHARE:
return rule.can_share
else:
return False
return None
return cls._match_sorted_path_rules(path, action, cls._sort_path_rules(rules))
@classmethod
async def check_system_permission(cls, user_id: int, permission_code: str) -> bool:
@@ -251,35 +323,20 @@ class PermissionService:
cls, user_id: int, path: str, action: str
) -> PathPermissionResult:
"""检查路径权限并返回详细结果"""
user = await UserAccount.get_or_none(id=user_id)
if not user:
context = await cls._get_permission_context(user_id)
if not context.exists:
return PathPermissionResult(path=path, action=action, allowed=False)
# 超级管理员
if user.is_admin:
if context.is_admin:
return PathPermissionResult(path=path, action=action, allowed=True)
# 获取用户角色
user_roles = await UserRole.filter(user_id=user_id)
role_ids = [ur.role_id for ur in user_roles]
if not role_ids:
if not context.path_rules:
return PathPermissionResult(path=path, action=action, allowed=False)
# 获取路径规则
path_rules = await PathRule.filter(role_id__in=role_ids).order_by("-priority")
normalized_path = PathMatcher.normalize_path(path)
# 查找匹配的规则
matched_rule = None
for rule in sorted(
path_rules,
key=lambda r: (
r.priority,
PathMatcher.get_pattern_specificity(r.path_pattern, r.is_regex),
),
reverse=True,
):
for rule in context.path_rules:
if PathMatcher.match_pattern(
normalized_path, rule.path_pattern, rule.is_regex
):
@@ -322,19 +379,30 @@ class PermissionService:
"""清除权限缓存"""
if user_id is None:
cls._cache.clear()
cls._context_cache.clear()
else:
# 清除特定用户的缓存
keys_to_delete = [k for k in cls._cache if k.startswith(f"{user_id}:")]
for k in keys_to_delete:
del cls._cache[k]
cls._context_cache.pop(user_id, None)
@classmethod
async def filter_paths_by_permission(
cls, user_id: int, paths: List[str], action: str
) -> List[str]:
"""过滤出用户有权限的路径列表"""
if not paths:
return []
context = await cls._get_permission_context(user_id)
if not context.exists:
return []
if context.is_admin:
return list(paths)
result = []
for path in paths:
if await cls.check_path_permission(user_id, path, action):
normalized_path = PathMatcher.normalize_path(path)
if cls._check_path_permission_with_context(user_id, normalized_path, action, context):
result.append(path)
return result

View File

@@ -84,8 +84,9 @@ async def get_file_stat(
full_path: str,
request: Request,
current_user: Annotated[User, Depends(get_current_active_user)],
verbose: bool = Query(False, description="是否返回扩展元数据"),
):
stat = await VirtualFSService.stat(full_path)
stat = await VirtualFSService.stat(full_path, verbose=verbose)
return success(stat)

View File

@@ -1,3 +1,4 @@
import inspect
from typing import Any, Dict, List, Tuple
from fastapi import HTTPException
@@ -14,6 +15,23 @@ from .resolver import VirtualFSResolverMixin
class VirtualFSListingMixin(VirtualFSResolverMixin):
@staticmethod
async def _call_stat_file(
stat_func,
root: str,
rel: str,
*,
include_metadata: bool = False,
):
try:
parameters = inspect.signature(stat_func).parameters
except (TypeError, ValueError):
parameters = {}
if "include_metadata" in parameters:
return await stat_func(root, rel, include_metadata=include_metadata)
return await stat_func(root, rel)
@classmethod
async def path_is_directory(cls, path: str) -> bool:
adapter_instance, _, root, rel = await cls.resolve_adapter_and_rel(path)
@@ -24,7 +42,7 @@ class VirtualFSListingMixin(VirtualFSResolverMixin):
if not callable(stat_func):
raise HTTPException(501, detail="Adapter does not implement stat_file")
try:
info = await stat_func(root, rel)
info = await cls._call_stat_file(stat_func, root, rel, include_metadata=False)
except FileNotFoundError:
raise HTTPException(404, detail="Path not found")
if isinstance(info, dict):
@@ -110,7 +128,12 @@ class VirtualFSListingMixin(VirtualFSResolverMixin):
stat_file = getattr(adapter_instance, "stat_file", None)
if callable(stat_file):
try:
parent_info = await stat_file(effective_root, rel)
parent_info = await cls._call_stat_file(
stat_file,
effective_root,
rel,
include_metadata=False,
)
if isinstance(parent_info, dict):
parent_info.setdefault("name", rel.split("/")[-1])
parent_info["is_dir"] = bool(parent_info.get("is_dir", True))
@@ -121,7 +144,12 @@ class VirtualFSListingMixin(VirtualFSResolverMixin):
stat_file = getattr(adapter_instance, "stat_file", None)
if callable(stat_file):
try:
parent_info = await stat_file(effective_root, parent_rel)
parent_info = await cls._call_stat_file(
stat_file,
effective_root,
parent_rel,
include_metadata=False,
)
if isinstance(parent_info, dict):
parent_info.setdefault("name", parent_rel.split("/")[-1])
parent_info["is_dir"] = bool(parent_info.get("is_dir", True))
@@ -222,13 +250,18 @@ class VirtualFSListingMixin(VirtualFSResolverMixin):
}
@classmethod
async def stat_file(cls, path: str):
async def stat_file(cls, path: str, verbose: bool = False):
adapter_instance, _, root, rel = await cls.resolve_adapter_and_rel(path)
stat_func = getattr(adapter_instance, "stat_file", None)
if not callable(stat_func):
raise HTTPException(501, detail="Adapter does not implement stat_file")
try:
info = await stat_func(root, rel)
info = await cls._call_stat_file(
stat_func,
root,
rel,
include_metadata=verbose,
)
except FileNotFoundError as exc:
raise HTTPException(404, detail=str(exc))
@@ -241,7 +274,7 @@ class VirtualFSListingMixin(VirtualFSResolverMixin):
rel_name = rel.rstrip("/").split("/")[-1] if rel else path.rstrip("/").split("/")[-1]
name_hint = str(info.get("name") or rel_name or "")
info["has_thumbnail"] = bool(not is_dir and (is_image_filename(name_hint) or is_video_filename(name_hint)))
if not is_dir:
if verbose and not is_dir:
vector_index = await cls._gather_vector_index(path)
if vector_index is not None:
info["vector_index"] = vector_index
@@ -263,38 +296,26 @@ class VirtualFSListingMixin(VirtualFSResolverMixin):
过滤掉用户没有读取权限的条目
"""
# 首先获取完整的目录列表
result = await cls.list_virtual_dir(path, page_num, page_size, sort_by, sort_order)
# 检查用户是否是管理员(管理员可以看到所有内容)
from models.database import UserAccount
user = await UserAccount.get_or_none(id=user_id)
if user and user.is_admin:
return result
# 过滤无权限的条目
items = result.get("items", [])
if not items:
return result
norm = cls._normalize_path(path).rstrip("/") or "/"
filtered_items = []
path_pairs: List[Tuple[str, Dict]] = []
for item in items:
item_name = item.get("name", "")
if norm == "/":
item_path = f"/{item_name}"
else:
item_path = f"{norm}/{item_name}"
# 检查用户是否有读取权限
has_permission = await PermissionService.check_path_permission(
user_id, item_path, PathAction.READ
)
if has_permission:
filtered_items.append(item)
# 更新结果
result["items"] = filtered_items
path_pairs.append((item_path, item))
allowed_paths = await PermissionService.filter_paths_by_permission(
user_id,
[item_path for item_path, _ in path_pairs],
PathAction.READ,
)
allowed_set = set(allowed_paths)
result["items"] = [item for item_path, item in path_pairs if item_path in allowed_set]
return result

View File

@@ -144,9 +144,9 @@ class VirtualFSRouteMixin(VirtualFSTempLinkMixin):
return response
@classmethod
async def stat(cls, full_path: str):
async def stat(cls, full_path: str, verbose: bool = False):
full_path = cls._normalize_path(full_path)
return await cls.stat_file(full_path)
return await cls.stat_file(full_path, verbose=verbose)
@classmethod
async def write_uploaded_file(cls, full_path: str, data: bytes):

View File

@@ -28,12 +28,12 @@ async def search_files(
data = await VirtualFSSearchService.search(q, top_k, mode, page, page_size)
items = data.get("items") if isinstance(data, dict) else None
if isinstance(items, list) and items:
filtered = []
for item in items:
path = getattr(item, "path", None)
if not path:
continue
if await PermissionService.check_path_permission(user.id, str(path), PathAction.READ):
filtered.append(item)
data["items"] = filtered
path_pairs = [(str(item.path), item) for item in items if getattr(item, "path", None)]
allowed_paths = await PermissionService.filter_paths_by_permission(
user.id,
[path for path, _ in path_pairs],
PathAction.READ,
)
allowed_set = set(allowed_paths)
data["items"] = [item for path, item in path_pairs if path in allowed_set]
return success(data)

166
uv.lock generated
View File

@@ -63,7 +63,7 @@ wheels = [
[[package]]
name = "aiohttp"
version = "3.13.3"
version = "3.13.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohappyeyeballs" },
@@ -74,42 +74,42 @@ dependencies = [
{ name = "propcache" },
{ name = "yarl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" }
sdist = { url = "https://files.pythonhosted.org/packages/45/4a/064321452809dae953c1ed6e017504e72551a26b6f5708a5a80e4bf556ff/aiohttp-3.13.4.tar.gz", hash = "sha256:d97a6d09c66087890c2ab5d49069e1e570583f7ac0314ecf98294c1b6aaebd38", size = 7859748, upload-time = "2026-03-28T17:19:40.6Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238, upload-time = "2026-01-03T17:31:17.909Z" },
{ url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292, upload-time = "2026-01-03T17:31:19.919Z" },
{ url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021, upload-time = "2026-01-03T17:31:21.636Z" },
{ url = "https://files.pythonhosted.org/packages/54/d4/438efabdf74e30aeceb890c3290bbaa449780583b1270b00661126b8aae4/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6", size = 1717263, upload-time = "2026-01-03T17:31:23.296Z" },
{ url = "https://files.pythonhosted.org/packages/71/f2/7bddc7fd612367d1459c5bcf598a9e8f7092d6580d98de0e057eb42697ad/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687", size = 1669107, upload-time = "2026-01-03T17:31:25.334Z" },
{ url = "https://files.pythonhosted.org/packages/00/5a/1aeaecca40e22560f97610a329e0e5efef5e0b5afdf9f857f0d93839ab2e/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26", size = 1760196, upload-time = "2026-01-03T17:31:27.394Z" },
{ url = "https://files.pythonhosted.org/packages/f8/f8/0ff6992bea7bd560fc510ea1c815f87eedd745fe035589c71ce05612a19a/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a", size = 1843591, upload-time = "2026-01-03T17:31:29.238Z" },
{ url = "https://files.pythonhosted.org/packages/e3/d1/e30e537a15f53485b61f5be525f2157da719819e8377298502aebac45536/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1", size = 1720277, upload-time = "2026-01-03T17:31:31.053Z" },
{ url = "https://files.pythonhosted.org/packages/84/45/23f4c451d8192f553d38d838831ebbc156907ea6e05557f39563101b7717/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25", size = 1548575, upload-time = "2026-01-03T17:31:32.87Z" },
{ url = "https://files.pythonhosted.org/packages/6a/ed/0a42b127a43712eda7807e7892c083eadfaf8429ca8fb619662a530a3aab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603", size = 1679455, upload-time = "2026-01-03T17:31:34.76Z" },
{ url = "https://files.pythonhosted.org/packages/2e/b5/c05f0c2b4b4fe2c9d55e73b6d3ed4fd6c9dc2684b1d81cbdf77e7fad9adb/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a", size = 1687417, upload-time = "2026-01-03T17:31:36.699Z" },
{ url = "https://files.pythonhosted.org/packages/c9/6b/915bc5dad66aef602b9e459b5a973529304d4e89ca86999d9d75d80cbd0b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926", size = 1729968, upload-time = "2026-01-03T17:31:38.622Z" },
{ url = "https://files.pythonhosted.org/packages/11/3b/e84581290a9520024a08640b63d07673057aec5ca548177a82026187ba73/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba", size = 1545690, upload-time = "2026-01-03T17:31:40.57Z" },
{ url = "https://files.pythonhosted.org/packages/f5/04/0c3655a566c43fd647c81b895dfe361b9f9ad6d58c19309d45cff52d6c3b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c", size = 1746390, upload-time = "2026-01-03T17:31:42.857Z" },
{ url = "https://files.pythonhosted.org/packages/1f/53/71165b26978f719c3419381514c9690bd5980e764a09440a10bb816ea4ab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43", size = 1702188, upload-time = "2026-01-03T17:31:44.984Z" },
{ url = "https://files.pythonhosted.org/packages/29/a7/cbe6c9e8e136314fa1980da388a59d2f35f35395948a08b6747baebb6aa6/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1", size = 433126, upload-time = "2026-01-03T17:31:47.463Z" },
{ url = "https://files.pythonhosted.org/packages/de/56/982704adea7d3b16614fc5936014e9af85c0e34b58f9046655817f04306e/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984", size = 459128, upload-time = "2026-01-03T17:31:49.2Z" },
{ url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512, upload-time = "2026-01-03T17:31:51.134Z" },
{ url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444, upload-time = "2026-01-03T17:31:52.85Z" },
{ url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798, upload-time = "2026-01-03T17:31:54.91Z" },
{ url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835, upload-time = "2026-01-03T17:31:56.733Z" },
{ url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486, upload-time = "2026-01-03T17:31:58.65Z" },
{ url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951, upload-time = "2026-01-03T17:32:00.989Z" },
{ url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001, upload-time = "2026-01-03T17:32:03.122Z" },
{ url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246, upload-time = "2026-01-03T17:32:05.255Z" },
{ url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131, upload-time = "2026-01-03T17:32:07.607Z" },
{ url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196, upload-time = "2026-01-03T17:32:09.59Z" },
{ url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841, upload-time = "2026-01-03T17:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193, upload-time = "2026-01-03T17:32:13.705Z" },
{ url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979, upload-time = "2026-01-03T17:32:15.965Z" },
{ url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193, upload-time = "2026-01-03T17:32:18.219Z" },
{ url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801, upload-time = "2026-01-03T17:32:20.25Z" },
{ url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523, upload-time = "2026-01-03T17:32:22.215Z" },
{ url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" },
{ url = "https://files.pythonhosted.org/packages/6d/29/6657cc37ae04cacc2dbf53fb730a06b6091cc4cbe745028e047c53e6d840/aiohttp-3.13.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:e0a2c961fc92abeff61d6444f2ce6ad35bb982db9fc8ff8a47455beacf454a57", size = 749363, upload-time = "2026-03-28T17:17:24.044Z" },
{ url = "https://files.pythonhosted.org/packages/90/7f/30ccdf67ca3d24b610067dc63d64dcb91e5d88e27667811640644aa4a85d/aiohttp-3.13.4-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:153274535985a0ff2bff1fb6c104ed547cec898a09213d21b0f791a44b14d933", size = 499317, upload-time = "2026-03-28T17:17:26.199Z" },
{ url = "https://files.pythonhosted.org/packages/93/13/e372dd4e68ad04ee25dafb050c7f98b0d91ea643f7352757e87231102555/aiohttp-3.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:351f3171e2458da3d731ce83f9e6b9619e325c45cbd534c7759750cabf453ad7", size = 500477, upload-time = "2026-03-28T17:17:28.279Z" },
{ url = "https://files.pythonhosted.org/packages/e5/fe/ee6298e8e586096fb6f5eddd31393d8544f33ae0792c71ecbb4c2bef98ac/aiohttp-3.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f989ac8bc5595ff761a5ccd32bdb0768a117f36dd1504b1c2c074ed5d3f4df9c", size = 1737227, upload-time = "2026-03-28T17:17:30.587Z" },
{ url = "https://files.pythonhosted.org/packages/b0/b9/a7a0463a09e1a3fe35100f74324f23644bfc3383ac5fd5effe0722a5f0b7/aiohttp-3.13.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d36fc1709110ec1e87a229b201dd3ddc32aa01e98e7868083a794609b081c349", size = 1694036, upload-time = "2026-03-28T17:17:33.29Z" },
{ url = "https://files.pythonhosted.org/packages/57/7c/8972ae3fb7be00a91aee6b644b2a6a909aedb2c425269a3bfd90115e6f8f/aiohttp-3.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42adaeea83cbdf069ab94f5103ce0787c21fb1a0153270da76b59d5578302329", size = 1786814, upload-time = "2026-03-28T17:17:36.035Z" },
{ url = "https://files.pythonhosted.org/packages/93/01/c81e97e85c774decbaf0d577de7d848934e8166a3a14ad9f8aa5be329d28/aiohttp-3.13.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:92deb95469928cc41fd4b42a95d8012fa6df93f6b1c0a83af0ffbc4a5e218cde", size = 1866676, upload-time = "2026-03-28T17:17:38.441Z" },
{ url = "https://files.pythonhosted.org/packages/5a/5f/5b46fe8694a639ddea2cd035bf5729e4677ea882cb251396637e2ef1590d/aiohttp-3.13.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c0c7c07c4257ef3a1df355f840bc62d133bcdef5c1c5ba75add3c08553e2eed", size = 1740842, upload-time = "2026-03-28T17:17:40.783Z" },
{ url = "https://files.pythonhosted.org/packages/20/a2/0d4b03d011cca6b6b0acba8433193c1e484efa8d705ea58295590fe24203/aiohttp-3.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f062c45de8a1098cb137a1898819796a2491aec4e637a06b03f149315dff4d8f", size = 1566508, upload-time = "2026-03-28T17:17:43.235Z" },
{ url = "https://files.pythonhosted.org/packages/98/17/e689fd500da52488ec5f889effd6404dece6a59de301e380f3c64f167beb/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:76093107c531517001114f0ebdb4f46858ce818590363e3e99a4a2280334454a", size = 1700569, upload-time = "2026-03-28T17:17:46.165Z" },
{ url = "https://files.pythonhosted.org/packages/d8/0d/66402894dbcf470ef7db99449e436105ea862c24f7ea4c95c683e635af35/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:6f6ec32162d293b82f8b63a16edc80769662fbd5ae6fbd4936d3206a2c2cc63b", size = 1707407, upload-time = "2026-03-28T17:17:48.825Z" },
{ url = "https://files.pythonhosted.org/packages/2f/eb/af0ab1a3650092cbd8e14ef29e4ab0209e1460e1c299996c3f8288b3f1ff/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5903e2db3d202a00ad9f0ec35a122c005e85d90c9836ab4cda628f01edf425e2", size = 1752214, upload-time = "2026-03-28T17:17:51.206Z" },
{ url = "https://files.pythonhosted.org/packages/5a/bf/72326f8a98e4c666f292f03c385545963cc65e358835d2a7375037a97b57/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2d5bea57be7aca98dbbac8da046d99b5557c5cf4e28538c4c786313078aca09e", size = 1562162, upload-time = "2026-03-28T17:17:53.634Z" },
{ url = "https://files.pythonhosted.org/packages/67/9f/13b72435f99151dd9a5469c96b3b5f86aa29b7e785ca7f35cf5e538f74c0/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:bcf0c9902085976edc0232b75006ef38f89686901249ce14226b6877f88464fb", size = 1768904, upload-time = "2026-03-28T17:17:55.991Z" },
{ url = "https://files.pythonhosted.org/packages/18/bc/28d4970e7d5452ac7776cdb5431a1164a0d9cf8bd2fffd67b4fb463aa56d/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3295f98bfeed2e867cab588f2a146a9db37a85e3ae9062abf46ba062bd29165", size = 1723378, upload-time = "2026-03-28T17:17:58.348Z" },
{ url = "https://files.pythonhosted.org/packages/53/74/b32458ca1a7f34d65bdee7aef2036adbe0438123d3d53e2b083c453c24dd/aiohttp-3.13.4-cp314-cp314-win32.whl", hash = "sha256:a598a5c5767e1369d8f5b08695cab1d8160040f796c4416af76fd773d229b3c9", size = 438711, upload-time = "2026-03-28T17:18:00.728Z" },
{ url = "https://files.pythonhosted.org/packages/40/b2/54b487316c2df3e03a8f3435e9636f8a81a42a69d942164830d193beb56a/aiohttp-3.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:c555db4bc7a264bead5a7d63d92d41a1122fcd39cc62a4db815f45ad46f9c2c8", size = 464977, upload-time = "2026-03-28T17:18:03.367Z" },
{ url = "https://files.pythonhosted.org/packages/47/fb/e41b63c6ce71b07a59243bb8f3b457ee0c3402a619acb9d2c0d21ef0e647/aiohttp-3.13.4-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45abbbf09a129825d13c18c7d3182fecd46d9da3cfc383756145394013604ac1", size = 781549, upload-time = "2026-03-28T17:18:05.779Z" },
{ url = "https://files.pythonhosted.org/packages/97/53/532b8d28df1e17e44c4d9a9368b78dcb6bf0b51037522136eced13afa9e8/aiohttp-3.13.4-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:74c80b2bc2c2adb7b3d1941b2b60701ee2af8296fc8aad8b8bc48bc25767266c", size = 514383, upload-time = "2026-03-28T17:18:08.096Z" },
{ url = "https://files.pythonhosted.org/packages/1b/1f/62e5d400603e8468cd635812d99cb81cfdc08127a3dc474c647615f31339/aiohttp-3.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c97989ae40a9746650fa196894f317dafc12227c808c774929dda0ff873a5954", size = 518304, upload-time = "2026-03-28T17:18:10.642Z" },
{ url = "https://files.pythonhosted.org/packages/90/57/2326b37b10896447e3c6e0cbef4fe2486d30913639a5cfd1332b5d870f82/aiohttp-3.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dae86be9811493f9990ef44fff1685f5c1a3192e9061a71a109d527944eed551", size = 1893433, upload-time = "2026-03-28T17:18:13.121Z" },
{ url = "https://files.pythonhosted.org/packages/d2/b4/a24d82112c304afdb650167ef2fe190957d81cbddac7460bedd245f765aa/aiohttp-3.13.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1db491abe852ca2fa6cc48a3341985b0174b3741838e1341b82ac82c8bd9e871", size = 1755901, upload-time = "2026-03-28T17:18:16.21Z" },
{ url = "https://files.pythonhosted.org/packages/9e/2d/0883ef9d878d7846287f036c162a951968f22aabeef3ac97b0bea6f76d5d/aiohttp-3.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0e5d701c0aad02a7dce72eef6b93226cf3734330f1a31d69ebbf69f33b86666e", size = 1876093, upload-time = "2026-03-28T17:18:18.703Z" },
{ url = "https://files.pythonhosted.org/packages/ad/52/9204bb59c014869b71971addad6778f005daa72a96eed652c496789d7468/aiohttp-3.13.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8ac32a189081ae0a10ba18993f10f338ec94341f0d5df8fff348043962f3c6f8", size = 1970815, upload-time = "2026-03-28T17:18:21.858Z" },
{ url = "https://files.pythonhosted.org/packages/d6/b5/e4eb20275a866dde0f570f411b36c6b48f7b53edfe4f4071aa1b0728098a/aiohttp-3.13.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98e968cdaba43e45c73c3f306fca418c8009a957733bac85937c9f9cf3f4de27", size = 1816223, upload-time = "2026-03-28T17:18:24.729Z" },
{ url = "https://files.pythonhosted.org/packages/d8/23/e98075c5bb146aa61a1239ee1ac7714c85e814838d6cebbe37d3fe19214a/aiohttp-3.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca114790c9144c335d538852612d3e43ea0f075288f4849cf4b05d6cd2238ce7", size = 1649145, upload-time = "2026-03-28T17:18:27.269Z" },
{ url = "https://files.pythonhosted.org/packages/d6/c1/7bad8be33bb06c2bb224b6468874346026092762cbec388c3bdb65a368ee/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ea2e071661ba9cfe11eabbc81ac5376eaeb3061f6e72ec4cc86d7cdd1ffbdbbb", size = 1816562, upload-time = "2026-03-28T17:18:29.847Z" },
{ url = "https://files.pythonhosted.org/packages/5c/10/c00323348695e9a5e316825969c88463dcc24c7e9d443244b8a2c9cf2eae/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:34e89912b6c20e0fd80e07fa401fd218a410aa1ce9f1c2f1dad6db1bd0ce0927", size = 1800333, upload-time = "2026-03-28T17:18:32.269Z" },
{ url = "https://files.pythonhosted.org/packages/84/43/9b2147a1df3559f49bd723e22905b46a46c068a53adb54abdca32c4de180/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0e217cf9f6a42908c52b46e42c568bd57adc39c9286ced31aaace614b6087965", size = 1820617, upload-time = "2026-03-28T17:18:35.238Z" },
{ url = "https://files.pythonhosted.org/packages/a9/7f/b3481a81e7a586d02e99387b18c6dafff41285f6efd3daa2124c01f87eae/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:0c296f1221e21ba979f5ac1964c3b78cfde15c5c5f855ffd2caab337e9cd9182", size = 1643417, upload-time = "2026-03-28T17:18:37.949Z" },
{ url = "https://files.pythonhosted.org/packages/8f/72/07181226bc99ce1124e0f89280f5221a82d3ae6a6d9d1973ce429d48e52b/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d99a9d168ebaffb74f36d011750e490085ac418f4db926cce3989c8fe6cb6b1b", size = 1849286, upload-time = "2026-03-28T17:18:40.534Z" },
{ url = "https://files.pythonhosted.org/packages/1a/e6/1b3566e103eca6da5be4ae6713e112a053725c584e96574caf117568ffef/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cb19177205d93b881f3f89e6081593676043a6828f59c78c17a0fd6c1fbed2ba", size = 1782635, upload-time = "2026-03-28T17:18:43.073Z" },
{ url = "https://files.pythonhosted.org/packages/37/58/1b11c71904b8d079eb0c39fe664180dd1e14bebe5608e235d8bfbadc8929/aiohttp-3.13.4-cp314-cp314t-win32.whl", hash = "sha256:c606aa5656dab6552e52ca368e43869c916338346bfaf6304e15c58fb113ea30", size = 472537, upload-time = "2026-03-28T17:18:46.286Z" },
{ url = "https://files.pythonhosted.org/packages/bc/8f/87c56a1a1977d7dddea5b31e12189665a140fdb48a71e9038ff90bb564ec/aiohttp-3.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:014dcc10ec8ab8db681f0d68e939d1e9286a5aa2b993cbbdb0db130853e02144", size = 506381, upload-time = "2026-03-28T17:18:48.74Z" },
]
[[package]]
@@ -347,55 +347,55 @@ wheels = [
[[package]]
name = "cryptography"
version = "46.0.5"
version = "46.0.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" }
sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" },
{ url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" },
{ url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" },
{ url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" },
{ url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" },
{ url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" },
{ url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" },
{ url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" },
{ url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" },
{ url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" },
{ url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" },
{ url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" },
{ url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" },
{ url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" },
{ url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" },
{ url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" },
{ url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" },
{ url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" },
{ url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" },
{ url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" },
{ url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" },
{ url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" },
{ url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" },
{ url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" },
{ url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" },
{ url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" },
{ url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" },
{ url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" },
{ url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" },
{ url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" },
{ url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" },
{ url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" },
{ url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" },
{ url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" },
{ url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" },
{ url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" },
{ url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" },
{ url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" },
{ url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" },
{ url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" },
{ url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" },
{ url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" },
{ url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" },
{ url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" },
{ url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" },
{ url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" },
{ url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" },
{ url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" },
{ url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" },
{ url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" },
{ url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" },
{ url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" },
{ url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" },
{ url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" },
{ url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" },
{ url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" },
{ url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" },
{ url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" },
{ url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" },
{ url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" },
{ url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" },
{ url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" },
{ url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" },
{ url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" },
{ url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" },
{ url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" },
{ url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" },
{ url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" },
{ url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" },
{ url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" },
{ url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" },
{ url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" },
{ url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" },
{ url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" },
{ url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" },
{ url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" },
{ url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" },
{ url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" },
{ url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" },
{ url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" },
{ url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" },
{ url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" },
{ url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" },
{ url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" },
]
[[package]]
@@ -977,11 +977,11 @@ sdist = { url = "https://files.pythonhosted.org/packages/44/66/2c17bae31c9066137
[[package]]
name = "pyasn1"
version = "0.6.2"
version = "0.6.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fe/b6/6e630dff89739fcd427e3f72b3d905ce0acb85a45d4ec3e2678718a3487f/pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b", size = 146586, upload-time = "2026-01-16T18:04:18.534Z" }
sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/44/b5/a96872e5184f354da9c84ae119971a0a4c221fe9b27a4d94bd43f2596727/pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf", size = 83371, upload-time = "2026-01-16T18:04:17.174Z" },
{ url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" },
]
[[package]]

View File

@@ -10,38 +10,7 @@ import { Routes, Route, Navigate } from 'react-router';
import SetupPage from './pages/SetupPage.tsx';
import { I18nProvider } from './i18n';
function AppInner() {
const [status, setStatus] = useState<SystemStatus | null>(null);
useEffect(() => {
async function checkInitialization() {
try {
const status = await getStatus();
setStatus(status);
document.title = status.title;
let favicon = document.querySelector("link[rel*='icon']") as HTMLLinkElement | null;
if (!favicon) {
favicon = document.createElement('link');
favicon.rel = 'icon';
document.head.appendChild(favicon);
}
if (favicon) {
favicon.href = status.favicon || status.logo;
}
} catch (error) {
console.error("Failed to check initialization status:", error);
}
}
checkInitialization();
}, []);
if (status === null) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<Spin size="large" />
</div>
);
}
function AppInner({ status }: { status: SystemStatus }) {
return (
<SystemContext.Provider value={status}>
<AuthProvider>
@@ -61,9 +30,41 @@ function AppInner() {
}
export default function App() {
const [status, setStatus] = useState<SystemStatus | null>(null);
useEffect(() => {
async function checkInitialization() {
try {
const nextStatus = await getStatus();
setStatus(nextStatus);
document.title = nextStatus.title;
let favicon = document.querySelector("link[rel*='icon']") as HTMLLinkElement | null;
if (!favicon) {
favicon = document.createElement('link');
favicon.rel = 'icon';
document.head.appendChild(favicon);
}
if (favicon) {
favicon.href = nextStatus.favicon || nextStatus.logo;
}
} catch (error) {
console.error("Failed to check initialization status:", error);
}
}
checkInitialization();
}, []);
if (status === null) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<Spin size="large" />
</div>
);
}
return (
<I18nProvider>
<AppInner />
<I18nProvider defaultLanguage={status.default_language}>
<AppInner status={status} />
</I18nProvider>
);
}

View File

@@ -13,10 +13,11 @@ export interface AdapterItem {
export interface AdapterTypeField {
key: string;
label: string;
type: 'string' | 'password' | 'number' | 'boolean';
type: 'string' | 'password' | 'number' | 'boolean' | 'select';
required?: boolean;
placeholder?: string;
default?: any;
options?: string[];
}
export interface AdapterTypeMeta {

View File

@@ -1,4 +1,5 @@
import request from './client';
import type { Lang } from '../i18n/lang';
export async function getConfig(key: string) {
return request<{ key: string; value: string }>('/config/?key=' + encodeURIComponent(key));
@@ -25,6 +26,7 @@ export interface SystemStatus {
logo: string;
favicon: string;
is_initialized: boolean;
default_language?: Lang;
app_domain?: string;
file_domain?: string;
}

View File

@@ -86,7 +86,12 @@ export const vfsApi = {
thumb: (path: string, w=256, h=256, fit='cover') =>
request<ArrayBuffer>(`/fs/thumb/${encodeURI(path.replace(/^\/+/, ''))}?w=${w}&h=${h}&fit=${fit}`),
streamUrl: (path: string) => `${API_BASE_URL}/fs/stream/${encodeURI(path.replace(/^\/+/, ''))}`,
stat: (path: string) => request(`/fs/stat/${encodeURI(path.replace(/^\/+/, ''))}`),
stat: (path: string, options?: { verbose?: boolean }) => {
const params = new URLSearchParams();
if (options?.verbose) params.set('verbose', 'true');
const query = params.toString();
return request(`/fs/stat/${encodeURI(path.replace(/^\/+/, ''))}${query ? `?${query}` : ''}`);
},
getTempLinkToken: (path: string, expiresIn: number = 3600) =>
request<{token: string, path: string, url: string}>(`/fs/temp-link/${encodeURI(path.replace(/^\/+/, ''))}?expires_in=${expiresIn}`),
getTempPublicUrl: (token: string) => `${API_BASE_URL}/fs/public/${token}`,

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useMemo, useRef } from 'react';
import type { AppComponentProps, AppOpenComponentProps } from '../types';
import type { PluginItem } from '../../api/plugins';
import { useI18n } from '../../i18n';
export interface PluginAppHostProps extends AppComponentProps {
plugin: PluginItem;
@@ -34,6 +35,7 @@ export const PluginAppHost: React.FC<PluginAppHostProps> = ({
entry,
onRequestClose,
}) => {
const { lang } = useI18n();
const iframeRef = useRef<HTMLIFrameElement>(null);
const onCloseRef = useRef(onRequestClose);
onCloseRef.current = onRequestClose;
@@ -45,10 +47,11 @@ export const PluginAppHost: React.FC<PluginAppHostProps> = ({
pluginVersion: plugin.version || '',
pluginStyles: JSON.stringify(getPluginStylePaths(plugin)),
mode: 'file',
lang,
filePath,
entry: JSON.stringify(entry),
}),
[plugin, filePath, entry]
[plugin, filePath, entry, lang]
);
useEffect(() => {
@@ -86,6 +89,7 @@ export interface PluginAppOpenHostProps extends AppOpenComponentProps {
* 注意:同源且不加 sandbox 时,不是安全沙箱(插件仍可通过 window.parent 访问宿主)。
*/
export const PluginAppOpenHost: React.FC<PluginAppOpenHostProps> = ({ plugin, onRequestClose }) => {
const { lang } = useI18n();
const iframeRef = useRef<HTMLIFrameElement>(null);
const onCloseRef = useRef(onRequestClose);
onCloseRef.current = onRequestClose;
@@ -97,8 +101,9 @@ export const PluginAppOpenHost: React.FC<PluginAppOpenHostProps> = ({ plugin, on
pluginVersion: plugin.version || '',
pluginStyles: JSON.stringify(getPluginStylePaths(plugin)),
mode: 'app',
lang,
}),
[plugin]
[plugin, lang]
);
useEffect(() => {

View File

@@ -2,8 +2,8 @@ import { createContext, useCallback, useContext, useMemo, useState, useEffect }
import type { PropsWithChildren } from 'react';
import en from './locales/en.json';
import zhOverrides from './locales/zh.json';
import { normalizeLang, persistLang, readStoredLang, type Lang } from './lang';
type Lang = 'zh' | 'en';
type Dict = Record<string, string>;
const dicts: Record<Lang, Dict> = {
@@ -11,9 +11,13 @@ const dicts: Record<Lang, Dict> = {
zh: { ...en, ...zhOverrides },
};
interface SetLangOptions {
persist?: boolean;
}
export interface I18nContextValue {
lang: Lang;
setLang: (lang: Lang) => void;
setLang: (lang: Lang, options?: SetLangOptions) => void;
t: (key: string, params?: Record<string, string | number>) => string;
}
@@ -24,13 +28,26 @@ function interpolate(template: string, params?: Record<string, string | number>)
return template.replace(/\{(\w+)\}/g, (_, k) => String(params[k] ?? `{${k}}`));
}
export function I18nProvider({ children }: PropsWithChildren) {
const [lang, setLangState] = useState<Lang>(() => (localStorage.getItem('lang') as Lang) || 'zh');
interface I18nProviderProps {
defaultLanguage?: Lang;
}
const setLang = useCallback((l: Lang) => {
setLangState(l);
localStorage.setItem('lang', l);
}, []);
export function I18nProvider({ children, defaultLanguage }: PropsWithChildren<I18nProviderProps>) {
const fallbackLang = normalizeLang(defaultLanguage, 'zh');
const [lang, setLangState] = useState<Lang>(() => readStoredLang() ?? fallbackLang);
const setLang = useCallback((nextLang: Lang, options?: SetLangOptions) => {
const normalized = normalizeLang(nextLang, fallbackLang);
setLangState(normalized);
if (options?.persist === false) return;
persistLang(normalized);
}, [fallbackLang]);
useEffect(() => {
if (!readStoredLang()) {
setLangState(fallbackLang);
}
}, [fallbackLang]);
useEffect(() => {
document.documentElement.lang = lang;

42
web/src/i18n/lang.ts Normal file
View File

@@ -0,0 +1,42 @@
export type Lang = 'zh' | 'en';
const LANG_STORAGE_KEY = 'lang';
export function parseLang(raw: unknown): Lang | null {
if (typeof raw !== 'string') return null;
const value = raw.trim().toLowerCase();
if (!value) return null;
if (value === 'en' || value.startsWith('en-')) return 'en';
if (value === 'zh' || value.startsWith('zh-')) return 'zh';
return null;
}
export function normalizeLang(raw: unknown, fallback: Lang = 'zh'): Lang {
return parseLang(raw) ?? fallback;
}
export function readStoredLang(): Lang | null {
if (typeof window === 'undefined') return null;
try {
return parseLang(window.localStorage.getItem(LANG_STORAGE_KEY));
} catch {
return null;
}
}
export function persistLang(lang: Lang): void {
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(LANG_STORAGE_KEY, lang);
} catch {
void 0;
}
}
export function getActiveLang(fallback: Lang = 'zh'): Lang {
if (typeof document !== 'undefined') {
const documentLang = parseLang(document.documentElement.lang);
if (documentLang) return documentLang;
}
return readStoredLang() ?? fallback;
}

View File

@@ -27,10 +27,13 @@
"Register failed": "Register failed",
"Please input email!": "Please input email!",
"Profile": "Profile",
"Client Authorization": "Client Authorization",
"Account Settings": "Account Settings",
"Language": "Language",
"Chinese": "中文",
"English": "English",
"Default Language": "Default Language",
"Used when the user has not selected a language": "Used when the user has not selected a language",
"Full Name": "Full Name",
"Email": "Email",
"Change Password": "Change Password",

View File

@@ -50,8 +50,13 @@
"Register failed": "注册失败",
"Please input email!": "请输入邮箱!",
"Profile": "个人资料",
"Client Authorization": "客户端授权",
"Account Settings": "账户设置",
"Language": "语言",
"Chinese": "中文",
"English": "English",
"Default Language": "默认语言",
"Used when the user has not selected a language": "用户未手动选择语言时使用",
"Full Name": "昵称",
"Email": "邮箱",
"Change Password": "修改密码",

View File

@@ -1,6 +1,6 @@
import { Layout, Button, Dropdown, theme, Flex, Avatar, Typography, Tooltip } from 'antd';
import { SearchOutlined, MenuUnfoldOutlined, LogoutOutlined, UserOutlined, RobotOutlined, BellOutlined } from '@ant-design/icons';
import { memo, useState } from 'react';
import { Layout, Button, Dropdown, theme, Flex, Avatar, Typography, Tooltip, Modal, QRCode } from 'antd';
import { SearchOutlined, MenuUnfoldOutlined, LogoutOutlined, UserOutlined, RobotOutlined, BellOutlined, QrcodeOutlined } from '@ant-design/icons';
import { memo, useMemo, useState } from 'react';
import SearchDialog from './SearchDialog.tsx';
import { authApi } from '../api/auth.ts';
import { useNavigate } from 'react-router';
@@ -26,11 +26,16 @@ const TopHeader = memo(function TopHeader({ collapsed, onToggle, onOpenAiAgent,
const [searchOpen, setSearchOpen] = useState(false);
const navigate = useNavigate();
const { t } = useI18n();
const { user } = useAuth();
const { user, token: authToken } = useAuth();
const [profileOpen, setProfileOpen] = useState(false);
const [clientAuthOpen, setClientAuthOpen] = useState(false);
const [noticesOpen, setNoticesOpen] = useState(false);
const status = useSystemStatus();
const { isMobile } = useResponsive();
const clientAuthPayload = useMemo(() => JSON.stringify({
base_url: window.location.origin,
token: authToken || '',
}), [authToken]);
const handleLogout = () => {
authApi.logout();
@@ -38,6 +43,7 @@ const TopHeader = memo(function TopHeader({ collapsed, onToggle, onOpenAiAgent,
};
const openProfile = () => setProfileOpen(true);
const openClientAuth = () => setClientAuthOpen(true);
return (
<Header
@@ -96,6 +102,7 @@ const TopHeader = memo(function TopHeader({ collapsed, onToggle, onOpenAiAgent,
menu={{
items: [
{ key: 'profile', label: t('Profile'), icon: <UserOutlined />, onClick: openProfile },
{ key: 'client-auth', label: t('Client Authorization'), icon: <QrcodeOutlined />, onClick: openClientAuth },
{ key: 'logout', label: t('Log Out'), icon: <LogoutOutlined />, onClick: handleLogout },
],
}}
@@ -114,6 +121,18 @@ const TopHeader = memo(function TopHeader({ collapsed, onToggle, onOpenAiAgent,
</Button>
</Dropdown>
<ProfileModal open={profileOpen} onClose={() => setProfileOpen(false)} />
<Modal
title={t('Client Authorization')}
open={clientAuthOpen}
onCancel={() => setClientAuthOpen(false)}
footer={null}
width={320}
centered
>
<Flex justify="center" style={{ padding: '8px 0' }}>
<QRCode value={clientAuthPayload} size={220} />
</Flex>
</Modal>
<NoticesModal open={noticesOpen} onClose={() => setNoticesOpen(false)} version={status?.version || ''} />
</Flex>
</Header>

View File

@@ -180,6 +180,14 @@ const AdaptersPage = memo(function AdaptersPage() {
let valuePropName: string | undefined;
if (field.type === 'password') inputNode = <Input.Password placeholder={field.placeholder} />;
if (field.type === 'number') inputNode = <Input type="number" placeholder={field.placeholder} />;
if (field.type === 'select') {
inputNode = (
<Select
placeholder={field.placeholder}
options={(field.options || []).map(option => ({ value: option, label: t(option) }))}
/>
);
}
if (field.type === 'boolean') {
inputNode = <Switch />;
valuePropName = 'checked';

View File

@@ -181,7 +181,7 @@ const FileExplorerPage = memo(function FileExplorerPage() {
setDetailLoading(true);
try {
const fullPath = (entryBasePath === '/' ? '' : entryBasePath) + '/' + entry.name;
const stat = await vfsApi.stat(fullPath);
const stat = await vfsApi.stat(fullPath, { verbose: true });
setDetailData(stat as Record<string, unknown>);
} catch (error) {
const messageText = error instanceof Error ? error.message : String(error);

View File

@@ -168,22 +168,19 @@ export function useFileActions({ path, refresh, clearSelection, onShare, onGetDi
refresh();
}, [normalizeDestination, normalizeFullPath, t, buildEntryDestination, refresh]);
const doDownload = useCallback(async (entry: VfsEntry) => {
const doDownload = useCallback((entry: VfsEntry) => {
if (entry.is_dir) {
message.warning(t('Downloading folders is not supported'));
return;
}
try {
const buf = await vfsApi.readFile((path === '/' ? '' : path) + '/' + entry.name);
const blob = new Blob([buf]);
const url = URL.createObjectURL(blob);
const url = vfsApi.streamUrl((path === '/' ? '' : path) + '/' + entry.name);
const a = document.createElement('a');
a.href = url;
a.download = entry.name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (e: any) {
message.error(e.message || t('Download failed'));
}

View File

@@ -66,7 +66,7 @@ export default function SystemSettingsPage({ tabKey, onTabNavigate }: SystemSett
});
}, [t]);
const handleSave = async (values: Record<string, unknown>) => {
const handleSave = async (values: Record<string, unknown>): Promise<boolean> => {
setLoading(true);
try {
for (const [key, value] of Object.entries(values)) {
@@ -81,10 +81,13 @@ export default function SystemSettingsPage({ tabKey, onTabNavigate }: SystemSett
if (Object.keys(values).some(k => Object.values(THEME_KEYS).includes(k))) {
await refreshTheme();
}
return true;
} catch (e: any) {
message.error(e.message || t('Save failed'));
return false;
} finally {
setLoading(false);
}
setLoading(false);
};
// 离开“外观设置”时,恢复后端持久化配置(取消未保存的预览)

View File

@@ -2,6 +2,7 @@ import { Alert, Button, Divider, Form, Input, Select, Switch, message } from 'an
import { useEffect, useMemo, useState } from 'react';
import { rolesApi, type RoleInfo } from '../../../api/roles';
import { useI18n } from '../../../i18n';
import { normalizeLang, readStoredLang } from '../../../i18n/lang';
interface AppConfigKey {
key: string;
@@ -12,7 +13,7 @@ interface AppConfigKey {
interface AppSettingsTabProps {
config: Record<string, string>;
loading: boolean;
onSave: (values: Record<string, unknown>) => Promise<void>;
onSave: (values: Record<string, unknown>) => Promise<boolean>;
configKeys: AppConfigKey[];
}
@@ -22,7 +23,7 @@ export default function AppSettingsTab({
onSave,
configKeys,
}: AppSettingsTabProps) {
const { t } = useI18n();
const { t, setLang } = useI18n();
const [rolesLoading, setRolesLoading] = useState(false);
const [roles, setRoles] = useState<RoleInfo[]>([]);
@@ -52,6 +53,7 @@ export default function AppSettingsTab({
const roleId = roleIdRaw ? Number(roleIdRaw) : undefined;
return {
...Object.fromEntries(configKeys.map(({ key, default: def }) => [key, config[key] ?? def ?? ''])),
APP_DEFAULT_LANGUAGE: normalizeLang(config.APP_DEFAULT_LANGUAGE, 'zh'),
AUTH_ALLOW_REGISTER: allowRegister,
AUTH_DEFAULT_REGISTER_ROLE_ID: Number.isFinite(roleId) ? roleId : undefined,
};
@@ -66,12 +68,17 @@ export default function AppSettingsTab({
for (const { key } of configKeys) {
payload[key] = vals[key];
}
const defaultLanguage = normalizeLang(vals.APP_DEFAULT_LANGUAGE, 'zh');
payload.APP_DEFAULT_LANGUAGE = defaultLanguage;
const allow = !!vals.AUTH_ALLOW_REGISTER;
payload.AUTH_ALLOW_REGISTER = allow ? 'true' : 'false';
if (allow) {
payload.AUTH_DEFAULT_REGISTER_ROLE_ID = String(vals.AUTH_DEFAULT_REGISTER_ROLE_ID);
}
await onSave(payload);
const saved = await onSave(payload);
if (saved && !readStoredLang()) {
setLang(defaultLanguage, { persist: false });
}
}}
style={{ marginTop: 24 }}
key={JSON.stringify(config)}
@@ -82,6 +89,20 @@ export default function AppSettingsTab({
</Form.Item>
))}
<Form.Item
name="APP_DEFAULT_LANGUAGE"
label={t('Default Language')}
extra={t('Used when the user has not selected a language')}
>
<Select
size="large"
options={[
{ value: 'zh', label: t('Chinese') },
{ value: 'en', label: t('English') },
]}
/>
</Form.Item>
<Divider titlePlacement="left">{t('Registration Settings')}</Divider>
<Alert

View File

@@ -13,7 +13,7 @@ interface ThemeKeyMap {
interface AppearanceSettingsTabProps {
config: Record<string, string>;
loading: boolean;
onSave: (values: Record<string, unknown>) => Promise<void>;
onSave: (values: Record<string, unknown>) => Promise<boolean>;
themeKeys: ThemeKeyMap;
}

View File

@@ -28,7 +28,7 @@ import {
interface EmailSettingsTabProps {
config: Record<string, string>;
loading: boolean;
onSave: (values: Record<string, unknown>) => Promise<void>;
onSave: (values: Record<string, unknown>) => Promise<boolean>;
}
interface EmailFormValues {

View File

@@ -5,7 +5,7 @@ import { useI18n } from '../../../i18n';
interface ProtocolMappingsTabProps {
config: Record<string, string>;
loading: boolean;
onSave: (values: Record<string, unknown>) => Promise<void>;
onSave: (values: Record<string, unknown>) => Promise<boolean>;
}
const WEBDAV_KEY = 'WEBDAV_MAPPING_ENABLED';

View File

@@ -7,12 +7,14 @@ import type { PluginItem } from './api/plugins';
import { pluginsApi } from './api/plugins';
import request from './api/client';
import { vfsApi, type VfsEntry } from './api/vfs';
import { parseLang } from './i18n/lang';
type FrameMode = 'file' | 'app';
type FrameQuery = {
pluginKey: string;
mode: FrameMode;
lang: string;
filePath: string;
pluginVersion: string;
pluginStyles: string[] | null;
@@ -65,6 +67,7 @@ function getQuery(): FrameQuery {
const params = new URLSearchParams(window.location.search);
const pluginKey = (params.get('pluginKey') || '').trim();
const mode = (params.get('mode') || 'file') as FrameMode;
const lang = (params.get('lang') || '').trim();
const filePath = (params.get('filePath') || '').trim();
const pluginVersion = (params.get('pluginVersion') || '').trim();
@@ -88,7 +91,7 @@ function getQuery(): FrameQuery {
}
: null;
return { pluginKey, mode, filePath, pluginVersion, pluginStyles, entry };
return { pluginKey, mode, lang, filePath, pluginVersion, pluginStyles, entry };
}
function postToParent(data: any) {
@@ -279,9 +282,14 @@ async function buildFileContext(filePath: string, entryOverride: VfsEntry | null
}
async function main() {
const query = getQuery();
const frameLang = parseLang(query.lang);
if (frameLang) {
document.documentElement.lang = frameLang;
}
initExternals();
const { pluginKey, mode, filePath, pluginVersion, pluginStyles, entry } = getQuery();
const { pluginKey, mode, filePath, pluginVersion, pluginStyles, entry } = query;
if (!pluginKey) {
renderStatus('Missing pluginKey in query string', true);
return;

View File

@@ -21,8 +21,7 @@ import { pluginsApi } from '../api/plugins';
// 类型定义
import type { VfsEntry, DirListing } from '../api/client';
import type { PluginItem } from '../api/plugins';
type Lang = 'zh' | 'en';
import { getActiveLang, normalizeLang, type Lang } from '../i18n/lang';
type Dict = Record<string, string>;
type Dicts = Partial<Record<Lang, Dict>>;
@@ -197,10 +196,8 @@ declare global {
* 初始化并暴露外部依赖
*/
export function initExternals(): void {
const normalizeLang = (raw: unknown): Lang => (raw === 'en' ? 'en' : 'zh');
const i18nApi = {
getLang: () => normalizeLang(localStorage.getItem('lang')),
getLang: () => getActiveLang(),
subscribe: (cb: (lang: Lang) => void) => {
const handler = (e: Event) => {
const lang = (e as CustomEvent)?.detail?.lang as Lang;