Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

File endpoints: use query parameters for NGINX/Caddy compatibility #6376

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions app/user_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,21 @@ def get_user_data_path(request, check_exists = False, param = "file"):

return path

def get_user_data_path_v1(request, check_exists=False, param="file"):
"""Reads a file-like parameter from the query string."""
file = request.query.get(param)
if not file:
return web.Response(status=400)

path = self.get_request_user_filepath(request, file)
if not path:
return web.Response(status=403)

if check_exists and not os.path.exists(path):
return web.Response(status=404)

return path

@routes.get("/userdata/{file}")
async def getuserdata(request):
path = get_user_data_path(request, check_exists=True)
Expand All @@ -219,6 +234,14 @@ async def getuserdata(request):

return web.FileResponse(path)

@routes.get("/v1/userdata/file")
async def getuserdata_v1(request):
path = get_user_data_path_v1(request, check_exists=True)
if not isinstance(path, str):
return path

return web.FileResponse(path)

@routes.post("/userdata/{file}")
async def post_userdata(request):
"""
Expand Down Expand Up @@ -268,6 +291,53 @@ async def post_userdata(request):

return web.json_response(resp)

@routes.post("/v1/userdata/file")
async def post_userdata_v1(request):
"""
Upload or update a user data file.

This endpoint handles file uploads to a user's data directory, with options for
controlling overwrite behavior and response format.

Query Parameters:
- file: The target file path (URL encoded if necessary).
- overwrite (optional): If "false", prevents overwriting existing files. Defaults to "true".
- full_info (optional): If "true", returns detailed file information (path, size, modified time).
If "false", returns only the relative file path.

Returns:
- 400: If 'file' parameter is missing.
- 403: If the requested path is not allowed.
- 409: If overwrite=false and the file already exists.
- 200: JSON response with either:
- Full file information (if full_info=true)
- Relative file path (if full_info=false)

The request body should contain the raw file content to be written.
"""
path = get_user_data_path_v1(request)
if not isinstance(path, str):
return path

overwrite = request.query.get("overwrite", 'true') != "false"
full_info = request.query.get("full_info", 'false').lower() == "true"

if not overwrite and os.path.exists(path):
return web.Response(status=409, text="File already exists")

body = await request.read()

with open(path, "wb") as f:
f.write(body)

user_path = self.get_request_user_filepath(request, None)
if full_info:
resp = get_file_info(path, user_path)
else:
resp = os.path.relpath(path, user_path)

return web.json_response(resp)

@routes.delete("/userdata/{file}")
async def delete_userdata(request):
path = get_user_data_path(request, check_exists=True)
Expand All @@ -278,6 +348,16 @@ async def delete_userdata(request):

return web.Response(status=204)

@routes.delete("/v1/userdata/file")
async def delete_userdata_v1(request):
path = get_user_data_path_v1(request, check_exists=True)
if not isinstance(path, str):
return path

os.remove(path)

return web.Response(status=204)

@routes.post("/userdata/{file}/move/{dest}")
async def move_userdata(request):
"""
Expand Down Expand Up @@ -328,3 +408,52 @@ async def move_userdata(request):
resp = os.path.relpath(dest, user_path)

return web.json_response(resp)

@routes.post("/v1/userdata/file/move")
async def move_userdata_v1(request):
"""
Move or rename a user data file.

This endpoint handles moving or renaming files within a user's data directory, with options for
controlling overwrite behavior and response format.

Query Parameters:
- source: The source file path (URL encoded if necessary)
- dest: The destination file path (URL encoded if necessary)
- overwrite (optional): If "false", prevents overwriting existing files. Defaults to "true".
- full_info (optional): If "true", returns detailed file information (path, size, modified time).
If "false", returns only the relative file path.

Returns:
- 400: If either 'file' or 'dest' parameter is missing
- 403: If either requested path is not allowed
- 404: If the source file does not exist
- 409: If overwrite=false and the destination file already exists
- 200: JSON response with either:
- Full file information (if full_info=true)
- Relative file path (if full_info=false)
"""
source = get_user_data_path_v1(request, check_exists=True, param="source")
if not isinstance(source, str):
return source

dest = get_user_data_path_v1(request, check_exists=False, param="dest")
if not isinstance(dest, str):
return dest

overwrite = request.query.get("overwrite", 'true') != "false"
full_info = request.query.get('full_info', 'false').lower() == "true"

if not overwrite and os.path.exists(dest):
return web.Response(status=409, text="File already exists")

logging.info(f"moving '{source}' -> '{dest}'")
shutil.move(source, dest)

user_path = self.get_request_user_filepath(request, None)
if full_info:
resp = get_file_info(dest, user_path)
else:
resp = os.path.relpath(dest, user_path)

return web.json_response(resp)
113 changes: 113 additions & 0 deletions tests-unit/prompt_server_test/user_manager_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,19 @@ async def test_post_userdata_new_file(aiohttp_client, app, tmp_path):
assert f.read() == content


async def test_post_userdata_new_file_v1(aiohttp_client, app, tmp_path):
client = await aiohttp_client(app)
content = b"test content"
resp = await client.post("/v1/userdata/file?file=test.txt", data=content)
bigcat88 marked this conversation as resolved.
Show resolved Hide resolved

assert resp.status == 200
assert await resp.text() == '"test.txt"'

