Spaces:
Running
Running
Deploy 1bd20861 from GitHub Actions
Browse files- app/api/v1/upload.py +26 -0
- app/config.py +26 -0
- app/core/url_guard.py +27 -0
- app/services/extractors.py +55 -2
- app/services/object_storage.py +150 -0
- tests/unit/test_object_storage.py +175 -0
app/api/v1/upload.py
CHANGED
|
@@ -119,6 +119,32 @@ async def upload_document(
|
|
| 119 |
pass
|
| 120 |
raise
|
| 121 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
# Internal storage reference — resolved only against UPLOAD_DIR. Storing a
|
| 123 |
# bare key rather than an absolute path keeps the row portable across
|
| 124 |
# containers and gives the SSRF guard a value it can safely confine.
|
|
|
|
| 119 |
pass
|
| 120 |
raise
|
| 121 |
|
| 122 |
+
# Mirror the bytes into durable storage. UPLOAD_DIR is on the container
|
| 123 |
+
# filesystem, which is wiped on every restart in the deployed environment,
|
| 124 |
+
# so the local copy alone is a cache rather than storage. A failure here is
|
| 125 |
+
# logged and tolerated: the upload still works for this container's
|
| 126 |
+
# lifetime, and rejecting it outright would be a worse outcome than storing
|
| 127 |
+
# it with reduced durability.
|
| 128 |
+
from app.services import object_storage
|
| 129 |
+
|
| 130 |
+
if object_storage.is_configured():
|
| 131 |
+
with open(local_path, "rb") as f:
|
| 132 |
+
stored = await object_storage.put_object(
|
| 133 |
+
local_filename, f.read(), content_type
|
| 134 |
+
)
|
| 135 |
+
if not stored:
|
| 136 |
+
logger.warning(
|
| 137 |
+
"Upload %s is on local disk only and will not survive a "
|
| 138 |
+
"restart: durable storage write failed.",
|
| 139 |
+
local_filename,
|
| 140 |
+
)
|
| 141 |
+
else:
|
| 142 |
+
logger.warning(
|
| 143 |
+
"Durable storage is not configured; upload %s will be lost when "
|
| 144 |
+
"the container restarts. Set SUPABASE_URL and SUPABASE_SERVICE_KEY.",
|
| 145 |
+
local_filename,
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
# Internal storage reference — resolved only against UPLOAD_DIR. Storing a
|
| 149 |
# bare key rather than an absolute path keeps the row portable across
|
| 150 |
# containers and gives the SSRF guard a value it can safely confine.
|
app/config.py
CHANGED
|
@@ -150,6 +150,32 @@ class Settings(BaseSettings):
|
|
| 150 |
description="Root directory for locally stored uploads. All storage:// "
|
| 151 |
"URLs resolve inside this directory and may not escape it.",
|
| 152 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
MAX_FILE_SIZE_MB: int = Field(default=50)
|
| 154 |
ALLOWED_FILE_TYPES: List[str] = Field(
|
| 155 |
default=[
|
|
|
|
| 150 |
description="Root directory for locally stored uploads. All storage:// "
|
| 151 |
"URLs resolve inside this directory and may not escape it.",
|
| 152 |
)
|
| 153 |
+
# ── Durable object storage ────────────────────────────────────────────────
|
| 154 |
+
# UPLOAD_DIR lives on the container filesystem, which is ephemeral on
|
| 155 |
+
# HuggingFace Spaces: a restart returns it to the state baked into the
|
| 156 |
+
# image, i.e. empty, while the Postgres rows describing those files
|
| 157 |
+
# survive. Documents then appear healthy until something needs the
|
| 158 |
+
# original bytes, at which point extraction fails with "Stored file no
|
| 159 |
+
# longer exists". Setting these moves the bytes somewhere that outlives
|
| 160 |
+
# the container; leaving them unset keeps the previous local-disk
|
| 161 |
+
# behaviour so local development needs no cloud credentials.
|
| 162 |
+
SUPABASE_URL: str = Field(
|
| 163 |
+
default="",
|
| 164 |
+
description="Supabase project URL, e.g. https://xxxx.supabase.co. "
|
| 165 |
+
"Enables durable upload storage when set together with "
|
| 166 |
+
"SUPABASE_SERVICE_KEY.",
|
| 167 |
+
)
|
| 168 |
+
SUPABASE_SERVICE_KEY: str = Field(
|
| 169 |
+
default="",
|
| 170 |
+
description="Supabase service role key. The uploads bucket is private "
|
| 171 |
+
"and only this backend reads it, so the anon key is not sufficient.",
|
| 172 |
+
)
|
| 173 |
+
SUPABASE_STORAGE_BUCKET: str = Field(
|
| 174 |
+
default="documents",
|
| 175 |
+
description="Bucket name for uploaded documents. Create it as a "
|
| 176 |
+
"private bucket in the Supabase dashboard.",
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
MAX_FILE_SIZE_MB: int = Field(default=50)
|
| 180 |
ALLOWED_FILE_TYPES: List[str] = Field(
|
| 181 |
default=[
|
app/core/url_guard.py
CHANGED
|
@@ -239,3 +239,30 @@ def resolve_storage_path(file_url: str) -> str:
|
|
| 239 |
def is_internal_storage_url(file_url: str) -> bool:
|
| 240 |
"""True if this URL refers to a file this backend stored itself."""
|
| 241 |
return urlparse(file_url).scheme in (STORAGE_SCHEME, "file")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 239 |
def is_internal_storage_url(file_url: str) -> bool:
|
| 240 |
"""True if this URL refers to a file this backend stored itself."""
|
| 241 |
return urlparse(file_url).scheme in (STORAGE_SCHEME, "file")
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
def storage_key_from_url(file_url: str) -> str:
|
| 245 |
+
"""
|
| 246 |
+
Extract the bare object key from an internal storage URL.
|
| 247 |
+
|
| 248 |
+
This is the name used both for the file inside UPLOAD_DIR and for the
|
| 249 |
+
object in durable storage, so the two stay addressable by the same value.
|
| 250 |
+
Legacy file:// rows hold an absolute path; only its basename is meaningful
|
| 251 |
+
as a key.
|
| 252 |
+
"""
|
| 253 |
+
parsed = urlparse(file_url)
|
| 254 |
+
|
| 255 |
+
if parsed.scheme == STORAGE_SCHEME:
|
| 256 |
+
raw = f"{parsed.netloc}{parsed.path}"
|
| 257 |
+
elif parsed.scheme == "file":
|
| 258 |
+
raw = parsed.path
|
| 259 |
+
if os.name == "nt" and raw.startswith("/"):
|
| 260 |
+
raw = raw[1:]
|
| 261 |
+
else:
|
| 262 |
+
raise UnsafeURLError(f"Not an internal storage URL: {parsed.scheme}")
|
| 263 |
+
|
| 264 |
+
key = os.path.basename(raw)
|
| 265 |
+
if not key:
|
| 266 |
+
raise UnsafeURLError("Storage URL is missing a key")
|
| 267 |
+
|
| 268 |
+
return key
|
app/services/extractors.py
CHANGED
|
@@ -19,14 +19,61 @@ import httpx
|
|
| 19 |
|
| 20 |
from app.config import settings
|
| 21 |
from app.core.url_guard import (
|
|
|
|
| 22 |
fetch_remote_file,
|
| 23 |
is_internal_storage_url,
|
| 24 |
resolve_storage_path,
|
|
|
|
| 25 |
)
|
| 26 |
|
| 27 |
logger = logging.getLogger(__name__)
|
| 28 |
|
| 29 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
def _read_capped(path: str, max_bytes: int) -> bytes:
|
| 31 |
"""Read a local file, refusing anything over the size ceiling."""
|
| 32 |
if os.path.getsize(path) > max_bytes:
|
|
@@ -311,8 +358,14 @@ async def download_and_extract(file_url: str, file_type: str) -> ExtractedDocume
|
|
| 311 |
|
| 312 |
if is_internal_storage_url(file_url):
|
| 313 |
# Path is confined to UPLOAD_DIR by the guard; traversal is rejected.
|
| 314 |
-
|
| 315 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 316 |
else:
|
| 317 |
# User-supplied URL: https only, public addresses only, size-capped.
|
| 318 |
file_bytes = await fetch_remote_file(file_url, max_bytes=max_bytes)
|
|
|
|
| 19 |
|
| 20 |
from app.config import settings
|
| 21 |
from app.core.url_guard import (
|
| 22 |
+
UnsafeURLError,
|
| 23 |
fetch_remote_file,
|
| 24 |
is_internal_storage_url,
|
| 25 |
resolve_storage_path,
|
| 26 |
+
storage_key_from_url,
|
| 27 |
)
|
| 28 |
|
| 29 |
logger = logging.getLogger(__name__)
|
| 30 |
|
| 31 |
|
| 32 |
+
async def _restore_from_durable_storage(file_url: str, max_bytes: int) -> bytes:
|
| 33 |
+
"""
|
| 34 |
+
Recover a document whose local copy has been lost, and re-cache it.
|
| 35 |
+
|
| 36 |
+
Raises UnsafeURLError with the original message when no durable copy
|
| 37 |
+
exists, so callers upstream see the same failure they always did.
|
| 38 |
+
"""
|
| 39 |
+
from app.services import object_storage
|
| 40 |
+
|
| 41 |
+
key = storage_key_from_url(file_url)
|
| 42 |
+
|
| 43 |
+
if not object_storage.is_configured():
|
| 44 |
+
raise UnsafeURLError(
|
| 45 |
+
"Stored file no longer exists, and durable storage is not "
|
| 46 |
+
"configured. Set SUPABASE_URL and SUPABASE_SERVICE_KEY so uploads "
|
| 47 |
+
"survive a container restart."
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
data = await object_storage.get_object(key)
|
| 51 |
+
if data is None:
|
| 52 |
+
raise UnsafeURLError("Stored file no longer exists")
|
| 53 |
+
|
| 54 |
+
if len(data) > max_bytes:
|
| 55 |
+
raise UnsafeURLError("Stored file exceeds the maximum allowed size")
|
| 56 |
+
|
| 57 |
+
logger.info("Restored %s from durable storage after local copy was lost", key)
|
| 58 |
+
|
| 59 |
+
# Repopulate the local cache; failure here is not fatal because the bytes
|
| 60 |
+
# needed for this request are already in hand.
|
| 61 |
+
try:
|
| 62 |
+
os.makedirs(settings.UPLOAD_DIR, exist_ok=True)
|
| 63 |
+
await asyncio.to_thread(
|
| 64 |
+
_write_bytes, os.path.join(settings.UPLOAD_DIR, key), data
|
| 65 |
+
)
|
| 66 |
+
except OSError as exc:
|
| 67 |
+
logger.warning("Could not re-cache %s locally: %s", key, exc)
|
| 68 |
+
|
| 69 |
+
return data
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _write_bytes(path: str, data: bytes) -> None:
|
| 73 |
+
with open(path, "wb") as f:
|
| 74 |
+
f.write(data)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
def _read_capped(path: str, max_bytes: int) -> bytes:
|
| 78 |
"""Read a local file, refusing anything over the size ceiling."""
|
| 79 |
if os.path.getsize(path) > max_bytes:
|
|
|
|
| 358 |
|
| 359 |
if is_internal_storage_url(file_url):
|
| 360 |
# Path is confined to UPLOAD_DIR by the guard; traversal is rejected.
|
| 361 |
+
try:
|
| 362 |
+
local_path = await asyncio.to_thread(resolve_storage_path, file_url)
|
| 363 |
+
file_bytes = await asyncio.to_thread(_read_capped, local_path, max_bytes)
|
| 364 |
+
except UnsafeURLError:
|
| 365 |
+
# The local copy is gone — the usual cause is a container restart
|
| 366 |
+
# wiping UPLOAD_DIR. Fall back to the durable copy and repopulate
|
| 367 |
+
# the local cache so subsequent reads are local again.
|
| 368 |
+
file_bytes = await _restore_from_durable_storage(file_url, max_bytes)
|
| 369 |
else:
|
| 370 |
# User-supplied URL: https only, public addresses only, size-capped.
|
| 371 |
file_bytes = await fetch_remote_file(file_url, max_bytes=max_bytes)
|
app/services/object_storage.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Durable object storage for uploaded documents.
|
| 3 |
+
|
| 4 |
+
Why this exists
|
| 5 |
+
---------------
|
| 6 |
+
Uploads were written to UPLOAD_DIR on the container's own filesystem. That
|
| 7 |
+
filesystem is ephemeral on HuggingFace Spaces: every rebuild or restart starts
|
| 8 |
+
from the image, so the directory comes back empty. The database rows survive
|
| 9 |
+
(Postgres is external), which makes the failure especially confusing — the
|
| 10 |
+
document still lists, still shows its analysis, and still answers questions
|
| 11 |
+
from its stored embeddings, right up until something needs the original bytes
|
| 12 |
+
again. Then:
|
| 13 |
+
|
| 14 |
+
app.core.url_guard.UnsafeURLError: Stored file no longer exists
|
| 15 |
+
|
| 16 |
+
That is exactly what happened in production: a deploy restarted the Space,
|
| 17 |
+
every uploaded PDF went with it, and the next re-analysis destroyed the
|
| 18 |
+
document's chunks before discovering the source was gone.
|
| 19 |
+
|
| 20 |
+
The fix is to keep the bytes somewhere that outlives the container. Supabase
|
| 21 |
+
is already a dependency of this project for Postgres, and its Storage API is
|
| 22 |
+
plain HTTP, so this needs no new package — just httpx, which is already here.
|
| 23 |
+
|
| 24 |
+
Degrading gracefully
|
| 25 |
+
--------------------
|
| 26 |
+
If SUPABASE_URL / SUPABASE_SERVICE_KEY are not set, every function here
|
| 27 |
+
reports "not configured" and callers fall back to local disk exactly as
|
| 28 |
+
before. Nothing breaks; durability is simply not gained. This keeps local
|
| 29 |
+
development and the test suite working without cloud credentials.
|
| 30 |
+
"""
|
| 31 |
+
|
| 32 |
+
import logging
|
| 33 |
+
from typing import Optional
|
| 34 |
+
|
| 35 |
+
import httpx
|
| 36 |
+
|
| 37 |
+
from app.config import settings
|
| 38 |
+
|
| 39 |
+
logger = logging.getLogger(__name__)
|
| 40 |
+
|
| 41 |
+
# Uploads are capped at MAX_FILE_SIZE_MB, so a generous per-request timeout is
|
| 42 |
+
# still bounded. Cold Supabase connections occasionally take a few seconds.
|
| 43 |
+
_TIMEOUT = httpx.Timeout(30.0, connect=10.0)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def is_configured() -> bool:
|
| 47 |
+
"""True when a durable bucket is available to store objects in."""
|
| 48 |
+
return bool(settings.SUPABASE_URL and settings.SUPABASE_SERVICE_KEY)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _object_url(key: str) -> str:
|
| 52 |
+
base = settings.SUPABASE_URL.rstrip("/")
|
| 53 |
+
bucket = settings.SUPABASE_STORAGE_BUCKET
|
| 54 |
+
return f"{base}/storage/v1/object/{bucket}/{key}"
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _headers() -> dict:
|
| 58 |
+
# The service key is used rather than the anon key: this bucket is private
|
| 59 |
+
# and the backend is the only thing that should read or write it.
|
| 60 |
+
return {
|
| 61 |
+
"Authorization": f"Bearer {settings.SUPABASE_SERVICE_KEY}",
|
| 62 |
+
"apikey": settings.SUPABASE_SERVICE_KEY,
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
async def put_object(key: str, data: bytes, content_type: str) -> bool:
|
| 67 |
+
"""
|
| 68 |
+
Store bytes under `key`. Returns True on success.
|
| 69 |
+
|
| 70 |
+
Failure is logged and reported, never raised: an upload that reached local
|
| 71 |
+
disk is still usable for the current container's lifetime, and refusing the
|
| 72 |
+
whole upload because the durable copy failed would be a worse outcome than
|
| 73 |
+
storing it with reduced durability.
|
| 74 |
+
"""
|
| 75 |
+
if not is_configured():
|
| 76 |
+
return False
|
| 77 |
+
|
| 78 |
+
try:
|
| 79 |
+
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
| 80 |
+
response = await client.post(
|
| 81 |
+
_object_url(key),
|
| 82 |
+
content=data,
|
| 83 |
+
headers={
|
| 84 |
+
**_headers(),
|
| 85 |
+
"Content-Type": content_type,
|
| 86 |
+
# Objects are keyed by UUID and never rewritten, so an
|
| 87 |
+
# existing key means a retry of the same upload.
|
| 88 |
+
"x-upsert": "true",
|
| 89 |
+
},
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
if response.status_code in (200, 201):
|
| 93 |
+
logger.info("Stored %s in durable storage (%d bytes)", key, len(data))
|
| 94 |
+
return True
|
| 95 |
+
|
| 96 |
+
logger.error(
|
| 97 |
+
"Durable storage rejected %s: %s %s",
|
| 98 |
+
key,
|
| 99 |
+
response.status_code,
|
| 100 |
+
response.text[:200],
|
| 101 |
+
)
|
| 102 |
+
return False
|
| 103 |
+
|
| 104 |
+
except Exception as exc:
|
| 105 |
+
logger.error("Durable storage write failed for %s: %s", key, exc)
|
| 106 |
+
return False
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
async def get_object(key: str) -> Optional[bytes]:
|
| 110 |
+
"""
|
| 111 |
+
Fetch bytes for `key`, or None when unavailable.
|
| 112 |
+
|
| 113 |
+
None covers both "not configured" and "not found", because the caller does
|
| 114 |
+
the same thing in either case: fall back to local disk and, failing that,
|
| 115 |
+
report the file as missing.
|
| 116 |
+
"""
|
| 117 |
+
if not is_configured():
|
| 118 |
+
return None
|
| 119 |
+
|
| 120 |
+
try:
|
| 121 |
+
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
| 122 |
+
response = await client.get(_object_url(key), headers=_headers())
|
| 123 |
+
|
| 124 |
+
if response.status_code == 200:
|
| 125 |
+
return response.content
|
| 126 |
+
|
| 127 |
+
if response.status_code != 404:
|
| 128 |
+
logger.error(
|
| 129 |
+
"Durable storage read failed for %s: %s %s",
|
| 130 |
+
key,
|
| 131 |
+
response.status_code,
|
| 132 |
+
response.text[:200],
|
| 133 |
+
)
|
| 134 |
+
return None
|
| 135 |
+
|
| 136 |
+
except Exception as exc:
|
| 137 |
+
logger.error("Durable storage read failed for %s: %s", key, exc)
|
| 138 |
+
return None
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
async def delete_object(key: str) -> None:
|
| 142 |
+
"""Best-effort removal, so deleting a document does not leak storage."""
|
| 143 |
+
if not is_configured():
|
| 144 |
+
return
|
| 145 |
+
|
| 146 |
+
try:
|
| 147 |
+
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
| 148 |
+
await client.delete(_object_url(key), headers=_headers())
|
| 149 |
+
except Exception as exc:
|
| 150 |
+
logger.warning("Durable storage delete failed for %s: %s", key, exc)
|
tests/unit/test_object_storage.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Unit tests for durable upload storage.
|
| 3 |
+
|
| 4 |
+
These exist because of a production incident. Uploads were written only to
|
| 5 |
+
UPLOAD_DIR, which lives on the container filesystem. HuggingFace Spaces
|
| 6 |
+
restarts the container on every deploy and the filesystem comes back as it was
|
| 7 |
+
baked into the image — empty — while the Postgres rows describing those files
|
| 8 |
+
survive. A document therefore kept listing, kept showing its analysis and kept
|
| 9 |
+
answering questions from its stored embeddings, right up until something asked
|
| 10 |
+
for the original bytes:
|
| 11 |
+
|
| 12 |
+
app.core.url_guard.UnsafeURLError: Stored file no longer exists
|
| 13 |
+
|
| 14 |
+
A re-analysis then destroyed the document's chunks before discovering the
|
| 15 |
+
source was gone, which turned a recoverable situation into data loss.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
import os
|
| 19 |
+
|
| 20 |
+
import pytest
|
| 21 |
+
|
| 22 |
+
from app.core.url_guard import UnsafeURLError, storage_key_from_url
|
| 23 |
+
from app.services import object_storage
|
| 24 |
+
|
| 25 |
+
pytestmark = pytest.mark.unit
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# ── Key extraction ─────────────────────────────────────────────────────────────
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def test_storage_key_from_storage_url():
|
| 32 |
+
assert storage_key_from_url("storage://abc123.pdf") == "abc123.pdf"
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def test_storage_key_from_legacy_file_url():
|
| 36 |
+
"""Older rows hold an absolute path; only the basename is a usable key."""
|
| 37 |
+
assert storage_key_from_url("file:///app/uploads/abc123.pdf") == "abc123.pdf"
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def test_storage_key_rejects_external_url():
|
| 41 |
+
with pytest.raises(UnsafeURLError):
|
| 42 |
+
storage_key_from_url("https://example.com/abc123.pdf")
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def test_storage_key_rejects_empty_key():
|
| 46 |
+
with pytest.raises(UnsafeURLError):
|
| 47 |
+
storage_key_from_url("storage://")
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
# ── Configuration gating ───────────────────────────────────────────────────────
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def test_not_configured_without_credentials(monkeypatch):
|
| 54 |
+
monkeypatch.setattr(object_storage.settings, "SUPABASE_URL", "")
|
| 55 |
+
monkeypatch.setattr(object_storage.settings, "SUPABASE_SERVICE_KEY", "")
|
| 56 |
+
assert object_storage.is_configured() is False
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def test_not_configured_with_only_a_url(monkeypatch):
|
| 60 |
+
"""A URL without a key cannot authenticate, so it is not usable."""
|
| 61 |
+
monkeypatch.setattr(
|
| 62 |
+
object_storage.settings, "SUPABASE_URL", "https://x.supabase.co"
|
| 63 |
+
)
|
| 64 |
+
monkeypatch.setattr(object_storage.settings, "SUPABASE_SERVICE_KEY", "")
|
| 65 |
+
assert object_storage.is_configured() is False
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def test_configured_with_both(monkeypatch):
|
| 69 |
+
monkeypatch.setattr(
|
| 70 |
+
object_storage.settings, "SUPABASE_URL", "https://x.supabase.co"
|
| 71 |
+
)
|
| 72 |
+
monkeypatch.setattr(object_storage.settings, "SUPABASE_SERVICE_KEY", "svc-key")
|
| 73 |
+
assert object_storage.is_configured() is True
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
@pytest.mark.asyncio
|
| 77 |
+
async def test_unconfigured_calls_are_inert(monkeypatch):
|
| 78 |
+
"""
|
| 79 |
+
With no credentials the module must not attempt any network call, so local
|
| 80 |
+
development and CI keep working exactly as before.
|
| 81 |
+
"""
|
| 82 |
+
monkeypatch.setattr(object_storage.settings, "SUPABASE_URL", "")
|
| 83 |
+
monkeypatch.setattr(object_storage.settings, "SUPABASE_SERVICE_KEY", "")
|
| 84 |
+
|
| 85 |
+
assert await object_storage.put_object("k.pdf", b"data", "application/pdf") is False
|
| 86 |
+
assert await object_storage.get_object("k.pdf") is None
|
| 87 |
+
await object_storage.delete_object("k.pdf") # must not raise
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
# ── Recovery after the local copy is lost ──────────────────────────────────────
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
@pytest.mark.asyncio
|
| 94 |
+
async def test_missing_local_file_is_restored_from_durable_storage(
|
| 95 |
+
monkeypatch, tmp_path
|
| 96 |
+
):
|
| 97 |
+
"""The exact scenario that lost a document in production."""
|
| 98 |
+
from app.services import extractors
|
| 99 |
+
|
| 100 |
+
monkeypatch.setattr(extractors.settings, "UPLOAD_DIR", str(tmp_path))
|
| 101 |
+
monkeypatch.setattr(object_storage, "is_configured", lambda: True)
|
| 102 |
+
|
| 103 |
+
async def fake_get(key):
|
| 104 |
+
assert key == "abc123.pdf"
|
| 105 |
+
return b"%PDF-1.4 recovered"
|
| 106 |
+
|
| 107 |
+
monkeypatch.setattr(object_storage, "get_object", fake_get)
|
| 108 |
+
|
| 109 |
+
data = await extractors._restore_from_durable_storage(
|
| 110 |
+
"storage://abc123.pdf", max_bytes=1024
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
assert data == b"%PDF-1.4 recovered"
|
| 114 |
+
# The local cache is repopulated so the next read does not go over the wire.
|
| 115 |
+
assert (tmp_path / "abc123.pdf").read_bytes() == b"%PDF-1.4 recovered"
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
@pytest.mark.asyncio
|
| 119 |
+
async def test_missing_everywhere_reports_the_original_failure(monkeypatch, tmp_path):
|
| 120 |
+
from app.services import extractors
|
| 121 |
+
|
| 122 |
+
monkeypatch.setattr(extractors.settings, "UPLOAD_DIR", str(tmp_path))
|
| 123 |
+
monkeypatch.setattr(object_storage, "is_configured", lambda: True)
|
| 124 |
+
|
| 125 |
+
async def fake_get(key):
|
| 126 |
+
return None
|
| 127 |
+
|
| 128 |
+
monkeypatch.setattr(object_storage, "get_object", fake_get)
|
| 129 |
+
|
| 130 |
+
with pytest.raises(UnsafeURLError, match="no longer exists"):
|
| 131 |
+
await extractors._restore_from_durable_storage(
|
| 132 |
+
"storage://gone.pdf", max_bytes=1024
|
| 133 |
+
)
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
@pytest.mark.asyncio
|
| 137 |
+
async def test_unconfigured_recovery_explains_how_to_fix_it(monkeypatch, tmp_path):
|
| 138 |
+
"""
|
| 139 |
+
An operator reading this error should learn why the file vanished and what
|
| 140 |
+
to set so it stops happening, not just that it is absent.
|
| 141 |
+
"""
|
| 142 |
+
from app.services import extractors
|
| 143 |
+
|
| 144 |
+
monkeypatch.setattr(extractors.settings, "UPLOAD_DIR", str(tmp_path))
|
| 145 |
+
monkeypatch.setattr(object_storage, "is_configured", lambda: False)
|
| 146 |
+
|
| 147 |
+
with pytest.raises(UnsafeURLError) as exc:
|
| 148 |
+
await extractors._restore_from_durable_storage(
|
| 149 |
+
"storage://gone.pdf", max_bytes=1024
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
message = str(exc.value)
|
| 153 |
+
assert "SUPABASE_URL" in message
|
| 154 |
+
assert "restart" in message
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
@pytest.mark.asyncio
|
| 158 |
+
async def test_oversized_restored_file_is_rejected(monkeypatch, tmp_path):
|
| 159 |
+
"""The size cap must hold on the recovery path too, not just on upload."""
|
| 160 |
+
from app.services import extractors
|
| 161 |
+
|
| 162 |
+
monkeypatch.setattr(extractors.settings, "UPLOAD_DIR", str(tmp_path))
|
| 163 |
+
monkeypatch.setattr(object_storage, "is_configured", lambda: True)
|
| 164 |
+
|
| 165 |
+
async def fake_get(key):
|
| 166 |
+
return b"x" * 5000
|
| 167 |
+
|
| 168 |
+
monkeypatch.setattr(object_storage, "get_object", fake_get)
|
| 169 |
+
|
| 170 |
+
with pytest.raises(UnsafeURLError, match="maximum allowed size"):
|
| 171 |
+
await extractors._restore_from_durable_storage(
|
| 172 |
+
"storage://big.pdf", max_bytes=1024
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
assert not os.path.exists(tmp_path / "big.pdf")
|