diff --git a/app/user_manager.py b/app/user_manager.py index e7381e621d9..7b884537113 100644 --- a/app/user_manager.py +++ b/app/user_manager.py @@ -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) @@ -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): """ @@ -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) @@ -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): """ @@ -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) diff --git a/tests-unit/prompt_server_test/user_manager_test.py b/tests-unit/prompt_server_test/user_manager_test.py index 7e523cbf486..e965a0d21ec 100644 --- a/tests-unit/prompt_server_test/user_manager_test.py +++ b/tests-unit/prompt_server_test/user_manager_test.py @@ -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) + + 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: @@ -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: @@ -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" @@ -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: @@ -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: @@ -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: @@ -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"