@weirdfingers/baseboards 0.5.3 → 0.6.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/README.md +1 -1
- package/package.json +1 -1
- package/templates/api/alembic/env.py +9 -1
- package/templates/api/alembic/versions/20250101_000000_initial_schema.py +107 -49
- package/templates/api/alembic/versions/20251022_174729_remove_provider_name_from_generations.py +7 -3
- package/templates/api/alembic/versions/20251023_165852_switch_to_declarative_base_and_mapping.py +57 -1
- package/templates/api/alembic/versions/20251202_000000_add_artifact_lineage.py +134 -0
- package/templates/api/alembic/versions/2025925_62735_add_seed_data_for_default_tenant.py +8 -5
- package/templates/api/config/generators.yaml +111 -0
- package/templates/api/src/boards/__init__.py +1 -1
- package/templates/api/src/boards/api/app.py +2 -1
- package/templates/api/src/boards/api/endpoints/tenant_registration.py +1 -1
- package/templates/api/src/boards/api/endpoints/uploads.py +150 -0
- package/templates/api/src/boards/auth/factory.py +1 -1
- package/templates/api/src/boards/dbmodels/__init__.py +8 -22
- package/templates/api/src/boards/generators/artifact_resolution.py +45 -12
- package/templates/api/src/boards/generators/implementations/fal/audio/__init__.py +16 -1
- package/templates/api/src/boards/generators/implementations/fal/audio/beatoven_music_generation.py +171 -0
- package/templates/api/src/boards/generators/implementations/fal/audio/beatoven_sound_effect_generation.py +167 -0
- package/templates/api/src/boards/generators/implementations/fal/audio/elevenlabs_sound_effects_v2.py +194 -0
- package/templates/api/src/boards/generators/implementations/fal/audio/elevenlabs_tts_eleven_v3.py +209 -0
- package/templates/api/src/boards/generators/implementations/fal/audio/fal_elevenlabs_tts_turbo_v2_5.py +206 -0
- package/templates/api/src/boards/generators/implementations/fal/audio/fal_minimax_speech_26_hd.py +237 -0
- package/templates/api/src/boards/generators/implementations/fal/audio/minimax_speech_2_6_turbo.py +1 -1
- package/templates/api/src/boards/generators/implementations/fal/image/__init__.py +30 -0
- package/templates/api/src/boards/generators/implementations/fal/image/clarity_upscaler.py +220 -0
- package/templates/api/src/boards/generators/implementations/fal/image/crystal_upscaler.py +173 -0
- package/templates/api/src/boards/generators/implementations/fal/image/fal_ideogram_character.py +227 -0
- package/templates/api/src/boards/generators/implementations/fal/image/flux_2.py +203 -0
- package/templates/api/src/boards/generators/implementations/fal/image/flux_2_edit.py +230 -0
- package/templates/api/src/boards/generators/implementations/fal/image/flux_2_pro.py +204 -0
- package/templates/api/src/boards/generators/implementations/fal/image/flux_2_pro_edit.py +221 -0
- package/templates/api/src/boards/generators/implementations/fal/image/gemini_25_flash_image.py +177 -0
- package/templates/api/src/boards/generators/implementations/fal/image/gpt_image_1_edit_image.py +182 -0
- package/templates/api/src/boards/generators/implementations/fal/image/gpt_image_1_mini.py +167 -0
- package/templates/api/src/boards/generators/implementations/fal/image/ideogram_character_edit.py +299 -0
- package/templates/api/src/boards/generators/implementations/fal/image/ideogram_v2.py +190 -0
- package/templates/api/src/boards/generators/implementations/fal/image/nano_banana_pro_edit.py +226 -0
- package/templates/api/src/boards/generators/implementations/fal/image/qwen_image.py +249 -0
- package/templates/api/src/boards/generators/implementations/fal/image/qwen_image_edit.py +244 -0
- package/templates/api/src/boards/generators/implementations/fal/video/__init__.py +42 -0
- package/templates/api/src/boards/generators/implementations/fal/video/bytedance_seedance_v1_pro_text_to_video.py +209 -0
- package/templates/api/src/boards/generators/implementations/fal/video/creatify_lipsync.py +161 -0
- package/templates/api/src/boards/generators/implementations/fal/video/fal_bytedance_seedance_v1_pro_image_to_video.py +222 -0
- package/templates/api/src/boards/generators/implementations/fal/video/fal_minimax_hailuo_02_standard_text_to_video.py +152 -0
- package/templates/api/src/boards/generators/implementations/fal/video/fal_pixverse_lipsync.py +197 -0
- package/templates/api/src/boards/generators/implementations/fal/video/fal_sora_2_text_to_video.py +173 -0
- package/templates/api/src/boards/generators/implementations/fal/video/infinitalk.py +221 -0
- package/templates/api/src/boards/generators/implementations/fal/video/kling_video_v2_5_turbo_pro_image_to_video.py +175 -0
- package/templates/api/src/boards/generators/implementations/fal/video/minimax_hailuo_2_3_pro_image_to_video.py +153 -0
- package/templates/api/src/boards/generators/implementations/fal/video/sora2_image_to_video.py +172 -0
- package/templates/api/src/boards/generators/implementations/fal/video/sora_2_image_to_video_pro.py +175 -0
- package/templates/api/src/boards/generators/implementations/fal/video/sora_2_text_to_video_pro.py +163 -0
- package/templates/api/src/boards/generators/implementations/fal/video/sync_lipsync_v2_pro.py +155 -0
- package/templates/api/src/boards/generators/implementations/fal/video/veed_lipsync.py +174 -0
- package/templates/api/src/boards/generators/implementations/fal/video/veo3.py +194 -0
- package/templates/api/src/boards/generators/implementations/fal/video/veo31_first_last_frame_to_video.py +1 -1
- package/templates/api/src/boards/generators/implementations/fal/video/wan_pro_image_to_video.py +158 -0
- package/templates/api/src/boards/graphql/access_control.py +1 -1
- package/templates/api/src/boards/graphql/mutations/root.py +16 -4
- package/templates/api/src/boards/graphql/resolvers/board.py +0 -2
- package/templates/api/src/boards/graphql/resolvers/generation.py +10 -233
- package/templates/api/src/boards/graphql/resolvers/lineage.py +381 -0
- package/templates/api/src/boards/graphql/resolvers/upload.py +463 -0
- package/templates/api/src/boards/graphql/types/generation.py +62 -26
- package/templates/api/src/boards/middleware.py +1 -1
- package/templates/api/src/boards/storage/factory.py +2 -2
- package/templates/api/src/boards/tenant_isolation.py +9 -9
- package/templates/api/src/boards/workers/actors.py +10 -1
- package/templates/web/package.json +1 -1
- package/templates/web/src/app/boards/[boardId]/page.tsx +14 -5
- package/templates/web/src/app/lineage/[generationId]/page.tsx +233 -0
- package/templates/web/src/components/boards/ArtifactPreview.tsx +20 -1
- package/templates/web/src/components/boards/UploadArtifact.tsx +253 -0
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
"""Resolvers for artifact upload operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ipaddress
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from decimal import Decimal
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
from urllib.parse import urlparse
|
|
10
|
+
from uuid import UUID
|
|
11
|
+
|
|
12
|
+
import aiohttp
|
|
13
|
+
import strawberry
|
|
14
|
+
from sqlalchemy import select
|
|
15
|
+
from sqlalchemy.orm import selectinload
|
|
16
|
+
|
|
17
|
+
from ...auth.context import AuthContext
|
|
18
|
+
from ...database.connection import get_async_session
|
|
19
|
+
from ...dbmodels import Boards, Generations
|
|
20
|
+
from ...logging import get_logger
|
|
21
|
+
from ...storage.factory import create_storage_manager
|
|
22
|
+
from ..access_control import get_auth_context_from_info
|
|
23
|
+
from ..types.generation import ArtifactType
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from ..types.generation import Generation as GenerationType
|
|
27
|
+
from ..types.generation import UploadArtifactInput
|
|
28
|
+
|
|
29
|
+
logger = get_logger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _validate_mime_type(
|
|
33
|
+
content_type: str, artifact_type: ArtifactType, filename: str | None
|
|
34
|
+
) -> tuple[bool, str | None]:
|
|
35
|
+
"""
|
|
36
|
+
Validate that MIME type matches the expected artifact type.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
content_type: The MIME type to validate (e.g., "image/jpeg")
|
|
40
|
+
artifact_type: The expected artifact type enum
|
|
41
|
+
filename: Optional filename for additional context
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
Tuple of (is_valid, error_message)
|
|
45
|
+
"""
|
|
46
|
+
# Define allowed MIME types for each artifact type
|
|
47
|
+
allowed_mime_types = {
|
|
48
|
+
ArtifactType.IMAGE: [
|
|
49
|
+
"image/jpeg",
|
|
50
|
+
"image/jpg",
|
|
51
|
+
"image/png",
|
|
52
|
+
"image/gif",
|
|
53
|
+
"image/webp",
|
|
54
|
+
"image/bmp",
|
|
55
|
+
"image/svg+xml",
|
|
56
|
+
],
|
|
57
|
+
ArtifactType.VIDEO: [
|
|
58
|
+
"video/mp4",
|
|
59
|
+
"video/quicktime",
|
|
60
|
+
"video/x-msvideo",
|
|
61
|
+
"video/webm",
|
|
62
|
+
"video/mpeg",
|
|
63
|
+
"video/x-matroska",
|
|
64
|
+
],
|
|
65
|
+
ArtifactType.AUDIO: [
|
|
66
|
+
"audio/mpeg",
|
|
67
|
+
"audio/mp3",
|
|
68
|
+
"audio/wav",
|
|
69
|
+
"audio/ogg",
|
|
70
|
+
"audio/webm",
|
|
71
|
+
"audio/x-m4a",
|
|
72
|
+
"audio/mp4",
|
|
73
|
+
],
|
|
74
|
+
ArtifactType.TEXT: [
|
|
75
|
+
"text/plain",
|
|
76
|
+
"text/markdown",
|
|
77
|
+
"application/json",
|
|
78
|
+
"text/html",
|
|
79
|
+
"text/csv",
|
|
80
|
+
],
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
# Normalize MIME type (remove charset, etc.)
|
|
84
|
+
mime_type = content_type.split(";")[0].strip().lower()
|
|
85
|
+
|
|
86
|
+
# Check if artifact type is supported
|
|
87
|
+
if artifact_type not in allowed_mime_types:
|
|
88
|
+
return False, f"Unsupported artifact type: {artifact_type.value}"
|
|
89
|
+
|
|
90
|
+
# Check if MIME type is allowed for this artifact type
|
|
91
|
+
if mime_type not in allowed_mime_types[artifact_type]:
|
|
92
|
+
# Also check for generic types
|
|
93
|
+
mime_category = mime_type.split("/")[0]
|
|
94
|
+
if mime_category != artifact_type.value:
|
|
95
|
+
return (
|
|
96
|
+
False,
|
|
97
|
+
f"MIME type '{mime_type}' does not match artifact type '{artifact_type.value}'",
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
return True, None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _is_safe_url(url: str) -> tuple[bool, str | None]:
|
|
104
|
+
"""
|
|
105
|
+
Validate URL to prevent SSRF attacks.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
Tuple of (is_safe, error_message)
|
|
109
|
+
"""
|
|
110
|
+
try:
|
|
111
|
+
parsed = urlparse(url)
|
|
112
|
+
|
|
113
|
+
# Only allow http and https
|
|
114
|
+
if parsed.scheme not in ("http", "https"):
|
|
115
|
+
return (
|
|
116
|
+
False,
|
|
117
|
+
f"URL scheme '{parsed.scheme}' not allowed. " "Only http and https are supported.",
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
hostname = parsed.hostname
|
|
121
|
+
if not hostname:
|
|
122
|
+
return False, "Invalid URL: no hostname found"
|
|
123
|
+
|
|
124
|
+
# Block localhost
|
|
125
|
+
if hostname.lower() in ("localhost", "127.0.0.1", "::1"):
|
|
126
|
+
return False, "Access to localhost is not allowed"
|
|
127
|
+
|
|
128
|
+
# Try to resolve hostname to IP
|
|
129
|
+
try:
|
|
130
|
+
# Check if it's already an IP address
|
|
131
|
+
ip = ipaddress.ip_address(hostname)
|
|
132
|
+
|
|
133
|
+
# Block private IP ranges
|
|
134
|
+
if ip.is_private:
|
|
135
|
+
return False, f"Access to private IP address {ip} is not allowed"
|
|
136
|
+
|
|
137
|
+
# Block link-local addresses (including AWS metadata endpoint)
|
|
138
|
+
if ip.is_link_local:
|
|
139
|
+
return False, f"Access to link-local address {ip} is not allowed"
|
|
140
|
+
|
|
141
|
+
# Block loopback
|
|
142
|
+
if ip.is_loopback:
|
|
143
|
+
return False, f"Access to loopback address {ip} is not allowed"
|
|
144
|
+
|
|
145
|
+
except ValueError:
|
|
146
|
+
# Not an IP address, it's a hostname - this is OK
|
|
147
|
+
# In production, you might want to resolve the hostname and check the IP
|
|
148
|
+
# but that adds complexity and potential DNS rebinding issues
|
|
149
|
+
pass
|
|
150
|
+
|
|
151
|
+
return True, None
|
|
152
|
+
|
|
153
|
+
except Exception as e:
|
|
154
|
+
return False, f"Invalid URL: {e}"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
async def upload_artifact_from_url(
|
|
158
|
+
info: strawberry.Info,
|
|
159
|
+
input: UploadArtifactInput,
|
|
160
|
+
) -> GenerationType:
|
|
161
|
+
"""Upload artifact from URL (synchronous)."""
|
|
162
|
+
from ...config import settings
|
|
163
|
+
|
|
164
|
+
auth_context = await get_auth_context_from_info(info)
|
|
165
|
+
if not auth_context or not auth_context.is_authenticated:
|
|
166
|
+
raise RuntimeError("Authentication required")
|
|
167
|
+
|
|
168
|
+
if not input.file_url:
|
|
169
|
+
raise RuntimeError("file_url is required")
|
|
170
|
+
|
|
171
|
+
# Validate URL to prevent SSRF attacks
|
|
172
|
+
is_safe, error_msg = _is_safe_url(input.file_url)
|
|
173
|
+
if not is_safe:
|
|
174
|
+
logger.warning("Unsafe URL blocked", url=input.file_url, reason=error_msg)
|
|
175
|
+
raise RuntimeError(f"URL not allowed: {error_msg}")
|
|
176
|
+
|
|
177
|
+
# Download file from URL
|
|
178
|
+
async with aiohttp.ClientSession() as http_session:
|
|
179
|
+
try:
|
|
180
|
+
async with http_session.get(
|
|
181
|
+
input.file_url, timeout=aiohttp.ClientTimeout(total=60)
|
|
182
|
+
) as resp:
|
|
183
|
+
if resp.status != 200:
|
|
184
|
+
raise RuntimeError(f"Failed to download from URL: HTTP {resp.status}")
|
|
185
|
+
|
|
186
|
+
# Check Content-Length before downloading to prevent memory exhaustion
|
|
187
|
+
content_length = resp.headers.get("Content-Length")
|
|
188
|
+
if content_length:
|
|
189
|
+
file_size = int(content_length)
|
|
190
|
+
if file_size > settings.max_upload_size:
|
|
191
|
+
raise RuntimeError(
|
|
192
|
+
f"File size ({file_size} bytes) exceeds maximum allowed "
|
|
193
|
+
f"size ({settings.max_upload_size} bytes)"
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
content = await resp.read()
|
|
197
|
+
content_type = resp.headers.get("Content-Type", "application/octet-stream")
|
|
198
|
+
|
|
199
|
+
# Extract filename from URL if not provided
|
|
200
|
+
filename = input.original_filename
|
|
201
|
+
if not filename:
|
|
202
|
+
path = urlparse(input.file_url).path
|
|
203
|
+
filename = path.split("/")[-1] if path else "uploaded_file"
|
|
204
|
+
|
|
205
|
+
except aiohttp.ClientError as e:
|
|
206
|
+
logger.error("URL download failed", url=input.file_url, error=str(e))
|
|
207
|
+
raise RuntimeError("Failed to download file from URL") from e
|
|
208
|
+
|
|
209
|
+
# Process upload
|
|
210
|
+
return await _process_upload(
|
|
211
|
+
auth_context=auth_context,
|
|
212
|
+
board_id=input.board_id,
|
|
213
|
+
artifact_type=input.artifact_type,
|
|
214
|
+
file_content=content,
|
|
215
|
+
filename=filename,
|
|
216
|
+
content_type=content_type,
|
|
217
|
+
user_description=input.user_description,
|
|
218
|
+
parent_generation_id=input.parent_generation_id,
|
|
219
|
+
upload_source="url",
|
|
220
|
+
source_url=input.file_url,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
async def upload_artifact_from_file(
|
|
225
|
+
auth_context: AuthContext,
|
|
226
|
+
board_id: UUID,
|
|
227
|
+
artifact_type: str,
|
|
228
|
+
file_content: bytes,
|
|
229
|
+
filename: str | None,
|
|
230
|
+
content_type: str | None,
|
|
231
|
+
user_description: str | None,
|
|
232
|
+
parent_generation_id: UUID | None,
|
|
233
|
+
) -> GenerationType:
|
|
234
|
+
"""Upload artifact from file (synchronous)."""
|
|
235
|
+
return await _process_upload(
|
|
236
|
+
auth_context=auth_context,
|
|
237
|
+
board_id=board_id,
|
|
238
|
+
artifact_type=ArtifactType(artifact_type),
|
|
239
|
+
file_content=file_content,
|
|
240
|
+
filename=filename or "uploaded_file",
|
|
241
|
+
content_type=content_type or "application/octet-stream",
|
|
242
|
+
user_description=user_description,
|
|
243
|
+
parent_generation_id=parent_generation_id,
|
|
244
|
+
upload_source="file",
|
|
245
|
+
source_url=None,
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _sanitize_filename(filename: str) -> str:
|
|
250
|
+
"""
|
|
251
|
+
Sanitize filename to prevent path traversal and other security issues.
|
|
252
|
+
|
|
253
|
+
Returns:
|
|
254
|
+
Sanitized filename (basename only, no path components)
|
|
255
|
+
"""
|
|
256
|
+
import os
|
|
257
|
+
import re
|
|
258
|
+
|
|
259
|
+
# Get basename only (remove any path components)
|
|
260
|
+
filename = os.path.basename(filename)
|
|
261
|
+
|
|
262
|
+
# Remove any null bytes
|
|
263
|
+
filename = filename.replace("\x00", "")
|
|
264
|
+
|
|
265
|
+
# Replace potentially dangerous characters (including backslash for Windows paths)
|
|
266
|
+
filename = re.sub(r'[<>:"|?*\\]', "_", filename)
|
|
267
|
+
|
|
268
|
+
# Remove leading/trailing whitespace and dots
|
|
269
|
+
filename = filename.strip(". ")
|
|
270
|
+
|
|
271
|
+
# If filename is empty after sanitization, use a default
|
|
272
|
+
if not filename:
|
|
273
|
+
filename = "uploaded_file"
|
|
274
|
+
|
|
275
|
+
return filename
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
async def _process_upload(
|
|
279
|
+
auth_context: AuthContext,
|
|
280
|
+
board_id: UUID,
|
|
281
|
+
artifact_type: ArtifactType,
|
|
282
|
+
file_content: bytes,
|
|
283
|
+
filename: str,
|
|
284
|
+
content_type: str,
|
|
285
|
+
user_description: str | None,
|
|
286
|
+
parent_generation_id: UUID | None,
|
|
287
|
+
upload_source: str,
|
|
288
|
+
source_url: str | None,
|
|
289
|
+
) -> GenerationType:
|
|
290
|
+
"""Common upload processing logic.
|
|
291
|
+
|
|
292
|
+
Args:
|
|
293
|
+
auth_context: Authentication context for the request
|
|
294
|
+
board_id: UUID of the board to upload to
|
|
295
|
+
artifact_type: Type of artifact being uploaded (enum)
|
|
296
|
+
file_content: Binary content of the file
|
|
297
|
+
filename: Original filename
|
|
298
|
+
content_type: MIME type of the file
|
|
299
|
+
user_description: Optional user-provided description
|
|
300
|
+
parent_generation_id: Optional parent generation UUID
|
|
301
|
+
upload_source: Source of upload ("file" or "url")
|
|
302
|
+
source_url: URL if uploaded from URL, None otherwise
|
|
303
|
+
|
|
304
|
+
Returns:
|
|
305
|
+
GenerationType object representing the uploaded artifact
|
|
306
|
+
"""
|
|
307
|
+
from ...config import settings
|
|
308
|
+
from ..types.generation import Generation as GenerationType
|
|
309
|
+
from ..types.generation import GenerationStatus
|
|
310
|
+
|
|
311
|
+
# Sanitize filename to prevent path traversal
|
|
312
|
+
filename = _sanitize_filename(filename)
|
|
313
|
+
|
|
314
|
+
# Validate MIME type matches artifact type
|
|
315
|
+
is_valid, error_msg = _validate_mime_type(content_type, artifact_type, filename)
|
|
316
|
+
if not is_valid:
|
|
317
|
+
logger.warning(
|
|
318
|
+
"Invalid MIME type for artifact",
|
|
319
|
+
mime_type=content_type,
|
|
320
|
+
artifact_type=artifact_type.value,
|
|
321
|
+
reason=error_msg,
|
|
322
|
+
)
|
|
323
|
+
raise RuntimeError(f"Invalid file type: {error_msg}")
|
|
324
|
+
|
|
325
|
+
# Validate file size (double-check even after Content-Length check)
|
|
326
|
+
if len(file_content) > settings.max_upload_size:
|
|
327
|
+
raise RuntimeError(
|
|
328
|
+
f"File size ({len(file_content)} bytes) exceeds maximum allowed "
|
|
329
|
+
f"size ({settings.max_upload_size} bytes)"
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
async with get_async_session() as session:
|
|
333
|
+
# Validate board access
|
|
334
|
+
board_stmt = (
|
|
335
|
+
select(Boards).where(Boards.id == board_id).options(selectinload(Boards.board_members))
|
|
336
|
+
)
|
|
337
|
+
board = (await session.execute(board_stmt)).scalar_one_or_none()
|
|
338
|
+
|
|
339
|
+
if not board:
|
|
340
|
+
raise RuntimeError("Board not found")
|
|
341
|
+
|
|
342
|
+
# Check permissions (same as create_generation)
|
|
343
|
+
if not auth_context.user_id:
|
|
344
|
+
raise RuntimeError("User ID is required")
|
|
345
|
+
|
|
346
|
+
is_owner = board.owner_id == auth_context.user_id
|
|
347
|
+
is_editor = any(
|
|
348
|
+
m.user_id == auth_context.user_id and m.role in {"editor", "admin"}
|
|
349
|
+
for m in board.board_members
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
if not is_owner and not is_editor:
|
|
353
|
+
raise RuntimeError(
|
|
354
|
+
"Permission denied: You don't have permission to upload to this board"
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
# Create generation record (status=pending temporarily)
|
|
358
|
+
gen = Generations()
|
|
359
|
+
gen.tenant_id = auth_context.tenant_id
|
|
360
|
+
gen.board_id = board_id
|
|
361
|
+
gen.user_id = auth_context.user_id
|
|
362
|
+
gen.generator_name = f"user-upload-{artifact_type.value}"
|
|
363
|
+
gen.artifact_type = artifact_type.value
|
|
364
|
+
gen.status = "pending"
|
|
365
|
+
gen.progress = Decimal(0.0)
|
|
366
|
+
gen.input_params = {
|
|
367
|
+
"upload_source": upload_source,
|
|
368
|
+
"original_filename": filename,
|
|
369
|
+
"source_url": source_url,
|
|
370
|
+
"user_description": user_description,
|
|
371
|
+
}
|
|
372
|
+
gen.output_metadata = {
|
|
373
|
+
"file_size": len(file_content),
|
|
374
|
+
"mime_type": content_type,
|
|
375
|
+
"upload_timestamp": datetime.now(UTC).isoformat(),
|
|
376
|
+
}
|
|
377
|
+
# If parent_generation_id is provided, add it to input_artifacts
|
|
378
|
+
if parent_generation_id:
|
|
379
|
+
gen.input_artifacts = [
|
|
380
|
+
{
|
|
381
|
+
"generation_id": str(parent_generation_id),
|
|
382
|
+
"role": "parent",
|
|
383
|
+
"artifact_type": artifact_type.value,
|
|
384
|
+
}
|
|
385
|
+
]
|
|
386
|
+
else:
|
|
387
|
+
gen.input_artifacts = []
|
|
388
|
+
gen.started_at = datetime.now(UTC)
|
|
389
|
+
|
|
390
|
+
session.add(gen)
|
|
391
|
+
await session.flush() # Get ID
|
|
392
|
+
|
|
393
|
+
try:
|
|
394
|
+
# Upload to storage
|
|
395
|
+
storage_manager = create_storage_manager()
|
|
396
|
+
artifact_ref = await storage_manager.store_artifact(
|
|
397
|
+
artifact_id=str(gen.id),
|
|
398
|
+
content=file_content,
|
|
399
|
+
artifact_type=artifact_type.value,
|
|
400
|
+
content_type=content_type,
|
|
401
|
+
tenant_id=str(auth_context.tenant_id),
|
|
402
|
+
board_id=str(board_id),
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
# Update generation with storage info
|
|
406
|
+
gen.storage_url = artifact_ref.storage_url
|
|
407
|
+
gen.status = "completed"
|
|
408
|
+
gen.progress = Decimal(100.0)
|
|
409
|
+
gen.completed_at = datetime.now(UTC)
|
|
410
|
+
|
|
411
|
+
# Update metadata with storage details
|
|
412
|
+
if gen.output_metadata is None:
|
|
413
|
+
gen.output_metadata = {}
|
|
414
|
+
gen.output_metadata["storage_key"] = artifact_ref.storage_key
|
|
415
|
+
gen.output_metadata["storage_provider"] = artifact_ref.storage_provider
|
|
416
|
+
|
|
417
|
+
await session.commit()
|
|
418
|
+
await session.refresh(gen)
|
|
419
|
+
|
|
420
|
+
logger.info(
|
|
421
|
+
"Artifact uploaded",
|
|
422
|
+
generation_id=str(gen.id),
|
|
423
|
+
artifact_type=artifact_type,
|
|
424
|
+
file_size=len(file_content),
|
|
425
|
+
upload_source=upload_source,
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
# Convert to GraphQL type
|
|
429
|
+
return GenerationType(
|
|
430
|
+
id=gen.id,
|
|
431
|
+
tenant_id=gen.tenant_id,
|
|
432
|
+
board_id=gen.board_id,
|
|
433
|
+
user_id=gen.user_id,
|
|
434
|
+
generator_name=gen.generator_name,
|
|
435
|
+
artifact_type=ArtifactType(gen.artifact_type),
|
|
436
|
+
storage_url=gen.storage_url,
|
|
437
|
+
thumbnail_url=gen.thumbnail_url,
|
|
438
|
+
additional_files=gen.additional_files or [],
|
|
439
|
+
input_params=gen.input_params or {},
|
|
440
|
+
output_metadata=gen.output_metadata or {},
|
|
441
|
+
external_job_id=gen.external_job_id,
|
|
442
|
+
status=GenerationStatus(gen.status),
|
|
443
|
+
progress=float(gen.progress),
|
|
444
|
+
error_message=gen.error_message,
|
|
445
|
+
started_at=gen.started_at,
|
|
446
|
+
completed_at=gen.completed_at,
|
|
447
|
+
created_at=gen.created_at,
|
|
448
|
+
updated_at=gen.updated_at,
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
except Exception as e:
|
|
452
|
+
# Mark as failed
|
|
453
|
+
gen.status = "failed"
|
|
454
|
+
gen.error_message = str(e)
|
|
455
|
+
gen.completed_at = datetime.now(UTC)
|
|
456
|
+
await session.commit()
|
|
457
|
+
|
|
458
|
+
logger.error(
|
|
459
|
+
"Upload failed",
|
|
460
|
+
generation_id=str(gen.id),
|
|
461
|
+
error=str(e),
|
|
462
|
+
)
|
|
463
|
+
raise RuntimeError(f"Upload failed: {e}") from e
|
|
@@ -46,6 +46,56 @@ class AdditionalFile:
|
|
|
46
46
|
metadata: strawberry.scalars.JSON # type: ignore[reportInvalidTypeForm]
|
|
47
47
|
|
|
48
48
|
|
|
49
|
+
@strawberry.input
|
|
50
|
+
class UploadArtifactInput:
|
|
51
|
+
"""Input for uploading an artifact from URL."""
|
|
52
|
+
|
|
53
|
+
board_id: UUID
|
|
54
|
+
artifact_type: ArtifactType
|
|
55
|
+
file_url: str | None = None
|
|
56
|
+
original_filename: str | None = None
|
|
57
|
+
user_description: str | None = None
|
|
58
|
+
parent_generation_id: UUID | None = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@strawberry.type
|
|
62
|
+
class ArtifactLineage:
|
|
63
|
+
"""Represents a single input artifact relationship with role metadata."""
|
|
64
|
+
|
|
65
|
+
generation_id: UUID
|
|
66
|
+
role: str
|
|
67
|
+
artifact_type: ArtifactType
|
|
68
|
+
|
|
69
|
+
@strawberry.field
|
|
70
|
+
async def generation(
|
|
71
|
+
self, info: strawberry.Info
|
|
72
|
+
) -> Annotated["Generation", strawberry.lazy(".generation")] | None:
|
|
73
|
+
"""Resolve the full generation object for this input."""
|
|
74
|
+
from ..resolvers.lineage import resolve_generation_by_id
|
|
75
|
+
|
|
76
|
+
return await resolve_generation_by_id(info, self.generation_id)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@strawberry.type
|
|
80
|
+
class AncestryNode:
|
|
81
|
+
"""Represents a node in the ancestry tree."""
|
|
82
|
+
|
|
83
|
+
generation: Annotated["Generation", strawberry.lazy(".generation")]
|
|
84
|
+
depth: int
|
|
85
|
+
role: str | None
|
|
86
|
+
parents: list["AncestryNode"]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@strawberry.type
|
|
90
|
+
class DescendantNode:
|
|
91
|
+
"""Represents a node in the descendants tree."""
|
|
92
|
+
|
|
93
|
+
generation: Annotated["Generation", strawberry.lazy(".generation")]
|
|
94
|
+
depth: int
|
|
95
|
+
role: str | None
|
|
96
|
+
children: list["DescendantNode"]
|
|
97
|
+
|
|
98
|
+
|
|
49
99
|
@strawberry.type
|
|
50
100
|
class Generation:
|
|
51
101
|
"""Generation type for GraphQL API."""
|
|
@@ -68,10 +118,6 @@ class Generation:
|
|
|
68
118
|
input_params: strawberry.scalars.JSON # type: ignore[reportInvalidTypeForm]
|
|
69
119
|
output_metadata: strawberry.scalars.JSON # type: ignore[reportInvalidTypeForm]
|
|
70
120
|
|
|
71
|
-
# Lineage
|
|
72
|
-
parent_generation_id: UUID | None
|
|
73
|
-
input_generation_ids: list[UUID]
|
|
74
|
-
|
|
75
121
|
# Job tracking
|
|
76
122
|
external_job_id: str | None
|
|
77
123
|
status: GenerationStatus
|
|
@@ -99,32 +145,22 @@ class Generation:
|
|
|
99
145
|
return await resolve_generation_user(self, info)
|
|
100
146
|
|
|
101
147
|
@strawberry.field
|
|
102
|
-
async def
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
"""Get the parent generation if any."""
|
|
106
|
-
if not self.parent_generation_id:
|
|
107
|
-
return None
|
|
108
|
-
from ..resolvers.generation import resolve_generation_parent
|
|
148
|
+
async def input_artifacts(self, info: strawberry.Info) -> list[ArtifactLineage]:
|
|
149
|
+
"""Get input artifacts with role metadata."""
|
|
150
|
+
from ..resolvers.lineage import resolve_input_artifacts
|
|
109
151
|
|
|
110
|
-
return await
|
|
152
|
+
return await resolve_input_artifacts(self, info)
|
|
111
153
|
|
|
112
154
|
@strawberry.field
|
|
113
|
-
async def
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
"""Get input generations used for this generation."""
|
|
117
|
-
if not self.input_generation_ids:
|
|
118
|
-
return []
|
|
119
|
-
from ..resolvers.generation import resolve_generation_inputs
|
|
155
|
+
async def ancestry(self, info: strawberry.Info, max_depth: int = 25) -> AncestryNode:
|
|
156
|
+
"""Get complete ancestry tree up to max_depth levels."""
|
|
157
|
+
from ..resolvers.lineage import resolve_ancestry
|
|
120
158
|
|
|
121
|
-
return await
|
|
159
|
+
return await resolve_ancestry(self, info, max_depth)
|
|
122
160
|
|
|
123
161
|
@strawberry.field
|
|
124
|
-
async def
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
"""Get child generations derived from this one."""
|
|
128
|
-
from ..resolvers.generation import resolve_generation_children
|
|
162
|
+
async def descendants(self, info: strawberry.Info, max_depth: int = 25) -> DescendantNode:
|
|
163
|
+
"""Get complete descendants tree up to max_depth levels."""
|
|
164
|
+
from ..resolvers.lineage import resolve_descendants
|
|
129
165
|
|
|
130
|
-
return await
|
|
166
|
+
return await resolve_descendants(self, info, max_depth)
|
|
@@ -260,7 +260,7 @@ class TenantRoutingMiddleware(BaseHTTPMiddleware):
|
|
|
260
260
|
content={
|
|
261
261
|
"error": "Missing X-Tenant header",
|
|
262
262
|
"detail": (
|
|
263
|
-
"X-Tenant header is required in multi-tenant mode
|
|
263
|
+
"X-Tenant header is required in multi-tenant mode for this endpoint"
|
|
264
264
|
),
|
|
265
265
|
"multi_tenant_mode": True,
|
|
266
266
|
},
|
|
@@ -60,7 +60,7 @@ except ImportError:
|
|
|
60
60
|
S3StorageProvider = None
|
|
61
61
|
_s3_available = False
|
|
62
62
|
logger.warning(
|
|
63
|
-
"S3 storage not available.
|
|
63
|
+
"S3 storage not available. Install with: pip install weirdfingers-boards[storage-s3]"
|
|
64
64
|
)
|
|
65
65
|
|
|
66
66
|
try:
|
|
@@ -71,7 +71,7 @@ except ImportError:
|
|
|
71
71
|
GCSStorageProvider = None
|
|
72
72
|
_gcs_available = False
|
|
73
73
|
logger.warning(
|
|
74
|
-
"GCS storage not available.
|
|
74
|
+
"GCS storage not available. Install with: pip install weirdfingers-boards[storage-gcs]"
|
|
75
75
|
)
|
|
76
76
|
|
|
77
77
|
|
|
@@ -214,8 +214,8 @@ class TenantIsolationValidator:
|
|
|
214
214
|
stmt = text(
|
|
215
215
|
"""
|
|
216
216
|
SELECT u.id as user_id, b.id as board_id, b.tenant_id as board_tenant_id
|
|
217
|
-
FROM users u
|
|
218
|
-
JOIN boards b ON u.id = b.owner_id
|
|
217
|
+
FROM boards.users u
|
|
218
|
+
JOIN boards.boards b ON u.id = b.owner_id
|
|
219
219
|
WHERE u.tenant_id = :tenant_id AND b.tenant_id != :tenant_id
|
|
220
220
|
"""
|
|
221
221
|
)
|
|
@@ -240,8 +240,8 @@ class TenantIsolationValidator:
|
|
|
240
240
|
g.tenant_id,
|
|
241
241
|
g.board_id,
|
|
242
242
|
b.tenant_id as board_tenant_id
|
|
243
|
-
FROM generations g
|
|
244
|
-
JOIN boards b ON g.board_id = b.id
|
|
243
|
+
FROM boards.generations g
|
|
244
|
+
JOIN boards.boards b ON g.board_id = b.id
|
|
245
245
|
WHERE g.tenant_id = :tenant_id AND b.tenant_id != :tenant_id
|
|
246
246
|
"""
|
|
247
247
|
)
|
|
@@ -275,9 +275,9 @@ class TenantIsolationValidator:
|
|
|
275
275
|
bm.user_id,
|
|
276
276
|
b.tenant_id as board_tenant_id,
|
|
277
277
|
u.tenant_id as user_tenant_id
|
|
278
|
-
FROM board_members bm
|
|
279
|
-
JOIN boards b ON bm.board_id = b.id
|
|
280
|
-
JOIN users u ON bm.user_id = u.id
|
|
278
|
+
FROM boards.board_members bm
|
|
279
|
+
JOIN boards.boards b ON bm.board_id = b.id
|
|
280
|
+
JOIN boards.users u ON bm.user_id = u.id
|
|
281
281
|
WHERE b.tenant_id = :tenant_id AND u.tenant_id != :tenant_id
|
|
282
282
|
"""
|
|
283
283
|
)
|
|
@@ -325,8 +325,8 @@ class TenantIsolationValidator:
|
|
|
325
325
|
stmt = text(
|
|
326
326
|
"""
|
|
327
327
|
SELECT COUNT(*) as count
|
|
328
|
-
FROM board_members bm
|
|
329
|
-
JOIN boards b ON bm.board_id = b.id
|
|
328
|
+
FROM boards.board_members bm
|
|
329
|
+
JOIN boards.boards b ON bm.board_id = b.id
|
|
330
330
|
WHERE b.tenant_id = :tenant_id
|
|
331
331
|
"""
|
|
332
332
|
)
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import traceback
|
|
6
|
+
from typing import Any
|
|
6
7
|
|
|
7
8
|
import dramatiq
|
|
8
9
|
from dramatiq import actor
|
|
@@ -92,6 +93,7 @@ async def process_generation(generation_id: str) -> None:
|
|
|
92
93
|
# Build and validate typed inputs
|
|
93
94
|
# First resolve any artifact fields (generation IDs -> artifact objects)
|
|
94
95
|
# This happens automatically via type introspection
|
|
96
|
+
lineage_metadata: list[dict[str, Any]] = []
|
|
95
97
|
try:
|
|
96
98
|
input_schema = generator.get_input_schema()
|
|
97
99
|
|
|
@@ -99,12 +101,19 @@ async def process_generation(generation_id: str) -> None:
|
|
|
99
101
|
from ..generators.artifact_resolution import resolve_input_artifacts
|
|
100
102
|
|
|
101
103
|
async with get_async_session() as session:
|
|
102
|
-
resolved_params = await resolve_input_artifacts(
|
|
104
|
+
resolved_params, lineage_metadata = await resolve_input_artifacts(
|
|
103
105
|
input_params,
|
|
104
106
|
input_schema, # Schema is introspected to find artifact fields
|
|
105
107
|
session,
|
|
106
108
|
tenant_id,
|
|
107
109
|
)
|
|
110
|
+
|
|
111
|
+
# Store lineage metadata in the generation
|
|
112
|
+
if lineage_metadata:
|
|
113
|
+
generation = await jobs_repo.get_generation(session, generation_id)
|
|
114
|
+
generation.input_artifacts = lineage_metadata
|
|
115
|
+
await session.commit()
|
|
116
|
+
|
|
108
117
|
typed_inputs = input_schema.model_validate(resolved_params)
|
|
109
118
|
except Exception as e:
|
|
110
119
|
error_msg = "Invalid input parameters"
|