morphosx 0.4.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.
- morphosx/app/__init__.py +0 -0
- morphosx/app/api/__init__.py +0 -0
- morphosx/app/api/assets.py +346 -0
- morphosx/app/cli.py +22 -0
- morphosx/app/core/__init__.py +0 -0
- morphosx/app/core/auth.py +43 -0
- morphosx/app/core/security.py +60 -0
- morphosx/app/engine/__init__.py +0 -0
- morphosx/app/engine/archive.py +58 -0
- morphosx/app/engine/audio.py +40 -0
- morphosx/app/engine/bim.py +91 -0
- morphosx/app/engine/document.py +48 -0
- morphosx/app/engine/font.py +55 -0
- morphosx/app/engine/model3d.py +84 -0
- morphosx/app/engine/office.py +78 -0
- morphosx/app/engine/processor.py +140 -0
- morphosx/app/engine/raw.py +44 -0
- morphosx/app/engine/text.py +103 -0
- morphosx/app/engine/video.py +78 -0
- morphosx/app/engine/vips.py +96 -0
- morphosx/app/main.py +28 -0
- morphosx/app/settings.py +72 -0
- morphosx/app/storage/__init__.py +0 -0
- morphosx/app/storage/base.py +41 -0
- morphosx/app/storage/local.py +84 -0
- morphosx/app/storage/s3.py +106 -0
- morphosx-0.4.0.dist-info/LICENSE +21 -0
- morphosx-0.4.0.dist-info/METADATA +188 -0
- morphosx-0.4.0.dist-info/RECORD +31 -0
- morphosx-0.4.0.dist-info/WHEEL +4 -0
- morphosx-0.4.0.dist-info/entry_points.txt +5 -0
morphosx/app/__init__.py
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
import uuid
|
|
3
|
+
from mimetypes import guess_extension
|
|
4
|
+
from typing import Optional
|
|
5
|
+
from fastapi import APIRouter, Depends, HTTPException, Query, Response, UploadFile, File
|
|
6
|
+
|
|
7
|
+
from morphosx.app.core.security import verify_signature, generate_signature
|
|
8
|
+
from morphosx.app.core.auth import get_current_user
|
|
9
|
+
from morphosx.app.engine.processor import ImageProcessor, ProcessingOptions, ImageFormat
|
|
10
|
+
from morphosx.app.engine.video import VideoProcessor
|
|
11
|
+
from morphosx.app.engine.audio import AudioProcessor
|
|
12
|
+
from morphosx.app.engine.document import DocumentProcessor
|
|
13
|
+
from morphosx.app.engine.raw import RawProcessor
|
|
14
|
+
from morphosx.app.engine.vips import VipsProcessor
|
|
15
|
+
from morphosx.app.engine.text import TextProcessor
|
|
16
|
+
from morphosx.app.engine.office import OfficeProcessor
|
|
17
|
+
from morphosx.app.engine.font import FontProcessor
|
|
18
|
+
from morphosx.app.engine.model3d import Model3DProcessor
|
|
19
|
+
from morphosx.app.engine.archive import ArchiveProcessor
|
|
20
|
+
from morphosx.app.engine.bim import BIMProcessor
|
|
21
|
+
from morphosx.app.storage.local import LocalStorage
|
|
22
|
+
from morphosx.app.storage.s3 import S3Storage
|
|
23
|
+
from morphosx.app.settings import settings
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
router = APIRouter(prefix="/assets", tags=["Assets"])
|
|
27
|
+
|
|
28
|
+
# Singleton instances factory
|
|
29
|
+
def get_storage():
|
|
30
|
+
if settings.storage_type == "s3":
|
|
31
|
+
if not settings.s3_bucket:
|
|
32
|
+
raise ValueError("S3_BUCKET is required for s3 storage_type")
|
|
33
|
+
return S3Storage(
|
|
34
|
+
bucket_name=settings.s3_bucket,
|
|
35
|
+
region_name=settings.s3_region,
|
|
36
|
+
endpoint_url=settings.s3_endpoint,
|
|
37
|
+
access_key_id=settings.s3_access_key,
|
|
38
|
+
secret_access_key=settings.s3_secret_key
|
|
39
|
+
)
|
|
40
|
+
return LocalStorage(base_directory=settings.storage_root)
|
|
41
|
+
|
|
42
|
+
def get_processor():
|
|
43
|
+
if settings.engine_type == "vips":
|
|
44
|
+
return VipsProcessor()
|
|
45
|
+
return ImageProcessor()
|
|
46
|
+
|
|
47
|
+
storage = get_storage()
|
|
48
|
+
processor = get_processor()
|
|
49
|
+
video_processor = VideoProcessor()
|
|
50
|
+
audio_processor = AudioProcessor()
|
|
51
|
+
document_processor = DocumentProcessor()
|
|
52
|
+
raw_processor = RawProcessor()
|
|
53
|
+
text_processor = TextProcessor()
|
|
54
|
+
office_processor = OfficeProcessor()
|
|
55
|
+
font_processor = FontProcessor()
|
|
56
|
+
model3d_processor = Model3DProcessor()
|
|
57
|
+
archive_processor = ArchiveProcessor()
|
|
58
|
+
bim_processor = BIMProcessor()
|
|
59
|
+
|
|
60
|
+
VIDEO_EXTENSIONS = {".mp4", ".webm", ".mov", ".avi"}
|
|
61
|
+
AUDIO_EXTENSIONS = {".mp3", ".wav", ".ogg", ".flac"}
|
|
62
|
+
DOCUMENT_EXTENSIONS = {".pdf"}
|
|
63
|
+
RAW_EXTENSIONS = {".cr2", ".nef", ".dng", ".arw"}
|
|
64
|
+
TEXT_EXTENSIONS = {".json", ".xml", ".md"}
|
|
65
|
+
OFFICE_EXTENSIONS = {".docx", ".pptx", ".xlsx"}
|
|
66
|
+
FONT_EXTENSIONS = {".ttf", ".otf"}
|
|
67
|
+
MODEL3D_EXTENSIONS = {".stl", ".obj", ".glb", ".gltf"}
|
|
68
|
+
ARCHIVE_EXTENSIONS = {".zip", ".tar", ".gz"}
|
|
69
|
+
BIM_EXTENSIONS = {".ifc"}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@router.post("/upload")
|
|
74
|
+
async def upload_asset(
|
|
75
|
+
file: UploadFile = File(...),
|
|
76
|
+
private: bool = Query(False),
|
|
77
|
+
folder: Optional[str] = Query(None),
|
|
78
|
+
current_user: Optional[str] = Depends(get_current_user)
|
|
79
|
+
):
|
|
80
|
+
"""
|
|
81
|
+
Upload a new asset.
|
|
82
|
+
- If 'private=True' and user is logged in: saved to 'users/{user_id}/{folder}/'
|
|
83
|
+
- Otherwise: saved to 'originals/{folder}/'
|
|
84
|
+
"""
|
|
85
|
+
asset_uuid = str(uuid.uuid4())
|
|
86
|
+
ext = guess_extension(file.content_type) or ".bin"
|
|
87
|
+
|
|
88
|
+
# Determine base prefix
|
|
89
|
+
if private:
|
|
90
|
+
if not current_user:
|
|
91
|
+
raise HTTPException(status_code=401, detail="Authentication required for private uploads")
|
|
92
|
+
base_prefix = f"users/{current_user}"
|
|
93
|
+
else:
|
|
94
|
+
base_prefix = "originals"
|
|
95
|
+
|
|
96
|
+
# Sanitize and add custom folder if provided
|
|
97
|
+
path_parts = [base_prefix]
|
|
98
|
+
if folder:
|
|
99
|
+
sanitized_folder = folder.strip("/")
|
|
100
|
+
if sanitized_folder:
|
|
101
|
+
path_parts.append(sanitized_folder)
|
|
102
|
+
|
|
103
|
+
asset_id = f"{'/'.join(path_parts)}/{asset_uuid}{ext}"
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
content = await file.read()
|
|
107
|
+
saved_id = await storage.save_asset(asset_id, content)
|
|
108
|
+
|
|
109
|
+
# Clean ID for the response (for private assets we keep the user prefix)
|
|
110
|
+
clean_id = saved_id if private else Path(saved_id).name
|
|
111
|
+
|
|
112
|
+
# Determine if it's a video to suggest thumbnail params
|
|
113
|
+
is_video = ext.lower() in VIDEO_EXTENSIONS
|
|
114
|
+
|
|
115
|
+
# Generate a sample signed URL
|
|
116
|
+
sample_fmt = ImageFormat.WEBP
|
|
117
|
+
sample_q = settings.default_quality
|
|
118
|
+
sig = generate_signature(
|
|
119
|
+
asset_id=clean_id,
|
|
120
|
+
width=None,
|
|
121
|
+
height=None,
|
|
122
|
+
format=sample_fmt.value.lower(),
|
|
123
|
+
quality=sample_q,
|
|
124
|
+
secret_key=settings.secret_key,
|
|
125
|
+
user_id=current_user if private else None
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
url = f"{settings.api_prefix}/assets/{clean_id}?s={sig}"
|
|
129
|
+
if is_video:
|
|
130
|
+
url += "&t=1"
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
"asset_id": clean_id,
|
|
134
|
+
"url": url,
|
|
135
|
+
"is_private": private,
|
|
136
|
+
"owner": current_user if private else "public",
|
|
137
|
+
"mime_type": file.content_type,
|
|
138
|
+
"size": len(content)
|
|
139
|
+
}
|
|
140
|
+
except Exception as e:
|
|
141
|
+
raise HTTPException(status_code=500, detail=f"Upload failed: {str(e)}")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@router.get("/{asset_id:path}")
|
|
145
|
+
async def get_processed_asset(
|
|
146
|
+
asset_id: str,
|
|
147
|
+
w: Optional[int] = Query(None, alias="width", ge=1, le=settings.max_image_dimension),
|
|
148
|
+
h: Optional[int] = Query(None, alias="height", ge=1, le=settings.max_image_dimension),
|
|
149
|
+
fmt: Optional[ImageFormat] = Query(None, alias="format"),
|
|
150
|
+
q: Optional[int] = Query(None, alias="quality", ge=1, le=100),
|
|
151
|
+
preset: Optional[str] = Query(None, alias="preset"),
|
|
152
|
+
t: float = Query(0.0, alias="time", ge=0.0),
|
|
153
|
+
p: int = Query(1, alias="page", ge=1),
|
|
154
|
+
s: str = Query(..., alias="signature", description="HMAC-SHA256 signature"),
|
|
155
|
+
current_user: Optional[str] = Depends(get_current_user),
|
|
156
|
+
):
|
|
157
|
+
"""
|
|
158
|
+
Retrieve and process an asset.
|
|
159
|
+
Supports Smart Presets and User-bound protected assets.
|
|
160
|
+
"""
|
|
161
|
+
# 0. Apply Preset Logic
|
|
162
|
+
target_w = w
|
|
163
|
+
target_h = h
|
|
164
|
+
target_fmt = fmt
|
|
165
|
+
target_q = q if q else settings.default_quality
|
|
166
|
+
|
|
167
|
+
if preset:
|
|
168
|
+
if preset not in settings.presets:
|
|
169
|
+
raise HTTPException(status_code=400, detail=f"Invalid preset: {preset}")
|
|
170
|
+
|
|
171
|
+
config = settings.presets[preset]
|
|
172
|
+
# Preset values act as defaults, explicit query params override them
|
|
173
|
+
target_w = w if w else config.get("width")
|
|
174
|
+
target_h = h if h else config.get("height")
|
|
175
|
+
target_fmt = fmt if fmt else ImageFormat(config.get("format").upper())
|
|
176
|
+
target_q = q if q else config.get("quality", settings.default_quality)
|
|
177
|
+
|
|
178
|
+
# 1. Signature Verification (SECURITY FIRST)
|
|
179
|
+
# We enforce that if an asset is in the "users/" directory, it MUST have a matching current_user
|
|
180
|
+
is_private = asset_id.startswith("users/")
|
|
181
|
+
if is_private:
|
|
182
|
+
# Extract owner ID from path: "users/{user_id}/file.jpg"
|
|
183
|
+
parts = asset_id.split("/")
|
|
184
|
+
if len(parts) < 3:
|
|
185
|
+
raise HTTPException(status_code=400, detail="Invalid private asset path")
|
|
186
|
+
|
|
187
|
+
owner_id = parts[1]
|
|
188
|
+
if current_user != owner_id:
|
|
189
|
+
raise HTTPException(status_code=403, detail="Not authorized to access this private asset")
|
|
190
|
+
|
|
191
|
+
is_valid = verify_signature(
|
|
192
|
+
asset_id=asset_id,
|
|
193
|
+
width=w,
|
|
194
|
+
height=h,
|
|
195
|
+
format=fmt.value.lower() if fmt else "",
|
|
196
|
+
quality=q if q else 0,
|
|
197
|
+
signature_to_verify=s,
|
|
198
|
+
secret_key=settings.secret_key,
|
|
199
|
+
preset=preset,
|
|
200
|
+
user_id=current_user
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
if not is_valid:
|
|
204
|
+
raise HTTPException(status_code=403, detail="Invalid signature")
|
|
205
|
+
|
|
206
|
+
options = ProcessingOptions(
|
|
207
|
+
width=target_w,
|
|
208
|
+
height=target_h,
|
|
209
|
+
format=fmt_val,
|
|
210
|
+
quality=target_q
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
# 2. Define Cache Paths (Include timestamp or page for media derivatives)
|
|
214
|
+
cache_key = options.get_cache_key()
|
|
215
|
+
is_video = Path(asset_id).suffix.lower() in VIDEO_EXTENSIONS
|
|
216
|
+
is_document = Path(asset_id).suffix.lower() in DOCUMENT_EXTENSIONS
|
|
217
|
+
|
|
218
|
+
if is_video:
|
|
219
|
+
cache_key = f"t{t}_{cache_key}"
|
|
220
|
+
elif is_document:
|
|
221
|
+
cache_key = f"p{p}_{cache_key}"
|
|
222
|
+
|
|
223
|
+
derivative_id = f"cache/{asset_id}/{cache_key}"
|
|
224
|
+
|
|
225
|
+
try:
|
|
226
|
+
# 3. Cache Check (HIT)
|
|
227
|
+
try:
|
|
228
|
+
derivative_bytes = await storage.get_asset(derivative_id)
|
|
229
|
+
return Response(
|
|
230
|
+
content=derivative_bytes,
|
|
231
|
+
media_type=f"image/{options.format.value.lower()}",
|
|
232
|
+
headers={
|
|
233
|
+
"Cache-Control": "public, max-age=31536000, immutable",
|
|
234
|
+
"X-MorphosX-Cache": "HIT"
|
|
235
|
+
}
|
|
236
|
+
)
|
|
237
|
+
except FileNotFoundError:
|
|
238
|
+
# 4. Cache Miss (MISS)
|
|
239
|
+
pass
|
|
240
|
+
|
|
241
|
+
# 5. Fetch Original (Always from originals/ folder)
|
|
242
|
+
original_id = f"originals/{asset_id}"
|
|
243
|
+
source_bytes = await storage.get_asset(original_id)
|
|
244
|
+
|
|
245
|
+
# 6. Transform Pipeline
|
|
246
|
+
is_audio = Path(asset_id).suffix.lower() in AUDIO_EXTENSIONS
|
|
247
|
+
is_raw = Path(asset_id).suffix.lower() in RAW_EXTENSIONS
|
|
248
|
+
is_text = Path(asset_id).suffix.lower() in TEXT_EXTENSIONS
|
|
249
|
+
is_office = Path(asset_id).suffix.lower() in OFFICE_EXTENSIONS
|
|
250
|
+
is_font = Path(asset_id).suffix.lower() in FONT_EXTENSIONS
|
|
251
|
+
is_model3d = Path(asset_id).suffix.lower() in MODEL3D_EXTENSIONS
|
|
252
|
+
is_archive = Path(asset_id).suffix.lower() in ARCHIVE_EXTENSIONS
|
|
253
|
+
is_bim = Path(asset_id).suffix.lower() in BIM_EXTENSIONS
|
|
254
|
+
|
|
255
|
+
if is_video:
|
|
256
|
+
# Video: Extract Frame -> Process as Image
|
|
257
|
+
frame_bytes = video_processor.extract_thumbnail(source_bytes, t)
|
|
258
|
+
processed_data, mime_type = processor.process(frame_bytes, options)
|
|
259
|
+
elif is_audio:
|
|
260
|
+
# Audio: Generate Waveform -> Process as Image
|
|
261
|
+
waveform_bytes = audio_processor.generate_waveform(source_bytes, w or 800, h or 200)
|
|
262
|
+
processed_data, mime_type = processor.process(waveform_bytes, options)
|
|
263
|
+
elif is_document:
|
|
264
|
+
# Document: Extract Page -> Process as Image
|
|
265
|
+
# We extract at 150 DPI to ensure crisp text before potential downscaling
|
|
266
|
+
page_bytes = document_processor.extract_page_as_image(source_bytes, p, dpi=150)
|
|
267
|
+
processed_data, mime_type = processor.process(page_bytes, options)
|
|
268
|
+
elif is_raw:
|
|
269
|
+
# RAW: Extract Preview -> Process as Image
|
|
270
|
+
preview_bytes = raw_processor.extract_preview(source_bytes)
|
|
271
|
+
processed_data, mime_type = processor.process(preview_bytes, options)
|
|
272
|
+
elif is_text:
|
|
273
|
+
# Text: Render to Image -> Process as Image
|
|
274
|
+
rendered_bytes = text_processor.render_to_image(source_bytes, asset_id, options)
|
|
275
|
+
processed_data, mime_type = processor.process(rendered_bytes, options)
|
|
276
|
+
elif is_office:
|
|
277
|
+
# Office: Generate Summary Card -> Process as Image
|
|
278
|
+
office_card_bytes = office_processor.render_thumbnail(source_bytes, asset_id)
|
|
279
|
+
processed_data, mime_type = processor.process(office_card_bytes, options)
|
|
280
|
+
elif is_font:
|
|
281
|
+
# Font: Render Specimen -> Process as Image
|
|
282
|
+
specimen_bytes = font_processor.render_specimen(source_bytes, {})
|
|
283
|
+
processed_data, mime_type = processor.process(specimen_bytes, options)
|
|
284
|
+
elif is_model3d:
|
|
285
|
+
# 3D: Generate Blueprint -> Process as Image
|
|
286
|
+
model_bytes = model3d_processor.render_thumbnail(source_bytes, asset_id)
|
|
287
|
+
processed_data, mime_type = processor.process(model_bytes, options)
|
|
288
|
+
elif is_archive:
|
|
289
|
+
# Archive: Generate Content List -> Process as Image
|
|
290
|
+
archive_bytes = archive_processor.render_thumbnail(source_bytes, asset_id)
|
|
291
|
+
processed_data, mime_type = processor.process(archive_bytes, options)
|
|
292
|
+
elif is_bim:
|
|
293
|
+
# BIM: Generate Building Data Card -> Process as Image
|
|
294
|
+
bim_bytes = bim_processor.render_summary(source_bytes, asset_id)
|
|
295
|
+
processed_data, mime_type = processor.process(bim_bytes, options)
|
|
296
|
+
else:
|
|
297
|
+
# Image: Process directly
|
|
298
|
+
processed_data, mime_type = processor.process(source_bytes, options)
|
|
299
|
+
|
|
300
|
+
# 7. Store derivative for future requests
|
|
301
|
+
await storage.save_asset(derivative_id, processed_data)
|
|
302
|
+
|
|
303
|
+
return Response(
|
|
304
|
+
content=processed_data,
|
|
305
|
+
media_type=mime_type,
|
|
306
|
+
headers={
|
|
307
|
+
"Cache-Control": "public, max-age=31536000, immutable",
|
|
308
|
+
"X-MorphosX-Cache": "MISS"
|
|
309
|
+
}
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
except FileNotFoundError:
|
|
313
|
+
raise HTTPException(status_code=404, detail="Asset not found")
|
|
314
|
+
except Exception as e:
|
|
315
|
+
raise HTTPException(status_code=500, detail=f"Processing error: {str(e)}")
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
@router.get("/list/{path:path}")
|
|
319
|
+
async def list_assets(
|
|
320
|
+
path: str = "",
|
|
321
|
+
current_user: Optional[str] = Depends(get_current_user)
|
|
322
|
+
):
|
|
323
|
+
"""
|
|
324
|
+
List files and folders in a given path.
|
|
325
|
+
"""
|
|
326
|
+
# Security: If path starts with users/, verify ownership
|
|
327
|
+
if path.startswith("users/"):
|
|
328
|
+
parts = path.split("/")
|
|
329
|
+
if len(parts) >= 2:
|
|
330
|
+
owner_id = parts[1]
|
|
331
|
+
if current_user != owner_id:
|
|
332
|
+
raise HTTPException(status_code=403, detail="Not authorized to browse this folder")
|
|
333
|
+
|
|
334
|
+
# If path is empty, default to listing 'originals/' (public root)
|
|
335
|
+
if not path:
|
|
336
|
+
path = "originals"
|
|
337
|
+
|
|
338
|
+
try:
|
|
339
|
+
items = await storage.list_assets(path)
|
|
340
|
+
return {
|
|
341
|
+
"path": path,
|
|
342
|
+
"items": items
|
|
343
|
+
}
|
|
344
|
+
except Exception as e:
|
|
345
|
+
raise HTTPException(status_code=500, detail=f"Listing failed: {str(e)}")
|
|
346
|
+
|
morphosx/app/cli.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import uvicorn
|
|
2
|
+
import argparse
|
|
3
|
+
|
|
4
|
+
def main():
|
|
5
|
+
parser = argparse.ArgumentParser(description="MorphosX Media Engine CLI")
|
|
6
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
7
|
+
|
|
8
|
+
# Command: start
|
|
9
|
+
start_parser = subparsers.add_parser("start", help="Start the MorphosX server")
|
|
10
|
+
start_parser.add_argument("--host", default="0.0.0.0", help="Host to bind to")
|
|
11
|
+
start_parser.add_argument("--port", type=int, default=8000, help="Port to bind to")
|
|
12
|
+
start_parser.add_argument("--reload", action="store_true", help="Enable auto-reload")
|
|
13
|
+
|
|
14
|
+
args = parser.parse_args()
|
|
15
|
+
|
|
16
|
+
if args.command == "start":
|
|
17
|
+
uvicorn.run("morphosx.app.main:app", host=args.host, port=args.port, reload=args.reload)
|
|
18
|
+
else:
|
|
19
|
+
parser.print_help()
|
|
20
|
+
|
|
21
|
+
if __name__ == "__main__":
|
|
22
|
+
main()
|
|
File without changes
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
from datetime import datetime, timedelta
|
|
3
|
+
from jose import JWTError, jwt
|
|
4
|
+
from fastapi import Depends, HTTPException, status
|
|
5
|
+
from fastapi.security import OAuth2PasswordBearer
|
|
6
|
+
from morphosx.app.settings import settings
|
|
7
|
+
|
|
8
|
+
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token", auto_error=False)
|
|
9
|
+
|
|
10
|
+
def get_current_user(token: Optional[str] = Depends(oauth2_scheme)) -> Optional[str]:
|
|
11
|
+
"""
|
|
12
|
+
Decodes the JWT token and returns the user_id (subject).
|
|
13
|
+
If no token is provided, returns None (for public/unauthenticated access).
|
|
14
|
+
"""
|
|
15
|
+
if not token:
|
|
16
|
+
return None
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
payload = jwt.decode(token, settings.secret_key, algorithms=["HS256"])
|
|
20
|
+
user_id: str = payload.get("sub")
|
|
21
|
+
if user_id is None:
|
|
22
|
+
return None
|
|
23
|
+
return user_id
|
|
24
|
+
except JWTError:
|
|
25
|
+
# Invalid token
|
|
26
|
+
raise HTTPException(
|
|
27
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
28
|
+
detail="Could not validate credentials",
|
|
29
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
|
33
|
+
"""
|
|
34
|
+
Helper to generate a JWT for testing or initial setup.
|
|
35
|
+
"""
|
|
36
|
+
to_encode = data.copy()
|
|
37
|
+
if expires_delta:
|
|
38
|
+
expire = datetime.utcnow() + expires_delta
|
|
39
|
+
else:
|
|
40
|
+
expire = datetime.utcnow() + timedelta(minutes=15)
|
|
41
|
+
to_encode.update({"exp": expire})
|
|
42
|
+
encoded_jwt = jwt.encode(to_encode, settings.secret_key, algorithm="HS256")
|
|
43
|
+
return encoded_jwt
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import hmac
|
|
2
|
+
import hashlib
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def generate_signature(
|
|
7
|
+
asset_id: str,
|
|
8
|
+
width: Optional[int],
|
|
9
|
+
height: Optional[int],
|
|
10
|
+
format: str,
|
|
11
|
+
quality: int,
|
|
12
|
+
secret_key: str,
|
|
13
|
+
preset: Optional[str] = None,
|
|
14
|
+
user_id: Optional[str] = None
|
|
15
|
+
) -> str:
|
|
16
|
+
"""
|
|
17
|
+
Generate an HMAC-SHA256 signature for image transformation parameters.
|
|
18
|
+
|
|
19
|
+
:param asset_id: The unique ID of the original asset.
|
|
20
|
+
:param width: Target width (or None).
|
|
21
|
+
:param height: Target height (or None).
|
|
22
|
+
:param format: Output format (e.g. 'webp').
|
|
23
|
+
:param quality: Output quality (e.g. 80).
|
|
24
|
+
:param secret_key: The server-side secret key.
|
|
25
|
+
:param preset: Optional preset name (e.g. 'thumb').
|
|
26
|
+
:param user_id: Optional user identifier for protected assets.
|
|
27
|
+
:return: Hexadecimal signature string (first 16 chars for brevity).
|
|
28
|
+
"""
|
|
29
|
+
# Create a canonical representation of the transformation parameters
|
|
30
|
+
# This ensures consistency: order matters!
|
|
31
|
+
payload = f"{asset_id}|w{width}|h{height}|f{format}|q{quality}|p{preset}|u{user_id}"
|
|
32
|
+
|
|
33
|
+
signature = hmac.new(
|
|
34
|
+
secret_key.encode('utf-8'),
|
|
35
|
+
payload.encode('utf-8'),
|
|
36
|
+
hashlib.sha256
|
|
37
|
+
).hexdigest()
|
|
38
|
+
|
|
39
|
+
# We take only the first 16 chars for a cleaner URL, still 2^64 variations
|
|
40
|
+
return signature[:16]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def verify_signature(
|
|
44
|
+
asset_id: str,
|
|
45
|
+
width: Optional[int],
|
|
46
|
+
height: Optional[int],
|
|
47
|
+
format: str,
|
|
48
|
+
quality: int,
|
|
49
|
+
signature_to_verify: str,
|
|
50
|
+
secret_key: str,
|
|
51
|
+
preset: Optional[str] = None,
|
|
52
|
+
user_id: Optional[str] = None
|
|
53
|
+
) -> bool:
|
|
54
|
+
"""
|
|
55
|
+
Check if a provided signature matches the expected signature for those parameters.
|
|
56
|
+
"""
|
|
57
|
+
expected = generate_signature(asset_id, width, height, format, quality, secret_key, preset, user_id)
|
|
58
|
+
|
|
59
|
+
# Use hmac.compare_digest to prevent timing attacks
|
|
60
|
+
return hmac.compare_digest(expected, signature_to_verify)
|
|
File without changes
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import zipfile
|
|
3
|
+
import tarfile
|
|
4
|
+
from PIL import Image, ImageDraw
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ArchiveProcessor:
|
|
8
|
+
"""
|
|
9
|
+
Engine for generating content-list previews for ZIP and TAR archives.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
def render_thumbnail(self, archive_data: bytes, filename: str) -> bytes:
|
|
13
|
+
"""
|
|
14
|
+
Create a preview of the archive contents.
|
|
15
|
+
"""
|
|
16
|
+
ext = filename.split(".")[-1].lower()
|
|
17
|
+
file_list = []
|
|
18
|
+
try:
|
|
19
|
+
if ext == "zip":
|
|
20
|
+
with zipfile.ZipFile(io.BytesIO(archive_data)) as z:
|
|
21
|
+
file_list = [f.filename for f in z.infolist()[:15]]
|
|
22
|
+
total = len(z.infolist())
|
|
23
|
+
elif ext == "tar" or filename.endswith(".tar.gz"):
|
|
24
|
+
with tarfile.open(fileobj=io.BytesIO(archive_data)) as t:
|
|
25
|
+
file_list = [f.name for f in t.getmembers()[:15]]
|
|
26
|
+
total = len(t.getmembers())
|
|
27
|
+
|
|
28
|
+
summary = "
|
|
29
|
+
".join(file_list)
|
|
30
|
+
if total > 15:
|
|
31
|
+
summary += f"
|
|
32
|
+
... and {total - 15} more files."
|
|
33
|
+
|
|
34
|
+
title = f"Archive: {filename} ({total} files)"
|
|
35
|
+
return self._create_folder_card(title, summary)
|
|
36
|
+
|
|
37
|
+
except Exception as e:
|
|
38
|
+
return self._create_folder_card("Archive Error", f"Could not read archive: {str(e)}")
|
|
39
|
+
|
|
40
|
+
def _create_folder_card(self, title: str, text: str) -> bytes:
|
|
41
|
+
"""Render a folder-style card image."""
|
|
42
|
+
width, height = 800, 600
|
|
43
|
+
img = Image.new("RGB", (width, height), color=(255, 250, 230)) # Manila folder yellow
|
|
44
|
+
draw = ImageDraw.Draw(img)
|
|
45
|
+
|
|
46
|
+
# Draw a folder 'tab'
|
|
47
|
+
draw.rectangle([20, 10, 200, 40], fill=(210, 180, 100))
|
|
48
|
+
|
|
49
|
+
# Draw main folder body
|
|
50
|
+
draw.rectangle([20, 40, 780, 580], outline=(180, 150, 80), width=3)
|
|
51
|
+
|
|
52
|
+
# Draw text
|
|
53
|
+
draw.text((40, 60), title, fill=(100, 80, 20))
|
|
54
|
+
draw.text((40, 110), text, fill=(60, 50, 30))
|
|
55
|
+
|
|
56
|
+
output = io.BytesIO()
|
|
57
|
+
img.save(output, format="JPEG")
|
|
58
|
+
return output.getvalue()
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import ffmpeg
|
|
2
|
+
import os
|
|
3
|
+
import tempfile
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AudioProcessor:
|
|
7
|
+
"""
|
|
8
|
+
Core engine for audio manipulation and waveform generation.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
def generate_waveform(self, audio_data: bytes, width: int = 800, height: int = 200, color: str = 'cyan') -> bytes:
|
|
12
|
+
"""
|
|
13
|
+
Generate a waveform image (PNG) from an audio file.
|
|
14
|
+
|
|
15
|
+
:param audio_data: Raw audio bytes.
|
|
16
|
+
:param width: Target width of the waveform image.
|
|
17
|
+
:param height: Target height of the waveform image.
|
|
18
|
+
:param color: The color of the waveform.
|
|
19
|
+
:return: PNG image bytes.
|
|
20
|
+
"""
|
|
21
|
+
with tempfile.NamedTemporaryFile(delete=False, suffix=".audio") as tmp:
|
|
22
|
+
tmp.write(audio_data)
|
|
23
|
+
tmp_path = tmp.name
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
# Command: ffmpeg -i input -filter_complex "showwavespic=s=800x200:colors=cyan" -frames:v 1 output.png
|
|
27
|
+
# This generates a visual representation of the audio amplitudes.
|
|
28
|
+
out, _ = (
|
|
29
|
+
ffmpeg
|
|
30
|
+
.input(tmp_path)
|
|
31
|
+
.filter('showwavespic', s=f"{width}x{height}", colors=color)
|
|
32
|
+
.output('pipe:', vframes=1, format='image2', vcodec='png')
|
|
33
|
+
.run(capture_stdout=True, quiet=True)
|
|
34
|
+
)
|
|
35
|
+
return out
|
|
36
|
+
except ffmpeg.Error as e:
|
|
37
|
+
raise RuntimeError(f"FFmpeg waveform generation failed: {e.stderr.decode()}")
|
|
38
|
+
finally:
|
|
39
|
+
if os.path.exists(tmp_path):
|
|
40
|
+
os.remove(tmp_path)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import io
|
|
2
|
+
from PIL import Image, ImageDraw
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class BIMProcessor:
|
|
6
|
+
"""
|
|
7
|
+
Engine for generating technical summaries for BIM (IFC) files.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
def render_summary(self, ifc_data: bytes, filename: str) -> bytes:
|
|
11
|
+
"""
|
|
12
|
+
Create a technical data card for an IFC file.
|
|
13
|
+
"""
|
|
14
|
+
try:
|
|
15
|
+
import ifcopenshell
|
|
16
|
+
import ifcopenshell.util.element
|
|
17
|
+
except ImportError:
|
|
18
|
+
raise RuntimeError("ifcopenshell is not installed. Run 'pip install morphosx[bim]' to enable this feature.")
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
# Load IFC from memory
|
|
22
|
+
# IfcOpenShell usually expects a file path, but we can use a temp file
|
|
23
|
+
# or try the direct string loader if the version supports it.
|
|
24
|
+
# For robustness in memory-only environments, we use a temporary named file.
|
|
25
|
+
import tempfile
|
|
26
|
+
import os
|
|
27
|
+
|
|
28
|
+
with tempfile.NamedTemporaryFile(delete=False, suffix=".ifc") as tmp:
|
|
29
|
+
tmp.write(ifc_data)
|
|
30
|
+
tmp_path = tmp.name
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
model = ifcopenshell.open(tmp_path)
|
|
34
|
+
|
|
35
|
+
# Metadata extraction
|
|
36
|
+
project = model.by_type("IfcProject")[0] if model.by_type("IfcProject") else None
|
|
37
|
+
site = model.by_type("IfcSite")[0] if model.by_type("IfcSite") else None
|
|
38
|
+
|
|
39
|
+
walls = len(model.by_type("IfcWall"))
|
|
40
|
+
windows = len(model.by_type("IfcWindow"))
|
|
41
|
+
doors = len(model.by_type("IfcDoor"))
|
|
42
|
+
stories = len(model.by_type("IfcBuildingStorey"))
|
|
43
|
+
|
|
44
|
+
title = f"BIM Project: {project.Name if project else 'Unnamed'}"
|
|
45
|
+
summary = (
|
|
46
|
+
f"Site: {site.Name if site else 'Unknown'}
|
|
47
|
+
"
|
|
48
|
+
f"Building Stories: {stories}
|
|
49
|
+
|
|
50
|
+
"
|
|
51
|
+
f"Element Count:
|
|
52
|
+
"
|
|
53
|
+
f"- Walls: {walls}
|
|
54
|
+
"
|
|
55
|
+
f"- Windows: {windows}
|
|
56
|
+
"
|
|
57
|
+
f"- Doors: {doors}
|
|
58
|
+
|
|
59
|
+
"
|
|
60
|
+
f"Schema: {model.schema}"
|
|
61
|
+
)
|
|
62
|
+
finally:
|
|
63
|
+
if os.path.exists(tmp_path):
|
|
64
|
+
os.remove(tmp_path)
|
|
65
|
+
|
|
66
|
+
return self._create_bim_card(title, summary)
|
|
67
|
+
|
|
68
|
+
except Exception as e:
|
|
69
|
+
return self._create_bim_card("BIM Parsing Error", f"Could not parse IFC: {str(e)}")
|
|
70
|
+
|
|
71
|
+
def _create_bim_card(self, title: str, text: str) -> bytes:
|
|
72
|
+
"""Render a technical architecture-style card."""
|
|
73
|
+
width, height = 800, 600
|
|
74
|
+
img = Image.new("RGB", (width, height), color=(30, 30, 35)) # Dark gray
|
|
75
|
+
draw = ImageDraw.Draw(img)
|
|
76
|
+
|
|
77
|
+
# Draw some 'architectural' lines
|
|
78
|
+
draw.line([0, 100, width, 100], fill=(100, 200, 100), width=2)
|
|
79
|
+
draw.line([100, 0, 100, height], fill=(100, 200, 100), width=1)
|
|
80
|
+
|
|
81
|
+
# Text
|
|
82
|
+
draw.text((120, 40), title, fill=(255, 255, 255))
|
|
83
|
+
draw.text((120, 130), text, fill=(180, 200, 180))
|
|
84
|
+
|
|
85
|
+
# Simple house icon silhouette
|
|
86
|
+
draw.polygon([(600, 200), (750, 200), (675, 100)], outline=(100, 255, 100), width=2)
|
|
87
|
+
draw.rectangle([620, 200, 730, 300], outline=(100, 255, 100), width=2)
|
|
88
|
+
|
|
89
|
+
output = io.BytesIO()
|
|
90
|
+
img.save(output, format="JPEG")
|
|
91
|
+
return output.getvalue()
|