Skip to content

Commit d224c0e

Browse files
committed
[api][filebrowser] Complete API redesign with comprehensive REST endpoints, validation, and separation of concerns
- Major architectural refactoring of the Hue filebrowser introducing a modern, comprehensive REST API with enhanced validation, error handling, and multi-filesystem support. ## Key Changes API Architecture: - 17 new REST endpoints with resource-based design (/api/v1/storage/*) - FileAPI, DirectoryAPI with CRUD operations - Comprehensive operation endpoints (exists, copy, move, delete, permissions etc) Validation Framework: - Pydantic schemas with security-first validation - DRF serializers wrapping Pydantic for multi-layer validation - Path traversal protection, file extension restrictions, size limits etc Business Logic Redesign: - Pure, filesystem-agnostic operations in operations.py - Strategy Pattern file readers (gzip, bz2, snappy, avro, parquet) - Atomic operations with enhanced error handling Multi-Filesystem Support: - Enhanced ProxyFS for seamless HDFS/S3/Azure/GCS operations - Unified abstraction with consistent behavior across platforms Technical Improvements: - Range request support for file downloads and video streaming - Streaming operations for memory efficiency - Comprehensive logging and structured error responses File Changes: - api.py: Complete REST API implementation - operations.py: Pure business logic functions - schemas.py: Comprehensive Pydantic validation - serializers.py: DRF integration layer - utils.py: Enhanced utilities with FileReader classes - s3fs.py/abfs.py/gs.py: Cloud storage optimizations - api_public_urls_v1.py: New endpoint routing
1 parent c209f6e commit d224c0e

File tree

12 files changed

+2927
-889
lines changed

12 files changed

+2927
-889
lines changed

apps/filebrowser/src/filebrowser/api.py

Lines changed: 505 additions & 666 deletions
Large diffs are not rendered by default.

apps/filebrowser/src/filebrowser/operations.py

Lines changed: 797 additions & 9 deletions
Large diffs are not rendered by default.

apps/filebrowser/src/filebrowser/schemas.py

Lines changed: 529 additions & 0 deletions
Large diffs are not rendered by default.

apps/filebrowser/src/filebrowser/serializers.py

Lines changed: 323 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,26 @@
1717
from pydantic import ValidationError
1818
from rest_framework import serializers
1919

20-
from filebrowser.schemas import RenameSchema
20+
from filebrowser.schemas import (
21+
CheckExistsSchema,
22+
CompressFilesSchema,
23+
CopyOperationSchema,
24+
CreateDirectorySchema,
25+
CreateFileSchema,
26+
DeleteOperationSchema,
27+
ExtractArchiveSchema,
28+
GetFileContentsSchema,
29+
GetStatsSchema,
30+
GetTrashPathSchema,
31+
ListDirectorySchema,
32+
MoveOperationSchema,
33+
RenameSchema,
34+
SaveFileSchema,
35+
SetOwnershipSchema,
36+
SetPermissionsSchema,
37+
SetReplicationSchema,
38+
TrashRestoreSchema,
39+
)
2140

2241

2342
class UploadFileSerializer(serializers.Serializer):
@@ -44,3 +63,306 @@ def validate(self, data):
4463
raise serializers.ValidationError(e.errors())
4564

