265 lines
6.5 KiB
Python
265 lines
6.5 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import hmac
|
|
import time
|
|
from typing import Any, Optional
|
|
from urllib.parse import urlsplit
|
|
|
|
from app.config import settings
|
|
|
|
|
|
class ResourceSignedUrlError(RuntimeError):
|
|
"""
|
|
资源签名 URL 生成异常。
|
|
"""
|
|
pass
|
|
|
|
|
|
def _to_int(value: Any, default: int) -> int:
|
|
"""
|
|
安全转换整数。
|
|
"""
|
|
try:
|
|
return int(value)
|
|
except (TypeError, ValueError):
|
|
return default
|
|
|
|
|
|
def _get_sign_secret(secret: Optional[str] = None) -> str:
|
|
"""
|
|
获取资源签名密钥。
|
|
|
|
优先级:
|
|
1. 函数传入 secret
|
|
2. app.config.settings.RESOURCE_SIGN_SECRET
|
|
"""
|
|
sign_secret = secret or getattr(settings, "RESOURCE_SIGN_SECRET", "")
|
|
|
|
if not sign_secret or not str(sign_secret).strip():
|
|
raise ResourceSignedUrlError("RESOURCE_SIGN_SECRET 未配置")
|
|
|
|
return str(sign_secret)
|
|
|
|
|
|
def _get_sign_expire_seconds(expire_seconds: Optional[int] = None) -> int:
|
|
"""
|
|
获取资源签名有效期秒数。
|
|
"""
|
|
if expire_seconds is not None:
|
|
seconds = _to_int(expire_seconds, 3600)
|
|
else:
|
|
seconds = _to_int(getattr(settings, "RESOURCE_SIGN_EXPIRE_SECONDS", 3600), 3600)
|
|
|
|
if seconds <= 0:
|
|
seconds = 3600
|
|
|
|
return seconds
|
|
|
|
|
|
def _get_expire_arg_name() -> str:
|
|
"""
|
|
获取过期时间参数名。
|
|
默认 exp。
|
|
"""
|
|
name = getattr(settings, "RESOURCE_SIGN_ARG_EXPIRE", "exp")
|
|
name = str(name or "exp").strip()
|
|
return name or "exp"
|
|
|
|
|
|
def _get_signature_arg_name() -> str:
|
|
"""
|
|
获取签名参数名。
|
|
默认 sign。
|
|
"""
|
|
name = getattr(settings, "RESOURCE_SIGN_ARG_SIGNATURE", "sign")
|
|
name = str(name or "sign").strip()
|
|
return name or "sign"
|
|
|
|
|
|
def _extract_sign_uri(resource_url: str) -> str:
|
|
"""
|
|
提取用于签名的 URI path。
|
|
|
|
例如:
|
|
http://www.test6.com/generation/video/a.mp4?x=1
|
|
|
|
用于签名的是:
|
|
/generation/video/a.mp4
|
|
|
|
注意:
|
|
OpenResty/Lua 侧建议使用 ngx.var.uri 或 r.uri 参与签名,
|
|
不要使用完整 URL,也不要包含 query string。
|
|
"""
|
|
if not resource_url or not str(resource_url).strip():
|
|
raise ResourceSignedUrlError("resource_url 不能为空")
|
|
|
|
url = str(resource_url).strip()
|
|
parsed = urlsplit(url)
|
|
|
|
sign_uri = parsed.path or url
|
|
|
|
if not sign_uri.startswith("/"):
|
|
sign_uri = "/" + sign_uri
|
|
|
|
return sign_uri
|
|
|
|
|
|
def generate_resource_signature(
|
|
resource_url: str,
|
|
expires_at: int,
|
|
secret: Optional[str] = None,
|
|
) -> str:
|
|
"""
|
|
生成资源 URL 签名。
|
|
|
|
签名规则:
|
|
|
|
message = "{uri}:{exp}"
|
|
sign = hmac_sha256(secret, message).hexdigest()
|
|
|
|
例如:
|
|
|
|
uri = "/generation/video/a.mp4"
|
|
exp = 1780000000
|
|
message = "/generation/video/a.mp4:1780000000"
|
|
|
|
OpenResty/Lua 侧必须使用完全一致的 message 规则。
|
|
"""
|
|
sign_secret = _get_sign_secret(secret)
|
|
sign_uri = _extract_sign_uri(resource_url)
|
|
|
|
expires_at = _to_int(expires_at, 0)
|
|
if expires_at <= 0:
|
|
raise ResourceSignedUrlError("expires_at 必须是有效的 Unix 时间戳")
|
|
|
|
message = f"generation_resource_controller:{sign_uri}:{expires_at}"
|
|
|
|
return hmac.new(
|
|
sign_secret.encode("utf-8"),
|
|
message.encode("utf-8"),
|
|
hashlib.sha256,
|
|
).hexdigest()
|
|
|
|
|
|
def _append_query_params(resource_url: str, params: dict[str, Any]) -> str:
|
|
"""
|
|
按用户要求追加 URL 参数:
|
|
|
|
- 原 URL 有 ?,用 ¶m=value 追加
|
|
- 原 URL 没有 ?,用 ?param=value 追加
|
|
|
|
同时兼容 #fragment,参数会追加到 # 前面。
|
|
"""
|
|
url = str(resource_url).strip()
|
|
|
|
if not url:
|
|
raise ResourceSignedUrlError("resource_url 不能为空")
|
|
|
|
base_url = url
|
|
fragment = ""
|
|
|
|
if "#" in url:
|
|
base_url, fragment_part = url.split("#", 1)
|
|
fragment = "#" + fragment_part
|
|
|
|
separator = "&" if "?" in base_url else "?"
|
|
|
|
query_string = "&".join(
|
|
f"{key}={value}"
|
|
for key, value in params.items()
|
|
if key and value is not None
|
|
)
|
|
|
|
if not query_string:
|
|
return url
|
|
|
|
return f"{base_url}{separator}{query_string}{fragment}"
|
|
|
|
|
|
def build_resource_signed_url(
|
|
resource_url: str | None,
|
|
expire_seconds: Optional[int] = None,
|
|
secret: Optional[str] = None,
|
|
now_ts: Optional[int] = None,
|
|
) -> str:
|
|
"""
|
|
生成带时效签名的资源 URL。
|
|
|
|
参数:
|
|
resource_url:
|
|
外部传入的资源 URL,可以是完整 URL,也可以是 path。
|
|
|
|
例如:
|
|
http://www.test6.com/generation/video/a.mp4
|
|
http://www.test6.com/generation/video/a.mp4?from=history
|
|
/generation/video/a.mp4
|
|
|
|
expire_seconds:
|
|
有效期秒数,不传则使用 settings.RESOURCE_SIGN_EXPIRE_SECONDS。
|
|
|
|
secret:
|
|
可选,自定义签名密钥。不传则使用 settings.RESOURCE_SIGN_SECRET。
|
|
|
|
now_ts:
|
|
可选,当前时间戳。主要用于单元测试,正常业务不需要传。
|
|
|
|
返回:
|
|
带 exp 和 sign 参数的 URL。
|
|
|
|
示例:
|
|
http://www.test6.com/generation/video/a.mp4?exp=1780000000&sign=xxxx
|
|
|
|
http://www.test6.com/generation/video/a.mp4?from=history&exp=1780000000&sign=xxxx
|
|
"""
|
|
if not resource_url or not resource_url.startswith("/generate/"):
|
|
return resource_url
|
|
|
|
seconds = _get_sign_expire_seconds(expire_seconds)
|
|
current_ts = _to_int(now_ts, int(time.time())) if now_ts is not None else int(time.time())
|
|
|
|
expires_at = current_ts + seconds
|
|
|
|
expire_arg_name = _get_expire_arg_name()
|
|
signature_arg_name = _get_signature_arg_name()
|
|
|
|
signature = generate_resource_signature(
|
|
resource_url=resource_url,
|
|
expires_at=expires_at,
|
|
secret=secret,
|
|
)
|
|
|
|
return _append_query_params(
|
|
resource_url=resource_url,
|
|
params={
|
|
expire_arg_name: expires_at,
|
|
signature_arg_name: signature,
|
|
},
|
|
)
|
|
|
|
|
|
def build_resource_signed_urls(
|
|
resource_urls: list[str],
|
|
expire_seconds: Optional[int] = None,
|
|
secret: Optional[str] = None,
|
|
) -> list[str]:
|
|
"""
|
|
批量生成资源签名 URL。
|
|
|
|
用于历史记录列表、资源列表等场景。
|
|
"""
|
|
if not resource_urls:
|
|
return []
|
|
|
|
now_ts = int(time.time())
|
|
|
|
return [
|
|
build_resource_signed_url(
|
|
resource_url=url,
|
|
expire_seconds=expire_seconds,
|
|
secret=secret,
|
|
now_ts=now_ts,
|
|
)
|
|
for url in resource_urls
|
|
if url
|
|
] |