swiftpy-framework 0.1.0__py3-none-any.whl
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.
- swiftpy/.gitignore +0 -0
- swiftpy/__init__.py +572 -0
- swiftpy/admin/__init__.py +50 -0
- swiftpy/admin/auth.py +522 -0
- swiftpy/admin/registry.py +567 -0
- swiftpy/admin/views.py +1583 -0
- swiftpy/ai/__init__.py +199 -0
- swiftpy/ai/_deterministic.py +40 -0
- swiftpy/ai/_fake.py +604 -0
- swiftpy/ai/agents/__init__.py +30 -0
- swiftpy/ai/agents/base_agent.py +506 -0
- swiftpy/ai/agents/memory.py +864 -0
- swiftpy/ai/client.py +1011 -0
- swiftpy/ai/conversation.py +171 -0
- swiftpy/ai/embeddings.py +321 -0
- swiftpy/ai/exceptions.py +75 -0
- swiftpy/ai/history_strategies.py +157 -0
- swiftpy/ai/middleware.py +918 -0
- swiftpy/ai/observability/__init__.py +28 -0
- swiftpy/ai/observability/drivers.py +763 -0
- swiftpy/ai/observability/observer.py +364 -0
- swiftpy/ai/pool.py +102 -0
- swiftpy/ai/pricing.py +101 -0
- swiftpy/ai/prompts/__init__.py +6 -0
- swiftpy/ai/prompts/loader.py +343 -0
- swiftpy/ai/providers/__init__.py +22 -0
- swiftpy/ai/providers/anthropic_provider.py +353 -0
- swiftpy/ai/providers/base.py +273 -0
- swiftpy/ai/providers/openai_provider.py +275 -0
- swiftpy/ai/rag/__init__.py +53 -0
- swiftpy/ai/rag/context_formatter.py +263 -0
- swiftpy/ai/rag/pipeline_builder.py +544 -0
- swiftpy/ai/rag/reranker.py +484 -0
- swiftpy/ai/rag/result.py +78 -0
- swiftpy/ai/rag/retriever.py +315 -0
- swiftpy/ai/redis_store.py +227 -0
- swiftpy/ai/streaming.py +115 -0
- swiftpy/ai/tools/__init__.py +22 -0
- swiftpy/ai/tools/base_tool.py +489 -0
- swiftpy/ai/tools/first_party.py +384 -0
- swiftpy/ai/tools/tool_loop.py +417 -0
- swiftpy/ai/vectorstore/__init__.py +46 -0
- swiftpy/ai/vectorstore/base.py +542 -0
- swiftpy/ai/vectorstore/chroma_adapter.py +435 -0
- swiftpy/ai/vectorstore/in_memory_adapter.py +321 -0
- swiftpy/ai/vectorstore/lancedb_adapter.py +370 -0
- swiftpy/ai/vectorstore/pgvector_adapter.py +496 -0
- swiftpy/ai/vectorstore/pinecone_adapter.py +445 -0
- swiftpy/ai/vectorstore/qdrant_adapter.py +418 -0
- swiftpy/auth/__init__.py +50 -0
- swiftpy/auth/api_key_guard.py +544 -0
- swiftpy/auth/auth_manager.py +259 -0
- swiftpy/auth/exceptions.py +71 -0
- swiftpy/auth/jwt_guard.py +230 -0
- swiftpy/auth/jwt_issuer.py +658 -0
- swiftpy/auth/token_blacklist.py +300 -0
- swiftpy/cache/__init__.py +14 -0
- swiftpy/cache/cache.py +251 -0
- swiftpy/cache/driver.py +216 -0
- swiftpy/cache/drivers/memory.py +199 -0
- swiftpy/cache/drivers/redis.py +421 -0
- swiftpy/console/__init__.py +0 -0
- swiftpy/console/cli.py +68 -0
- swiftpy/console/commands/__init__.py +0 -0
- swiftpy/console/commands/cache.py +164 -0
- swiftpy/console/commands/make.py +935 -0
- swiftpy/console/commands/migrate.py +280 -0
- swiftpy/console/commands/new.py +1806 -0
- swiftpy/console/commands/queue.py +306 -0
- swiftpy/console/commands/seed.py +252 -0
- swiftpy/console/commands/start.py +171 -0
- swiftpy/console/commands/storage.py +104 -0
- swiftpy/console/loader.py +21 -0
- swiftpy/console/stubs/__init__.py +26 -0
- swiftpy/console/stubs/controller.stub +14 -0
- swiftpy/console/stubs/event.stub +14 -0
- swiftpy/console/stubs/job.stub +22 -0
- swiftpy/console/stubs/listener.stub +14 -0
- swiftpy/console/stubs/mail.stub +20 -0
- swiftpy/console/stubs/middleware.stub +16 -0
- swiftpy/console/stubs/migration.alter.stub +17 -0
- swiftpy/console/stubs/migration.api_keys.stub +22 -0
- swiftpy/console/stubs/migration.blank.stub +11 -0
- swiftpy/console/stubs/migration.create.stub +15 -0
- swiftpy/console/stubs/migration.stub +11 -0
- swiftpy/console/stubs/migration.update.stub +17 -0
- swiftpy/console/stubs/model.stub +17 -0
- swiftpy/console/stubs/provider.stub +18 -0
- swiftpy/console/stubs/repository.stub +10 -0
- swiftpy/console/stubs/request.stub +16 -0
- swiftpy/console/stubs/resource.stub +21 -0
- swiftpy/console/stubs/schema.stub +12 -0
- swiftpy/console/stubs/seeder.stub +14 -0
- swiftpy/console/stubs/service.stub +10 -0
- swiftpy/core/__init__.py +0 -0
- swiftpy/core/application.py +1306 -0
- swiftpy/core/bootstrap.py +30 -0
- swiftpy/core/config.py +432 -0
- swiftpy/core/container.py +596 -0
- swiftpy/core/container_exceptions.py +18 -0
- swiftpy/core/context.py +148 -0
- swiftpy/core/exceptions.py +352 -0
- swiftpy/core/providers/base.py +35 -0
- swiftpy/database/__init__.py +41 -0
- swiftpy/database/exceptions.py +42 -0
- swiftpy/database/identifiers.py +105 -0
- swiftpy/database/migrations/__init__.py +36 -0
- swiftpy/database/migrations/blueprint.py +431 -0
- swiftpy/database/migrations/lock.py +104 -0
- swiftpy/database/migrations/migration.py +22 -0
- swiftpy/database/migrations/migrator.py +507 -0
- swiftpy/database/migrations/schema.py +435 -0
- swiftpy/database/orm/__init__.py +33 -0
- swiftpy/database/orm/fields.py +92 -0
- swiftpy/database/orm/model.py +2034 -0
- swiftpy/database/orm/registry.py +33 -0
- swiftpy/database/orm/relationships.py +836 -0
- swiftpy/database/pagination/__init__.py +16 -0
- swiftpy/database/pagination/contracts.py +60 -0
- swiftpy/database/pagination/paginator.py +112 -0
- swiftpy/database/pagination/simple_paginator.py +97 -0
- swiftpy/database/pool.py +218 -0
- swiftpy/database/query_builder.py +1803 -0
- swiftpy/database/seeder.py +39 -0
- swiftpy/database/transaction.py +276 -0
- swiftpy/events/__init__.py +10 -0
- swiftpy/events/dispatcher.py +308 -0
- swiftpy/events/redis_bus.py +552 -0
- swiftpy/filesystem/__init__.py +227 -0
- swiftpy/filesystem/disk.py +378 -0
- swiftpy/filesystem/drivers/__init__.py +11 -0
- swiftpy/filesystem/drivers/base.py +156 -0
- swiftpy/filesystem/drivers/local.py +559 -0
- swiftpy/filesystem/drivers/s3.py +655 -0
- swiftpy/filesystem/events.py +37 -0
- swiftpy/filesystem/exceptions.py +127 -0
- swiftpy/filesystem/manager.py +138 -0
- swiftpy/filesystem/metadata.py +22 -0
- swiftpy/filesystem/paths.py +99 -0
- swiftpy/filesystem/service_provider.py +17 -0
- swiftpy/filesystem/uploads/__init__.py +13 -0
- swiftpy/filesystem/uploads/handlers.py +362 -0
- swiftpy/filesystem/uploads/uploaded_file.py +222 -0
- swiftpy/filesystem/uploads/validators.py +186 -0
- swiftpy/filesystem/visibility.py +22 -0
- swiftpy/helpers/__init__.py +42 -0
- swiftpy/helpers/helper.py +180 -0
- swiftpy/http/exceptions.py +41 -0
- swiftpy/http/middleware.py +307 -0
- swiftpy/http/request.py +375 -0
- swiftpy/http/response.py +442 -0
- swiftpy/http/router.py +1050 -0
- swiftpy/mail/__init__.py +58 -0
- swiftpy/mail/attachments.py +255 -0
- swiftpy/mail/config.py +71 -0
- swiftpy/mail/envelope.py +183 -0
- swiftpy/mail/exceptions.py +90 -0
- swiftpy/mail/facade.py +312 -0
- swiftpy/mail/mailable.py +53 -0
- swiftpy/mail/manager.py +240 -0
- swiftpy/mail/message.py +186 -0
- swiftpy/mail/models.py +19 -0
- swiftpy/mail/queue/__init__.py +5 -0
- swiftpy/mail/queue/job.py +279 -0
- swiftpy/mail/service_provider.py +19 -0
- swiftpy/mail/templates/__init__.py +5 -0
- swiftpy/mail/templates/renderer.py +173 -0
- swiftpy/mail/transports/__init__.py +21 -0
- swiftpy/mail/transports/api/__init__.py +13 -0
- swiftpy/mail/transports/api/base.py +194 -0
- swiftpy/mail/transports/api/mailgun.py +158 -0
- swiftpy/mail/transports/api/resend.py +156 -0
- swiftpy/mail/transports/api/sendgrid.py +172 -0
- swiftpy/mail/transports/base.py +24 -0
- swiftpy/mail/transports/console.py +91 -0
- swiftpy/mail/transports/sendmail.py +128 -0
- swiftpy/mail/transports/smtp.py +466 -0
- swiftpy/queue/__init__.py +13 -0
- swiftpy/queue/dispatcher.py +813 -0
- swiftpy/queue/job.py +541 -0
- swiftpy/queue/worker.py +556 -0
- swiftpy/support/__init__.py +12 -0
- swiftpy/support/keys.py +108 -0
- swiftpy/support/redis_config.py +223 -0
- swiftpy/support/strings.py +114 -0
- swiftpy/testing/__init__.py +20 -0
- swiftpy/testing/fakes/__init__.py +11 -0
- swiftpy/testing/fakes/ai_fake.py +21 -0
- swiftpy/testing/fakes/vectorstore_fake.py +194 -0
- swiftpy/testing/fixtures.py +124 -0
- swiftpy/testing/http_client.py +730 -0
- swiftpy/testing/test_case.py +126 -0
- swiftpy/validation/__init__.py +97 -0
- swiftpy/validation/exceptions.py +26 -0
- swiftpy/validation/form_request.py +302 -0
- swiftpy/validation/protocols.py +33 -0
- swiftpy/validation/rules/__init__.py +89 -0
- swiftpy/validation/rules/builtin.py +391 -0
- swiftpy/validation/rules/exists.py +86 -0
- swiftpy/validation/rules/registry.py +290 -0
- swiftpy/validation/rules/required.py +55 -0
- swiftpy/validation/rules/unique.py +138 -0
- swiftpy/validation/validator.py +612 -0
- swiftpy/websockets/__init__.py +19 -0
- swiftpy/websockets/connection.py +317 -0
- swiftpy/websockets/manager.py +271 -0
- swiftpy_framework-0.1.0.dist-info/METADATA +603 -0
- swiftpy_framework-0.1.0.dist-info/RECORD +211 -0
- swiftpy_framework-0.1.0.dist-info/WHEEL +4 -0
- swiftpy_framework-0.1.0.dist-info/entry_points.txt +2 -0
- swiftpy_framework-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import builtins
|
|
4
|
+
|
|
5
|
+
from swiftpy.http.exceptions import HttpException, PayloadTooLargeError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FilesystemException(Exception):
|
|
9
|
+
"""Base exception for all filesystem and storage operations."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DiskNotFoundError(FilesystemException, KeyError):
|
|
13
|
+
"""Raised when an unconfigured disk is requested."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class FileNotFoundError(FilesystemException, builtins.FileNotFoundError):
|
|
17
|
+
"""Raised when a requested file does not exist on storage."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class FileAlreadyExistsError(FilesystemException, FileExistsError):
|
|
21
|
+
"""Raised when a file already exists and overwrite is false."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PathTraversalError(FilesystemException, PermissionError):
|
|
25
|
+
"""Raised when a path traversal attempt is detected."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ReadOnlyFileSystemError(FilesystemException, PermissionError):
|
|
29
|
+
"""Raised when a write or delete operation is attempted on a read-only disk."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class StorageQuotaExceededError(FilesystemException):
|
|
33
|
+
"""Raised when storage disk quota or size threshold is exceeded."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class InvalidSignatureError(FilesystemException):
|
|
37
|
+
"""Raised when a signed temporary URL has an invalid signature or is expired."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class DriverNotSupportedError(FilesystemException):
|
|
41
|
+
"""Raised when a driver name is unknown or an operation is unsupported."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class FileUploadError(FilesystemException):
|
|
45
|
+
"""Base exception for HTTP upload operations."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class FileValidationError(FileUploadError, HttpException):
|
|
49
|
+
"""Raised when file upload validation fails. Defaults to HTTP 422."""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
errors: dict[str, list[str]],
|
|
54
|
+
message: str = "The uploaded file was invalid.",
|
|
55
|
+
headers: dict[str, str] | None = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
self.errors = errors
|
|
58
|
+
super().__init__(message=message, status_code=422, headers=headers)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class FileTooLargeError(FileUploadError, PayloadTooLargeError):
|
|
62
|
+
"""Raised when an uploaded file or total request exceeds configured size limits."""
|
|
63
|
+
|
|
64
|
+
def __init__(self, message: str = "Uploaded file payload is too large") -> None:
|
|
65
|
+
super().__init__(message=message)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class InvalidMimeTypeError(FileValidationError):
|
|
69
|
+
"""Raised when an uploaded file's MIME type does not match allowed types."""
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self,
|
|
73
|
+
errors_or_message: str | dict[str, list[str]] = "Invalid file MIME type.",
|
|
74
|
+
message: str | None = None,
|
|
75
|
+
headers: dict[str, str] | None = None,
|
|
76
|
+
field_name: str = "file",
|
|
77
|
+
) -> None:
|
|
78
|
+
errors, resolved_msg = _normalize_validation_args(
|
|
79
|
+
errors_or_message,
|
|
80
|
+
message=message,
|
|
81
|
+
default_message="Invalid file MIME type.",
|
|
82
|
+
field_name=field_name,
|
|
83
|
+
)
|
|
84
|
+
super().__init__(errors=errors, message=resolved_msg, headers=headers)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class InvalidFileExtensionError(FileValidationError):
|
|
88
|
+
"""Raised when an uploaded file's extension is disallowed."""
|
|
89
|
+
|
|
90
|
+
def __init__(
|
|
91
|
+
self,
|
|
92
|
+
errors_or_message: str | dict[str, list[str]] = "Invalid file extension.",
|
|
93
|
+
message: str | None = None,
|
|
94
|
+
headers: dict[str, str] | None = None,
|
|
95
|
+
field_name: str = "file",
|
|
96
|
+
) -> None:
|
|
97
|
+
errors, resolved_msg = _normalize_validation_args(
|
|
98
|
+
errors_or_message,
|
|
99
|
+
message=message,
|
|
100
|
+
default_message="Invalid file extension.",
|
|
101
|
+
field_name=field_name,
|
|
102
|
+
)
|
|
103
|
+
super().__init__(errors=errors, message=resolved_msg, headers=headers)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _normalize_validation_args(
|
|
107
|
+
errors_or_message: str | dict[str, list[str]],
|
|
108
|
+
*,
|
|
109
|
+
message: str | None,
|
|
110
|
+
default_message: str,
|
|
111
|
+
field_name: str,
|
|
112
|
+
) -> tuple[dict[str, list[str]], str]:
|
|
113
|
+
if isinstance(errors_or_message, str):
|
|
114
|
+
resolved_msg = message or errors_or_message
|
|
115
|
+
return {field_name: [resolved_msg]}, resolved_msg
|
|
116
|
+
first_msg = next(
|
|
117
|
+
(msgs[0] for msgs in errors_or_message.values() if msgs),
|
|
118
|
+
default_message,
|
|
119
|
+
)
|
|
120
|
+
return errors_or_message, message or first_msg
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class MalformedMultipartError(FileUploadError, HttpException):
|
|
124
|
+
"""Raised when multipart form body is corrupted or improperly formatted."""
|
|
125
|
+
|
|
126
|
+
def __init__(self, message: str = "Malformed multipart request payload") -> None:
|
|
127
|
+
super().__init__(message=message, status_code=400)
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from swiftpy.core.config import Config
|
|
7
|
+
from swiftpy.core.container import get_container
|
|
8
|
+
from swiftpy.filesystem.disk import StorageDisk
|
|
9
|
+
from swiftpy.filesystem.drivers.base import StorageDriver
|
|
10
|
+
from swiftpy.filesystem.drivers.local import LocalStorageDriver
|
|
11
|
+
from swiftpy.filesystem.drivers.s3 import S3StorageDriver
|
|
12
|
+
from swiftpy.filesystem.exceptions import DiskNotFoundError, DriverNotSupportedError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FilesystemManager:
|
|
16
|
+
"""
|
|
17
|
+
Registry and factory for configured StorageDisk instances.
|
|
18
|
+
Integrates with SwiftPY's configuration repository and DI container.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
_instance: FilesystemManager | None = None
|
|
22
|
+
|
|
23
|
+
def __init__(self, config: Config | None = None) -> None:
|
|
24
|
+
if config is None:
|
|
25
|
+
try:
|
|
26
|
+
container = get_container()
|
|
27
|
+
if container.has(Config):
|
|
28
|
+
self.config = container.resolve(Config)
|
|
29
|
+
else:
|
|
30
|
+
self.config = Config.get_instance()
|
|
31
|
+
except RuntimeError:
|
|
32
|
+
self.config = Config.get_instance()
|
|
33
|
+
else:
|
|
34
|
+
self.config = config
|
|
35
|
+
|
|
36
|
+
self._disks: dict[str, StorageDisk] = {}
|
|
37
|
+
self._custom_creators: dict[str, Callable[[dict[str, Any]], StorageDriver]] = {}
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def get_instance(cls) -> FilesystemManager:
|
|
41
|
+
try:
|
|
42
|
+
container = get_container()
|
|
43
|
+
if container.has(cls):
|
|
44
|
+
return container.resolve(cls)
|
|
45
|
+
except RuntimeError:
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
if cls._instance is None:
|
|
49
|
+
cls._instance = cls()
|
|
50
|
+
return cls._instance
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def default_disk_name(self) -> str:
|
|
54
|
+
return str(self.config.get("filesystems.default", "local"))
|
|
55
|
+
|
|
56
|
+
def extend(
|
|
57
|
+
self,
|
|
58
|
+
driver_name: str,
|
|
59
|
+
factory: Callable[[dict[str, Any]], StorageDriver],
|
|
60
|
+
) -> None:
|
|
61
|
+
"""Register a custom storage driver factory."""
|
|
62
|
+
self._custom_creators[driver_name] = factory
|
|
63
|
+
|
|
64
|
+
def disk(self, name: str | None = None) -> StorageDisk:
|
|
65
|
+
"""Resolve a storage disk instance by name, or return the default disk."""
|
|
66
|
+
disk_name = name or self.default_disk_name
|
|
67
|
+
|
|
68
|
+
if disk_name in self._disks:
|
|
69
|
+
return self._disks[disk_name]
|
|
70
|
+
|
|
71
|
+
disk_instance = self._resolve_disk(disk_name)
|
|
72
|
+
self._disks[disk_name] = disk_instance
|
|
73
|
+
return disk_instance
|
|
74
|
+
|
|
75
|
+
def build(self, disk_config: dict[str, Any], name: str = "on_demand") -> StorageDisk:
|
|
76
|
+
"""Build an unmanaged on-demand storage disk from a configuration dict."""
|
|
77
|
+
driver = self._create_driver(disk_config)
|
|
78
|
+
read_only = bool(disk_config.get("read_only", False))
|
|
79
|
+
prefix = str(disk_config.get("prefix", ""))
|
|
80
|
+
return StorageDisk(name=name, driver=driver, read_only=read_only, prefix=prefix)
|
|
81
|
+
|
|
82
|
+
def _resolve_disk(self, name: str) -> StorageDisk:
|
|
83
|
+
disks = self.config.get("filesystems.disks", {})
|
|
84
|
+
if not isinstance(disks, dict) or name not in disks:
|
|
85
|
+
raise DiskNotFoundError(f"Storage disk '{name}' is not configured.")
|
|
86
|
+
|
|
87
|
+
disk_config = disks[name]
|
|
88
|
+
if not isinstance(disk_config, dict):
|
|
89
|
+
raise DiskNotFoundError(f"Configuration for disk '{name}' must be a dictionary.")
|
|
90
|
+
|
|
91
|
+
driver = self._create_driver(disk_config)
|
|
92
|
+
read_only = bool(disk_config.get("read_only", False))
|
|
93
|
+
prefix = str(disk_config.get("prefix", ""))
|
|
94
|
+
|
|
95
|
+
return StorageDisk(name=name, driver=driver, read_only=read_only, prefix=prefix)
|
|
96
|
+
|
|
97
|
+
def _create_driver(self, disk_config: dict[str, Any]) -> StorageDriver:
|
|
98
|
+
driver_type = str(disk_config.get("driver", "local")).lower()
|
|
99
|
+
|
|
100
|
+
if driver_type in self._custom_creators:
|
|
101
|
+
return self._custom_creators[driver_type](disk_config)
|
|
102
|
+
|
|
103
|
+
if driver_type == "local":
|
|
104
|
+
root = str(disk_config.get("root", "storage/app/private"))
|
|
105
|
+
url_prefix = str(disk_config.get("url", "/storage"))
|
|
106
|
+
visibility = str(disk_config.get("visibility", "private"))
|
|
107
|
+
raw_signing_key = self.config.get("filesystems.signing_key")
|
|
108
|
+
if raw_signing_key is None or (
|
|
109
|
+
isinstance(raw_signing_key, str)
|
|
110
|
+
and (not raw_signing_key.strip() or raw_signing_key.strip().lower() == "none")
|
|
111
|
+
):
|
|
112
|
+
raw_signing_key = self.config.get("app.key")
|
|
113
|
+
signing_key = (
|
|
114
|
+
str(raw_signing_key)
|
|
115
|
+
if isinstance(raw_signing_key, str)
|
|
116
|
+
and raw_signing_key.strip()
|
|
117
|
+
and raw_signing_key.strip().lower() != "none"
|
|
118
|
+
else None
|
|
119
|
+
)
|
|
120
|
+
permissions = disk_config.get("permissions")
|
|
121
|
+
return LocalStorageDriver(
|
|
122
|
+
root=root,
|
|
123
|
+
url_prefix=url_prefix,
|
|
124
|
+
default_visibility=visibility,
|
|
125
|
+
signing_key=signing_key,
|
|
126
|
+
permissions=permissions if isinstance(permissions, dict) else None,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
if driver_type == "s3":
|
|
130
|
+
return S3StorageDriver.from_config(disk_config)
|
|
131
|
+
|
|
132
|
+
raise DriverNotSupportedError(f"Unsupported storage driver: '{driver_type}'")
|
|
133
|
+
|
|
134
|
+
async def close(self) -> None:
|
|
135
|
+
"""Close and clean up all instantiated storage drivers."""
|
|
136
|
+
for disk_instance in self._disks.values():
|
|
137
|
+
await disk_instance.driver.close()
|
|
138
|
+
self._disks.clear()
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from swiftpy.filesystem.visibility import Visibility
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(slots=True, frozen=True)
|
|
11
|
+
class FileMetadata:
|
|
12
|
+
"""Unified metadata representation for a stored file."""
|
|
13
|
+
|
|
14
|
+
path: str
|
|
15
|
+
size: int
|
|
16
|
+
mime_type: str
|
|
17
|
+
last_modified: datetime
|
|
18
|
+
visibility: Visibility | str = Visibility.PRIVATE
|
|
19
|
+
etag: str | None = None
|
|
20
|
+
checksum: str | None = None
|
|
21
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
22
|
+
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import uuid
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from urllib.parse import unquote
|
|
8
|
+
|
|
9
|
+
from swiftpy.filesystem.exceptions import PathTraversalError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PathNormalizer:
|
|
13
|
+
"""Security-focused path normalization and collision-resistant name generation."""
|
|
14
|
+
|
|
15
|
+
_SUSPICIOUS_ENCODED = re.compile(r"%2e|%2f|%5c", re.IGNORECASE)
|
|
16
|
+
_SAFE_EXT = re.compile(r"^[a-zA-Z0-9]+$")
|
|
17
|
+
_WINDOWS_DRIVE = re.compile(r"^[a-zA-Z]:(?:[/\\]|$)", re.IGNORECASE)
|
|
18
|
+
|
|
19
|
+
@classmethod
|
|
20
|
+
def normalize(cls, path: str) -> str:
|
|
21
|
+
"""
|
|
22
|
+
Normalize a relative storage path.
|
|
23
|
+
Strips leading/trailing slashes, normalizes separators to '/',
|
|
24
|
+
and rejects path traversal attempts.
|
|
25
|
+
"""
|
|
26
|
+
if "\x00" in path:
|
|
27
|
+
raise PathTraversalError("Null byte detected in storage path.")
|
|
28
|
+
|
|
29
|
+
# Reject Windows drive letters (e.g., C:\, C:/, D:)
|
|
30
|
+
if cls._WINDOWS_DRIVE.match(path):
|
|
31
|
+
raise PathTraversalError(f"Windows drive letter detected in storage path: '{path}'.")
|
|
32
|
+
|
|
33
|
+
# Check for URL-encoded and double-encoded traversal indicators
|
|
34
|
+
decoded = unquote(path)
|
|
35
|
+
double_decoded = unquote(decoded)
|
|
36
|
+
if "\x00" in decoded or "\x00" in double_decoded:
|
|
37
|
+
raise PathTraversalError("Null byte detected in storage path.")
|
|
38
|
+
|
|
39
|
+
if ".." in decoded or ".." in double_decoded:
|
|
40
|
+
raise PathTraversalError(f"Encoded path traversal sequence detected in '{path}'.")
|
|
41
|
+
|
|
42
|
+
if (cls._SUSPICIOUS_ENCODED.search(path) or cls._SUSPICIOUS_ENCODED.search(decoded)) and (
|
|
43
|
+
"/" in decoded or "\\" in decoded or "/" in double_decoded or "\\" in double_decoded
|
|
44
|
+
):
|
|
45
|
+
raise PathTraversalError("Encoded path traversal characters detected.")
|
|
46
|
+
|
|
47
|
+
clean = path.replace("\\", "/")
|
|
48
|
+
parts = [p for p in clean.split("/") if p and p != "."]
|
|
49
|
+
|
|
50
|
+
for part in parts:
|
|
51
|
+
if part == "..":
|
|
52
|
+
raise PathTraversalError(f"Path traversal sequence '..' detected in '{path}'.")
|
|
53
|
+
if re.match(r"^[a-zA-Z]:$", part, re.IGNORECASE):
|
|
54
|
+
raise PathTraversalError(f"Windows drive letter detected in storage path: '{path}'.")
|
|
55
|
+
|
|
56
|
+
return "/".join(parts)
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def resolve_safe_path(cls, root: str | Path, path: str) -> Path:
|
|
60
|
+
"""
|
|
61
|
+
Resolve a relative path against a storage root and strictly ensure
|
|
62
|
+
it does not escape the root via symlinks or relative references.
|
|
63
|
+
"""
|
|
64
|
+
clean_rel = cls.normalize(path)
|
|
65
|
+
root_path = Path(root).resolve()
|
|
66
|
+
target = (root_path / clean_rel).resolve()
|
|
67
|
+
|
|
68
|
+
if target != root_path and not target.is_relative_to(root_path):
|
|
69
|
+
raise PathTraversalError(
|
|
70
|
+
f"Storage path '{path}' escapes configured root '{root}'."
|
|
71
|
+
)
|
|
72
|
+
return target
|
|
73
|
+
|
|
74
|
+
@classmethod
|
|
75
|
+
def generate_name(
|
|
76
|
+
cls,
|
|
77
|
+
original_filename: str | None = None,
|
|
78
|
+
prefix: str = "",
|
|
79
|
+
extension: str | None = None,
|
|
80
|
+
) -> str:
|
|
81
|
+
"""
|
|
82
|
+
Generate a cryptographically random, collision-resistant storage filename.
|
|
83
|
+
Preserves or overrides a sanitized extension.
|
|
84
|
+
"""
|
|
85
|
+
random_hex = uuid.uuid4().hex
|
|
86
|
+
ext = extension
|
|
87
|
+
|
|
88
|
+
if ext is None and original_filename:
|
|
89
|
+
sanitized = original_filename.replace("\x00", "")
|
|
90
|
+
_, raw_ext = os.path.splitext(sanitized)
|
|
91
|
+
candidate = raw_ext.lstrip(".").lower()
|
|
92
|
+
if cls._SAFE_EXT.match(candidate):
|
|
93
|
+
ext = candidate
|
|
94
|
+
|
|
95
|
+
filename = f"{prefix}{random_hex}"
|
|
96
|
+
if ext:
|
|
97
|
+
filename = f"{filename}.{ext}"
|
|
98
|
+
return filename
|
|
99
|
+
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from swiftpy.core.providers.base import ServiceProvider
|
|
4
|
+
from swiftpy.filesystem.manager import FilesystemManager
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class FilesystemServiceProvider(ServiceProvider):
|
|
8
|
+
"""
|
|
9
|
+
Registers the FilesystemManager singleton into the application DI container.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
def register(self) -> None:
|
|
13
|
+
self.container.singleton(FilesystemManager)
|
|
14
|
+
|
|
15
|
+
def boot(self) -> None:
|
|
16
|
+
pass
|
|
17
|
+
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from swiftpy.filesystem.uploads.handlers import FormData, MultipartParser
|
|
4
|
+
from swiftpy.filesystem.uploads.uploaded_file import UploadedFile
|
|
5
|
+
from swiftpy.filesystem.uploads.validators import FileValidator
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"FileValidator",
|
|
9
|
+
"FormData",
|
|
10
|
+
"MultipartParser",
|
|
11
|
+
"UploadedFile",
|
|
12
|
+
]
|
|
13
|
+
|