4665
return data
66+
67+
68+
class GetFileContentsSerializer(serializers.Serializer):
69+
"""
70+
Validates the parameters for the file contents API.
71+
"""
72+
73+
path = serializers.CharField(required=True, allow_blank=False)
74+
# Range specifiers (mutually exclusive)
75+
offset = serializers.IntegerField(required=False, min_value=0)
76+
length = serializers.IntegerField(required=False, min_value=1)
77+
begin = serializers.IntegerField(required=False, min_value=1)
78+
end = serializers.IntegerField(required=False, min_value=1)
79+
# Options
80+
encoding = serializers.CharField(required=False)
81+
compression = serializers.CharField(required=False)
82+
read_until_newline = serializers.BooleanField(default=False)
83+
84+
def validate(self, data):
85+
try:
86+
GetFileContentsSchema.model_validate(data)
87+
except ValidationError as e:
88+
raise serializers.ValidationError(e.errors())
89+
return data
90+
91+
92+
class DownloadFileSerializer(serializers.Serializer):
93+
"""
94+
Validates the parameters for the file download API.
95+
"""
96+
97+
path = serializers.CharField(required=True, allow_blank=False, help_text="Path to the file to download")
98+
disposition = serializers.ChoiceField(
99+
choices=["attachment", "inline"],
100+
default="attachment",
101+
help_text="Content-Disposition type: 'attachment' forces download, 'inline' attempts browser display",
102+
)
103+
104+
def validate_path(self, value):
105+
if not value or value.strip() != value:
106+
raise serializers.ValidationError("Path cannot be empty or contain only whitespace")
107+
108+
if ".." in value or value.startswith("~"):
109+
raise serializers.ValidationError("Invalid path: path traversal patterns are not allowed")
110+
111+
return value
112+
113+
114+
class CreateFileSerializer(serializers.Serializer):
115+
"""
116+
Validates the parameters for the file creation API.
117+
"""
118+
119+
path = serializers.CharField(required=True, allow_blank=False, help_text="Path where the file should be created")
120+
overwrite = serializers.BooleanField(default=False, help_text="Whether to overwrite if file exists")
121+
encoding = serializers.CharField(default="utf-8", help_text="Character encoding for the file")
122+
initial_content = serializers.CharField(required=False, help_text="Initial content to write to the file")
123+
124+
def validate(self, data):
125+
try:
126+
CreateFileSchema.model_validate(data)
127+
except ValidationError as e:
128+
raise serializers.ValidationError(e.errors())
129+
return data
130+
131+
132+
class SaveFileSerializer(serializers.Serializer):
133+
"""
134+
Validates the parameters for the file save/edit API.
135+
"""
136+
137+
path = serializers.CharField(required=True, allow_blank=False, help_text="Path to the file to save")
138+
contents = serializers.CharField(required=True, help_text="File contents to save")
139+
encoding = serializers.CharField(default="utf-8", help_text="Character encoding for the file")
140+
create_parent_dirs = serializers.BooleanField(default=False, help_text="Create parent directories if they don't exist")
141+
142+
def validate(self, data):
143+
try:
144+
SaveFileSchema.model_validate(data)
145+
except ValidationError as e:
146+
raise serializers.ValidationError(e.errors())
147+
return data
148+
149+
150+
class ListDirectorySerializer(serializers.Serializer):
151+
"""
152+
Validates the parameters for the directory listing API.
153+
"""
154+
155+
path = serializers.CharField(default="/", help_text="Directory path to list")
156+
pagenum = serializers.IntegerField(default=1, min_value=1, max_value=10000, help_text="Page number (1-10000)")
157+
pagesize = serializers.IntegerField(default=30, min_value=1, max_value=1000, help_text="Items per page (1-1000)")
158+
sortby = serializers.ChoiceField(
159+
choices=["name", "size", "type", "mtime", "atime", "user", "group"],
160+
default="name",
161+
help_text="Sort by field",
162+
)
163+
descending = serializers.BooleanField(default=False, help_text="Sort in descending order")
164+
filter = serializers.CharField(required=False, max_length=255, help_text="Filter results by filename substring")
165+
166+
def validate(self, data):
167+
try:
168+
ListDirectorySchema.model_validate(data)
169+
except ValidationError as e:
170+
raise serializers.ValidationError(e.errors())
171+
return data
172+
173+
174+
class CreateDirectorySerializer(serializers.Serializer):
175+
"""
176+
Validates the parameters for the directory creation API.
177+
"""
178+
179+
path = serializers.CharField(required=True, allow_blank=False, help_text="Full path of the directory to create")
180+
181+
def validate(self, data):
182+
try:
183+
CreateDirectorySchema.model_validate(data)
184+
except ValidationError as e:
185+
raise serializers.ValidationError(e.errors())
186+
return data
187+
188+
189+
class GetStatsSerializer(serializers.Serializer):
190+
"""
191+
Validates the parameters for the path statistics API.
192+
"""
193+
194+
path = serializers.CharField(required=True, allow_blank=False)
195+
include_content_summary = serializers.BooleanField(default=False)
196+
197+
def validate(self, data):
198+
try:
199+
GetStatsSchema.model_validate(data)
200+
except ValidationError as e:
201+
raise serializers.ValidationError(e.errors())
202+
return data
203+
204+
205+
class CheckExistsSerializer(serializers.Serializer):
206+
"""Serializer for checking if paths exist."""
207+
208+
paths = serializers.ListField(child=serializers.CharField(), required=True, min_length=1)
209+
210+
def validate(self, data):
211+
try:
212+
CheckExistsSchema.model_validate(data)
213+
except ValidationError as e:
214+
raise serializers.ValidationError(e.errors())
215+
return data
216+
217+
218+
class CopyOperationSerializer(serializers.Serializer):
219+
"""Serializer for copy operation."""
220+
221+
source_paths = serializers.ListField(child=serializers.CharField(), required=True, min_length=1)
222+
destination_path = serializers.CharField(required=True, allow_blank=False)
223+
224+
def validate(self, data):
225+
try:
226+
CopyOperationSchema.model_validate(data)
227+
except ValidationError as e:
228+
raise serializers.ValidationError(e.errors())
229+
return data
230+
231+
232+
class MoveOperationSerializer(serializers.Serializer):
233+
"""Serializer for move operation."""
234+
235+
source_paths = serializers.ListField(child=serializers.CharField(), required=True, min_length=1)
236+
destination_path = serializers.CharField(required=True, allow_blank=False)
237+
238+
def validate(self, data):
239+
try:
240+
MoveOperationSchema.model_validate(data)
241+
except ValidationError as e:
242+
raise serializers.ValidationError(e.errors())
243+
return data
244+
245+
246+
class DeleteOperationSerializer(serializers.Serializer):
247+
"""Serializer for delete operation."""
248+
249+
paths = serializers.ListField(child=serializers.CharField(), required=True, min_length=1)
250+
skip_trash = serializers.BooleanField(default=False)
251+
252+
def validate(self, data):
253+
try:
254+
DeleteOperationSchema.model_validate(data)
255+
except ValidationError as e:
256+
raise serializers.ValidationError(e.errors())
257+
return data
258+
259+
260+
class TrashRestoreSerializer(serializers.Serializer):
261+
"""Serializer for restoring from trash."""
262+
263+
paths = serializers.ListField(child=serializers.CharField(), required=True, min_length=1)
264+
265+
def validate(self, data):
266+
try:
267+
TrashRestoreSchema.model_validate(data)
268+
except ValidationError as e:
269+
raise serializers.ValidationError(e.errors())
270+
return data
271+
272+
273+
class SetPermissionsSerializer(serializers.Serializer):
274+
"""Serializer for setting file/directory permissions."""
275+
276+
paths = serializers.ListField(child=serializers.CharField(), required=True, min_length=1)
277+
mode = serializers.CharField(required=False)
278+
recursive = serializers.BooleanField(default=False)
279+
# Individual permission fields
280+
user_read = serializers.BooleanField(required=False)
281+
user_write = serializers.BooleanField(required=False)
282+
user_execute = serializers.BooleanField(required=False)
283+
group_read = serializers.BooleanField(required=False)
284+
group_write = serializers.BooleanField(required=False)
285+
group_execute = serializers.BooleanField(required=False)
286+
other_read = serializers.BooleanField(required=False)
287+
other_write = serializers.BooleanField(required=False)
288+
other_execute = serializers.BooleanField(required=False)
289+
sticky = serializers.BooleanField(required=False)
290+
291+
def validate(self, data):
292+
try:
293+
SetPermissionsSchema.model_validate(data)
294+
except ValidationError as e:
295+
raise serializers.ValidationError(e.errors())
296+
return data
297+
298+
299+
class SetOwnershipSerializer(serializers.Serializer):
300+
"""Serializer for setting file/directory ownership."""
301+
302+
paths = serializers.ListField(child=serializers.CharField(), required=True, min_length=1)
303+
user = serializers.CharField(required=False)
304+
group = serializers.CharField(required=False)
305+
recursive = serializers.BooleanField(default=False)
306+
307+
def validate(self, data):
308+
try:
309+
SetOwnershipSchema.model_validate(data)
310+
except ValidationError as e:
311+
raise serializers.ValidationError(e.errors())
312+
return data
313+
314+
315+
class SetReplicationSerializer(serializers.Serializer):
316+
"""Serializer for setting replication factor."""
317+
318+
path = serializers.CharField(required=True, allow_blank=False)
319+
replication_factor = serializers.IntegerField(required=True, min_value=1)
320+
321+
def validate(self, data):
322+
try:
323+
SetReplicationSchema.model_validate(data)
324+
except ValidationError as e:
325+
raise serializers.ValidationError(e.errors())
326+
return data
327+
328+
329+
class CompressFilesSerializer(serializers.Serializer):
330+
"""Serializer for compressing files."""
331+
332+
file_names = serializers.ListField(child=serializers.CharField(), required=True, min_length=1)
333+
upload_path = serializers.CharField(required=True, allow_blank=False)
334+
archive_name = serializers.CharField(required=True, allow_blank=False)
335+
336+
def validate(self, data):
337+
try:
338+
CompressFilesSchema.model_validate(data)
339+
except ValidationError as e:
340+
raise serializers.ValidationError(e.errors())
341+
return data
342+
343+
344+
class ExtractArchiveSerializer(serializers.Serializer):
345+
"""Serializer for extracting archives."""
346+
347+
upload_path = serializers.CharField(required=True, allow_blank=False)
348+
archive_name = serializers.CharField(required=True, allow_blank=False)
349+
350+
def validate(self, data):
351+
try:
352+
ExtractArchiveSchema.model_validate(data)
353+
except ValidationError as e:
354+
raise serializers.ValidationError(e.errors())
355+
return data
356+
357+
358+
class GetTrashPathSerializer(serializers.Serializer):
359+
"""Serializer for getting trash path."""
360+
361+
path = serializers.CharField(required=True, allow_blank=False)
362+
363+
def validate(self, data):
364+
try:
365+
GetTrashPathSchema.model_validate(data)
366+
except ValidationError as e:
367+
raise serializers.ValidationError(e.errors())
368+
return data

0 commit comments

Comments
 (0)