Ethscriptions commited on
Commit
d459060
·
verified ·
1 Parent(s): 3021a47

Upload r2_storage.py

Browse files
Files changed (1) hide show
  1. r2_storage.py +178 -0
r2_storage.py ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from dataclasses import dataclass
3
+ from pathlib import Path
4
+ from typing import Optional, Tuple
5
+ from urllib.parse import urlparse, urlunparse
6
+
7
+
8
+ def _split_endpoint_bucket(endpoint: str) -> Tuple[str, str, str]:
9
+ parsed = urlparse(endpoint)
10
+ if not parsed.scheme or not parsed.netloc:
11
+ return endpoint, "", ""
12
+
13
+ path = (parsed.path or "").strip("/")
14
+ if not path:
15
+ return urlunparse((parsed.scheme, parsed.netloc, "", "", "", "")), "", ""
16
+
17
+ parts = [part for part in path.split("/") if part]
18
+ bucket = parts[0] if parts else ""
19
+ base_prefix = "/".join(parts[1:]) if len(parts) > 1 else ""
20
+ base_endpoint = urlunparse((parsed.scheme, parsed.netloc, "", "", "", ""))
21
+ return base_endpoint, bucket, base_prefix
22
+
23
+
24
+ def _join_key(prefix: str, key: str) -> str:
25
+ prefix = (prefix or "").strip("/")
26
+ key = (key or "").lstrip("/")
27
+ if not prefix:
28
+ return key
29
+ if not key:
30
+ return prefix
31
+ return f"{prefix}/{key}"
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class R2Config:
36
+ endpoint_url: str
37
+ bucket: str
38
+ access_key_id: str
39
+ secret_access_key: str
40
+ base_prefix: str = ""
41
+
42
+
43
+ class R2Storage:
44
+ def __init__(self, config: Optional[R2Config] = None):
45
+ self.config = config or self._load_config_from_env()
46
+ self._client = None
47
+
48
+ @staticmethod
49
+ def _load_config_from_env() -> R2Config:
50
+ endpoint_raw = (os.getenv("R2_ENDPOINT") or os.getenv("R2_Endpoint") or "").strip()
51
+ access_key_id = (os.getenv("R2_ACCESS_KEY_ID") or os.getenv("R2_ID") or "").strip()
52
+ secret_access_key = (os.getenv("R2_SECRET_ACCESS_KEY") or os.getenv("R2_API") or "").strip()
53
+ bucket = (os.getenv("R2_BUCKET") or "").strip()
54
+ base_prefix = (os.getenv("R2_PREFIX") or "").strip()
55
+
56
+ if endpoint_raw and not bucket:
57
+ endpoint_url, bucket_from_path, prefix_from_path = _split_endpoint_bucket(endpoint_raw)
58
+ bucket = bucket or bucket_from_path
59
+ endpoint_raw = endpoint_url
60
+ base_prefix = base_prefix or prefix_from_path
61
+
62
+ endpoint_url = endpoint_raw
63
+ if not endpoint_url or not bucket or not access_key_id or not secret_access_key:
64
+ missing = []
65
+ if not endpoint_url:
66
+ missing.append("R2_ENDPOINT / R2_Endpoint")
67
+ if not bucket:
68
+ missing.append("R2_BUCKET (或把 bucket 放到 R2_Endpoint 的路径里)")
69
+ if not access_key_id:
70
+ missing.append("R2_ACCESS_KEY_ID / R2_ID")
71
+ if not secret_access_key:
72
+ missing.append("R2_SECRET_ACCESS_KEY / R2_API")
73
+ raise ValueError(f"R2 配置缺失: {', '.join(missing)}")
74
+
75
+ if secret_access_key.startswith("cfat_"):
76
+ raise ValueError("R2_SECRET_ACCESS_KEY 看起来是 Cloudflare API Token(cfat_),不是 R2 的 S3 Secret Access Key。请在 Cloudflare 控制台生成 R2 的 S3 API 访问密钥并替换。")
77
+
78
+ return R2Config(
79
+ endpoint_url=endpoint_url,
80
+ bucket=bucket,
81
+ access_key_id=access_key_id,
82
+ secret_access_key=secret_access_key,
83
+ base_prefix=base_prefix,
84
+ )
85
+
86
+ def _get_client(self):
87
+ if self._client is not None:
88
+ return self._client
89
+
90
+ try:
91
+ import boto3
92
+ from botocore.client import Config
93
+ except Exception as exc:
94
+ raise RuntimeError("缺少依赖 boto3,请先安装 requirements.txt") from exc
95
+
96
+ self._client = boto3.client(
97
+ "s3",
98
+ endpoint_url=self.config.endpoint_url,
99
+ aws_access_key_id=self.config.access_key_id,
100
+ aws_secret_access_key=self.config.secret_access_key,
101
+ region_name="auto",
102
+ config=Config(signature_version="s3v4", s3={"addressing_style": "path"}),
103
+ )
104
+ return self._client
105
+
106
+ def resolve_key(self, key: str) -> str:
107
+ return _join_key(self.config.base_prefix, key)
108
+
109
+ def exists(self, key: str) -> bool:
110
+ client = self._get_client()
111
+ resolved_key = self.resolve_key(key)
112
+ try:
113
+ client.head_object(Bucket=self.config.bucket, Key=resolved_key)
114
+ return True
115
+ except Exception as exc:
116
+ try:
117
+ from botocore.exceptions import ClientError
118
+ except Exception:
119
+ raise
120
+
121
+ if isinstance(exc, ClientError):
122
+ error = (exc.response or {}).get("Error") or {}
123
+ code = str(error.get("Code") or "")
124
+ if code in {"404", "NoSuchKey", "NotFound"}:
125
+ return False
126
+ raise
127
+
128
+ def upload_file(self, local_path: str | Path, key: str, content_type: Optional[str] = None) -> str:
129
+ client = self._get_client()
130
+ resolved_key = self.resolve_key(key)
131
+ path = Path(local_path)
132
+ if not path.exists():
133
+ raise FileNotFoundError(str(path))
134
+
135
+ kwargs = {}
136
+ if content_type:
137
+ kwargs["ContentType"] = content_type
138
+
139
+ with path.open("rb") as handle:
140
+ client.put_object(Bucket=self.config.bucket, Key=resolved_key, Body=handle, **kwargs)
141
+ return resolved_key
142
+
143
+ def download_file(self, key: str, local_path: str | Path) -> Path:
144
+ client = self._get_client()
145
+ resolved_key = self.resolve_key(key)
146
+ path = Path(local_path)
147
+ path.parent.mkdir(parents=True, exist_ok=True)
148
+ response = client.get_object(Bucket=self.config.bucket, Key=resolved_key)
149
+ body = response.get("Body")
150
+ try:
151
+ with path.open("wb") as handle:
152
+ handle.write(body.read())
153
+ finally:
154
+ if body is not None:
155
+ try:
156
+ body.close()
157
+ except Exception:
158
+ pass
159
+ return path
160
+
161
+ def list_keys(self, prefix: str = "", max_keys: int = 20) -> list[str]:
162
+ client = self._get_client()
163
+ resolved_prefix = self.resolve_key(prefix) if prefix else ""
164
+ params = {"Bucket": self.config.bucket, "MaxKeys": int(max_keys)}
165
+ if resolved_prefix:
166
+ params["Prefix"] = resolved_prefix
167
+ response = client.list_objects_v2(**params)
168
+ contents = response.get("Contents") or []
169
+ return [item.get("Key", "") for item in contents if isinstance(item, dict) and item.get("Key")]
170
+
171
+ def presign_get_url(self, key: str, expires_in: int = 3600) -> str:
172
+ client = self._get_client()
173
+ resolved_key = self.resolve_key(key)
174
+ return client.generate_presigned_url(
175
+ ClientMethod="get_object",
176
+ Params={"Bucket": self.config.bucket, "Key": resolved_key},
177
+ ExpiresIn=int(expires_in),
178
+ )