autoforge-ai 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/check-code.md +32 -0
- package/.claude/commands/checkpoint.md +40 -0
- package/.claude/commands/create-spec.md +613 -0
- package/.claude/commands/expand-project.md +234 -0
- package/.claude/commands/gsd-to-autoforge-spec.md +10 -0
- package/.claude/commands/review-pr.md +75 -0
- package/.claude/templates/app_spec.template.txt +331 -0
- package/.claude/templates/coding_prompt.template.md +265 -0
- package/.claude/templates/initializer_prompt.template.md +354 -0
- package/.claude/templates/testing_prompt.template.md +146 -0
- package/.env.example +64 -0
- package/LICENSE.md +676 -0
- package/README.md +423 -0
- package/agent.py +444 -0
- package/api/__init__.py +10 -0
- package/api/database.py +536 -0
- package/api/dependency_resolver.py +449 -0
- package/api/migration.py +156 -0
- package/auth.py +83 -0
- package/autoforge_paths.py +315 -0
- package/autonomous_agent_demo.py +293 -0
- package/bin/autoforge.js +3 -0
- package/client.py +607 -0
- package/env_constants.py +27 -0
- package/examples/OPTIMIZE_CONFIG.md +230 -0
- package/examples/README.md +531 -0
- package/examples/org_config.yaml +172 -0
- package/examples/project_allowed_commands.yaml +139 -0
- package/lib/cli.js +791 -0
- package/mcp_server/__init__.py +1 -0
- package/mcp_server/feature_mcp.py +988 -0
- package/package.json +53 -0
- package/parallel_orchestrator.py +1800 -0
- package/progress.py +247 -0
- package/prompts.py +427 -0
- package/pyproject.toml +17 -0
- package/rate_limit_utils.py +132 -0
- package/registry.py +614 -0
- package/requirements-prod.txt +14 -0
- package/security.py +959 -0
- package/server/__init__.py +17 -0
- package/server/main.py +261 -0
- package/server/routers/__init__.py +32 -0
- package/server/routers/agent.py +177 -0
- package/server/routers/assistant_chat.py +327 -0
- package/server/routers/devserver.py +309 -0
- package/server/routers/expand_project.py +239 -0
- package/server/routers/features.py +746 -0
- package/server/routers/filesystem.py +514 -0
- package/server/routers/projects.py +524 -0
- package/server/routers/schedules.py +356 -0
- package/server/routers/settings.py +127 -0
- package/server/routers/spec_creation.py +357 -0
- package/server/routers/terminal.py +453 -0
- package/server/schemas.py +593 -0
- package/server/services/__init__.py +36 -0
- package/server/services/assistant_chat_session.py +496 -0
- package/server/services/assistant_database.py +304 -0
- package/server/services/chat_constants.py +57 -0
- package/server/services/dev_server_manager.py +557 -0
- package/server/services/expand_chat_session.py +399 -0
- package/server/services/process_manager.py +657 -0
- package/server/services/project_config.py +475 -0
- package/server/services/scheduler_service.py +683 -0
- package/server/services/spec_chat_session.py +502 -0
- package/server/services/terminal_manager.py +756 -0
- package/server/utils/__init__.py +1 -0
- package/server/utils/process_utils.py +134 -0
- package/server/utils/project_helpers.py +32 -0
- package/server/utils/validation.py +54 -0
- package/server/websocket.py +903 -0
- package/start.py +456 -0
- package/ui/dist/assets/index-8W_wmZzz.js +168 -0
- package/ui/dist/assets/index-B47Ubhox.css +1 -0
- package/ui/dist/assets/vendor-flow-CVNK-_lx.js +7 -0
- package/ui/dist/assets/vendor-query-BUABzP5o.js +1 -0
- package/ui/dist/assets/vendor-radix-DTNNCg2d.js +45 -0
- package/ui/dist/assets/vendor-react-qkC6yhPU.js +1 -0
- package/ui/dist/assets/vendor-utils-COeKbHgx.js +2 -0
- package/ui/dist/assets/vendor-xterm-DP_gxef0.js +16 -0
- package/ui/dist/index.html +23 -0
- package/ui/dist/ollama.png +0 -0
- package/ui/dist/vite.svg +6 -0
- package/ui/package.json +57 -0
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Filesystem Router
|
|
3
|
+
==================
|
|
4
|
+
|
|
5
|
+
API endpoints for browsing the filesystem for project folder selection.
|
|
6
|
+
Provides cross-platform support for Windows, macOS, and Linux.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import functools
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from fastapi import APIRouter, HTTPException, Query
|
|
17
|
+
|
|
18
|
+
from security import SENSITIVE_DIRECTORIES
|
|
19
|
+
|
|
20
|
+
# Module logger
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
from ..schemas import (
|
|
24
|
+
CreateDirectoryRequest,
|
|
25
|
+
DirectoryEntry,
|
|
26
|
+
DirectoryListResponse,
|
|
27
|
+
DriveInfo,
|
|
28
|
+
PathValidationResponse,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
router = APIRouter(prefix="/api/filesystem", tags=["filesystem"])
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# =============================================================================
|
|
35
|
+
# Platform-Specific Blocked Paths
|
|
36
|
+
# =============================================================================
|
|
37
|
+
|
|
38
|
+
# Windows blocked paths
|
|
39
|
+
WINDOWS_BLOCKED = {
|
|
40
|
+
"C:\\Windows",
|
|
41
|
+
"C:\\Program Files",
|
|
42
|
+
"C:\\Program Files (x86)",
|
|
43
|
+
"C:\\ProgramData",
|
|
44
|
+
"C:\\System Volume Information",
|
|
45
|
+
"C:\\$Recycle.Bin",
|
|
46
|
+
"C:\\Recovery",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
# macOS blocked paths
|
|
50
|
+
MACOS_BLOCKED = {
|
|
51
|
+
"/System",
|
|
52
|
+
"/Library",
|
|
53
|
+
"/private",
|
|
54
|
+
"/usr",
|
|
55
|
+
"/bin",
|
|
56
|
+
"/sbin",
|
|
57
|
+
"/etc",
|
|
58
|
+
"/var",
|
|
59
|
+
"/Volumes",
|
|
60
|
+
"/cores",
|
|
61
|
+
"/opt",
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
# Linux blocked paths
|
|
65
|
+
LINUX_BLOCKED = {
|
|
66
|
+
"/etc",
|
|
67
|
+
"/var",
|
|
68
|
+
"/usr",
|
|
69
|
+
"/bin",
|
|
70
|
+
"/sbin",
|
|
71
|
+
"/boot",
|
|
72
|
+
"/proc",
|
|
73
|
+
"/sys",
|
|
74
|
+
"/dev",
|
|
75
|
+
"/root",
|
|
76
|
+
"/lib",
|
|
77
|
+
"/lib64",
|
|
78
|
+
"/run",
|
|
79
|
+
"/tmp",
|
|
80
|
+
"/opt",
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
# Universal blocked paths (relative to home directory).
|
|
84
|
+
# Delegates to the canonical SENSITIVE_DIRECTORIES set in security.py so that
|
|
85
|
+
# the filesystem browser and the EXTRA_READ_PATHS validator share one source of truth.
|
|
86
|
+
UNIVERSAL_BLOCKED_RELATIVE = SENSITIVE_DIRECTORIES
|
|
87
|
+
|
|
88
|
+
# Patterns for files that should not be shown
|
|
89
|
+
HIDDEN_PATTERNS = [
|
|
90
|
+
r"^\.env", # .env files
|
|
91
|
+
r".*\.key$", # Key files
|
|
92
|
+
r".*\.pem$", # PEM files
|
|
93
|
+
r".*credentials.*", # Credential files
|
|
94
|
+
r".*secrets.*", # Secrets files
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@functools.lru_cache(maxsize=1)
|
|
99
|
+
def get_blocked_paths() -> frozenset[Path]:
|
|
100
|
+
"""
|
|
101
|
+
Get the set of blocked paths for the current platform.
|
|
102
|
+
|
|
103
|
+
Cached because the platform and home directory do not change at runtime,
|
|
104
|
+
and this function is called once per directory entry in list_directory().
|
|
105
|
+
"""
|
|
106
|
+
home = Path.home()
|
|
107
|
+
blocked = set()
|
|
108
|
+
|
|
109
|
+
# Add platform-specific blocked paths
|
|
110
|
+
if sys.platform == "win32":
|
|
111
|
+
for p in WINDOWS_BLOCKED:
|
|
112
|
+
blocked.add(Path(p).resolve())
|
|
113
|
+
elif sys.platform == "darwin":
|
|
114
|
+
for p in MACOS_BLOCKED:
|
|
115
|
+
blocked.add(Path(p).resolve())
|
|
116
|
+
else: # Linux
|
|
117
|
+
for p in LINUX_BLOCKED:
|
|
118
|
+
blocked.add(Path(p).resolve())
|
|
119
|
+
|
|
120
|
+
# Add universal blocked paths (relative to home)
|
|
121
|
+
for rel in UNIVERSAL_BLOCKED_RELATIVE:
|
|
122
|
+
blocked.add((home / rel).resolve())
|
|
123
|
+
|
|
124
|
+
return frozenset(blocked)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def is_path_blocked(path: Path) -> bool:
|
|
128
|
+
"""Check if a path is in the blocked list."""
|
|
129
|
+
try:
|
|
130
|
+
resolved = path.resolve()
|
|
131
|
+
except (OSError, ValueError):
|
|
132
|
+
return True # Can't resolve = blocked
|
|
133
|
+
|
|
134
|
+
blocked_paths = get_blocked_paths()
|
|
135
|
+
|
|
136
|
+
# Check if path is exactly a blocked path or inside one
|
|
137
|
+
for blocked in blocked_paths:
|
|
138
|
+
try:
|
|
139
|
+
resolved.relative_to(blocked)
|
|
140
|
+
return True
|
|
141
|
+
except ValueError:
|
|
142
|
+
pass
|
|
143
|
+
|
|
144
|
+
# Also check if blocked is inside path (for parent directories)
|
|
145
|
+
if resolved == blocked:
|
|
146
|
+
return True
|
|
147
|
+
|
|
148
|
+
return False
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def is_hidden_file(path: Path) -> bool:
|
|
152
|
+
"""Check if a file/directory is hidden (cross-platform)."""
|
|
153
|
+
name = path.name
|
|
154
|
+
|
|
155
|
+
# Unix-style: starts with dot
|
|
156
|
+
if name.startswith('.'):
|
|
157
|
+
return True
|
|
158
|
+
|
|
159
|
+
# Windows: check FILE_ATTRIBUTE_HIDDEN
|
|
160
|
+
if sys.platform == "win32":
|
|
161
|
+
try:
|
|
162
|
+
import ctypes
|
|
163
|
+
attrs = ctypes.windll.kernel32.GetFileAttributesW(str(path))
|
|
164
|
+
if attrs != -1 and (attrs & 0x02): # FILE_ATTRIBUTE_HIDDEN
|
|
165
|
+
return True
|
|
166
|
+
except Exception:
|
|
167
|
+
pass
|
|
168
|
+
|
|
169
|
+
return False
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def matches_blocked_pattern(name: str) -> bool:
|
|
173
|
+
"""Check if filename matches a blocked pattern."""
|
|
174
|
+
for pattern in HIDDEN_PATTERNS:
|
|
175
|
+
if re.match(pattern, name, re.IGNORECASE):
|
|
176
|
+
return True
|
|
177
|
+
return False
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def is_unc_path(path_str: str) -> bool:
|
|
181
|
+
"""Check if path is a Windows UNC path (network share)."""
|
|
182
|
+
return path_str.startswith("\\\\") or path_str.startswith("//")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# =============================================================================
|
|
186
|
+
# Endpoints
|
|
187
|
+
# =============================================================================
|
|
188
|
+
|
|
189
|
+
@router.get("/list", response_model=DirectoryListResponse)
|
|
190
|
+
async def list_directory(
|
|
191
|
+
path: str | None = Query(None, description="Directory path to list (defaults to home)"),
|
|
192
|
+
show_hidden: bool = Query(False, description="Include hidden files"),
|
|
193
|
+
):
|
|
194
|
+
"""
|
|
195
|
+
List contents of a directory.
|
|
196
|
+
|
|
197
|
+
Returns directories only (for folder selection).
|
|
198
|
+
On Windows, includes available drives.
|
|
199
|
+
"""
|
|
200
|
+
# Default to home directory
|
|
201
|
+
if path is None or path == "":
|
|
202
|
+
target = Path.home()
|
|
203
|
+
else:
|
|
204
|
+
# Security: Block UNC paths
|
|
205
|
+
if is_unc_path(path):
|
|
206
|
+
logger.warning("Blocked UNC path access attempt: %s", path)
|
|
207
|
+
raise HTTPException(
|
|
208
|
+
status_code=403,
|
|
209
|
+
detail="Network paths (UNC) are not allowed"
|
|
210
|
+
)
|
|
211
|
+
target = Path(path)
|
|
212
|
+
|
|
213
|
+
# Resolve symlinks and get absolute path
|
|
214
|
+
try:
|
|
215
|
+
target = target.resolve()
|
|
216
|
+
except (OSError, ValueError) as e:
|
|
217
|
+
raise HTTPException(status_code=400, detail=f"Invalid path: {e}")
|
|
218
|
+
|
|
219
|
+
# Security: Check if path is blocked
|
|
220
|
+
if is_path_blocked(target):
|
|
221
|
+
logger.warning("Blocked access to restricted path: %s", target)
|
|
222
|
+
raise HTTPException(
|
|
223
|
+
status_code=403,
|
|
224
|
+
detail="Access to this directory is not allowed"
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
# Check if path exists and is a directory
|
|
228
|
+
if not target.exists():
|
|
229
|
+
raise HTTPException(status_code=404, detail="Directory not found")
|
|
230
|
+
|
|
231
|
+
if not target.is_dir():
|
|
232
|
+
raise HTTPException(status_code=400, detail="Path is not a directory")
|
|
233
|
+
|
|
234
|
+
# Check read permission
|
|
235
|
+
if not os.access(target, os.R_OK):
|
|
236
|
+
raise HTTPException(status_code=403, detail="No read permission")
|
|
237
|
+
|
|
238
|
+
# List directory contents
|
|
239
|
+
entries = []
|
|
240
|
+
try:
|
|
241
|
+
for item in sorted(target.iterdir(), key=lambda x: x.name.lower()):
|
|
242
|
+
# Skip if blocked pattern
|
|
243
|
+
if matches_blocked_pattern(item.name):
|
|
244
|
+
continue
|
|
245
|
+
|
|
246
|
+
# Check if hidden
|
|
247
|
+
hidden = is_hidden_file(item)
|
|
248
|
+
if hidden and not show_hidden:
|
|
249
|
+
continue
|
|
250
|
+
|
|
251
|
+
# Security: Skip if item path is blocked
|
|
252
|
+
if is_path_blocked(item):
|
|
253
|
+
continue
|
|
254
|
+
|
|
255
|
+
# Only include directories for folder browsing
|
|
256
|
+
if item.is_dir():
|
|
257
|
+
try:
|
|
258
|
+
# Check if directory has any subdirectories
|
|
259
|
+
has_children = False
|
|
260
|
+
try:
|
|
261
|
+
for child in item.iterdir():
|
|
262
|
+
if child.is_dir() and not is_path_blocked(child):
|
|
263
|
+
has_children = True
|
|
264
|
+
break
|
|
265
|
+
except (PermissionError, OSError):
|
|
266
|
+
pass # Can't read = assume no children
|
|
267
|
+
|
|
268
|
+
entries.append(DirectoryEntry(
|
|
269
|
+
name=item.name,
|
|
270
|
+
path=item.as_posix(),
|
|
271
|
+
is_directory=True,
|
|
272
|
+
is_hidden=hidden,
|
|
273
|
+
size=None,
|
|
274
|
+
has_children=has_children,
|
|
275
|
+
))
|
|
276
|
+
except Exception:
|
|
277
|
+
pass # Skip items we can't process
|
|
278
|
+
|
|
279
|
+
except PermissionError:
|
|
280
|
+
raise HTTPException(status_code=403, detail="Permission denied")
|
|
281
|
+
except OSError as e:
|
|
282
|
+
raise HTTPException(status_code=500, detail=f"Error reading directory: {e}")
|
|
283
|
+
|
|
284
|
+
# Calculate parent path
|
|
285
|
+
parent_path = None
|
|
286
|
+
if target != target.parent: # Not at root
|
|
287
|
+
parent = target.parent
|
|
288
|
+
# Don't expose parent if it's blocked
|
|
289
|
+
if not is_path_blocked(parent):
|
|
290
|
+
parent_path = parent.as_posix()
|
|
291
|
+
|
|
292
|
+
# Get drives on Windows
|
|
293
|
+
drives = None
|
|
294
|
+
if sys.platform == "win32":
|
|
295
|
+
drives = get_windows_drives()
|
|
296
|
+
|
|
297
|
+
return DirectoryListResponse(
|
|
298
|
+
current_path=target.as_posix(),
|
|
299
|
+
parent_path=parent_path,
|
|
300
|
+
entries=entries,
|
|
301
|
+
drives=drives,
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@router.get("/drives", response_model=list[DriveInfo] | None)
|
|
306
|
+
async def list_drives():
|
|
307
|
+
"""
|
|
308
|
+
List available drives (Windows only).
|
|
309
|
+
|
|
310
|
+
Returns null on non-Windows platforms.
|
|
311
|
+
"""
|
|
312
|
+
if sys.platform != "win32":
|
|
313
|
+
return None
|
|
314
|
+
|
|
315
|
+
return get_windows_drives()
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def get_windows_drives() -> list[DriveInfo]:
|
|
319
|
+
"""Get list of available drives on Windows."""
|
|
320
|
+
drives = []
|
|
321
|
+
|
|
322
|
+
try:
|
|
323
|
+
import ctypes
|
|
324
|
+
import string
|
|
325
|
+
|
|
326
|
+
# Get bitmask of available drives
|
|
327
|
+
bitmask = ctypes.windll.kernel32.GetLogicalDrives()
|
|
328
|
+
|
|
329
|
+
for i, letter in enumerate(string.ascii_uppercase):
|
|
330
|
+
if bitmask & (1 << i):
|
|
331
|
+
drive_path = f"{letter}:\\"
|
|
332
|
+
try:
|
|
333
|
+
# Try to get volume label
|
|
334
|
+
volume_name = ctypes.create_unicode_buffer(1024)
|
|
335
|
+
ctypes.windll.kernel32.GetVolumeInformationW(
|
|
336
|
+
drive_path,
|
|
337
|
+
volume_name,
|
|
338
|
+
1024,
|
|
339
|
+
None, None, None, None, 0
|
|
340
|
+
)
|
|
341
|
+
label = volume_name.value or f"Local Disk ({letter}:)"
|
|
342
|
+
except Exception:
|
|
343
|
+
label = f"Drive ({letter}:)"
|
|
344
|
+
|
|
345
|
+
# Check if drive is accessible
|
|
346
|
+
available = os.path.exists(drive_path)
|
|
347
|
+
|
|
348
|
+
drives.append(DriveInfo(
|
|
349
|
+
letter=letter,
|
|
350
|
+
label=label,
|
|
351
|
+
available=available,
|
|
352
|
+
))
|
|
353
|
+
except Exception:
|
|
354
|
+
# Fallback: just list C: drive
|
|
355
|
+
drives.append(DriveInfo(letter="C", label="Local Disk (C:)", available=True))
|
|
356
|
+
|
|
357
|
+
return drives
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
@router.post("/validate", response_model=PathValidationResponse)
|
|
361
|
+
async def validate_path(path: str = Query(..., description="Path to validate")):
|
|
362
|
+
"""
|
|
363
|
+
Validate if a path is accessible and writable.
|
|
364
|
+
|
|
365
|
+
Used to check a path before creating a project there.
|
|
366
|
+
"""
|
|
367
|
+
# Security: Block UNC paths
|
|
368
|
+
if is_unc_path(path):
|
|
369
|
+
return PathValidationResponse(
|
|
370
|
+
valid=False,
|
|
371
|
+
exists=False,
|
|
372
|
+
is_directory=False,
|
|
373
|
+
can_read=False,
|
|
374
|
+
can_write=False,
|
|
375
|
+
message="Network paths (UNC) are not allowed",
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
try:
|
|
379
|
+
target = Path(path).resolve()
|
|
380
|
+
except (OSError, ValueError) as e:
|
|
381
|
+
return PathValidationResponse(
|
|
382
|
+
valid=False,
|
|
383
|
+
exists=False,
|
|
384
|
+
is_directory=False,
|
|
385
|
+
can_read=False,
|
|
386
|
+
can_write=False,
|
|
387
|
+
message=f"Invalid path: {e}",
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
# Security: Check if blocked
|
|
391
|
+
if is_path_blocked(target):
|
|
392
|
+
return PathValidationResponse(
|
|
393
|
+
valid=False,
|
|
394
|
+
exists=target.exists(),
|
|
395
|
+
is_directory=target.is_dir() if target.exists() else False,
|
|
396
|
+
can_read=False,
|
|
397
|
+
can_write=False,
|
|
398
|
+
message="Access to this directory is not allowed",
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
exists = target.exists()
|
|
402
|
+
is_dir = target.is_dir() if exists else False
|
|
403
|
+
can_read = os.access(target, os.R_OK) if exists else False
|
|
404
|
+
can_write = os.access(target, os.W_OK) if exists else False
|
|
405
|
+
|
|
406
|
+
# For non-existent paths, check if parent is writable
|
|
407
|
+
if not exists:
|
|
408
|
+
parent = target.parent
|
|
409
|
+
parent_exists = parent.exists()
|
|
410
|
+
parent_writable = os.access(parent, os.W_OK) if parent_exists else False
|
|
411
|
+
can_write = parent_writable
|
|
412
|
+
|
|
413
|
+
valid = is_dir and can_read and can_write if exists else can_write
|
|
414
|
+
message = ""
|
|
415
|
+
if not exists:
|
|
416
|
+
message = "Directory does not exist (will be created)"
|
|
417
|
+
elif not is_dir:
|
|
418
|
+
message = "Path is not a directory"
|
|
419
|
+
elif not can_read:
|
|
420
|
+
message = "No read permission"
|
|
421
|
+
elif not can_write:
|
|
422
|
+
message = "No write permission"
|
|
423
|
+
|
|
424
|
+
return PathValidationResponse(
|
|
425
|
+
valid=valid,
|
|
426
|
+
exists=exists,
|
|
427
|
+
is_directory=is_dir,
|
|
428
|
+
can_read=can_read,
|
|
429
|
+
can_write=can_write,
|
|
430
|
+
message=message,
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
@router.post("/create-directory")
|
|
435
|
+
async def create_directory(request: CreateDirectoryRequest):
|
|
436
|
+
"""
|
|
437
|
+
Create a new directory inside a parent directory.
|
|
438
|
+
|
|
439
|
+
Used for creating project folders from the folder browser.
|
|
440
|
+
"""
|
|
441
|
+
# Validate directory name
|
|
442
|
+
name = request.name.strip()
|
|
443
|
+
if not name:
|
|
444
|
+
raise HTTPException(status_code=400, detail="Directory name cannot be empty")
|
|
445
|
+
|
|
446
|
+
# Security: Block special directory names that could enable traversal
|
|
447
|
+
if name in ('.', '..') or '..' in name:
|
|
448
|
+
raise HTTPException(
|
|
449
|
+
status_code=400,
|
|
450
|
+
detail="Invalid directory name"
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
# Security: Check for invalid characters
|
|
454
|
+
invalid_chars = '<>:"/\\|?*' if sys.platform == "win32" else '/'
|
|
455
|
+
if any(c in name for c in invalid_chars):
|
|
456
|
+
raise HTTPException(
|
|
457
|
+
status_code=400,
|
|
458
|
+
detail="Directory name contains invalid characters"
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
# Security: Block UNC paths
|
|
462
|
+
if is_unc_path(request.parent_path):
|
|
463
|
+
raise HTTPException(status_code=403, detail="Network paths are not allowed")
|
|
464
|
+
|
|
465
|
+
try:
|
|
466
|
+
parent = Path(request.parent_path).resolve()
|
|
467
|
+
except (OSError, ValueError) as e:
|
|
468
|
+
raise HTTPException(status_code=400, detail=f"Invalid parent path: {e}")
|
|
469
|
+
|
|
470
|
+
# Security: Check if parent is blocked
|
|
471
|
+
if is_path_blocked(parent):
|
|
472
|
+
raise HTTPException(
|
|
473
|
+
status_code=403,
|
|
474
|
+
detail="Cannot create directory in this location"
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
# Check parent exists and is writable
|
|
478
|
+
if not parent.exists():
|
|
479
|
+
raise HTTPException(status_code=404, detail="Parent directory not found")
|
|
480
|
+
|
|
481
|
+
if not parent.is_dir():
|
|
482
|
+
raise HTTPException(status_code=400, detail="Parent path is not a directory")
|
|
483
|
+
|
|
484
|
+
if not os.access(parent, os.W_OK):
|
|
485
|
+
raise HTTPException(status_code=403, detail="No write permission")
|
|
486
|
+
|
|
487
|
+
# Create the new directory
|
|
488
|
+
new_dir = parent / name
|
|
489
|
+
|
|
490
|
+
if new_dir.exists():
|
|
491
|
+
raise HTTPException(status_code=409, detail="Directory already exists")
|
|
492
|
+
|
|
493
|
+
try:
|
|
494
|
+
new_dir.mkdir(parents=False, exist_ok=False)
|
|
495
|
+
logger.info("Created directory: %s", new_dir)
|
|
496
|
+
except OSError as e:
|
|
497
|
+
logger.error("Failed to create directory %s: %s", new_dir, e)
|
|
498
|
+
raise HTTPException(status_code=500, detail=f"Failed to create directory: {e}")
|
|
499
|
+
|
|
500
|
+
return {
|
|
501
|
+
"success": True,
|
|
502
|
+
"path": new_dir.as_posix(),
|
|
503
|
+
"message": f"Created directory: {name}",
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
@router.get("/home")
|
|
508
|
+
async def get_home_directory():
|
|
509
|
+
"""Get the user's home directory path."""
|
|
510
|
+
home = Path.home()
|
|
511
|
+
return {
|
|
512
|
+
"path": home.as_posix(),
|
|
513
|
+
"display_path": str(home),
|
|
514
|
+
}
|