@weirdfingers/baseboards 0.9.5 → 0.9.7
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/dist/index.js +561 -469
- package/dist/index.js.map +1 -1
- package/package.json +2 -5
- package/templates/README.md +0 -122
- package/templates/api/.env.example +0 -65
- package/templates/api/ARTIFACT_RESOLUTION_GUIDE.md +0 -148
- package/templates/api/Dockerfile +0 -32
- package/templates/api/README.md +0 -264
- package/templates/api/alembic/env.py +0 -114
- package/templates/api/alembic/script.py.mako +0 -28
- package/templates/api/alembic/versions/20250101_000000_initial_schema.py +0 -506
- package/templates/api/alembic/versions/20251022_174729_remove_provider_name_from_generations.py +0 -75
- package/templates/api/alembic/versions/20251023_165852_switch_to_declarative_base_and_mapping.py +0 -467
- package/templates/api/alembic/versions/20251202_000000_add_artifact_lineage.py +0 -134
- package/templates/api/alembic/versions/2025925_62735_add_seed_data_for_default_tenant.py +0 -88
- package/templates/api/alembic.ini +0 -36
- package/templates/api/config/generators.yaml +0 -237
- package/templates/api/config/storage_config.yaml +0 -26
- package/templates/api/docs/ADDING_GENERATORS.md +0 -409
- package/templates/api/docs/GENERATORS_API.md +0 -502
- package/templates/api/docs/MIGRATIONS.md +0 -472
- package/templates/api/docs/TESTING_LIVE_APIS.md +0 -417
- package/templates/api/docs/storage_providers.md +0 -337
- package/templates/api/pyproject.toml +0 -205
- package/templates/api/src/boards/__init__.py +0 -10
- package/templates/api/src/boards/api/app.py +0 -172
- package/templates/api/src/boards/api/auth.py +0 -75
- package/templates/api/src/boards/api/endpoints/__init__.py +0 -3
- package/templates/api/src/boards/api/endpoints/jobs.py +0 -76
- package/templates/api/src/boards/api/endpoints/setup.py +0 -505
- package/templates/api/src/boards/api/endpoints/sse.py +0 -129
- package/templates/api/src/boards/api/endpoints/storage.py +0 -155
- package/templates/api/src/boards/api/endpoints/tenant_registration.py +0 -296
- package/templates/api/src/boards/api/endpoints/uploads.py +0 -149
- package/templates/api/src/boards/api/endpoints/webhooks.py +0 -13
- package/templates/api/src/boards/auth/__init__.py +0 -15
- package/templates/api/src/boards/auth/adapters/__init__.py +0 -27
- package/templates/api/src/boards/auth/adapters/auth0.py +0 -220
- package/templates/api/src/boards/auth/adapters/base.py +0 -73
- package/templates/api/src/boards/auth/adapters/clerk.py +0 -172
- package/templates/api/src/boards/auth/adapters/jwt.py +0 -122
- package/templates/api/src/boards/auth/adapters/none.py +0 -102
- package/templates/api/src/boards/auth/adapters/oidc.py +0 -284
- package/templates/api/src/boards/auth/adapters/supabase.py +0 -110
- package/templates/api/src/boards/auth/context.py +0 -35
- package/templates/api/src/boards/auth/factory.py +0 -129
- package/templates/api/src/boards/auth/middleware.py +0 -221
- package/templates/api/src/boards/auth/provisioning.py +0 -129
- package/templates/api/src/boards/auth/tenant_extraction.py +0 -278
- package/templates/api/src/boards/cli.py +0 -354
- package/templates/api/src/boards/config.py +0 -131
- package/templates/api/src/boards/database/__init__.py +0 -7
- package/templates/api/src/boards/database/cli.py +0 -110
- package/templates/api/src/boards/database/connection.py +0 -292
- package/templates/api/src/boards/database/models.py +0 -19
- package/templates/api/src/boards/database/seed_data.py +0 -182
- package/templates/api/src/boards/dbmodels/__init__.py +0 -441
- package/templates/api/src/boards/generators/__init__.py +0 -57
- package/templates/api/src/boards/generators/artifact_resolution.py +0 -405
- package/templates/api/src/boards/generators/artifacts.py +0 -53
- package/templates/api/src/boards/generators/base.py +0 -144
- package/templates/api/src/boards/generators/implementations/__init__.py +0 -14
- package/templates/api/src/boards/generators/implementations/fal/__init__.py +0 -25
- package/templates/api/src/boards/generators/implementations/fal/audio/__init__.py +0 -23
- package/templates/api/src/boards/generators/implementations/fal/audio/beatoven_music_generation.py +0 -171
- package/templates/api/src/boards/generators/implementations/fal/audio/beatoven_sound_effect_generation.py +0 -167
- package/templates/api/src/boards/generators/implementations/fal/audio/chatterbox_text_to_speech.py +0 -176
- package/templates/api/src/boards/generators/implementations/fal/audio/chatterbox_tts_turbo.py +0 -195
- package/templates/api/src/boards/generators/implementations/fal/audio/elevenlabs_sound_effects_v2.py +0 -194
- package/templates/api/src/boards/generators/implementations/fal/audio/elevenlabs_tts_eleven_v3.py +0 -209
- package/templates/api/src/boards/generators/implementations/fal/audio/fal_elevenlabs_tts_turbo_v2_5.py +0 -206
- package/templates/api/src/boards/generators/implementations/fal/audio/fal_minimax_speech_26_hd.py +0 -237
- package/templates/api/src/boards/generators/implementations/fal/audio/minimax_music_v2.py +0 -173
- package/templates/api/src/boards/generators/implementations/fal/audio/minimax_speech_2_6_turbo.py +0 -221
- package/templates/api/src/boards/generators/implementations/fal/image/__init__.py +0 -63
- package/templates/api/src/boards/generators/implementations/fal/image/bytedance_seedream_v45_edit.py +0 -219
- package/templates/api/src/boards/generators/implementations/fal/image/clarity_upscaler.py +0 -220
- package/templates/api/src/boards/generators/implementations/fal/image/crystal_upscaler.py +0 -173
- package/templates/api/src/boards/generators/implementations/fal/image/fal_ideogram_character.py +0 -227
- package/templates/api/src/boards/generators/implementations/fal/image/flux_2.py +0 -203
- package/templates/api/src/boards/generators/implementations/fal/image/flux_2_edit.py +0 -230
- package/templates/api/src/boards/generators/implementations/fal/image/flux_2_pro.py +0 -204
- package/templates/api/src/boards/generators/implementations/fal/image/flux_2_pro_edit.py +0 -221
- package/templates/api/src/boards/generators/implementations/fal/image/flux_pro_kontext.py +0 -216
- package/templates/api/src/boards/generators/implementations/fal/image/flux_pro_ultra.py +0 -197
- package/templates/api/src/boards/generators/implementations/fal/image/gemini_25_flash_image.py +0 -177
- package/templates/api/src/boards/generators/implementations/fal/image/gemini_25_flash_image_edit.py +0 -208
- package/templates/api/src/boards/generators/implementations/fal/image/gpt_image_15_edit.py +0 -216
- package/templates/api/src/boards/generators/implementations/fal/image/gpt_image_1_5.py +0 -177
- package/templates/api/src/boards/generators/implementations/fal/image/gpt_image_1_edit_image.py +0 -182
- package/templates/api/src/boards/generators/implementations/fal/image/gpt_image_1_mini.py +0 -167
- package/templates/api/src/boards/generators/implementations/fal/image/ideogram_character_edit.py +0 -299
- package/templates/api/src/boards/generators/implementations/fal/image/ideogram_v2.py +0 -190
- package/templates/api/src/boards/generators/implementations/fal/image/imagen4_preview.py +0 -191
- package/templates/api/src/boards/generators/implementations/fal/image/imagen4_preview_fast.py +0 -179
- package/templates/api/src/boards/generators/implementations/fal/image/nano_banana.py +0 -183
- package/templates/api/src/boards/generators/implementations/fal/image/nano_banana_edit.py +0 -212
- package/templates/api/src/boards/generators/implementations/fal/image/nano_banana_pro.py +0 -179
- package/templates/api/src/boards/generators/implementations/fal/image/nano_banana_pro_edit.py +0 -226
- package/templates/api/src/boards/generators/implementations/fal/image/qwen_image.py +0 -249
- package/templates/api/src/boards/generators/implementations/fal/image/qwen_image_edit.py +0 -244
- package/templates/api/src/boards/generators/implementations/fal/image/reve_edit.py +0 -178
- package/templates/api/src/boards/generators/implementations/fal/image/reve_text_to_image.py +0 -155
- package/templates/api/src/boards/generators/implementations/fal/image/seedream_v45_text_to_image.py +0 -180
- package/templates/api/src/boards/generators/implementations/fal/utils.py +0 -61
- package/templates/api/src/boards/generators/implementations/fal/video/__init__.py +0 -77
- package/templates/api/src/boards/generators/implementations/fal/video/bytedance_seedance_v1_pro_text_to_video.py +0 -209
- package/templates/api/src/boards/generators/implementations/fal/video/creatify_lipsync.py +0 -161
- package/templates/api/src/boards/generators/implementations/fal/video/fal_bytedance_seedance_v1_pro_image_to_video.py +0 -222
- package/templates/api/src/boards/generators/implementations/fal/video/fal_minimax_hailuo_02_standard_text_to_video.py +0 -152
- package/templates/api/src/boards/generators/implementations/fal/video/fal_pixverse_lipsync.py +0 -197
- package/templates/api/src/boards/generators/implementations/fal/video/fal_sora_2_text_to_video.py +0 -173
- package/templates/api/src/boards/generators/implementations/fal/video/infinitalk.py +0 -221
- package/templates/api/src/boards/generators/implementations/fal/video/kling_video_ai_avatar_v2_pro.py +0 -168
- package/templates/api/src/boards/generators/implementations/fal/video/kling_video_ai_avatar_v2_standard.py +0 -159
- package/templates/api/src/boards/generators/implementations/fal/video/kling_video_v2_5_turbo_pro_image_to_video.py +0 -175
- package/templates/api/src/boards/generators/implementations/fal/video/kling_video_v2_5_turbo_pro_text_to_video.py +0 -168
- package/templates/api/src/boards/generators/implementations/fal/video/minimax_hailuo_2_3_pro_image_to_video.py +0 -153
- package/templates/api/src/boards/generators/implementations/fal/video/sora2_image_to_video.py +0 -172
- package/templates/api/src/boards/generators/implementations/fal/video/sora_2_image_to_video_pro.py +0 -175
- package/templates/api/src/boards/generators/implementations/fal/video/sora_2_text_to_video_pro.py +0 -163
- package/templates/api/src/boards/generators/implementations/fal/video/sync_lipsync_v2.py +0 -167
- package/templates/api/src/boards/generators/implementations/fal/video/sync_lipsync_v2_pro.py +0 -155
- package/templates/api/src/boards/generators/implementations/fal/video/veed_fabric_1_0.py +0 -180
- package/templates/api/src/boards/generators/implementations/fal/video/veed_lipsync.py +0 -174
- package/templates/api/src/boards/generators/implementations/fal/video/veo3.py +0 -194
- package/templates/api/src/boards/generators/implementations/fal/video/veo31.py +0 -190
- package/templates/api/src/boards/generators/implementations/fal/video/veo31_fast.py +0 -190
- package/templates/api/src/boards/generators/implementations/fal/video/veo31_fast_image_to_video.py +0 -191
- package/templates/api/src/boards/generators/implementations/fal/video/veo31_first_last_frame_to_video.py +0 -187
- package/templates/api/src/boards/generators/implementations/fal/video/veo31_image_to_video.py +0 -183
- package/templates/api/src/boards/generators/implementations/fal/video/veo31_reference_to_video.py +0 -172
- package/templates/api/src/boards/generators/implementations/fal/video/wan_25_preview_image_to_video.py +0 -212
- package/templates/api/src/boards/generators/implementations/fal/video/wan_25_preview_text_to_video.py +0 -208
- package/templates/api/src/boards/generators/implementations/fal/video/wan_pro_image_to_video.py +0 -158
- package/templates/api/src/boards/generators/implementations/kie/__init__.py +0 -11
- package/templates/api/src/boards/generators/implementations/kie/base.py +0 -316
- package/templates/api/src/boards/generators/implementations/kie/image/__init__.py +0 -3
- package/templates/api/src/boards/generators/implementations/kie/image/nano_banana_edit.py +0 -190
- package/templates/api/src/boards/generators/implementations/kie/utils.py +0 -98
- package/templates/api/src/boards/generators/implementations/kie/video/__init__.py +0 -8
- package/templates/api/src/boards/generators/implementations/kie/video/veo3.py +0 -161
- package/templates/api/src/boards/generators/implementations/openai/__init__.py +0 -1
- package/templates/api/src/boards/generators/implementations/openai/audio/__init__.py +0 -1
- package/templates/api/src/boards/generators/implementations/openai/audio/whisper.py +0 -69
- package/templates/api/src/boards/generators/implementations/openai/image/__init__.py +0 -1
- package/templates/api/src/boards/generators/implementations/openai/image/dalle3.py +0 -96
- package/templates/api/src/boards/generators/implementations/replicate/__init__.py +0 -1
- package/templates/api/src/boards/generators/implementations/replicate/image/__init__.py +0 -1
- package/templates/api/src/boards/generators/implementations/replicate/image/flux_pro.py +0 -88
- package/templates/api/src/boards/generators/implementations/replicate/video/__init__.py +0 -1
- package/templates/api/src/boards/generators/implementations/replicate/video/lipsync.py +0 -73
- package/templates/api/src/boards/generators/loader.py +0 -253
- package/templates/api/src/boards/generators/registry.py +0 -114
- package/templates/api/src/boards/generators/resolution.py +0 -632
- package/templates/api/src/boards/generators/testmods/class_gen.py +0 -34
- package/templates/api/src/boards/generators/testmods/import_side_effect.py +0 -35
- package/templates/api/src/boards/graphql/__init__.py +0 -7
- package/templates/api/src/boards/graphql/access_control.py +0 -136
- package/templates/api/src/boards/graphql/mutations/root.py +0 -148
- package/templates/api/src/boards/graphql/queries/root.py +0 -116
- package/templates/api/src/boards/graphql/resolvers/__init__.py +0 -8
- package/templates/api/src/boards/graphql/resolvers/auth.py +0 -12
- package/templates/api/src/boards/graphql/resolvers/board.py +0 -1053
- package/templates/api/src/boards/graphql/resolvers/generation.py +0 -666
- package/templates/api/src/boards/graphql/resolvers/generator.py +0 -50
- package/templates/api/src/boards/graphql/resolvers/lineage.py +0 -381
- package/templates/api/src/boards/graphql/resolvers/upload.py +0 -463
- package/templates/api/src/boards/graphql/resolvers/user.py +0 -25
- package/templates/api/src/boards/graphql/schema.py +0 -81
- package/templates/api/src/boards/graphql/types/board.py +0 -102
- package/templates/api/src/boards/graphql/types/generation.py +0 -166
- package/templates/api/src/boards/graphql/types/generator.py +0 -17
- package/templates/api/src/boards/graphql/types/user.py +0 -47
- package/templates/api/src/boards/jobs/repository.py +0 -153
- package/templates/api/src/boards/logging.py +0 -195
- package/templates/api/src/boards/middleware.py +0 -339
- package/templates/api/src/boards/progress/__init__.py +0 -4
- package/templates/api/src/boards/progress/models.py +0 -25
- package/templates/api/src/boards/progress/publisher.py +0 -64
- package/templates/api/src/boards/py.typed +0 -0
- package/templates/api/src/boards/redis_pool.py +0 -118
- package/templates/api/src/boards/storage/__init__.py +0 -52
- package/templates/api/src/boards/storage/base.py +0 -363
- package/templates/api/src/boards/storage/config.py +0 -187
- package/templates/api/src/boards/storage/factory.py +0 -288
- package/templates/api/src/boards/storage/implementations/__init__.py +0 -27
- package/templates/api/src/boards/storage/implementations/gcs.py +0 -340
- package/templates/api/src/boards/storage/implementations/local.py +0 -201
- package/templates/api/src/boards/storage/implementations/s3.py +0 -294
- package/templates/api/src/boards/storage/implementations/supabase.py +0 -218
- package/templates/api/src/boards/tenant_isolation.py +0 -446
- package/templates/api/src/boards/validation.py +0 -262
- package/templates/api/src/boards/workers/__init__.py +0 -1
- package/templates/api/src/boards/workers/actors.py +0 -274
- package/templates/api/src/boards/workers/cli.py +0 -125
- package/templates/api/src/boards/workers/context.py +0 -348
- package/templates/api/src/boards/workers/middleware.py +0 -58
- package/templates/api/src/py.typed +0 -0
- package/templates/compose.web.yaml +0 -35
- package/templates/compose.yaml +0 -116
- package/templates/docker/env.example +0 -23
- package/templates/web/.env.example +0 -28
- package/templates/web/Dockerfile +0 -51
- package/templates/web/components.json +0 -22
- package/templates/web/imageLoader.js +0 -18
- package/templates/web/next-env.d.ts +0 -5
- package/templates/web/next.config.js +0 -36
- package/templates/web/package.json +0 -41
- package/templates/web/postcss.config.mjs +0 -7
- package/templates/web/public/favicon.ico +0 -0
- package/templates/web/src/app/boards/[boardId]/page.tsx +0 -353
- package/templates/web/src/app/globals.css +0 -123
- package/templates/web/src/app/layout.tsx +0 -31
- package/templates/web/src/app/lineage/[generationId]/page.tsx +0 -235
- package/templates/web/src/app/page.tsx +0 -35
- package/templates/web/src/app/providers.tsx +0 -18
- package/templates/web/src/components/boards/ArtifactInputSlots.tsx +0 -206
- package/templates/web/src/components/boards/ArtifactPreview.tsx +0 -466
- package/templates/web/src/components/boards/GenerationGrid.tsx +0 -282
- package/templates/web/src/components/boards/GenerationInput.tsx +0 -370
- package/templates/web/src/components/boards/GeneratorSelector.tsx +0 -272
- package/templates/web/src/components/boards/UploadArtifact.tsx +0 -563
- package/templates/web/src/components/header.tsx +0 -32
- package/templates/web/src/components/theme-provider.tsx +0 -10
- package/templates/web/src/components/theme-toggle.tsx +0 -75
- package/templates/web/src/components/ui/alert-dialog.tsx +0 -157
- package/templates/web/src/components/ui/button.tsx +0 -58
- package/templates/web/src/components/ui/card.tsx +0 -92
- package/templates/web/src/components/ui/dropdown-menu.tsx +0 -200
- package/templates/web/src/components/ui/navigation-menu.tsx +0 -168
- package/templates/web/src/components/ui/toast.tsx +0 -128
- package/templates/web/src/components/ui/toaster.tsx +0 -35
- package/templates/web/src/components/ui/use-toast.ts +0 -187
- package/templates/web/src/hooks/useGeneratorMRU.ts +0 -57
- package/templates/web/src/lib/utils.ts +0 -6
- package/templates/web/tsconfig.json +0 -41
|
@@ -1,201 +0,0 @@
|
|
|
1
|
-
"""Local filesystem storage provider for development and self-hosted deployments."""
|
|
2
|
-
|
|
3
|
-
import json
|
|
4
|
-
from collections.abc import AsyncIterable
|
|
5
|
-
from datetime import timedelta
|
|
6
|
-
from pathlib import Path
|
|
7
|
-
from typing import Any
|
|
8
|
-
from urllib.parse import quote
|
|
9
|
-
|
|
10
|
-
import aiofiles
|
|
11
|
-
|
|
12
|
-
from ...logging import get_logger
|
|
13
|
-
from ..base import SecurityException, StorageException, StorageProvider
|
|
14
|
-
|
|
15
|
-
logger = get_logger(__name__)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
class LocalStorageProvider(StorageProvider):
|
|
19
|
-
"""Local filesystem storage for development and self-hosted with security."""
|
|
20
|
-
|
|
21
|
-
def __init__(self, base_path: Path, public_url_base: str | None = None):
|
|
22
|
-
self.base_path = Path(base_path).resolve() # Resolve to absolute path
|
|
23
|
-
self.public_url_base = public_url_base
|
|
24
|
-
self.base_path.mkdir(parents=True, exist_ok=True)
|
|
25
|
-
|
|
26
|
-
def _get_safe_file_path(self, key: str) -> Path:
|
|
27
|
-
"""Get file path with security validation."""
|
|
28
|
-
# Ensure the resolved path is within base_path
|
|
29
|
-
file_path = (self.base_path / key).resolve()
|
|
30
|
-
|
|
31
|
-
# Check that resolved path is within base directory
|
|
32
|
-
try:
|
|
33
|
-
file_path.relative_to(self.base_path)
|
|
34
|
-
except ValueError as e:
|
|
35
|
-
raise SecurityException(f"Path traversal detected: {key}") from e
|
|
36
|
-
|
|
37
|
-
return file_path
|
|
38
|
-
|
|
39
|
-
async def upload(
|
|
40
|
-
self,
|
|
41
|
-
key: str,
|
|
42
|
-
content: bytes | bytearray | memoryview | AsyncIterable[bytes],
|
|
43
|
-
content_type: str,
|
|
44
|
-
metadata: dict[str, Any] | None = None,
|
|
45
|
-
) -> str:
|
|
46
|
-
logger.info("Uploading file", key=key, content_type=content_type, metadata=metadata)
|
|
47
|
-
try:
|
|
48
|
-
file_path = self._get_safe_file_path(key)
|
|
49
|
-
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
-
|
|
51
|
-
# Handle both bytes-like and async iterable content
|
|
52
|
-
if isinstance(content, bytes | bytearray | memoryview):
|
|
53
|
-
# aiofiles accepts bytes-like objects directly
|
|
54
|
-
async with aiofiles.open(file_path, "wb") as f:
|
|
55
|
-
await f.write(content)
|
|
56
|
-
else: # isinstance(content, AsyncIterable):
|
|
57
|
-
async with aiofiles.open(file_path, "wb") as f:
|
|
58
|
-
async for chunk in content:
|
|
59
|
-
# Just write the chunk directly - aiofiles accepts bytes-like objects
|
|
60
|
-
# It will raise an error if chunk is not bytes-like
|
|
61
|
-
await f.write(chunk)
|
|
62
|
-
|
|
63
|
-
# Store metadata atomically
|
|
64
|
-
if metadata:
|
|
65
|
-
try:
|
|
66
|
-
metadata_path = file_path.with_suffix(file_path.suffix + ".meta")
|
|
67
|
-
metadata_json = json.dumps(metadata, indent=2)
|
|
68
|
-
|
|
69
|
-
async with aiofiles.open(metadata_path, "w") as f:
|
|
70
|
-
await f.write(metadata_json)
|
|
71
|
-
except Exception as e:
|
|
72
|
-
logger.warning(f"Failed to write metadata for {key}: {e}")
|
|
73
|
-
# Continue - metadata failure shouldn't fail the upload
|
|
74
|
-
|
|
75
|
-
logger.debug(f"Successfully uploaded {key} to local storage")
|
|
76
|
-
return self._get_public_url(key)
|
|
77
|
-
|
|
78
|
-
except OSError as e:
|
|
79
|
-
logger.error(f"File system error uploading {key}: {e}")
|
|
80
|
-
raise StorageException(f"Failed to write file: {e}") from e
|
|
81
|
-
except Exception as e:
|
|
82
|
-
logger.error(f"Unexpected error uploading {key}: {e}")
|
|
83
|
-
raise StorageException(f"Upload failed: {e}") from e
|
|
84
|
-
|
|
85
|
-
def _get_public_url(self, key: str) -> str:
|
|
86
|
-
"""Generate public URL for the stored file."""
|
|
87
|
-
if self.public_url_base:
|
|
88
|
-
# URL-encode the key for safety
|
|
89
|
-
encoded_key = quote(key, safe="/")
|
|
90
|
-
return f"{self.public_url_base.rstrip('/')}/{encoded_key}"
|
|
91
|
-
else:
|
|
92
|
-
return f"file://{self.base_path / key}"
|
|
93
|
-
|
|
94
|
-
async def download(self, key: str) -> bytes:
|
|
95
|
-
"""Download file content from local storage."""
|
|
96
|
-
try:
|
|
97
|
-
file_path = self._get_safe_file_path(key)
|
|
98
|
-
|
|
99
|
-
if not file_path.exists():
|
|
100
|
-
raise StorageException(f"File not found: {key}")
|
|
101
|
-
|
|
102
|
-
async with aiofiles.open(file_path, "rb") as f:
|
|
103
|
-
return await f.read()
|
|
104
|
-
|
|
105
|
-
except OSError as e:
|
|
106
|
-
logger.error(f"File system error downloading {key}: {e}")
|
|
107
|
-
raise StorageException(f"Failed to read file: {e}") from e
|
|
108
|
-
except Exception as e:
|
|
109
|
-
logger.error(f"Unexpected error downloading {key}: {e}")
|
|
110
|
-
raise StorageException(f"Download failed: {e}") from e
|
|
111
|
-
|
|
112
|
-
async def get_presigned_upload_url(
|
|
113
|
-
self, key: str, content_type: str, expires_in: timedelta | None = None
|
|
114
|
-
) -> dict[str, Any]:
|
|
115
|
-
"""Local storage doesn't support presigned URLs - return direct upload info."""
|
|
116
|
-
# For local storage, we can't really do presigned URLs
|
|
117
|
-
# This would be handled by the web server (e.g., FastAPI endpoint)
|
|
118
|
-
return {
|
|
119
|
-
"url": f"/api/storage/upload/{quote(key, safe='/')}",
|
|
120
|
-
"fields": {"content-type": content_type},
|
|
121
|
-
"method": "PUT",
|
|
122
|
-
"expires_at": None, # Handled by server session
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
async def get_presigned_download_url(
|
|
126
|
-
self, key: str, expires_in: timedelta | None = None
|
|
127
|
-
) -> str:
|
|
128
|
-
"""Return the public URL for local storage."""
|
|
129
|
-
return self._get_public_url(key)
|
|
130
|
-
|
|
131
|
-
async def delete(self, key: str) -> bool:
|
|
132
|
-
"""Delete file by storage key."""
|
|
133
|
-
try:
|
|
134
|
-
file_path = self._get_safe_file_path(key)
|
|
135
|
-
|
|
136
|
-
if not file_path.exists():
|
|
137
|
-
return False
|
|
138
|
-
|
|
139
|
-
# Delete the main file
|
|
140
|
-
file_path.unlink()
|
|
141
|
-
|
|
142
|
-
# Delete metadata file if it exists
|
|
143
|
-
metadata_path = file_path.with_suffix(file_path.suffix + ".meta")
|
|
144
|
-
if metadata_path.exists():
|
|
145
|
-
metadata_path.unlink()
|
|
146
|
-
|
|
147
|
-
logger.debug(f"Successfully deleted {key} from local storage")
|
|
148
|
-
return True
|
|
149
|
-
|
|
150
|
-
except OSError as e:
|
|
151
|
-
logger.error(f"File system error deleting {key}: {e}")
|
|
152
|
-
raise StorageException(f"Failed to delete file: {e}") from e
|
|
153
|
-
except Exception as e:
|
|
154
|
-
logger.error(f"Unexpected error deleting {key}: {e}")
|
|
155
|
-
raise StorageException(f"Delete failed: {e}") from e
|
|
156
|
-
|
|
157
|
-
async def exists(self, key: str) -> bool:
|
|
158
|
-
"""Check if file exists."""
|
|
159
|
-
try:
|
|
160
|
-
file_path = self._get_safe_file_path(key)
|
|
161
|
-
return file_path.exists()
|
|
162
|
-
except SecurityException:
|
|
163
|
-
return False
|
|
164
|
-
except Exception as e:
|
|
165
|
-
logger.warning(f"Error checking existence of {key}: {e}")
|
|
166
|
-
return False
|
|
167
|
-
|
|
168
|
-
async def get_metadata(self, key: str) -> dict[str, Any]:
|
|
169
|
-
"""Get file metadata (size, modified date, etc.)."""
|
|
170
|
-
try:
|
|
171
|
-
file_path = self._get_safe_file_path(key)
|
|
172
|
-
|
|
173
|
-
if not file_path.exists():
|
|
174
|
-
raise StorageException(f"File not found: {key}")
|
|
175
|
-
|
|
176
|
-
stat = file_path.stat()
|
|
177
|
-
|
|
178
|
-
# Try to load stored metadata
|
|
179
|
-
stored_metadata = {}
|
|
180
|
-
metadata_path = file_path.with_suffix(file_path.suffix + ".meta")
|
|
181
|
-
if metadata_path.exists():
|
|
182
|
-
try:
|
|
183
|
-
async with aiofiles.open(metadata_path) as f:
|
|
184
|
-
metadata_content = await f.read()
|
|
185
|
-
stored_metadata = json.loads(metadata_content)
|
|
186
|
-
except Exception as e:
|
|
187
|
-
logger.warning(f"Failed to load metadata for {key}: {e}")
|
|
188
|
-
|
|
189
|
-
return {
|
|
190
|
-
"size": stat.st_size,
|
|
191
|
-
"modified_time": stat.st_mtime,
|
|
192
|
-
"created_time": stat.st_ctime,
|
|
193
|
-
**stored_metadata,
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
except OSError as e:
|
|
197
|
-
logger.error(f"File system error getting metadata for {key}: {e}")
|
|
198
|
-
raise StorageException(f"Failed to get metadata: {e}") from e
|
|
199
|
-
except Exception as e:
|
|
200
|
-
logger.error(f"Unexpected error getting metadata for {key}: {e}")
|
|
201
|
-
raise StorageException(f"Get metadata failed: {e}") from e
|
|
@@ -1,294 +0,0 @@
|
|
|
1
|
-
"""AWS S3 storage provider with IAM auth and CloudFront CDN support."""
|
|
2
|
-
|
|
3
|
-
from collections.abc import AsyncIterator
|
|
4
|
-
from datetime import UTC, datetime, timedelta
|
|
5
|
-
from typing import TYPE_CHECKING, Any
|
|
6
|
-
|
|
7
|
-
if TYPE_CHECKING:
|
|
8
|
-
import boto3
|
|
9
|
-
|
|
10
|
-
try:
|
|
11
|
-
import aioboto3
|
|
12
|
-
import boto3
|
|
13
|
-
from botocore.config import Config
|
|
14
|
-
from botocore.exceptions import ClientError, NoCredentialsError
|
|
15
|
-
|
|
16
|
-
_s3_available = True
|
|
17
|
-
except ImportError:
|
|
18
|
-
boto3 = None
|
|
19
|
-
ClientError = None
|
|
20
|
-
NoCredentialsError = None
|
|
21
|
-
Config = None
|
|
22
|
-
aioboto3 = None
|
|
23
|
-
_s3_available = False
|
|
24
|
-
|
|
25
|
-
from ...logging import get_logger
|
|
26
|
-
from ..base import StorageException, StorageProvider
|
|
27
|
-
|
|
28
|
-
logger = get_logger(__name__)
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
class S3StorageProvider(StorageProvider):
|
|
32
|
-
"""AWS S3 storage with IAM auth, CloudFront CDN, and proper async patterns."""
|
|
33
|
-
|
|
34
|
-
def __init__(
|
|
35
|
-
self,
|
|
36
|
-
bucket: str,
|
|
37
|
-
region: str = "us-east-1",
|
|
38
|
-
aws_access_key_id: str | None = None,
|
|
39
|
-
aws_secret_access_key: str | None = None,
|
|
40
|
-
aws_session_token: str | None = None,
|
|
41
|
-
endpoint_url: str | None = None,
|
|
42
|
-
cloudfront_domain: str | None = None,
|
|
43
|
-
upload_config: dict[str, Any] | None = None,
|
|
44
|
-
):
|
|
45
|
-
if not _s3_available:
|
|
46
|
-
raise ImportError("boto3 and aioboto3 are required for S3StorageProvider")
|
|
47
|
-
|
|
48
|
-
self.bucket = bucket
|
|
49
|
-
self.region = region
|
|
50
|
-
self.aws_access_key_id = aws_access_key_id
|
|
51
|
-
self.aws_secret_access_key = aws_secret_access_key
|
|
52
|
-
self.aws_session_token = aws_session_token
|
|
53
|
-
self.endpoint_url = endpoint_url
|
|
54
|
-
self.cloudfront_domain = cloudfront_domain
|
|
55
|
-
|
|
56
|
-
# Default upload configuration
|
|
57
|
-
self.upload_config = {
|
|
58
|
-
"ServerSideEncryption": "AES256",
|
|
59
|
-
"StorageClass": "STANDARD",
|
|
60
|
-
**(upload_config or {}),
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
# Configure boto3 with optimized settings
|
|
64
|
-
self.config = Config( # type: ignore[reportUnknownMemberType]
|
|
65
|
-
region_name=self.region,
|
|
66
|
-
retries={"max_attempts": 3, "mode": "adaptive"},
|
|
67
|
-
max_pool_connections=50,
|
|
68
|
-
)
|
|
69
|
-
|
|
70
|
-
self._session: Any | None = None
|
|
71
|
-
|
|
72
|
-
def _get_session(self) -> Any:
|
|
73
|
-
"""Get or create the aioboto3 session."""
|
|
74
|
-
if self._session is None:
|
|
75
|
-
self._session = aioboto3.Session( # type: ignore[reportUnknownMemberType]
|
|
76
|
-
aws_access_key_id=self.aws_access_key_id,
|
|
77
|
-
aws_secret_access_key=self.aws_secret_access_key,
|
|
78
|
-
aws_session_token=self.aws_session_token,
|
|
79
|
-
region_name=self.region,
|
|
80
|
-
)
|
|
81
|
-
return self._session
|
|
82
|
-
|
|
83
|
-
async def upload(
|
|
84
|
-
self,
|
|
85
|
-
key: str,
|
|
86
|
-
content: bytes | AsyncIterator[bytes],
|
|
87
|
-
content_type: str,
|
|
88
|
-
metadata: dict[str, Any] | None = None,
|
|
89
|
-
) -> str:
|
|
90
|
-
"""Upload content to S3."""
|
|
91
|
-
try:
|
|
92
|
-
session = self._get_session()
|
|
93
|
-
|
|
94
|
-
# Prepare upload parameters
|
|
95
|
-
upload_params = {
|
|
96
|
-
"Bucket": self.bucket,
|
|
97
|
-
"Key": key,
|
|
98
|
-
"ContentType": content_type,
|
|
99
|
-
**self.upload_config,
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
# Add custom metadata (S3 requires x-amz-meta- prefix)
|
|
103
|
-
if metadata:
|
|
104
|
-
s3_metadata = {}
|
|
105
|
-
for k, v in metadata.items():
|
|
106
|
-
# Convert values to strings and sanitize keys
|
|
107
|
-
clean_key = k.replace("-", "_").replace(" ", "_")
|
|
108
|
-
s3_metadata[clean_key] = str(v)
|
|
109
|
-
upload_params["Metadata"] = s3_metadata
|
|
110
|
-
|
|
111
|
-
# Handle streaming content for large files
|
|
112
|
-
if isinstance(content, bytes):
|
|
113
|
-
upload_params["Body"] = content
|
|
114
|
-
else:
|
|
115
|
-
# Collect streaming content into memory for upload
|
|
116
|
-
# For very large files, consider using S3 multipart upload
|
|
117
|
-
chunks = []
|
|
118
|
-
total_size = 0
|
|
119
|
-
async for chunk in content:
|
|
120
|
-
chunks.append(chunk)
|
|
121
|
-
total_size += len(chunk)
|
|
122
|
-
# For files larger than 100MB, we could implement multipart upload
|
|
123
|
-
if total_size > 100 * 1024 * 1024:
|
|
124
|
-
logger.warning(
|
|
125
|
-
f"Large file upload ({total_size} bytes) - "
|
|
126
|
-
f"consider implementing multipart upload for key: {key}"
|
|
127
|
-
)
|
|
128
|
-
|
|
129
|
-
upload_params["Body"] = b"".join(chunks)
|
|
130
|
-
|
|
131
|
-
# Upload using aioboto3
|
|
132
|
-
async with session.client(
|
|
133
|
-
"s3", config=self.config, endpoint_url=self.endpoint_url
|
|
134
|
-
) as s3:
|
|
135
|
-
await s3.put_object(**upload_params)
|
|
136
|
-
|
|
137
|
-
# Return the CloudFront URL if configured, otherwise S3 URL
|
|
138
|
-
if self.cloudfront_domain:
|
|
139
|
-
return f"https://{self.cloudfront_domain}/{key}"
|
|
140
|
-
else:
|
|
141
|
-
return f"https://{self.bucket}.s3.{self.region}.amazonaws.com/{key}"
|
|
142
|
-
|
|
143
|
-
except Exception as e:
|
|
144
|
-
if isinstance(e, StorageException):
|
|
145
|
-
raise
|
|
146
|
-
logger.error(f"Unexpected error uploading {key} to S3: {e}")
|
|
147
|
-
raise StorageException(f"S3 upload failed: {e}") from e
|
|
148
|
-
|
|
149
|
-
async def download(self, key: str) -> bytes:
|
|
150
|
-
"""Download file content from S3."""
|
|
151
|
-
try:
|
|
152
|
-
session = self._get_session()
|
|
153
|
-
async with session.client(
|
|
154
|
-
"s3", config=self.config, endpoint_url=self.endpoint_url
|
|
155
|
-
) as s3:
|
|
156
|
-
response = await s3.get_object(Bucket=self.bucket, Key=key)
|
|
157
|
-
|
|
158
|
-
# Read the streaming body
|
|
159
|
-
content = await response["Body"].read()
|
|
160
|
-
return content
|
|
161
|
-
|
|
162
|
-
except Exception as e:
|
|
163
|
-
if isinstance(e, StorageException):
|
|
164
|
-
raise
|
|
165
|
-
logger.error(f"Failed to download {key} from S3: {e}")
|
|
166
|
-
raise StorageException(f"S3 download failed: {e}") from e
|
|
167
|
-
|
|
168
|
-
async def get_presigned_upload_url(
|
|
169
|
-
self,
|
|
170
|
-
key: str,
|
|
171
|
-
content_type: str,
|
|
172
|
-
expires_in: timedelta | None = None,
|
|
173
|
-
) -> dict[str, Any]:
|
|
174
|
-
"""Generate presigned URL for direct client uploads."""
|
|
175
|
-
if expires_in is None:
|
|
176
|
-
expires_in = timedelta(hours=1)
|
|
177
|
-
|
|
178
|
-
try:
|
|
179
|
-
session = self._get_session()
|
|
180
|
-
async with session.client(
|
|
181
|
-
"s3", config=self.config, endpoint_url=self.endpoint_url
|
|
182
|
-
) as s3:
|
|
183
|
-
# Generate presigned POST for direct uploads with form fields
|
|
184
|
-
response = await s3.generate_presigned_post(
|
|
185
|
-
Bucket=self.bucket,
|
|
186
|
-
Key=key,
|
|
187
|
-
Fields={"Content-Type": content_type, **self.upload_config},
|
|
188
|
-
Conditions=[
|
|
189
|
-
{"Content-Type": content_type},
|
|
190
|
-
[
|
|
191
|
-
"content-length-range",
|
|
192
|
-
1,
|
|
193
|
-
self.upload_config.get("max_file_size", 100 * 1024 * 1024),
|
|
194
|
-
],
|
|
195
|
-
],
|
|
196
|
-
ExpiresIn=int(expires_in.total_seconds()),
|
|
197
|
-
)
|
|
198
|
-
|
|
199
|
-
return {
|
|
200
|
-
"url": response["url"],
|
|
201
|
-
"fields": response["fields"],
|
|
202
|
-
"expires_at": (datetime.now(UTC) + expires_in).isoformat(),
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
except Exception as e:
|
|
206
|
-
if isinstance(e, StorageException):
|
|
207
|
-
raise
|
|
208
|
-
logger.error(f"Failed to create presigned upload URL for {key}: {e}")
|
|
209
|
-
raise StorageException(f"S3 presigned URL creation failed: {e}") from e
|
|
210
|
-
|
|
211
|
-
async def get_presigned_download_url(
|
|
212
|
-
self, key: str, expires_in: timedelta | None = None
|
|
213
|
-
) -> str:
|
|
214
|
-
"""Generate presigned URL for secure downloads."""
|
|
215
|
-
if expires_in is None:
|
|
216
|
-
expires_in = timedelta(hours=1)
|
|
217
|
-
|
|
218
|
-
try:
|
|
219
|
-
# Always use S3 native presigned URLs for security
|
|
220
|
-
session = self._get_session()
|
|
221
|
-
async with session.client(
|
|
222
|
-
"s3", config=self.config, endpoint_url=self.endpoint_url
|
|
223
|
-
) as s3:
|
|
224
|
-
url = await s3.generate_presigned_url(
|
|
225
|
-
"get_object",
|
|
226
|
-
Params={"Bucket": self.bucket, "Key": key},
|
|
227
|
-
ExpiresIn=int(expires_in.total_seconds()),
|
|
228
|
-
)
|
|
229
|
-
return url
|
|
230
|
-
|
|
231
|
-
except Exception as e:
|
|
232
|
-
if isinstance(e, StorageException):
|
|
233
|
-
raise
|
|
234
|
-
logger.error(f"Failed to create presigned download URL for {key}: {e}")
|
|
235
|
-
raise StorageException(f"S3 presigned download URL creation failed: {e}") from e
|
|
236
|
-
|
|
237
|
-
async def delete(self, key: str) -> bool:
|
|
238
|
-
"""Delete file by storage key."""
|
|
239
|
-
try:
|
|
240
|
-
session = self._get_session()
|
|
241
|
-
async with session.client(
|
|
242
|
-
"s3", config=self.config, endpoint_url=self.endpoint_url
|
|
243
|
-
) as s3:
|
|
244
|
-
await s3.delete_object(Bucket=self.bucket, Key=key)
|
|
245
|
-
return True
|
|
246
|
-
|
|
247
|
-
except Exception as e:
|
|
248
|
-
logger.error(f"Unexpected error deleting {key} from S3: {e}")
|
|
249
|
-
raise StorageException(f"S3 delete failed: {e}") from e
|
|
250
|
-
|
|
251
|
-
async def exists(self, key: str) -> bool:
|
|
252
|
-
"""Check if file exists."""
|
|
253
|
-
try:
|
|
254
|
-
session = self._get_session()
|
|
255
|
-
async with session.client(
|
|
256
|
-
"s3", config=self.config, endpoint_url=self.endpoint_url
|
|
257
|
-
) as s3:
|
|
258
|
-
await s3.head_object(Bucket=self.bucket, Key=key)
|
|
259
|
-
return True
|
|
260
|
-
except Exception:
|
|
261
|
-
return False
|
|
262
|
-
|
|
263
|
-
async def get_metadata(self, key: str) -> dict[str, Any]:
|
|
264
|
-
"""Get file metadata (size, modified date, etc.)."""
|
|
265
|
-
try:
|
|
266
|
-
session = self._get_session()
|
|
267
|
-
async with session.client(
|
|
268
|
-
"s3", config=self.config, endpoint_url=self.endpoint_url
|
|
269
|
-
) as s3:
|
|
270
|
-
response = await s3.head_object(Bucket=self.bucket, Key=key)
|
|
271
|
-
|
|
272
|
-
# Extract metadata
|
|
273
|
-
result = {
|
|
274
|
-
"size": response.get("ContentLength", 0),
|
|
275
|
-
"last_modified": response.get("LastModified"),
|
|
276
|
-
"content_type": response.get("ContentType"),
|
|
277
|
-
"etag": response.get("ETag", "").strip('"'),
|
|
278
|
-
"version_id": response.get("VersionId"),
|
|
279
|
-
"storage_class": response.get("StorageClass", "STANDARD"),
|
|
280
|
-
"server_side_encryption": response.get("ServerSideEncryption"),
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
# Add custom metadata (remove x-amz-meta- prefix)
|
|
284
|
-
custom_metadata = response.get("Metadata", {})
|
|
285
|
-
if custom_metadata:
|
|
286
|
-
result["custom_metadata"] = custom_metadata
|
|
287
|
-
|
|
288
|
-
return result
|
|
289
|
-
|
|
290
|
-
except Exception as e:
|
|
291
|
-
if isinstance(e, StorageException):
|
|
292
|
-
raise
|
|
293
|
-
logger.error(f"Failed to get metadata for {key} from S3: {e}")
|
|
294
|
-
raise StorageException(f"S3 get metadata failed: {e}") from e
|