Skip to content

Commit 8115a78

Browse files
Add /api/v2/userdata endpoint (#7817)
* Add list_userdata_v2 * nit * nit * nit * nit * please set me free * \\\\ * \\\\
1 parent c8cd7ad commit 8115a78

File tree

2 files changed

+164
-0
lines changed

2 files changed

+164
-0
lines changed

app/user_manager.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,112 @@ def process_full_path(full_path: str) -> FileInfo | str | list[str]:
197197

198198
return web.json_response(results)
199199

200+
@routes.get("/v2/userdata")
201+
async def list_userdata_v2(request):
202+
"""
203+
List files and directories in a user's data directory.
204+
205+
This endpoint provides a structured listing of contents within a specified
206+
subdirectory of the user's data storage.
207+
208+
Query Parameters:
209+
- path (optional): The relative path within the user's data directory
210+
to list. Defaults to the root ('').
211+
212+
Returns:
213+
- 400: If the requested path is invalid, outside the user's data directory, or is not a directory.
214+
- 404: If the requested path does not exist.
215+
- 403: If the user is invalid.
216+
- 500: If there is an error reading the directory contents.
217+
- 200: JSON response containing a list of file and directory objects.
218+
Each object includes:
219+
- name: The name of the file or directory.
220+
- type: 'file' or 'directory'.
221+
- path: The relative path from the user's data root.
222+
- size (for files): The size in bytes.
223+
- modified (for files): The last modified timestamp (Unix epoch).
224+
"""
225+
requested_rel_path = request.rel_url.query.get('path', '')
226+
227+
# URL-decode the path parameter
228+
try:
229+
requested_rel_path = parse.unquote(requested_rel_path)
230+
except Exception as e:
231+
logging.warning(f"Failed to decode path parameter: {requested_rel_path}, Error: {e}")
232+
return web.Response(status=400, text="Invalid characters in path parameter")
233+
234+
235+
# Check user validity and get the absolute path for the requested directory
236+
try:
237+
base_user_path = self.get_request_user_filepath(request, None, create_dir=False)
238+
239+
if requested_rel_path:
240+
target_abs_path = self.get_request_user_filepath(request, requested_rel_path, create_dir=False)
241+
else:
242+
target_abs_path = base_user_path
243+
244+
except KeyError as e:
245+
# Invalid user detected by get_request_user_id inside get_request_user_filepath
246+
logging.warning(f"Access denied for user: {e}")
247+
return web.Response(status=403, text="Invalid user specified in request")
248+
249+
250+
if not target_abs_path:
251+
# Path traversal or other issue detected by get_request_user_filepath
252+
return web.Response(status=400, text="Invalid path requested")
253+
254+
# Handle cases where the user directory or target path doesn't exist
255+
if not os.path.exists(target_abs_path):
256+
# Check if it's the base user directory that's missing (new user case)
257+
if target_abs_path == base_user_path:
258+
# It's okay if the base user directory doesn't exist yet, return empty list
259+
return web.json_response([])
260+
else:
261+
# A specific subdirectory was requested but doesn't exist
262+
return web.Response(status=404, text="Requested path not found")
263+
264+
if not os.path.isdir(target_abs_path):
265+
return web.Response(status=400, text="Requested path is not a directory")
266+
267+
results = []
268+
try:
269+
for root, dirs, files in os.walk(target_abs_path, topdown=True):
270+
# Process directories
271+
for dir_name in dirs:
272+
dir_path = os.path.join(root, dir_name)
273+
rel_path = os.path.relpath(dir_path, base_user_path).replace(os.sep, '/')
274+
results.append({
275+
"name": dir_name,
276+
"path": rel_path,
277+
"type": "directory"
278+
})
279+
280+
# Process files
281+
for file_name in files:
282+
file_path = os.path.join(root, file_name)
283+
rel_path = os.path.relpath(file_path, base_user_path).replace(os.sep, '/')
284+
entry_info = {
285+
"name": file_name,
286+
"path": rel_path,
287+
"type": "file"
288+
}
289+
try:
290+
stats = os.stat(file_path) # Use os.stat for potentially better performance with os.walk
291+
entry_info["size"] = stats.st_size
292+
entry_info["modified"] = stats.st_mtime
293+
except OSError as stat_error:
294+
logging.warning(f"Could not stat file {file_path}: {stat_error}")
295+
pass # Include file with available info
296+
results.append(entry_info)
297+
except OSError as e:
298+
logging.error(f"Error listing directory {target_abs_path}: {e}")
299+
return web.Response(status=500, text="Error reading directory contents")
300+
301+
# Sort results alphabetically, directories first then files
302+
results.sort(key=lambda x: (x['type'] != 'directory', x['name'].lower()))
303+
304+
return web.json_response(results)
305+
200306
def get_user_data_path(request, check_exists = False, param = "file"):
201307
file = request.match_info.get(param, None)
202308
if not file:

tests-unit/prompt_server_test/user_manager_test.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,3 +229,61 @@ async def test_move_userdata_full_info(aiohttp_client, app, tmp_path):
229229
assert not os.path.exists(tmp_path / "source.txt")
230230
with open(tmp_path / "dest.txt", "r") as f:
231231
assert f.read() == "test content"
232+
233+
234+
async def test_listuserdata_v2_empty_root(aiohttp_client, app):
235+
client = await aiohttp_client(app)
236+
resp = await client.get("/v2/userdata")
237+
assert resp.status == 200
238+
assert await resp.json() == []
239+
240+
241+
async def test_listuserdata_v2_nonexistent_subdirectory(aiohttp_client, app):
242+
client = await aiohttp_client(app)
243+
resp = await client.get("/v2/userdata?path=does_not_exist")
244+
assert resp.status == 404
245+
246+
247+
async def test_listuserdata_v2_default(aiohttp_client, app, tmp_path):
248+
os.makedirs(tmp_path / "test_dir" / "subdir")
249+
(tmp_path / "test_dir" / "file1.txt").write_text("content")
250+
(tmp_path / "test_dir" / "subdir" / "file2.txt").write_text("content")
251+
252+
client = await aiohttp_client(app)
253+
resp = await client.get("/v2/userdata?path=test_dir")
254+
assert resp.status == 200
255+
data = await resp.json()
256+
file_paths = {item["path"] for item in data if item["type"] == "file"}
257+
assert file_paths == {"test_dir/file1.txt", "test_dir/subdir/file2.txt"}
258+
259+
260+
async def test_listuserdata_v2_normalized_separators(aiohttp_client, app, tmp_path, monkeypatch):
261+
# Force backslash as os separator
262+
monkeypatch.setattr(os, 'sep', '\\')
263+
monkeypatch.setattr(os.path, 'sep', '\\')
264+
os.makedirs(tmp_path / "test_dir" / "subdir")
265+
(tmp_path / "test_dir" / "subdir" / "file1.txt").write_text("x")
266+
267+
client = await aiohttp_client(app)
268+
resp = await client.get("/v2/userdata?path=test_dir")
269+
assert resp.status == 200
270+
data = await resp.json()
271+
for item in data:
272+
assert "/" in item["path"]
273+
assert "\\" not in item["path"]\
274+
275+
async def test_listuserdata_v2_url_encoded_path(aiohttp_client, app, tmp_path):
276+
# Create a directory with a space in its name and a file inside
277+
os.makedirs(tmp_path / "my dir")
278+
(tmp_path / "my dir" / "file.txt").write_text("content")
279+
280+
client = await aiohttp_client(app)
281+
# Use URL-encoded space in path parameter
282+
resp = await client.get("/v2/userdata?path=my%20dir&recurse=false")
283+
assert resp.status == 200
284+
data = await resp.json()
285+
assert len(data) == 1
286+
entry = data[0]
287+
assert entry["name"] == "file.txt"
288+
# Ensure the path is correctly decoded and uses forward slash
289+
assert entry["path"] == "my dir/file.txt"

0 commit comments

Comments
 (0)