# Verify file was created with correct content
with open(tmp_path / "test.txt", "rb") as f:
assert f.read() == content


async def test_post_userdata_overwrite_existing(aiohttp_client, app, tmp_path):
# Create initial file
with open(tmp_path / "test.txt", "w") as f:
Expand All @@ -148,6 +161,23 @@ async def test_post_userdata_overwrite_existing(aiohttp_client, app, tmp_path):
assert f.read() == new_content


async def test_post_userdata_overwrite_existing_v1(aiohttp_client, app, tmp_path):
# Create initial file
with open(tmp_path / "test.txt", "w") as f:
f.write("initial content")

client = await aiohttp_client(app)
new_content = b"updated content"
resp = await client.post("/v1/userdata/file?file=test.txt", data=new_content)

assert resp.status == 200
assert await resp.text() == '"test.txt"'

# Verify file was overwritten
with open(tmp_path / "test.txt", "rb") as f:
assert f.read() == new_content


async def test_post_userdata_no_overwrite(aiohttp_client, app, tmp_path):
# Create initial file
with open(tmp_path / "test.txt", "w") as f:
Expand All @@ -163,6 +193,21 @@ async def test_post_userdata_no_overwrite(aiohttp_client, app, tmp_path):
assert f.read() == "initial content"


async def test_post_userdata_no_overwrite_v1(aiohttp_client, app, tmp_path):
# Create initial file
with open(tmp_path / "test.txt", "w") as f:
f.write("initial content")

client = await aiohttp_client(app)
resp = await client.post("/v1/userdata/file?file=test.txt&overwrite=false", data=b"new content")

assert resp.status == 409

# Verify original content unchanged
with open(tmp_path / "test.txt", "r") as f:
assert f.read() == "initial content"


async def test_post_userdata_full_info(aiohttp_client, app, tmp_path):
client = await aiohttp_client(app)
content = b"test content"
Expand All @@ -175,6 +220,18 @@ async def test_post_userdata_full_info(aiohttp_client, app, tmp_path):
assert "modified" in result


async def test_post_userdata_full_info_v1(aiohttp_client, app, tmp_path):
client = await aiohttp_client(app)
content = b"test content"
resp = await client.post("/v1/userdata/file?file=test.txt&full_info=true", data=content)

assert resp.status == 200
result = await resp.json()
assert result["path"] == "test.txt"
assert result["size"] == len(content)
assert "modified" in result


async def test_move_userdata(aiohttp_client, app, tmp_path):
# Create initial file
with open(tmp_path / "source.txt", "w") as f:
Expand All @@ -192,6 +249,23 @@ async def test_move_userdata(aiohttp_client, app, tmp_path):
assert f.read() == "test content"


async def test_move_userdata_v1(aiohttp_client, app, tmp_path):
# Create initial file
with open(tmp_path / "source.txt", "w") as f:
f.write("test content")

client = await aiohttp_client(app)
resp = await client.post("/v1/userdata/file/move?source=source.txt&dest=dest.txt")

assert resp.status == 200
assert await resp.text() == '"dest.txt"'

# Verify file was moved
assert not os.path.exists(tmp_path / "source.txt")
with open(tmp_path / "dest.txt", "r") as f:
assert f.read() == "test content"


async def test_move_userdata_no_overwrite(aiohttp_client, app, tmp_path):
# Create source and destination files
with open(tmp_path / "source.txt", "w") as f:
Expand All @@ -211,6 +285,25 @@ async def test_move_userdata_no_overwrite(aiohttp_client, app, tmp_path):
assert f.read() == "destination content"


async def test_move_userdata_no_overwrite_v1(aiohttp_client, app, tmp_path):
# Create source and destination files
with open(tmp_path / "source.txt", "w") as f:
f.write("source content")
with open(tmp_path / "dest.txt", "w") as f:
f.write("destination content")

client = await aiohttp_client(app)
resp = await client.post("/v1/userdata/file/move?source=source.txt&dest=dest.txt&overwrite=false")

assert resp.status == 409

# Verify files remain unchanged
with open(tmp_path / "source.txt", "r") as f:
assert f.read() == "source content"
with open(tmp_path / "dest.txt", "r") as f:
assert f.read() == "destination content"


async def test_move_userdata_full_info(aiohttp_client, app, tmp_path):
# Create initial file
with open(tmp_path / "source.txt", "w") as f:
Expand All @@ -229,3 +322,23 @@ async def test_move_userdata_full_info(aiohttp_client, app, tmp_path):
assert not os.path.exists(tmp_path / "source.txt")
with open(tmp_path / "dest.txt", "r") as f:
assert f.read() == "test content"


async def test_move_userdata_full_info_v1(aiohttp_client, app, tmp_path):
# Create initial file
with open(tmp_path / "source.txt", "w") as f:
f.write("test content")

client = await aiohttp_client(app)
resp = await client.post("/v1/userdata/file/move?source=source.txt&dest=dest.txt&full_info=true")

assert resp.status == 200
result = await resp.json()
assert result["path"] == "dest.txt"
assert result["size"] == len("test content")
assert "modified" in result

# Verify file was moved
assert not os.path.exists(tmp_path / "source.txt")
with open(tmp_path / "dest.txt", "r") as f:
assert f.read() == "test content"