Skip to content

Commit 9c0cba9

Browse files
committed
Add resumable parts and selectable upload hosts
1 parent e691a59 commit 9c0cba9

2 files changed

Lines changed: 465 additions & 0 deletions

File tree

‎src/uploadbot/providers.py‎

Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
"""Replayable uploads to File2Share, generic PUT hosts, and WebDAV."""
2+
3+
import asyncio
4+
import ipaddress
5+
import json
6+
import math
7+
import re
8+
import time
9+
import uuid
10+
from collections.abc import Awaitable, Callable
11+
from dataclasses import dataclass, field, replace
12+
from typing import TypeVar
13+
from urllib.parse import quote, urlsplit
14+
15+
import aiohttp
16+
17+
from uploadbot.errors import HostError, TransferError
18+
from uploadbot.http import HTTPTransport, Progress
19+
from uploadbot.models import UploadOptions, UploadResult
20+
from uploadbot.sources import ReplaySource
21+
from uploadbot.transfer import same_origin, upload_result
22+
from uploadbot.urls import upload_base_url
23+
24+
T = TypeVar("T")
25+
FILE2SHARE_LIMIT = 50_000_000_000
26+
RETRY_DELAY = 1.0
27+
28+
29+
class CompletionUncertain(TransferError):
30+
"""A non-idempotent completion may already have published a file."""
31+
32+
33+
@dataclass(frozen=True)
34+
class ProviderConfig:
35+
name: str
36+
kind: str
37+
url: str
38+
public_url: str | None = None
39+
headers: dict[str, str] = field(default_factory=dict, repr=False)
40+
41+
def __post_init__(self) -> None:
42+
if not re.fullmatch(r"[a-zA-Z0-9_-]{1,32}", self.name) or self.name == "auto":
43+
raise ValueError(
44+
"Provider names must contain 1 to 32 letters, digits, dashes or underscores"
45+
)
46+
if self.kind not in {"file2share", "put", "webdav"}:
47+
raise ValueError("Provider kind must be file2share, put or webdav")
48+
object.__setattr__(self, "url", upload_base_url(self.url))
49+
if self.public_url:
50+
object.__setattr__(self, "public_url", upload_base_url(self.public_url))
51+
if any(
52+
not isinstance(k, str) or not isinstance(v, str) or "\n" in k + v or "\r" in k + v
53+
for k, v in self.headers.items()
54+
):
55+
raise ValueError("Provider headers must be single-line strings")
56+
forbidden = {
57+
"host",
58+
"content-length",
59+
"transfer-encoding",
60+
"connection",
61+
"content-type",
62+
"max-days",
63+
}
64+
if any(key.lower() in forbidden for key in self.headers):
65+
raise ValueError("Provider headers cannot override HTTP framing or routing")
66+
parts = urlsplit(self.url)
67+
if self.headers and parts.scheme != "https":
68+
try:
69+
loopback = ipaddress.ip_address(parts.hostname or "").is_loopback
70+
except ValueError:
71+
loopback = parts.hostname == "localhost"
72+
if not loopback:
73+
raise ValueError("Provider credentials require HTTPS")
74+
75+
76+
async def retry(operation: Callable[[], Awaitable[T]], attempts: int = 3) -> T:
77+
for attempt in range(attempts):
78+
try:
79+
return await operation()
80+
except HostError as exc:
81+
if not exc.retryable or attempt + 1 == attempts or exc.retry_after > 60:
82+
raise
83+
await asyncio.sleep(max(exc.retry_after, min(2**attempt, 8) * RETRY_DELAY))
84+
raise AssertionError("unreachable")
85+
86+
87+
class Provider:
88+
def __init__(self, session: aiohttp.ClientSession, config: ProviderConfig, max_file_size: int):
89+
self.config = config
90+
self.name = config.name
91+
self.base_url = config.url.rstrip("/")
92+
self.max_file_size = (
93+
min(max_file_size, FILE2SHARE_LIMIT) if config.kind == "file2share" else max_file_size
94+
)
95+
self.transport = HTTPTransport(session)
96+
self.single_limit = 95_000_000
97+
98+
async def upload(
99+
self, source: ReplaySource, options: UploadOptions, progress: Progress | None = None
100+
) -> UploadResult:
101+
if options.encrypted != source.encrypted:
102+
raise TransferError("Encryption settings do not match the byte source.")
103+
if not 0 < source.size <= self.max_file_size:
104+
raise TransferError("The file exceeds this provider's size limit.")
105+
if self.config.kind == "file2share" and source.size > self.single_limit:
106+
return await self._multipart(source, options, progress)
107+
object_name = (
108+
f"{uuid.uuid4().hex}-{source.filename}"
109+
if self.config.kind in {"webdav", "put"}
110+
else source.filename
111+
)
112+
endpoint = f"{self.base_url}/{quote(object_name, safe='')}"
113+
114+
async def send() -> UploadResult:
115+
async with source.open() as chunks:
116+
response = await self.transport.stream(
117+
endpoint,
118+
chunks,
119+
source.size,
120+
progress,
121+
headers={**self.config.headers, "Max-Days": str(options.retention_days)},
122+
)
123+
if self.config.kind == "webdav":
124+
public = (self.config.public_url or self.base_url).rstrip("/")
125+
return UploadResult(
126+
f"{public}/{quote(object_name, safe='')}",
127+
source.filename,
128+
source.size,
129+
self.name,
130+
endpoint,
131+
encrypted=source.encrypted,
132+
)
133+
return self._result(
134+
response, self.base_url, source.filename, source.size, self.name, options
135+
)
136+
137+
# WebDAV reuses one object URL across retries. Plain PUT hosts may leave an orphan.
138+
return await retry(send)
139+
140+
async def _multipart(
141+
self, source: ReplaySource, options: UploadOptions, progress: Progress | None
142+
) -> UploadResult:
143+
async def start():
144+
return await self.transport.request(
145+
"POST",
146+
f"{self.base_url}/api/v1/uploads",
147+
data={"name": source.filename, "size": str(source.size)},
148+
headers=self.config.headers,
149+
)
150+
151+
response = await retry(start)
152+
try:
153+
info = json.loads(response.body)
154+
if not isinstance(info, dict):
155+
raise ValueError
156+
identifier = info["upload_id"]
157+
part_size, parts = info["part_size"], info["parts"]
158+
if type(part_size) is not int or type(parts) is not int:
159+
raise ValueError
160+
if not isinstance(identifier, str) or not re.fullmatch(
161+
r"[A-Za-z0-9_-]{1,128}", identifier
162+
):
163+
raise ValueError
164+
if not 1 <= part_size <= 50 * 1024 * 1024 or parts != math.ceil(
165+
source.size / part_size
166+
):
167+
raise ValueError
168+
if parts > 10000:
169+
raise ValueError
170+
except (ValueError, TypeError, KeyError, ZeroDivisionError, RecursionError):
171+
raise TransferError("File2Share returned invalid upload parameters.") from None
172+
shown = 0
173+
174+
for number in range(1, parts + 1):
175+
offset = (number - 1) * part_size
176+
size = min(part_size, source.size - offset)
177+
178+
async def part_progress(sent: int, total: int, *, offset: int = offset) -> None:
179+
nonlocal shown
180+
shown = max(shown, offset + sent)
181+
if progress:
182+
await progress(shown, source.size)
183+
184+
async def send_part(
185+
*,
186+
offset: int = offset,
187+
size: int = size,
188+
number: int = number,
189+
report: Progress = part_progress,
190+
) -> None:
191+
async with source.open(offset, size) as chunks:
192+
await self.transport.stream(
193+
f"{self.base_url}/api/v1/uploads/{identifier}/{number}",
194+
chunks,
195+
size,
196+
report,
197+
headers=self.config.headers,
198+
)
199+
200+
await retry(send_part)
201+
202+
async def complete():
203+
return await self.transport.request(
204+
"POST",
205+
f"{self.base_url}/api/v1/uploads/{identifier}/complete",
206+
headers={**self.config.headers, "Max-Days": str(options.retention_days)},
207+
)
208+
209+
# File2Share returns 404 after an already successful completion. Repeating
210+
# this POST cannot recover a lost success response, unlike numbered parts.
211+
try:
212+
response = await complete()
213+
except HostError as exc:
214+
if exc.retryable:
215+
raise CompletionUncertain(
216+
"The host did not confirm completion. The file may have been published; "
217+
"automatic fallback was stopped."
218+
) from None
219+
raise
220+
return self._result(
221+
response, self.base_url, source.filename, source.size, self.name, options
222+
)
223+
224+
def _result(self, response, base, filename, size, name, options) -> UploadResult:
225+
result = upload_result(response, base, filename, size, name, options)
226+
if self.config.kind == "file2share":
227+
result = replace(result, expires_at=time.time() + options.retention_days * 86400)
228+
return result
229+
230+
async def delete(self, result: UploadResult) -> None:
231+
if not result.delete_url or result.provider != self.name:
232+
raise TransferError("This provider did not supply a deletion link.")
233+
if not same_origin(result.delete_url, self.base_url):
234+
raise TransferError("The deletion link does not belong to this provider.")
235+
try:
236+
await self.transport.request("DELETE", result.delete_url, headers=self.config.headers)
237+
except HostError as exc:
238+
if exc.status not in {404, 410}:
239+
raise
240+
241+
242+
class ProviderRegistry:
243+
def __init__(self, providers: list[Provider]):
244+
if not providers or len({provider.name for provider in providers}) != len(providers):
245+
raise ValueError("Providers must have unique names")
246+
self.providers = {provider.name: provider for provider in providers}
247+
self.default = providers[0].name
248+
249+
async def upload(
250+
self,
251+
source: ReplaySource,
252+
choice: str,
253+
options: UploadOptions,
254+
progress: Progress | None = None,
255+
) -> UploadResult:
256+
if choice != "auto" and choice not in self.providers:
257+
raise TransferError("That upload provider is no longer configured. Use /provider.")
258+
candidates = list(self.providers.values()) if choice == "auto" else [self.providers[choice]]
259+
failure: TransferError | None = None
260+
for provider in candidates:
261+
try:
262+
return await provider.upload(source, options, progress)
263+
except CompletionUncertain:
264+
raise
265+
except TransferError as exc:
266+
failure = exc
267+
raise failure or TransferError("No upload provider is available.")
268+
269+
async def delete(self, result: UploadResult) -> None:
270+
provider = self.providers.get(result.provider)
271+
if provider is None:
272+
raise TransferError("The upload provider is no longer configured.")
273+
await provider.delete(result)

0 commit comments

Comments
 (0)