take an upload even with no output directory configured (v0.4.87)

POST /v1/files/upload exists so a client sends a file once and then names it:
inlining a 40 MB clip in every request is the waste it was built to remove. But
it stored its content-addressed files in the output directory and answered 501
when there was none configured -- so on such an install the only way to send
media was the inlining the endpoint replaces, and a client that had already
switched to uploading saw its photo edits fail outright.

An install always has a cache directory, so uploads fall back to it
(media.uploads_dir()). With an output directory nothing moves: an upload still
lands there beside the generated files. Both stores stay in the lookup path, so
an install that gains --file-path later does not lose track of what it already
holds, and /v1/files/<name> serves from the cache store only for up- names --
the cache directory does not quietly become a second public output directory.
501 now means there is nowhere at all to write; a non-writable store answers
507 rather than a 500 out of makedirs.

Composing a new video still needs an output directory, since there has to be
somewhere to write the result.
Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S8LAP3ueNzQJtwzhgVbq66
parent 965e8cdb
......@@ -16,7 +16,7 @@
# Canonical product version for CoderAI — single source of truth. Both the API
# metadata and the admin web UI read from here.
__version__ = "0.4.86"
__version__ = "0.4.87"
__tagline__ = "Complete orchestration, distribution and escalation of remotizable advanced inference."
# Configure the CUDA caching allocator BEFORE torch is imported anywhere.
......
......@@ -1272,18 +1272,29 @@ async def get_broker_capabilities():
@app.get("/v1/files/{filename}", summary="Download a generated file", tags=["Files"])
async def get_file(filename: str):
"""Serve uploaded/generated files."""
if not global_file_path:
raise HTTPException(status_code=404, detail="File not found")
# Prevent path traversal: resolve to real paths and confirm the result
# stays inside the configured directory.
safe_base = os.path.realpath(global_file_path)
candidate = os.path.realpath(os.path.join(global_file_path, filename))
if not (candidate == safe_base or candidate.startswith(safe_base + os.sep)):
raise HTTPException(status_code=403, detail="Access denied")
if not os.path.isfile(candidate):
raise HTTPException(status_code=404, detail="File not found")
return FileResponse(candidate)
"""Serve uploaded/generated files.
Generated files come from the output directory. An uploaded one (``up-``
prefix, from /v1/files/upload) may instead sit in the cache store this
install falls back to when no output directory is configured, so it is
looked up there as well — otherwise an upload could be accepted and then
not served back."""
from codai.compose import media
bases = [global_file_path]
if filename.startswith(media.UPLOAD_PREFIX):
bases.append(media.uploads_dir())
for base in bases:
if not base:
continue
# Prevent path traversal: resolve to real paths and confirm the result
# stays inside the directory we are serving from.
safe_base = os.path.realpath(base)
candidate = os.path.realpath(os.path.join(base, filename))
if not (candidate == safe_base or candidate.startswith(safe_base + os.sep)):
raise HTTPException(status_code=403, detail="Access denied")
if os.path.isfile(candidate):
return FileResponse(candidate)
raise HTTPException(status_code=404, detail="File not found")
_IMAGE_EXTS = {'.png', '.jpg', '.jpeg', '.webp', '.gif'}
......
......@@ -18,8 +18,15 @@ Storage is content-addressed (sha256 of the bytes), which makes re-uploading
the same file free and makes ``GET /v1/files/blob/{hash}`` a useful question —
a client can ask before sending. This mirrors ``/v1/loras/upload`` for LoRA
weights; the difference is that these files are served back by
``/v1/files/{name}``, so they live in the ordinary output directory under their
hash, not in a private blob store.
``/v1/files/{name}``, so they live under their hash in the ordinary output
directory rather than in a private blob store.
An install that configured no output directory used to refuse uploads outright
(501), which left a remote client with no way to send a 40 MB clip other than
inlining it in every request — the exact waste this endpoint exists to avoid.
So when there is no output directory the store falls back to the install's
cache directory (``media.uploads_dir()``); the ``sha256:`` id and the
``/v1/files/<name>`` URL both keep working, and nothing else is written there.
"""
import os
......@@ -40,12 +47,15 @@ _MAX_BYTES = 2 << 30 # 2 GiB: a minute of 4K is ~400 MB
def _files_dir() -> str:
d = media.files_dir()
d = media.uploads_dir()
if not d:
raise HTTPException(status_code=501, detail=(
"this install has no output directory configured, so uploads cannot be "
"stored (start the server with --file-path / files.path)"))
os.makedirs(d, exist_ok=True)
"this install has nowhere to store uploads: neither an output directory "
"(--file-path / files.path) nor a writable cache directory"))
try:
os.makedirs(d, exist_ok=True)
except OSError as exc:
raise HTTPException(status_code=507, detail=f"upload store {d} is not writable: {exc}")
return d
......
......@@ -150,6 +150,38 @@ def _files_dir() -> Optional[str]:
return files_dir()
def uploads_dir() -> Optional[str]:
"""Where ``POST /v1/files/upload`` keeps its content-addressed store.
The output directory when there is one, so an upload is served back by
``/v1/files/<name>`` beside the files this install generates. An install
with no output directory configured still has a cache directory, and
uploads land there instead of being refused: every media field accepts the
``sha256:`` id the upload returns, and ``/v1/files/<name>`` serves these
too (codai/api/app.py). Returns None only when even the cache directory
cannot be determined."""
return files_dir() or _cache_upload_dir()
def _cache_upload_dir() -> Optional[str]:
try:
from codai.platform_paths import user_cache_dir
return str(user_cache_dir() / "uploads")
except Exception:
return None
def _upload_dirs() -> list:
"""Every directory an upload may have landed in, nearest first. The cache
store stays in the list even once an output directory is configured, so an
install that gains one does not lose track of what it already holds."""
out = []
for d in (files_dir(), _cache_upload_dir()):
if d and d not in out:
out.append(d)
return out
def local_path_for_files_url(ref: str) -> Optional[str]:
"""If ``ref`` points at a file this install already holds, its path.
......@@ -169,14 +201,21 @@ def local_path_for_files_url(ref: str) -> Optional[str]:
return None
name = path.split("/v1/files/", 1)[1].split("/")[-1]
name = urllib.parse.unquote(name)
base = _files_dir()
if not (base and name):
return None
safe_base = os.path.realpath(base)
cand = os.path.realpath(os.path.join(base, name))
if not (cand == safe_base or cand.startswith(safe_base + os.sep)):
if not name:
return None
return cand if os.path.isfile(cand) else None
# An uploaded file may live in the cache store instead, on an install with
# no output directory; a generated one only ever lives in the output dir.
bases = _upload_dirs() if name.startswith(UPLOAD_PREFIX) else [_files_dir()]
for base in bases:
if not base:
continue
safe_base = os.path.realpath(base)
cand = os.path.realpath(os.path.join(base, name))
if not (cand == safe_base or cand.startswith(safe_base + os.sep)):
continue
if os.path.isfile(cand):
return cand
return None
_SHA256 = re.compile(r"^(?:sha256:)?([0-9a-f]{64})$", re.I)
......@@ -191,17 +230,15 @@ def blob_path(ref: str) -> Optional[str]:
if not m:
return None
h = m.group(1).lower()
base = files_dir()
if not base:
return None
try:
for name in os.listdir(base):
if name.startswith(UPLOAD_PREFIX + h):
p = os.path.join(base, name)
if os.path.isfile(p):
return p
except OSError:
return None
for base in _upload_dirs():
try:
for name in os.listdir(base):
if name.startswith(UPLOAD_PREFIX + h):
p = os.path.join(base, name)
if os.path.isfile(p):
return p
except OSError:
continue
return None
......
......@@ -182,6 +182,12 @@ The body may also be JSON (`{"file": "<base64 or data: URI>"}`) or the raw bytes
with a `Content-Type`. Video, image and audio only; the type is decided by the
content, not by the client's claim.
Uploading does not need an output directory. With `--file-path` set, an upload
lands there beside the generated files; without one it goes to the install's
cache directory instead, and both the `sha256:` id and the `/v1/files/<name>`
URL work the same way. (Composing a *new* video still needs an output
directory — there has to be somewhere to write the result.)
## The talking presenter
A person talking to camera is the format most short videos use, and it is
......
"""Uploading media to an install that configured no output directory.
`POST /v1/files/upload` exists so a remote client sends a file once and then
names it: inlining a 40 MB clip in every request is the waste the endpoint was
built to remove. But it stored its content-addressed files in the output
directory, and refused with 501 when there was none configured -- so on such an
install the only way to send media was the inlining it was meant to replace,
and a client that had already switched to uploading saw its photo edits fail
outright.
An install always has a cache directory, so uploads fall back to it. What has
to keep working from the caller's side, and is asserted here: the upload is
accepted, the `sha256:` id resolves, the `/v1/files/<name>` URL serves the
bytes back, and a generated file is NOT served out of the upload store.
"""
import hashlib
import io
import os
import pytest
def _png_bytes(w=6, h=6, colour=(3, 140, 90)):
from PIL import Image
buf = io.BytesIO()
Image.new("RGB", (w, h), colour).save(buf, format="PNG")
return buf.getvalue()
@pytest.fixture
def no_output_dir(tmp_path, monkeypatch):
"""An install with no --file-path, whose cache directory is ``tmp_path``."""
from codai.compose import media
from codai import platform_paths
monkeypatch.setattr(media, "global_file_path", None, raising=False)
for mod in ("codai.api.app", "codai.api.video", "codai.api.images"):
m = __import__(mod, fromlist=["x"])
monkeypatch.setattr(m, "global_file_path", None, raising=False)
monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path))
monkeypatch.setattr(platform_paths, "user_cache_dir",
lambda: tmp_path / "coderai", raising=False)
return tmp_path / "coderai" / "uploads"
def test_uploads_dir_falls_back_to_the_cache_dir(no_output_dir):
from codai.compose import media
assert media.files_dir() is None
assert media.uploads_dir() == str(no_output_dir)
def test_uploads_dir_is_the_output_dir_when_there_is_one(tmp_path, monkeypatch):
"""Nothing moves on an install that is already configured."""
from codai.compose import media
monkeypatch.setattr(media, "global_file_path", str(tmp_path), raising=False)
assert media.uploads_dir() == str(tmp_path)
def test_store_accepts_the_upload_instead_of_501(no_output_dir):
"""The defect, from the client's side: this used to raise 501."""
from codai.api import uploads
data = _png_bytes()
doc = uploads.store(data, "shot.png", "image/png")
h = hashlib.sha256(data).hexdigest()
assert doc["id"] == f"sha256:{h}"
assert doc["existed"] is False
assert (no_output_dir / doc["name"]).read_bytes() == data
def test_the_sha256_id_resolves_back(no_output_dir):
from codai.api import uploads
from codai.compose import media
data = _png_bytes()
doc = uploads.store(data)
assert media.fetch_bytes(doc["id"]) == data
assert media.blob_path(doc["id"].split(":", 1)[1]) is not None
def test_an_image_endpoint_takes_the_uploaded_id(no_output_dir):
"""What the failing photo edit actually needed."""
from codai.api import images, uploads
data = _png_bytes()
doc = uploads.store(data)
assert images._image_bytes(doc["id"]) == data
def test_the_files_url_resolves_back(no_output_dir):
from codai.api import uploads
from codai.compose import media
data = _png_bytes()
doc = uploads.store(data)
assert media.fetch_bytes(f"/v1/files/{doc['name']}") == data
def test_re_uploading_the_same_bytes_is_free(no_output_dir):
from codai.api import uploads
data = _png_bytes()
uploads.store(data)
again = uploads.store(data)
assert again["existed"] is True
def test_an_upload_is_served_back_over_http(no_output_dir):
"""/v1/files/<name> is how a client fetches its own file; accepting an
upload we then cannot serve would be a different bug."""
from fastapi import HTTPException
from codai.api import app as app_mod
from codai.api import uploads
import asyncio
data = _png_bytes()
doc = uploads.store(data)
resp = asyncio.get_event_loop_policy().new_event_loop().run_until_complete(
app_mod.get_file(doc["name"]))
assert open(resp.path, "rb").read() == data
def test_a_generated_name_is_not_served_from_the_upload_store(no_output_dir):
"""Only ``up-`` names fall back; the cache dir is not a second output dir."""
import asyncio
from fastapi import HTTPException
from codai.api import app as app_mod
os.makedirs(no_output_dir, exist_ok=True)
(no_output_dir / "secret.png").write_bytes(_png_bytes())
loop = asyncio.get_event_loop_policy().new_event_loop()
with pytest.raises(HTTPException) as exc:
loop.run_until_complete(app_mod.get_file("secret.png"))
assert exc.value.status_code == 404
def test_traversal_out_of_the_upload_store_is_refused(no_output_dir):
from codai.compose import media
from codai.api import uploads
uploads.store(_png_bytes())
assert media.local_path_for_files_url("/v1/files/up-../../etc/passwd") is None
def test_an_upload_stored_before_a_dir_was_configured_still_resolves(
no_output_dir, tmp_path, monkeypatch):
"""An install that gains --file-path later does not lose what it holds."""
from codai.api import uploads
from codai.compose import media
data = _png_bytes()
doc = uploads.store(data)
out = tmp_path / "output"
out.mkdir()
monkeypatch.setattr(media, "global_file_path", str(out), raising=False)
assert media.fetch_bytes(doc["id"]) == data
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment