tuspyserver 4.2.0__tar.gz → 4.2.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {tuspyserver-4.2.0 → tuspyserver-4.2.2}/PKG-INFO +1 -1
- {tuspyserver-4.2.0 → tuspyserver-4.2.2}/pyproject.toml +1 -1
- {tuspyserver-4.2.0 → tuspyserver-4.2.2}/src/tuspyserver/file.py +28 -1
- {tuspyserver-4.2.0 → tuspyserver-4.2.2}/src/tuspyserver/info.py +36 -6
- tuspyserver-4.2.2/src/tuspyserver/lock.py +185 -0
- {tuspyserver-4.2.0 → tuspyserver-4.2.2}/src/tuspyserver/params.py +4 -1
- tuspyserver-4.2.2/src/tuspyserver/request.py +259 -0
- {tuspyserver-4.2.0 → tuspyserver-4.2.2}/src/tuspyserver/router.py +1 -0
- tuspyserver-4.2.2/src/tuspyserver/routes/core.py +310 -0
- tuspyserver-4.2.2/src/tuspyserver/routes/creation.py +378 -0
- tuspyserver-4.2.2/src/tuspyserver/routes/termination.py +86 -0
- tuspyserver-4.2.0/src/tuspyserver/request.py +0 -182
- tuspyserver-4.2.0/src/tuspyserver/routes/core.py +0 -171
- tuspyserver-4.2.0/src/tuspyserver/routes/creation.py +0 -120
- tuspyserver-4.2.0/src/tuspyserver/routes/termination.py +0 -43
- {tuspyserver-4.2.0 → tuspyserver-4.2.2}/README.md +0 -0
- {tuspyserver-4.2.0 → tuspyserver-4.2.2}/src/tuspyserver/__init__.py +0 -0
- {tuspyserver-4.2.0 → tuspyserver-4.2.2}/src/tuspyserver/py.typed +0 -0
- {tuspyserver-4.2.0 → tuspyserver-4.2.2}/src/tuspyserver/routes/__init__.py +0 -0
|
@@ -26,6 +26,31 @@ class TusUploadFile:
|
|
|
26
26
|
params: Optional[TusUploadParams] = None,
|
|
27
27
|
):
|
|
28
28
|
self._options = options
|
|
29
|
+
|
|
30
|
+
# Ensure files_dir is a valid directory path
|
|
31
|
+
# Normalize the path and ensure parent directories exist
|
|
32
|
+
files_dir = os.path.abspath(os.path.expanduser(self._options.files_dir))
|
|
33
|
+
|
|
34
|
+
# Check if path exists and is actually a directory
|
|
35
|
+
if os.path.exists(files_dir):
|
|
36
|
+
if not os.path.isdir(files_dir):
|
|
37
|
+
raise OSError(
|
|
38
|
+
f"Upload directory path '{files_dir}' exists but is not a directory. "
|
|
39
|
+
f"Please ensure the path points to a valid directory."
|
|
40
|
+
)
|
|
41
|
+
else:
|
|
42
|
+
# create the files dir if necessary (must be done before creating files)
|
|
43
|
+
try:
|
|
44
|
+
os.makedirs(files_dir, exist_ok=True)
|
|
45
|
+
except OSError as e:
|
|
46
|
+
raise OSError(
|
|
47
|
+
f"Failed to create upload directory '{files_dir}': {e}. "
|
|
48
|
+
f"Please ensure the path is valid and writable."
|
|
49
|
+
) from e
|
|
50
|
+
|
|
51
|
+
# Update options with normalized path
|
|
52
|
+
self._options.files_dir = files_dir
|
|
53
|
+
|
|
29
54
|
# init
|
|
30
55
|
if uid is None:
|
|
31
56
|
# creating new file
|
|
@@ -34,7 +59,9 @@ class TusUploadFile:
|
|
|
34
59
|
else:
|
|
35
60
|
# reading existing file
|
|
36
61
|
self.uid = uid
|
|
37
|
-
if
|
|
62
|
+
# Only create the file if we're explicitly creating a new upload with params
|
|
63
|
+
# This happens when creating a partial or final upload with a specific uid
|
|
64
|
+
if params is not None and not self.exists:
|
|
38
65
|
self.create()
|
|
39
66
|
# create the files dir if necessary
|
|
40
67
|
if not os.path.exists(self._options.files_dir):
|
|
@@ -14,23 +14,30 @@ from tuspyserver.params import TusUploadParams
|
|
|
14
14
|
|
|
15
15
|
class TusUploadInfo:
|
|
16
16
|
_params: Optional[TusUploadParams]
|
|
17
|
+
_loaded: bool
|
|
17
18
|
file: TusUploadFile
|
|
18
19
|
|
|
19
20
|
def __init__(self, file: TusUploadFile, params: Optional[TusUploadParams] = None):
|
|
20
21
|
self.file = file
|
|
21
22
|
self._params = params
|
|
23
|
+
self._loaded = params is not None # If params provided, consider them loaded
|
|
22
24
|
# create if doesn't exist
|
|
23
25
|
if params and not self.exists:
|
|
24
26
|
self.serialize()
|
|
25
27
|
|
|
26
28
|
@property
|
|
27
29
|
def params(self):
|
|
28
|
-
|
|
30
|
+
# Only deserialize if we haven't loaded params yet
|
|
31
|
+
# This prevents overwriting in-memory params on every access
|
|
32
|
+
if not self._loaded:
|
|
33
|
+
self.deserialize()
|
|
34
|
+
self._loaded = True
|
|
29
35
|
return self._params
|
|
30
36
|
|
|
31
37
|
@params.setter
|
|
32
38
|
def params(self, value):
|
|
33
39
|
self._params = value
|
|
40
|
+
self._loaded = True # Mark as loaded since we're explicitly setting params
|
|
34
41
|
self.serialize()
|
|
35
42
|
|
|
36
43
|
@property
|
|
@@ -42,11 +49,34 @@ class TusUploadInfo:
|
|
|
42
49
|
return os.path.exists(self.path)
|
|
43
50
|
|
|
44
51
|
def serialize(self) -> None:
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
52
|
+
"""
|
|
53
|
+
Atomically serialize params to info file.
|
|
54
|
+
|
|
55
|
+
Uses a temporary file and atomic rename to prevent corruption
|
|
56
|
+
from concurrent writes.
|
|
57
|
+
"""
|
|
58
|
+
# Write to temporary file first
|
|
59
|
+
temp_path = f"{self.path}.tmp"
|
|
60
|
+
try:
|
|
61
|
+
with open(temp_path, "w") as f:
|
|
62
|
+
json_string = json.dumps(
|
|
63
|
+
self._params, indent=4, default=lambda k: k.__dict__
|
|
64
|
+
)
|
|
65
|
+
f.write(json_string)
|
|
66
|
+
f.flush()
|
|
67
|
+
# Ensure data is written to disk
|
|
68
|
+
os.fsync(f.fileno())
|
|
69
|
+
|
|
70
|
+
# Atomic rename - this is atomic on Unix systems
|
|
71
|
+
os.rename(temp_path, self.path)
|
|
72
|
+
except Exception:
|
|
73
|
+
# Clean up temp file if rename fails
|
|
74
|
+
try:
|
|
75
|
+
if os.path.exists(temp_path):
|
|
76
|
+
os.remove(temp_path)
|
|
77
|
+
except Exception:
|
|
78
|
+
pass
|
|
79
|
+
raise
|
|
50
80
|
|
|
51
81
|
def deserialize(self) -> Optional[TusUploadParams]:
|
|
52
82
|
if self.exists:
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""
|
|
2
|
+
File locking utilities for tus uploads.
|
|
3
|
+
|
|
4
|
+
Provides advisory file locking using fcntl (Unix) to prevent race conditions
|
|
5
|
+
during concurrent upload operations. This implementation matches tusd's approach
|
|
6
|
+
by using separate lock files stored in a .locks directory, where each lock file
|
|
7
|
+
contains the PID of the process holding the lock.
|
|
8
|
+
"""
|
|
9
|
+
import fcntl
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
from contextlib import contextmanager
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class FileLock:
|
|
20
|
+
"""
|
|
21
|
+
Advisory file lock using fcntl (Unix) with separate lock files.
|
|
22
|
+
|
|
23
|
+
Matches tusd's filelocker approach by creating separate .lock files
|
|
24
|
+
in a .locks directory. Each lock file contains the PID of the process
|
|
25
|
+
holding the lock, allowing for lock cleanup if a process crashes.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, file_path: str, locks_dir: Optional[str] = None):
|
|
29
|
+
"""
|
|
30
|
+
Initialize a file lock.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
file_path: Path to the upload file to lock
|
|
34
|
+
locks_dir: Directory to store lock files (defaults to {files_dir}/.locks)
|
|
35
|
+
"""
|
|
36
|
+
self.file_path = file_path
|
|
37
|
+
self.locks_dir = locks_dir
|
|
38
|
+
|
|
39
|
+
# Derive lock file path from upload file path
|
|
40
|
+
if locks_dir:
|
|
41
|
+
# Ensure locks directory exists
|
|
42
|
+
os.makedirs(locks_dir, exist_ok=True)
|
|
43
|
+
lock_filename = os.path.basename(file_path) + ".lock"
|
|
44
|
+
self.lock_file_path = os.path.join(locks_dir, lock_filename)
|
|
45
|
+
else:
|
|
46
|
+
# Default: create .locks directory next to upload file
|
|
47
|
+
upload_dir = os.path.dirname(file_path)
|
|
48
|
+
locks_dir = os.path.join(upload_dir, ".locks")
|
|
49
|
+
os.makedirs(locks_dir, exist_ok=True)
|
|
50
|
+
lock_filename = os.path.basename(file_path) + ".lock"
|
|
51
|
+
self.lock_file_path = os.path.join(locks_dir, lock_filename)
|
|
52
|
+
|
|
53
|
+
self._lock_fd: Optional[int] = None
|
|
54
|
+
|
|
55
|
+
def acquire(self, blocking: bool = True) -> bool:
|
|
56
|
+
"""
|
|
57
|
+
Acquire an exclusive lock on the upload file.
|
|
58
|
+
|
|
59
|
+
Creates a separate lock file and applies an exclusive lock on it.
|
|
60
|
+
The lock file contains the PID of the process holding the lock.
|
|
61
|
+
This matches tusd's filelocker approach.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
blocking: If True, block until lock is acquired. If False, return immediately.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
True if lock was acquired, False otherwise (only when blocking=False)
|
|
68
|
+
"""
|
|
69
|
+
try:
|
|
70
|
+
# Ensure locks directory exists
|
|
71
|
+
os.makedirs(os.path.dirname(self.lock_file_path), exist_ok=True)
|
|
72
|
+
|
|
73
|
+
# Open the lock file for read-write, create if it doesn't exist
|
|
74
|
+
# Use O_RDWR to allow both reading and writing
|
|
75
|
+
# Use O_CREAT to create the file if it doesn't exist
|
|
76
|
+
self._lock_fd = os.open(self.lock_file_path, os.O_RDWR | os.O_CREAT, 0o644)
|
|
77
|
+
|
|
78
|
+
# Ensure the lock file contains only the current PID before writing
|
|
79
|
+
try:
|
|
80
|
+
os.ftruncate(self._lock_fd, 0)
|
|
81
|
+
os.lseek(self._lock_fd, 0, os.SEEK_SET)
|
|
82
|
+
except OSError as e:
|
|
83
|
+
logger.warning(f"Failed to truncate lock file {self.lock_file_path}: {e}")
|
|
84
|
+
|
|
85
|
+
# Write PID to lock file (like tusd does)
|
|
86
|
+
try:
|
|
87
|
+
pid_str = str(os.getpid()).encode('utf-8')
|
|
88
|
+
os.write(self._lock_fd, pid_str)
|
|
89
|
+
os.fsync(self._lock_fd) # Ensure PID is written to disk
|
|
90
|
+
# Seek back to beginning for reading
|
|
91
|
+
os.lseek(self._lock_fd, 0, os.SEEK_SET)
|
|
92
|
+
except (IOError, OSError) as e:
|
|
93
|
+
# If writing PID fails, continue anyway - lock is still valid
|
|
94
|
+
logger.warning(f"Failed to write PID to lock file {self.lock_file_path}: {e}")
|
|
95
|
+
|
|
96
|
+
# Try to acquire exclusive lock (LOCK_EX) on the lock file
|
|
97
|
+
# If blocking=False, use LOCK_NB (non-blocking)
|
|
98
|
+
flags = fcntl.LOCK_EX
|
|
99
|
+
if not blocking:
|
|
100
|
+
flags |= fcntl.LOCK_NB
|
|
101
|
+
|
|
102
|
+
fcntl.flock(self._lock_fd, flags)
|
|
103
|
+
return True
|
|
104
|
+
except (IOError, OSError) as e:
|
|
105
|
+
# Lock acquisition failed
|
|
106
|
+
if self._lock_fd is not None:
|
|
107
|
+
try:
|
|
108
|
+
os.close(self._lock_fd)
|
|
109
|
+
except Exception:
|
|
110
|
+
pass
|
|
111
|
+
self._lock_fd = None
|
|
112
|
+
|
|
113
|
+
if not blocking and e.errno in (11, 35): # EAGAIN/EWOULDBLOCK
|
|
114
|
+
return False
|
|
115
|
+
raise
|
|
116
|
+
|
|
117
|
+
def release(self) -> None:
|
|
118
|
+
"""Release the lock and close the file descriptor."""
|
|
119
|
+
if self._lock_fd is not None:
|
|
120
|
+
try:
|
|
121
|
+
fcntl.flock(self._lock_fd, fcntl.LOCK_UN)
|
|
122
|
+
os.close(self._lock_fd)
|
|
123
|
+
except Exception as e:
|
|
124
|
+
logger.warning(f"Error releasing lock file descriptor: {e}")
|
|
125
|
+
finally:
|
|
126
|
+
self._lock_fd = None
|
|
127
|
+
|
|
128
|
+
# Remove the lock file (tusd does this)
|
|
129
|
+
# Note: We remove the lock file but keep the .locks directory
|
|
130
|
+
try:
|
|
131
|
+
if os.path.exists(self.lock_file_path):
|
|
132
|
+
os.remove(self.lock_file_path)
|
|
133
|
+
except Exception as e:
|
|
134
|
+
logger.warning(f"Error removing lock file {self.lock_file_path}: {e}")
|
|
135
|
+
|
|
136
|
+
def get_fd(self) -> Optional[int]:
|
|
137
|
+
"""
|
|
138
|
+
Get the file descriptor for the lock file.
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
File descriptor if lock is acquired, None otherwise
|
|
142
|
+
"""
|
|
143
|
+
return self._lock_fd
|
|
144
|
+
|
|
145
|
+
def __enter__(self):
|
|
146
|
+
"""Context manager entry."""
|
|
147
|
+
self.acquire(blocking=True)
|
|
148
|
+
return self
|
|
149
|
+
|
|
150
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
151
|
+
"""Context manager exit."""
|
|
152
|
+
self.release()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@contextmanager
|
|
156
|
+
def acquire_upload_lock(upload_path: str, locks_dir: Optional[str] = None, blocking: bool = True):
|
|
157
|
+
"""
|
|
158
|
+
Context manager for acquiring an upload lock.
|
|
159
|
+
|
|
160
|
+
This matches tusd's filelocker pattern by creating separate lock files
|
|
161
|
+
in a .locks directory. Each lock file contains the PID of the process
|
|
162
|
+
holding the lock.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
upload_path: Path to the upload file to lock
|
|
166
|
+
locks_dir: Directory to store lock files (defaults to {upload_dir}/.locks)
|
|
167
|
+
blocking: If True, block until lock is acquired
|
|
168
|
+
|
|
169
|
+
Yields:
|
|
170
|
+
FileLock instance with the locked file descriptor
|
|
171
|
+
|
|
172
|
+
Example:
|
|
173
|
+
with acquire_upload_lock("/path/to/upload") as lock:
|
|
174
|
+
# Lock is held via lock file in .locks directory
|
|
175
|
+
# Lock file contains PID of this process
|
|
176
|
+
pass
|
|
177
|
+
"""
|
|
178
|
+
lock = FileLock(upload_path, locks_dir=locks_dir)
|
|
179
|
+
try:
|
|
180
|
+
acquired = lock.acquire(blocking=blocking)
|
|
181
|
+
if not acquired:
|
|
182
|
+
raise IOError("Could not acquire lock")
|
|
183
|
+
yield lock
|
|
184
|
+
finally:
|
|
185
|
+
lock.release()
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from typing import Dict, Hashable, Optional, Union
|
|
1
|
+
from typing import Dict, Hashable, List, Optional, Union
|
|
2
2
|
|
|
3
3
|
from pydantic import BaseModel
|
|
4
4
|
|
|
@@ -13,3 +13,6 @@ class TusUploadParams(BaseModel):
|
|
|
13
13
|
upload_chunk_size: int = 0
|
|
14
14
|
expires: Optional[Union[float, str]]
|
|
15
15
|
error: Optional[str] = None
|
|
16
|
+
is_partial: bool = False
|
|
17
|
+
is_final: bool = False
|
|
18
|
+
partial_uploads: Optional[List[str]] = None
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
import datetime
|
|
5
|
+
import os
|
|
6
|
+
import typing
|
|
7
|
+
from typing import Callable, Optional
|
|
8
|
+
|
|
9
|
+
import inspect
|
|
10
|
+
|
|
11
|
+
if typing.TYPE_CHECKING:
|
|
12
|
+
from tuspyserver.router import TusRouterOptions
|
|
13
|
+
|
|
14
|
+
from fastapi import Depends, HTTPException, Path, Request
|
|
15
|
+
from starlette.requests import ClientDisconnect
|
|
16
|
+
|
|
17
|
+
from tuspyserver.file import TusUploadFile
|
|
18
|
+
from tuspyserver.lock import acquire_upload_lock
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def make_request_chunks_dep(options: TusRouterOptions):
|
|
22
|
+
async def request_chunks_dep(
|
|
23
|
+
request: Request,
|
|
24
|
+
uuid: str = Path(...),
|
|
25
|
+
post_request: bool = False,
|
|
26
|
+
file_dep: Callable[[dict], None] = Depends(options.file_dep),
|
|
27
|
+
) -> Optional[bool]:
|
|
28
|
+
# Create a copy of options to avoid mutating the original
|
|
29
|
+
file_options = deepcopy(options)
|
|
30
|
+
# call file_dep to possibly update the files_dir
|
|
31
|
+
result = file_dep({})
|
|
32
|
+
# if the callback returned a coroutine, await it
|
|
33
|
+
if inspect.isawaitable(result):
|
|
34
|
+
result = await result
|
|
35
|
+
if isinstance(result, dict):
|
|
36
|
+
file_options.files_dir = result.get("files_dir", file_options.files_dir)
|
|
37
|
+
|
|
38
|
+
# init file handle
|
|
39
|
+
file = TusUploadFile(uid=uuid, options=file_options)
|
|
40
|
+
# check if valid file exists BEFORE acquiring lock
|
|
41
|
+
if not file.exists or not file.info:
|
|
42
|
+
raise HTTPException(status_code=404, detail="Upload not found")
|
|
43
|
+
|
|
44
|
+
# Ensure uploads directory exists before locking
|
|
45
|
+
uploads_dir = file_options.files_dir
|
|
46
|
+
os.makedirs(uploads_dir, exist_ok=True)
|
|
47
|
+
|
|
48
|
+
# Create locks directory path (matches tusd's .locks directory)
|
|
49
|
+
locks_dir = os.path.join(uploads_dir, ".locks")
|
|
50
|
+
|
|
51
|
+
# Acquire lock BEFORE any validation or reading to prevent race conditions
|
|
52
|
+
# This ensures we have exclusive access during the entire write operation
|
|
53
|
+
upload_path = file.path
|
|
54
|
+
try:
|
|
55
|
+
with acquire_upload_lock(upload_path, locks_dir=locks_dir, blocking=True):
|
|
56
|
+
# Re-read info file while holding lock to get latest offset
|
|
57
|
+
# This prevents TOCTOU race condition
|
|
58
|
+
file._info._loaded = False # Force reload
|
|
59
|
+
current_info = file.info
|
|
60
|
+
|
|
61
|
+
if current_info is None:
|
|
62
|
+
raise HTTPException(status_code=404, detail="Upload not found")
|
|
63
|
+
|
|
64
|
+
# Check if upload has expired (distinguish from non-existent uploads)
|
|
65
|
+
if current_info.expires:
|
|
66
|
+
expires_dt = None
|
|
67
|
+
if isinstance(current_info.expires, str):
|
|
68
|
+
try:
|
|
69
|
+
# Try RFC 7231 format first (e.g., "Mon, 02 Jan 2006 15:04:05 GMT")
|
|
70
|
+
from email.utils import parsedate_to_datetime
|
|
71
|
+
expires_dt = parsedate_to_datetime(current_info.expires)
|
|
72
|
+
except (ValueError, TypeError):
|
|
73
|
+
# Fallback to ISO format
|
|
74
|
+
try:
|
|
75
|
+
expires_dt = datetime.datetime.fromisoformat(current_info.expires.replace('Z', '+00:00'))
|
|
76
|
+
except (ValueError, AttributeError):
|
|
77
|
+
pass
|
|
78
|
+
elif isinstance(current_info.expires, (int, float)):
|
|
79
|
+
expires_dt = datetime.datetime.fromtimestamp(current_info.expires)
|
|
80
|
+
|
|
81
|
+
if expires_dt:
|
|
82
|
+
now = datetime.datetime.now(expires_dt.tzinfo) if expires_dt.tzinfo else datetime.datetime.now()
|
|
83
|
+
if expires_dt < now:
|
|
84
|
+
raise HTTPException(status_code=410, detail="Upload expired")
|
|
85
|
+
|
|
86
|
+
# Validate Upload-Offset header matches current file offset (required for PATCH)
|
|
87
|
+
# Use the offset we just read while holding the lock
|
|
88
|
+
upload_offset = request.headers.get("upload-offset")
|
|
89
|
+
if upload_offset is not None:
|
|
90
|
+
try:
|
|
91
|
+
upload_offset = int(upload_offset)
|
|
92
|
+
if current_info.offset != upload_offset:
|
|
93
|
+
raise HTTPException(status_code=409, detail="Offset mismatch")
|
|
94
|
+
except (ValueError, TypeError):
|
|
95
|
+
raise HTTPException(status_code=400, detail="Invalid Upload-Offset header")
|
|
96
|
+
else:
|
|
97
|
+
# Upload-Offset is required for PATCH
|
|
98
|
+
raise HTTPException(status_code=400, detail="Upload-Offset header is required")
|
|
99
|
+
|
|
100
|
+
# Use the validated offset directly - don't re-read from file.info
|
|
101
|
+
# This prevents stale offset issues
|
|
102
|
+
new_params = current_info
|
|
103
|
+
validated_offset = current_info.offset
|
|
104
|
+
|
|
105
|
+
# init variables
|
|
106
|
+
has_chunks = False
|
|
107
|
+
total_bytes_written = 0
|
|
108
|
+
|
|
109
|
+
# process chunk stream - lock is held during entire operation
|
|
110
|
+
with open(upload_path, "ab") as f:
|
|
111
|
+
try:
|
|
112
|
+
async for chunk in request.stream():
|
|
113
|
+
has_chunks = True
|
|
114
|
+
# skip empty chunks but continue processing
|
|
115
|
+
if len(chunk) == 0:
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
# Calculate new offset based on validated offset + bytes written so far
|
|
119
|
+
new_offset = validated_offset + total_bytes_written
|
|
120
|
+
|
|
121
|
+
# Check if upload would exceed declared size
|
|
122
|
+
if (
|
|
123
|
+
new_params.size is not None
|
|
124
|
+
and new_offset + len(chunk) > new_params.size
|
|
125
|
+
):
|
|
126
|
+
raise HTTPException(
|
|
127
|
+
status_code=400,
|
|
128
|
+
detail="Upload would exceed declared Upload-Length",
|
|
129
|
+
)
|
|
130
|
+
# throw if max size exceeded
|
|
131
|
+
if new_offset + len(chunk) > options.max_size:
|
|
132
|
+
raise HTTPException(
|
|
133
|
+
status_code=413,
|
|
134
|
+
detail="Upload exceeds maximum allowed size",
|
|
135
|
+
)
|
|
136
|
+
# write chunk otherwise
|
|
137
|
+
f.write(chunk)
|
|
138
|
+
f.flush() # Ensure data is written to disk immediately
|
|
139
|
+
total_bytes_written += len(chunk)
|
|
140
|
+
|
|
141
|
+
# After all chunks are written, update params atomically
|
|
142
|
+
# Lock is still held here, so this is safe
|
|
143
|
+
new_params.offset = validated_offset + total_bytes_written
|
|
144
|
+
new_params.upload_chunk_size = total_bytes_written if total_bytes_written > 0 else 0
|
|
145
|
+
new_params.upload_part += 1
|
|
146
|
+
# Save updated params (atomic write while lock is held)
|
|
147
|
+
file.info = new_params
|
|
148
|
+
|
|
149
|
+
except HTTPException:
|
|
150
|
+
# HTTPExceptions should propagate - don't catch them here
|
|
151
|
+
# The lock will be released by the context manager
|
|
152
|
+
raise
|
|
153
|
+
except ClientDisconnect:
|
|
154
|
+
# Save the current offset before returning, so resume works correctly
|
|
155
|
+
# Lock is still held, so this is safe
|
|
156
|
+
new_params.offset = validated_offset + total_bytes_written
|
|
157
|
+
new_params.upload_chunk_size = total_bytes_written if total_bytes_written > 0 else 0
|
|
158
|
+
new_params.upload_part += 1
|
|
159
|
+
file.info = new_params
|
|
160
|
+
return False
|
|
161
|
+
except Exception as e:
|
|
162
|
+
# save the error
|
|
163
|
+
new_params.error = str(e)
|
|
164
|
+
new_params.offset = validated_offset + total_bytes_written
|
|
165
|
+
# save updated params
|
|
166
|
+
file.info = new_params
|
|
167
|
+
return False
|
|
168
|
+
finally:
|
|
169
|
+
f.close()
|
|
170
|
+
|
|
171
|
+
# For empty files in a POST request, we still want to return True
|
|
172
|
+
# to ensure the file gets created properly
|
|
173
|
+
# Lock is still held here
|
|
174
|
+
if post_request and not has_chunks:
|
|
175
|
+
# update params for empty file
|
|
176
|
+
new_params.offset = validated_offset
|
|
177
|
+
new_params.upload_chunk_size = 0
|
|
178
|
+
new_params.upload_part += 1
|
|
179
|
+
# save updated params
|
|
180
|
+
file.info = new_params
|
|
181
|
+
except (IOError, OSError) as e:
|
|
182
|
+
# Lock acquisition or file operation failed
|
|
183
|
+
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
|
184
|
+
|
|
185
|
+
return True
|
|
186
|
+
|
|
187
|
+
return request_chunks_dep
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def get_request_headers(request: Request, uuid: str, prefix: str = "files") -> dict:
|
|
191
|
+
proto = "http"
|
|
192
|
+
host = request.headers.get("host")
|
|
193
|
+
|
|
194
|
+
# Check for forwarded headers first (for proxy setups)
|
|
195
|
+
if request.headers.get("X-Forwarded-Proto") is not None:
|
|
196
|
+
proto = request.headers.get("X-Forwarded-Proto")
|
|
197
|
+
if request.headers.get("X-Forwarded-Host") is not None:
|
|
198
|
+
host = request.headers.get("X-Forwarded-Host")
|
|
199
|
+
|
|
200
|
+
# If no forwarded protocol, try to detect scheme from request URL
|
|
201
|
+
# This handles direct HTTPS connections (e.g., Uvicorn with SSL)
|
|
202
|
+
if proto == "http" and not request.headers.get("X-Forwarded-Proto"):
|
|
203
|
+
try:
|
|
204
|
+
# Use request.url.scheme to detect the actual protocol
|
|
205
|
+
if hasattr(request, 'url') and request.url.scheme:
|
|
206
|
+
proto = request.url.scheme
|
|
207
|
+
except Exception:
|
|
208
|
+
# Fallback to default if URL parsing fails
|
|
209
|
+
pass
|
|
210
|
+
|
|
211
|
+
# Ensure we have a host
|
|
212
|
+
if not host:
|
|
213
|
+
host = "localhost:8000" # fallback host
|
|
214
|
+
|
|
215
|
+
# Build the full path including root_path and router prefixes
|
|
216
|
+
# FastAPI includes the full mount path in request.url.path
|
|
217
|
+
# We need to construct the base path from the current request path
|
|
218
|
+
current_path = request.url.path.rstrip("/")
|
|
219
|
+
|
|
220
|
+
# The current path should end with our prefix (e.g., "/api/files")
|
|
221
|
+
# We want to extract everything before our prefix to build the location header
|
|
222
|
+
clean_prefix = prefix.lstrip("/").rstrip("/")
|
|
223
|
+
|
|
224
|
+
# Find where our prefix appears in the current path
|
|
225
|
+
if current_path == f"/{clean_prefix}":
|
|
226
|
+
# Handle case where current_path is exactly our prefix (e.g., "/files")
|
|
227
|
+
# This means there's no parent path in the URL, check for root_path
|
|
228
|
+
root_path = request.scope.get("root_path", "")
|
|
229
|
+
base_path = root_path.rstrip("/") if root_path else ""
|
|
230
|
+
elif current_path.endswith(f"/{clean_prefix}"):
|
|
231
|
+
# Extract the base path (everything before our prefix)
|
|
232
|
+
base_path = current_path[:-len(f"/{clean_prefix}")]
|
|
233
|
+
elif current_path.endswith(clean_prefix) and current_path != clean_prefix:
|
|
234
|
+
# Handle case where current_path doesn't have leading slash
|
|
235
|
+
base_path = current_path[:-len(clean_prefix)].rstrip("/")
|
|
236
|
+
else:
|
|
237
|
+
# Fallback: check for root_path in request scope
|
|
238
|
+
# This handles FastAPI root_path that might not be included in request.url.path
|
|
239
|
+
root_path = request.scope.get("root_path", "")
|
|
240
|
+
if root_path:
|
|
241
|
+
base_path = root_path.rstrip("/")
|
|
242
|
+
else:
|
|
243
|
+
base_path = ""
|
|
244
|
+
|
|
245
|
+
# Construct the full path
|
|
246
|
+
if base_path:
|
|
247
|
+
full_path = f"{base_path.rstrip('/')}/{clean_prefix}"
|
|
248
|
+
else:
|
|
249
|
+
full_path = clean_prefix
|
|
250
|
+
|
|
251
|
+
# Ensure path starts with /
|
|
252
|
+
if not full_path.startswith("/"):
|
|
253
|
+
full_path = "/" + full_path
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
"location": f"{proto}://{host}{full_path}/{uuid}",
|
|
257
|
+
"proto": proto,
|
|
258
|
+
"host": host,
|
|
259
|
+
}
|