Repository navigation
Expand file tree
/
Copy pathfunction_app.py
More file actions
391 lines (318 loc) · 13.1 KB
/
Copy pathfunction_app.py
File metadata and controls
391 lines (318 loc) · 13.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
"""
CloudStream REST API — DEPRECATED (Azure Functions implementation)
COM682 Coursework 2 — Meharjot Singh B00963621
⚠️ THIS FILE IS NOT IN PRODUCTION USE.
History:
Phase 2 of CW2: built the API as Azure Functions (Linux Consumption + Python).
Phase 3: discovered the Linux Consumption Function App on the Azure for
Students subscription couldn't start its host (HTTP 503 Site Unavailable
in all five allowed EU regions). After spending ~2 hours diagnosing this
as a platform-level limitation rather than a code issue, the API was
migrated to Azure App Service (Linux, Python 3.13, Flask). See app.py for
the current production code.
This file is preserved as evidence of the original architecture and the
pivot decision (Learning Outcome 4: identifying deficiencies and improving
practice). It is not imported, deployed, or executed by the live system.
"""
import azure.functions as func
import logging
import json
import os
import uuid
import base64
import datetime as dt
from typing import Any
import bcrypt
import jwt
from azure.identity import DefaultAzureCredential
from azure.storage.blob import BlobServiceClient, ContentSettings
from azure.cosmos import CosmosClient, exceptions as cosmos_exceptions
# ---------------------------------------------------------------------------
# App + configuration
# ---------------------------------------------------------------------------
app = func.FunctionApp(http_auth_level=func.AuthLevel.ANONYMOUS)
logger = logging.getLogger("cloudstream")
STORAGE_ACCOUNT = os.environ["STORAGE_ACCOUNT_NAME"]
COSMOS_ENDPOINT = os.environ["COSMOS_ENDPOINT"]
COSMOS_DATABASE = os.environ["COSMOS_DATABASE"]
MEDIA_CONTAINER = os.environ.get("BLOB_CONTAINER_MEDIA", "media")
JWT_SECRET = os.environ["JWT_SECRET"]
JWT_EXPIRY_HOURS = int(os.environ.get("JWT_EXPIRY_HOURS", "24"))
# ---------------------------------------------------------------------------
# Azure client singletons (created on first request, reused afterwards)
# ---------------------------------------------------------------------------
_credential = None
_blob_service = None
_cosmos_client = None
def credential() -> DefaultAzureCredential:
"""Lazy-init Azure AD credential. Managed identity in Azure, az-login locally."""
global _credential
if _credential is None:
_credential = DefaultAzureCredential()
return _credential
def blob_service() -> BlobServiceClient:
global _blob_service
if _blob_service is None:
url = f"https://{STORAGE_ACCOUNT}.blob.core.windows.net"
_blob_service = BlobServiceClient(account_url=url, credential=credential())
return _blob_service
def cosmos_client() -> CosmosClient:
global _cosmos_client
if _cosmos_client is None:
_cosmos_client = CosmosClient(COSMOS_ENDPOINT, credential=credential())
return _cosmos_client
def users_container():
return cosmos_client().get_database_client(COSMOS_DATABASE).get_container_client("users")
def media_container():
return cosmos_client().get_database_client(COSMOS_DATABASE).get_container_client("media")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def json_response(payload: Any, status: int = 200) -> func.HttpResponse:
return func.HttpResponse(
body=json.dumps(payload, default=str),
status_code=status,
mimetype="application/json",
headers={"Access-Control-Allow-Origin": "*"},
)
def error(msg: str, status: int = 400) -> func.HttpResponse:
return json_response({"error": msg}, status)
def issue_token(user_id: str, email: str) -> str:
payload = {
"sub": user_id,
"email": email,
"iat": dt.datetime.now(dt.timezone.utc),
"exp": dt.datetime.now(dt.timezone.utc) + dt.timedelta(hours=JWT_EXPIRY_HOURS),
}
return jwt.encode(payload, JWT_SECRET, algorithm="HS256")
def verify_token(req: func.HttpRequest) -> dict | None:
"""Return decoded JWT payload or None if missing/invalid."""
auth = req.headers.get("Authorization", "")
if not auth.startswith("Bearer "):
return None
try:
return jwt.decode(auth[7:], JWT_SECRET, algorithms=["HS256"])
except jwt.PyJWTError:
return None
def require_auth(req: func.HttpRequest) -> tuple[dict | None, func.HttpResponse | None]:
"""Returns (claims, None) on success or (None, error_response) on failure."""
claims = verify_token(req)
if not claims:
return None, error("Unauthorized — missing or invalid token", 401)
return claims, None
def find_media_by_id(media_id: str) -> dict | None:
"""Cross-partition lookup by mediaId. Avoids SDK partition_key header quirks."""
rows = list(media_container().query_items(
query="SELECT * FROM c WHERE c.mediaId = @mid",
parameters=[{"name": "@mid", "value": media_id}],
enable_cross_partition_query=True,
))
return rows[0] if rows else None
# ---------------------------------------------------------------------------
# Health
# ---------------------------------------------------------------------------
@app.route(route="health", methods=["GET"])
def health(req: func.HttpRequest) -> func.HttpResponse:
return json_response({"status": "ok", "service": "cloudstream", "time": dt.datetime.utcnow().isoformat()})
# ---------------------------------------------------------------------------
# User registration + login
# ---------------------------------------------------------------------------
@app.route(route="users/register", methods=["POST"])
def register(req: func.HttpRequest) -> func.HttpResponse:
try:
body = req.get_json()
except ValueError:
return error("Invalid JSON")
email = (body.get("email") or "").strip().lower()
password = body.get("password") or ""
display_name = body.get("displayName") or email.split("@")[0]
if not email or "@" not in email:
return error("Valid email required")
if len(password) < 8:
return error("Password must be at least 8 characters")
existing = list(users_container().query_items(
query="SELECT * FROM c WHERE c.email = @email",
parameters=[{"name": "@email", "value": email}],
enable_cross_partition_query=True,
))
if existing:
return error("User already exists", 409)
user_id = str(uuid.uuid4())
user_doc = {
"id": user_id,
"userId": user_id,
"email": email,
"displayName": display_name,
"passwordHash": bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode(),
"createdAt": dt.datetime.utcnow().isoformat(),
}
users_container().create_item(body=user_doc)
token = issue_token(user_id, email)
return json_response({"userId": user_id, "email": email, "displayName": display_name, "token": token}, 201)
@app.route(route="auth/login", methods=["POST"])
def login(req: func.HttpRequest) -> func.HttpResponse:
try:
body = req.get_json()
except ValueError:
return error("Invalid JSON")
email = (body.get("email") or "").strip().lower()
password = body.get("password") or ""
rows = list(users_container().query_items(
query="SELECT * FROM c WHERE c.email = @email",
parameters=[{"name": "@email", "value": email}],
enable_cross_partition_query=True,
))
if not rows:
return error("Invalid credentials", 401)
user = rows[0]
if not bcrypt.checkpw(password.encode(), user["passwordHash"].encode()):
return error("Invalid credentials", 401)
token = issue_token(user["userId"], user["email"])
return json_response({
"userId": user["userId"], "email": user["email"],
"displayName": user.get("displayName"), "token": token,
})
# ---------------------------------------------------------------------------
# Media — CRUD
# ---------------------------------------------------------------------------
@app.route(route="media", methods=["POST"])
def create_media(req: func.HttpRequest) -> func.HttpResponse:
"""
Upload a media file + metadata.
Request body (JSON):
{
"title": "Beach Sunset",
"description": "...",
"mediaType": "image" | "video",
"fileName": "sunset.jpg",
"fileBase64": "<base64-encoded file bytes>",
"contentType": "image/jpeg",
"tags": ["beach", "sunset"]
}
"""
claims, err = require_auth(req)
if err:
return err
try:
body = req.get_json()
except ValueError:
return error("Invalid JSON")
title = body.get("title")
media_type = body.get("mediaType", "image")
file_name = body.get("fileName", "file.bin")
file_b64 = body.get("fileBase64")
content_type = body.get("contentType", "application/octet-stream")
if not title or not file_b64:
return error("title and fileBase64 are required")
try:
file_bytes = base64.b64decode(file_b64)
except Exception:
return error("fileBase64 is not valid base64")
user_id = claims["sub"]
media_id = str(uuid.uuid4())
blob_name = f"{user_id}/{media_id}/{file_name}"
# Upload to Blob
try:
blob_client = blob_service().get_blob_client(container=MEDIA_CONTAINER, blob=blob_name)
blob_client.upload_blob(
file_bytes,
overwrite=True,
content_settings=ContentSettings(content_type=content_type),
)
file_url = blob_client.url
except Exception as e:
logger.exception("Blob upload failed")
return error(f"Upload failed: {e}", 500)
# Persist metadata
doc = {
"id": media_id,
"mediaId": media_id,
"userId": user_id,
"title": title,
"description": body.get("description", ""),
"mediaType": media_type,
"fileUrl": file_url,
"fileName": file_name,
"fileSize": len(file_bytes),
"contentType": content_type,
"tags": body.get("tags", []),
"status": "active",
"uploadDate": dt.datetime.utcnow().isoformat(),
}
media_container().create_item(body=doc)
return json_response(doc, 201)
@app.route(route="media", methods=["GET"])
def list_media(req: func.HttpRequest) -> func.HttpResponse:
"""
List media. Optional ?userId=xxx filters to one user.
"""
user_id = req.params.get("userId")
if user_id:
items = list(media_container().query_items(
query="SELECT * FROM c WHERE c.userId = @uid ORDER BY c.uploadDate DESC",
parameters=[{"name": "@uid", "value": user_id}],
partition_key=user_id,
))
else:
items = list(media_container().query_items(
query="SELECT * FROM c WHERE c.status = 'active' ORDER BY c.uploadDate DESC",
enable_cross_partition_query=True,
))
return json_response({"count": len(items), "items": items})
@app.route(route="media/{id}", methods=["GET"])
def get_media(req: func.HttpRequest) -> func.HttpResponse:
media_id = req.route_params.get("id")
doc = find_media_by_id(media_id)
if not doc:
return error("Media not found", 404)
return json_response(doc)
@app.route(route="media/{id}", methods=["PUT"])
def update_media(req: func.HttpRequest) -> func.HttpResponse:
claims, err = require_auth(req)
if err:
return err
media_id = req.route_params.get("id")
try:
body = req.get_json()
except ValueError:
return error("Invalid JSON")
user_id = claims["sub"]
logger.info(f"update_media: id={media_id} userId={user_id} fields={list(body.keys())}")
doc = find_media_by_id(media_id)
if not doc:
return error("Media not found", 404)
if doc.get("userId") != user_id:
return error("Not owned by you", 403)
for field in ("title", "description", "tags"):
if field in body:
doc[field] = body[field]
doc["updatedAt"] = dt.datetime.utcnow().isoformat()
result = media_container().upsert_item(body=doc)
logger.info(f"update_media: persisted _ts={result.get('_ts')}")
return json_response(result)
@app.route(route="media/{id}", methods=["DELETE"])
def delete_media(req: func.HttpRequest) -> func.HttpResponse:
claims, err = require_auth(req)
if err:
return err
media_id = req.route_params.get("id")
user_id = claims["sub"]
logger.info(f"delete_media: id={media_id} userId={user_id}")
doc = find_media_by_id(media_id)
if not doc:
return error("Media not found", 404)
if doc.get("userId") != user_id:
return error("Not owned by you", 403)
# Delete blob immediately (this part works fine)
file_url = doc.get("fileUrl", "")
try:
blob_path = file_url.split(f"/{MEDIA_CONTAINER}/", 1)[-1]
blob_service().get_blob_client(container=MEDIA_CONTAINER, blob=blob_path).delete_blob()
except Exception as e:
logger.warning(f"Blob delete failed (continuing): {e}")
# Soft-delete: mark as deleted in Cosmos via upsert (works reliably with RBAC)
doc["status"] = "deleted"
doc["deletedAt"] = dt.datetime.utcnow().isoformat()
media_container().upsert_item(body=doc)
logger.info(f"delete_media: soft-deleted {media_id}")
return json_response({"deleted": media_id})