diff --git a/migrations/0015_add_course_materials.sql b/migrations/0015_add_course_materials.sql new file mode 100644 index 0000000..85f2e2e --- /dev/null +++ b/migrations/0015_add_course_materials.sql @@ -0,0 +1,17 @@ +-- Migration 0015: Add course_materials table for activity file attachments + +CREATE TABLE IF NOT EXISTS course_materials ( + id TEXT PRIMARY KEY, + activity_id TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT, + file_key TEXT NOT NULL, + uploaded_by TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (activity_id) REFERENCES activities(id) ON DELETE CASCADE, + FOREIGN KEY (uploaded_by) REFERENCES users(id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS idx_materials_activity ON course_materials(activity_id); +CREATE INDEX IF NOT EXISTS idx_materials_uploader ON course_materials(uploaded_by); +CREATE INDEX IF NOT EXISTS idx_materials_created ON course_materials(activity_id, created_at DESC); diff --git a/public/activity-detail.html b/public/activity-detail.html index a589bb0..b731bb0 100644 --- a/public/activity-detail.html +++ b/public/activity-detail.html @@ -134,12 +134,20 @@

-
-
+
+
+ +
-
+ + +

Welcome! @@ -163,6 +171,21 @@

Prerequisites

+ + +
@@ -359,6 +382,104 @@

Prerequisites

renderSessions(data); renderAction(data); loadActivityContacts(data); + loadDetailMaterials(a.id); + } + + function switchDetailTab(tab) { + const isDetails = tab === 'details'; + const panelDetails = document.getElementById('tab-panel-details'); + const panelMaterials = document.getElementById('tab-panel-materials'); + if (panelDetails) panelDetails.classList.toggle('hidden', !isDetails); + if (panelMaterials) panelMaterials.classList.toggle('hidden', isDetails); + + const btnDetails = document.getElementById('tab-btn-details'); + const btnMaterials = document.getElementById('tab-btn-materials'); + + if (btnDetails && btnMaterials) { + btnDetails.setAttribute('aria-selected', isDetails ? 'true' : 'false'); + btnMaterials.setAttribute('aria-selected', isDetails ? 'false' : 'true'); + if (isDetails) { + btnDetails.className = 'px-6 py-3 text-sm font-medium border-b-2 border-teal-500 text-teal-600 dark:text-teal-400 bg-white dark:bg-gray-800 focus:outline-none flex items-center'; + btnMaterials.className = 'px-6 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-teal-600 dark:hover:text-teal-400 bg-white dark:bg-gray-800 focus:outline-none flex items-center'; + } else { + btnMaterials.className = 'px-6 py-3 text-sm font-medium border-b-2 border-teal-500 text-teal-600 dark:text-teal-400 bg-white dark:bg-gray-800 focus:outline-none flex items-center'; + btnDetails.className = 'px-6 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-teal-600 dark:hover:text-teal-400 bg-white dark:bg-gray-800 focus:outline-none flex items-center'; + } + } + } + + async function loadDetailMaterials(actId) { + const listEl = document.getElementById('detail-mat-list'); + const countEl = document.getElementById('detail-mat-count'); + const manageLink = document.getElementById('mat-manage-link'); + if (manageLink) manageLink.href = `/course-materials.html?activity_id=${encodeURIComponent(actId)}`; + if (!listEl) return; + + try { + const res = await fetch(`/api/activities/${encodeURIComponent(actId)}/materials`); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Failed to load materials'); + const mats = data.materials || []; + if (countEl) countEl.textContent = `(${mats.length})`; + if (!mats.length) { + listEl.innerHTML = '

No materials uploaded yet.

'; + return; + } + listEl.innerHTML = mats.slice(0, 5).map(m => ` +
+
+

${esc(m.title)}

+ ${m.description ? `

${esc(m.description)}

` : ''} +
+ +
+ `).join(''); + } catch (_) { + if (listEl) listEl.innerHTML = '

Failed to load materials.

'; + } + } + + async function downloadDetailMaterial(actId, mid) { + if (!token) { + alert('Please log in to download materials.'); + window.location.href = '/login'; + return; + } + try { + const res = await fetch(`/api/activities/${encodeURIComponent(actId)}/materials/${encodeURIComponent(mid)}/download`, { + headers: { 'Authorization': `Bearer ${token}` } + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Download failed'); + const payload = data.data || data; + let url = payload.download_url || ''; + const filename = payload.filename || payload.title || 'download'; + if (url.startsWith('/api/r2/')) { + const proxied = await fetch(url, { headers: { 'Authorization': `Bearer ${token}` } }); + if (!proxied.ok) throw new Error('Download failed'); + const blob = await proxied.blob(); + const objectUrl = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = objectUrl; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(objectUrl); + } else { + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.target = '_blank'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + } + } catch (e) { + alert(e.message || 'Download failed'); + } } function renderSessions(data) { diff --git a/public/course-materials.html b/public/course-materials.html new file mode 100644 index 0000000..d22f918 --- /dev/null +++ b/public/course-materials.html @@ -0,0 +1,654 @@ + + + + + + Course Materials - Alpha One Labs + + + + + + + + + + + + + + + +
+
+
+ +

Course Materials

+
+

Loading activity…

+
+
+ + +
+ + + + + +
+
+

+ + Materials + +

+ +
+ +
+
+
+
+
+ + + + + + +
+ +
+ + + + + + + + + + + + + + diff --git a/schema.sql b/schema.sql index 95ff25d..c327651 100644 --- a/schema.sql +++ b/schema.sql @@ -171,3 +171,19 @@ CREATE TABLE IF NOT EXISTS message_requests ( CREATE INDEX IF NOT EXISTS idx_message_requests_to_user ON message_requests(to_user_id, status); CREATE INDEX IF NOT EXISTS idx_message_requests_from_user ON message_requests(from_user_id); CREATE INDEX IF NOT EXISTS idx_message_requests_activity ON message_requests(activity_id); + +-- COURSE MATERIALS +CREATE TABLE IF NOT EXISTS course_materials ( + id TEXT PRIMARY KEY, + activity_id TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT, + file_key TEXT NOT NULL, + uploaded_by TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (activity_id) REFERENCES activities(id) ON DELETE CASCADE, + FOREIGN KEY (uploaded_by) REFERENCES users(id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS idx_materials_uploader ON course_materials(uploaded_by); +CREATE INDEX IF NOT EXISTS idx_materials_created ON course_materials(activity_id, created_at DESC); diff --git a/src/api/materials.py b/src/api/materials.py new file mode 100644 index 0000000..d86b44d --- /dev/null +++ b/src/api/materials.py @@ -0,0 +1,550 @@ +""" +Course Materials API – A2 module +================================= +Endpoints: + GET /api/activities/:activity_id/materials + POST /api/activities/:activity_id/materials + DELETE /api/activities/:activity_id/materials/:mid + GET /api/activities/:activity_id/materials/:mid/download + +Storage: Cloudflare R2 (env.R2_BUCKET) +Database: Cloudflare D1 (env.DB) +Auth: HMAC-SHA256 stateless tokens (verify_token from worker) +""" + +import json +import os + + +# --------------------------------------------------------------------------- +# Internal helpers (imported lazily from worker to avoid circular imports) +# --------------------------------------------------------------------------- + +def _worker(): + """Return the worker module. Imported once and cached.""" + import importlib, sys + if "worker" in sys.modules: + return sys.modules["worker"] + # When running inside the CF runtime the module is already loaded as + # the top-level entry-point; fall back to a direct import for tests. + return importlib.import_module("worker") + + +def _filename_from_key(file_key: str) -> str: + """Extract original filename from R2 key (materials/{act_id}/{uuid}_{filename}).""" + raw_name = file_key.split("/")[-1] + return raw_name[37:] if len(raw_name) > 37 else raw_name + + +def _new_id() -> str: + """Generate a UUID-v4 using os.urandom (no stdlib uuid needed).""" + b = bytearray(os.urandom(16)) + b[6] = (b[6] & 0x0F) | 0x40 # version 4 + b[8] = (b[8] & 0x3F) | 0x80 # RFC 4122 variant + h = b.hex() + return f"{h[:8]}-{h[8:12]}-{h[12:16]}-{h[16:20]}-{h[20:]}" + + +def _r2_key(activity_id: str, filename: str) -> str: + """Build a deterministic, collision-resistant R2 object key. + + Format: ``materials/{activity_id}/{uuid}_{sanitised_filename}`` + + The UUID prefix guarantees uniqueness even when the same filename is + uploaded twice. The sanitised filename is kept for human readability + inside the R2 bucket. + """ + safe_name = "".join( + c if (c.isalnum() or c in "-_.") else "_" + for c in filename + )[:128] # cap at 128 chars to stay well within R2 key limits + return f"materials/{activity_id}/{_new_id()}_{safe_name}" + + +async def _parse_multipart(req): + """ + Parse a multipart/form-data request using the CF Workers FormData API. + + Returns ``(fields_dict, error_response | None)`` where *fields_dict* + maps field names to either a string value (text fields) or a dict + ``{"filename": str, "bytes": bytes}`` (file fields). + + Falls back to a plain JSON body when the Content-Type is + ``application/json`` so that unit tests can exercise the handler + without constructing real multipart payloads. + """ + w = _worker() + content_type = (req.headers.get("Content-Type") or + req.headers.get("content-type") or "") + + # ---- JSON fallback (used by unit tests) -------------------------------- + if "application/json" in content_type: + try: + text = await req.text() + body = json.loads(text) + except Exception: + return None, w.err("Invalid JSON body", 400) + if not isinstance(body, dict): + return None, w.err("JSON body must be an object", 400) + return body, None + + # ---- Real multipart (CF Workers runtime) -------------------------------- + try: + form_data = await req.formData() + except Exception: + return None, w.err("Failed to parse multipart form data", 400) + + fields = {} + try: + # formData() returns a JS FormData object; iterate its entries. + for key, value in form_data.entries(): + # File entries expose a .name attribute (the original filename). + filename = getattr(value, "name", None) or getattr(value, "filename", None) + if filename is not None: + # It's a File/Blob – read raw bytes. + try: + arr_buf = await value.arrayBuffer() + import js + raw = bytes(js.Uint8Array.new(arr_buf)) + except Exception: + raw = b"" + fields[key] = {"filename": filename, "bytes": raw} + else: + fields[key] = str(value) + except Exception: + # Fallback: try the dict-like .get() interface some runtimes expose. + for field_name in ("title", "description", "file"): + val = form_data.get(field_name) + if val is None: + continue + filename = getattr(val, "name", None) or getattr(val, "filename", None) + if filename is not None: + try: + arr_buf = await val.arrayBuffer() + import js + raw = bytes(js.Uint8Array.new(arr_buf)) + except Exception: + raw = b"" + fields[field_name] = {"filename": filename, "bytes": raw} + else: + fields[field_name] = str(val) + + return fields, None + + +def _r2_bucket(env): + """Return the R2 bucket binding (supports MY_BUCKET, R2_BUCKET, or R2).""" + env_dict = getattr(env, "__dict__", {}) + if "MY_BUCKET" in env_dict: + return env_dict["MY_BUCKET"] + if "R2_BUCKET" in env_dict: + return env_dict["R2_BUCKET"] + if "R2" in env_dict: + return env_dict["R2"] + bucket = ( + getattr(env, "MY_BUCKET", None) + or getattr(env, "R2_BUCKET", None) + or getattr(env, "R2", None) + ) + if bucket is None: + raise AttributeError("No R2 bucket binding found on env (checked MY_BUCKET, R2_BUCKET, R2).") + return bucket + + +async def _upload_to_r2(env, key: str, data: bytes, content_type: str = "application/octet-stream", + original_filename: str = ""): + """Upload *data* to R2 under *key*. + + Stores ``Content-Type`` and ``Content-Disposition`` in R2 httpMetadata so + that presigned download URLs automatically serve the correct filename and + MIME type without needing a proxy. + + Uses ``_r2_bucket(env).put(key, data, options)`` which is the standard + Cloudflare Workers R2 binding API. + """ + bucket = _r2_bucket(env) + try: + from pyodide.ffi import to_js # noqa: PLC0415 – CF runtime only + import js # noqa: PLC0415 + http_meta = {"contentType": content_type} + if original_filename: + # Sanitize filename for Content-Disposition to avoid header injection. + safe_fn = ( + str(original_filename) + .replace("\\", "_") + .replace('"', "_") + .replace("\r", "") + .replace("\n", "") + ) + http_meta["contentDisposition"] = f'attachment; filename="{safe_fn}"' + options = to_js( + {"httpMetadata": http_meta}, + dict_converter=js.Object.fromEntries, + ) + await bucket.put(key, to_js(data, create_pyproxies=False), options) + except ImportError: + # Unit-test environment: bucket is a MagicMock / AsyncMock. + await bucket.put(key, data) + + +async def _delete_from_r2(env, key: str): + """Delete the object at *key* from R2 (best-effort; errors are swallowed).""" + try: + bucket = _r2_bucket(env) + await bucket.delete(key) + except Exception as exc: + w = _worker() + await w.capture_exception(exc, _env=env, where="materials._delete_from_r2") + + +async def _generate_download_url(env, key: str) -> str: + """Return a time-limited signed URL for *key* from R2. + + Cloudflare R2 supports ``createPresignedUrl`` on the bucket binding. + If the binding does not expose that method (e.g. older SDK or unit + tests), we fall back to a plain ``/api/r2/{key}`` path that the + worker can proxy. + """ + try: + bucket = _r2_bucket(env) + # CF Workers R2 binding: bucket.createPresignedUrl(key, {expiresIn}) + url = await bucket.createPresignedUrl(key, {"expiresIn": 3600}) + return str(url) + except Exception: + pass + # Fallback: internal proxy URL (worker must implement GET /api/r2/:key) + return f"/api/r2/{key}" + + +# --------------------------------------------------------------------------- +# Endpoint handlers +# --------------------------------------------------------------------------- + +async def list_materials(activity_id: str, req, env): + """GET /api/activities/:activity_id/materials + + Returns all materials for the given activity ordered by created_at DESC. + Authentication is optional – public read access is allowed so that + unenrolled visitors can see what materials an activity offers. + """ + w = _worker() + + # Validate activity exists + try: + act = await env.DB.prepare( + "SELECT id FROM activities WHERE id = ?" + ).bind(activity_id).first() + except Exception as exc: + await w.capture_exception(exc, req, env, "materials.list.activity_lookup") + return w.err("Database error", 500) + + if not act: + return w.err("Activity not found", 404) + + try: + res = await env.DB.prepare( + "SELECT id, activity_id, title, description, uploaded_by, created_at" + " FROM course_materials" + " WHERE activity_id = ?" + " ORDER BY created_at DESC" + ).bind(activity_id).all() + except Exception as exc: + await w.capture_exception(exc, req, env, "materials.list.query") + return w.err("Failed to fetch materials", 500) + + materials = [] + for row in res.results or []: + materials.append({ + "id": row.id, + "activity_id": row.activity_id, + "title": row.title, + "description": row.description or "", + "uploaded_by": row.uploaded_by, + "created_at": row.created_at, + }) + + return w.json_resp({"materials": materials, "count": len(materials)}) + + +async def upload_material(activity_id: str, req, env): + """POST /api/activities/:activity_id/materials + + Accepts multipart/form-data with fields: + - title (required, text) + - description (optional, text) + - file (required, binary) + + Uploads the file to R2 and stores metadata in D1. + Requires authentication. + """ + w = _worker() + + user = w.verify_token(req.headers.get("Authorization"), env.JWT_SECRET) + if not user: + return w.err("Authentication required", 401) + + # Validate activity exists and user is host + try: + act = await env.DB.prepare( + "SELECT id, host_id FROM activities WHERE id = ?" + ).bind(activity_id).first() + except Exception as exc: + await w.capture_exception(exc, req, env, "materials.upload.activity_lookup") + return w.err("Database error", 500) + + if not act: + return w.err("Activity not found", 404) + + host_id = getattr(act, "host_id", None) + if host_id and host_id != user["id"]: + return w.err("Only the activity creator can upload materials", 403) + + # Parse multipart body + fields, parse_err = await _parse_multipart(req) + if parse_err: + return parse_err + + title = (fields.get("title") or "").strip() if isinstance(fields.get("title"), str) else "" + description = (fields.get("description") or "").strip() if isinstance(fields.get("description"), str) else "" + + if not title: + return w.err("title is required", 400) + + file_field = fields.get("file") + if not file_field: + return w.err("file is required", 400) + + # Support both dict (multipart/JSON) and raw bytes (test injection) + if isinstance(file_field, dict): + filename = file_field.get("filename") or "upload" + raw_bytes = file_field.get("bytes") or b"" + # JSON fallback may provide a list of ints; normalise to bytes. + if isinstance(raw_bytes, list): + try: + file_bytes = bytes(raw_bytes) + except Exception: + return w.err("Invalid file bytes", 400) + elif isinstance(raw_bytes, (bytes, bytearray)): + file_bytes = bytes(raw_bytes) + else: + return w.err("Invalid file bytes", 400) + content_type = file_field.get("content_type") or "application/octet-stream" + elif isinstance(file_field, (bytes, bytearray)): + filename = fields.get("filename") or "upload" + file_bytes = bytes(file_field) + content_type = "application/octet-stream" + else: + return w.err("Invalid file field", 400) + + if not file_bytes: + return w.err("Uploaded file is empty", 400) + + # 50MB file size guard + MAX_FILE_SIZE = 50 * 1024 * 1024 + if len(file_bytes) > MAX_FILE_SIZE: + return w.err("File size exceeds 50MB limit", 400) + + # Build R2 key and upload + r2_key = _r2_key(activity_id, filename) + try: + await _upload_to_r2(env, r2_key, file_bytes, content_type, original_filename=filename) + except Exception as exc: + await w.capture_exception(exc, req, env, "materials.upload.r2_put") + return w.err("File upload failed — please try again", 500) + + # Persist metadata in D1 + mid = _new_id() + try: + await env.DB.prepare( + "INSERT INTO course_materials" + " (id, activity_id, title, description, file_key, uploaded_by)" + " VALUES (?, ?, ?, ?, ?, ?)" + ).bind(mid, activity_id, title, description, r2_key, user["id"]).run() + except Exception as exc: + # Compensating action: remove the orphaned R2 object + await _delete_from_r2(env, r2_key) + await w.capture_exception(exc, req, env, "materials.upload.db_insert") + return w.err("Failed to save material metadata — please try again", 500) + + return w.ok( + { + "id": mid, + "activity_id": activity_id, + "title": title, + "description": description, + "file_key": r2_key, # internal key – not a public URL + }, + "Material uploaded successfully", + ) + + +async def delete_material(activity_id: str, mid: str, req, env): + """DELETE /api/activities/:activity_id/materials/:mid + + Deletes the material record from D1 and the file from R2. + Only the uploader or the activity host may delete a material. + """ + w = _worker() + + user = w.verify_token(req.headers.get("Authorization"), env.JWT_SECRET) + if not user: + return w.err("Authentication required", 401) + + # Fetch the material (also validates activity_id matches) + try: + row = await env.DB.prepare( + "SELECT id, activity_id, file_key, uploaded_by" + " FROM course_materials" + " WHERE id = ? AND activity_id = ?" + ).bind(mid, activity_id).first() + except Exception as exc: + await w.capture_exception(exc, req, env, "materials.delete.lookup") + return w.err("Database error", 500) + + if not row: + return w.err("Material not found", 404) + + # Authorisation: uploader OR activity host may delete + is_uploader = (row.uploaded_by == user["id"]) + is_host = False + if not is_uploader: + try: + act = await env.DB.prepare( + "SELECT host_id FROM activities WHERE id = ?" + ).bind(activity_id).first() + is_host = bool(act and act.host_id == user["id"]) + except Exception as exc: + await w.capture_exception(exc, req, env, "materials.delete.host_check") + + if not is_uploader and not is_host: + return w.err("Permission denied", 403) + + # Delete from D1 first (so the record is gone even if R2 delete lags) + try: + await env.DB.prepare( + "DELETE FROM course_materials WHERE id = ?" + ).bind(mid).run() + except Exception as exc: + await w.capture_exception(exc, req, env, "materials.delete.db_delete") + return w.err("Failed to delete material record", 500) + + # Best-effort R2 deletion (errors are logged but do not fail the request) + await _delete_from_r2(env, row.file_key) + + return w.ok({"id": mid}, "Material deleted successfully") + + +async def update_material(activity_id: str, mid: str, req, env): + """PATCH /api/activities/:activity_id/materials/:mid + + Updates the title and/or description of an existing material. + Only the uploader or the activity host may edit a material. + """ + w = _worker() + + user = w.verify_token(req.headers.get("Authorization"), env.JWT_SECRET) + if not user: + return w.err("Authentication required", 401) + + # Fetch the material (also validates activity_id matches) + try: + row = await env.DB.prepare( + "SELECT id, activity_id, uploaded_by" + " FROM course_materials" + " WHERE id = ? AND activity_id = ?" + ).bind(mid, activity_id).first() + except Exception as exc: + await w.capture_exception(exc, req, env, "materials.update.lookup") + return w.err("Database error", 500) + + if not row: + return w.err("Material not found", 404) + + # Authorisation: uploader OR activity host may edit + is_uploader = (row.uploaded_by == user["id"]) + is_host = False + if not is_uploader: + try: + act = await env.DB.prepare( + "SELECT host_id FROM activities WHERE id = ?" + ).bind(activity_id).first() + is_host = bool(act and act.host_id == user["id"]) + except Exception as exc: + await w.capture_exception(exc, req, env, "materials.update.host_check") + + if not is_uploader and not is_host: + return w.err("Permission denied", 403) + + # Parse JSON body + body, parse_err = await w.parse_json_object(req) + if parse_err: + return parse_err + + title = (body.get("title") or "").strip() if isinstance(body.get("title"), str) else "" + description = (body.get("description") or "").strip() if isinstance(body.get("description"), str) else "" + + if not title: + return w.err("title is required", 400) + + try: + await env.DB.prepare( + "UPDATE course_materials SET title = ?, description = ? WHERE id = ?" + ).bind(title, description, mid).run() + except Exception as exc: + await w.capture_exception(exc, req, env, "materials.update.db_update") + return w.err("Failed to update material", 500) + + return w.ok( + {"id": mid, "title": title, "description": description}, + "Material updated successfully", + ) + + +async def download_material(activity_id: str, mid: str, req, env): + """GET /api/activities/:activity_id/materials/:mid/download + + Returns a time-limited signed URL for the material's R2 object. + Prefer presigned R2 URLs; if presigning isn't available, falls back to + the authenticated ``/api/r2/{key}`` proxy implemented in the worker. + Requires authentication. + """ + w = _worker() + + user = w.verify_token(req.headers.get("Authorization"), env.JWT_SECRET) + if not user: + return w.err("Authentication required", 401) + + # Fetch material (validates activity_id scope) + try: + row = await env.DB.prepare( + "SELECT id, title, file_key" + " FROM course_materials" + " WHERE id = ? AND activity_id = ?" + ).bind(mid, activity_id).first() + except Exception as exc: + await w.capture_exception(exc, req, env, "materials.download.lookup") + return w.err("Database error", 500) + + if not row: + return w.err("Material not found", 404) + + # Generate a signed / temporary download URL + try: + download_url = await _generate_download_url(env, row.file_key) + except Exception as exc: + await w.capture_exception(exc, req, env, "materials.download.url_gen") + return w.err("Failed to generate download URL", 500) + + # Derive the original filename from the R2 key + original_filename = _filename_from_key(row.file_key) + + return w.ok( + { + "id": row.id, + "title": row.title, + "filename": original_filename, + "download_url": download_url, + "expires_in": 3600, # seconds + }, + "Download URL generated", + ) diff --git a/src/worker.py b/src/worker.py index bf344bf..57ac793 100644 --- a/src/worker.py +++ b/src/worker.py @@ -1093,6 +1093,21 @@ async def send_password_reset_email(to_email: str, _username: str, token: str, e "CREATE INDEX IF NOT EXISTS idx_message_requests_to_user ON message_requests(to_user_id, status)", "CREATE INDEX IF NOT EXISTS idx_message_requests_from_user ON message_requests(from_user_id)", "CREATE INDEX IF NOT EXISTS idx_message_requests_activity ON message_requests(activity_id)", + # Course materials (A2 module) + """CREATE TABLE IF NOT EXISTS course_materials ( + id TEXT PRIMARY KEY, + activity_id TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT, + file_key TEXT NOT NULL, + uploaded_by TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (activity_id) REFERENCES activities(id) ON DELETE CASCADE, + FOREIGN KEY (uploaded_by) REFERENCES users(id) ON DELETE SET NULL + )""", + "CREATE INDEX IF NOT EXISTS idx_materials_activity ON course_materials(activity_id)", + "CREATE INDEX IF NOT EXISTS idx_materials_uploader ON course_materials(uploaded_by)", + "CREATE INDEX IF NOT EXISTS idx_materials_created ON course_materials(activity_id, created_at DESC)", ] @@ -7669,6 +7684,101 @@ async def _dispatch(request, env): return await api_send_thread_message(request, env, m_thread_send.group(1)) # --- END NEW --- + # Course Materials (A2 module) + m_mat_list = re.fullmatch(r"/api/activities/([A-Za-z0-9_-]+)/materials", path) + if m_mat_list: + from api.materials import list_materials, upload_material # noqa: PLC0415 + if method == "GET": + return await list_materials(m_mat_list.group(1), request, env) + if method == "POST": + return await upload_material(m_mat_list.group(1), request, env) + + m_mat_download = re.fullmatch( + r"/api/activities/([A-Za-z0-9_-]+)/materials/([A-Za-z0-9_-]+)/download", path + ) + if m_mat_download and method == "GET": + from api.materials import download_material # noqa: PLC0415 + return await download_material(m_mat_download.group(1), m_mat_download.group(2), request, env) + + m_mat_item = re.fullmatch( + r"/api/activities/([A-Za-z0-9_-]+)/materials/([A-Za-z0-9_-]+)", path + ) + if m_mat_item and method == "DELETE": + from api.materials import delete_material # noqa: PLC0415 + return await delete_material(m_mat_item.group(1), m_mat_item.group(2), request, env) + if m_mat_item and method == "PATCH": + from api.materials import update_material # noqa: PLC0415 + return await update_material(m_mat_item.group(1), m_mat_item.group(2), request, env) + + # R2 object proxy – fallback download when presigned URLs aren't available + # Matches /api/r2/ (the key may contain slashes) + if path.startswith("/api/r2/") and method == "GET": + r2_key = path[len("/api/r2/"):] + # Only allow course-material keys under the expected prefix + if not r2_key.startswith("materials/"): + return err("Invalid R2 key", 400) + # Accept token from Authorization header OR ?token= query param + # (browser navigation can't set headers, so we allow query param) + auth_header = request.headers.get("Authorization") or "" + if not auth_header: + qs_params = parse_qs(urlparse(request.url).query) + token_param = (qs_params.get("token") or [None])[0] + if token_param: + auth_header = token_param + user = verify_token(auth_header, env.JWT_SECRET) + if not user: + return err("Authentication required", 401) + try: + env_dict = getattr(env, "__dict__", {}) + if "MY_BUCKET" in env_dict: + r2_bucket = env_dict["MY_BUCKET"] + elif "R2_BUCKET" in env_dict: + r2_bucket = env_dict["R2_BUCKET"] + elif "R2" in env_dict: + r2_bucket = env_dict["R2"] + else: + r2_bucket = ( + getattr(env, "MY_BUCKET", None) + or getattr(env, "R2_BUCKET", None) + or getattr(env, "R2", None) + ) + if not r2_bucket: + return err("R2 storage not configured", 500) + obj = await r2_bucket.get(r2_key) + if obj is None: + return err("File not found", 404) + try: + from pyodide.ffi import to_js # noqa: PLC0415 + import js # noqa: PLC0415 + body_bytes = bytes(js.Uint8Array.new(await obj.arrayBuffer())) + except ImportError: + body_bytes = await obj.arrayBuffer() + ct = (getattr(obj, "httpMetadata", None) and + getattr(obj.httpMetadata, "contentType", None)) or "application/octet-stream" + # R2 key format: materials/{act_id}/{uuid}_{original_filename} + # Strip the UUID prefix (36 chars) + underscore to get original name + raw_name = r2_key.split("/")[-1] + filename = raw_name[37:] if len(raw_name) > 37 else raw_name + try: + import js as _js # noqa: PLC0415 + from pyodide.ffi import to_js as _to_js # noqa: PLC0415 + headers = _to_js({ + "Content-Type": ct, + "Content-Disposition": f'attachment; filename="{filename}"', + "Cache-Control": "private, max-age=3600", + }, dict_converter=_js.Object.fromEntries) + return _js.Response.new( + _to_js(body_bytes, create_pyproxies=False), + _to_js({"status": 200, "headers": headers}, + dict_converter=_js.Object.fromEntries), + ) + except ImportError: + # Unit-test / non-CF environment + return json_resp({"error": "R2 proxy not available in this environment"}, 501) + except Exception as exc: + await capture_exception(exc, request, env, "r2_proxy") + return err("Failed to retrieve file", 500) + # Notifications if path == "/api/notifications" and method == "GET": return await api_list_notifications(request, env) diff --git a/tests/test_api_materials.py b/tests/test_api_materials.py new file mode 100644 index 0000000..9ed1244 --- /dev/null +++ b/tests/test_api_materials.py @@ -0,0 +1,760 @@ +""" +Tests for the Course Materials API (A2 module). + +Covers: + * list_materials – GET /api/activities/:activity_id/materials + * upload_material – POST /api/activities/:activity_id/materials + * delete_material – DELETE /api/activities/:activity_id/materials/:mid + * download_material – GET /api/activities/:activity_id/materials/:mid/download + +Helper stubs: + * MockR2Bucket – simulates env.R2_BUCKET (put / delete / createPresignedUrl) +""" + +import importlib +import json +import sys +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import pytest + +# --------------------------------------------------------------------------- +# Ensure the stubs installed by conftest.py are present before any import +# --------------------------------------------------------------------------- +from tests.helpers import ( + MockDB, + MockRequest, + MockRow, + load_worker, + make_env, + make_stmt, +) + +# Load worker module (needed for create_token / verify_token) +worker = load_worker() + +# --------------------------------------------------------------------------- +# Load the materials module under test +# --------------------------------------------------------------------------- + +_MATERIALS_PATH = Path(__file__).parent.parent / "src" / "api" / "materials.py" + + +def _load_materials(): + """Load src/api/materials.py and inject the already-loaded worker stub.""" + # Make sure "worker" resolves to the already-loaded module inside materials.py + sys.modules.setdefault("worker", worker) + spec = importlib.util.spec_from_file_location("src.api.materials", _MATERIALS_PATH) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +materials = _load_materials() + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +JWT = "test-jwt-secret" +ENC = "test-encryption-key" +ACTIVITY_ID = "act-test-001" +MATERIAL_ID = "mat-test-001" +USER_ID = "usr-host-001" +OTHER_USER_ID = "usr-other-001" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _token(uid=USER_ID, username="alice", role="host"): + return worker.create_token(uid, username, role, JWT) + + +def _auth(uid=USER_ID, username="alice", role="host"): + return {"Authorization": f"Bearer {_token(uid, username, role)}"} + + +def _parse(resp): + return json.loads(resp.body) + + +def _make_material_row(**kwargs): + defaults = dict( + id=MATERIAL_ID, + activity_id=ACTIVITY_ID, + title="Lecture Slides", + description="Week 1 slides", + file_key=f"materials/{ACTIVITY_ID}/uuid_slides.pdf", + uploaded_by=USER_ID, + created_at="2024-06-01 10:00:00", + ) + defaults.update(kwargs) + return MockRow(**defaults) + + +# --------------------------------------------------------------------------- +# Mock R2 bucket +# --------------------------------------------------------------------------- + +class MockR2Bucket: + """Minimal R2 bucket stub that records calls.""" + + def __init__(self, presigned_url="https://r2.example.com/signed?token=abc"): + self._presigned_url = presigned_url + self.put = AsyncMock(return_value=None) + self.delete = AsyncMock(return_value=None) + self.createPresignedUrl = AsyncMock(return_value=presigned_url) + + +def _make_env_with_r2(db=None, r2=None): + env = make_env(db=db, enc_key=ENC, jwt_secret=JWT) + env.R2_BUCKET = r2 if r2 is not None else MockR2Bucket() + return env + + +# --------------------------------------------------------------------------- +# list_materials +# --------------------------------------------------------------------------- + +class TestListMaterials: + """GET /api/activities/:activity_id/materials""" + + def _req(self, activity_id=ACTIVITY_ID): + return MockRequest(method="GET", + url=f"http://localhost/api/activities/{activity_id}/materials") + + async def test_returns_materials_list(self): + row = _make_material_row() + env = _make_env_with_r2(db=MockDB([ + make_stmt(first=MockRow(id=ACTIVITY_ID)), # activity exists + make_stmt(all_results=[row]), # SELECT materials + ])) + resp = await materials.list_materials(ACTIVITY_ID, self._req(), env) + assert resp.status == 200 + data = _parse(resp) + assert "materials" in data + assert data["count"] == 1 + assert data["materials"][0]["id"] == MATERIAL_ID + assert data["materials"][0]["title"] == "Lecture Slides" + + async def test_empty_list_when_no_materials(self): + env = _make_env_with_r2(db=MockDB([ + make_stmt(first=MockRow(id=ACTIVITY_ID)), + make_stmt(all_results=[]), + ])) + resp = await materials.list_materials(ACTIVITY_ID, self._req(), env) + assert resp.status == 200 + data = _parse(resp) + assert data["materials"] == [] + assert data["count"] == 0 + + async def test_activity_not_found_returns_404(self): + env = _make_env_with_r2(db=MockDB([ + make_stmt(first=None), # activity not found + ])) + resp = await materials.list_materials("nonexistent", self._req("nonexistent"), env) + assert resp.status == 404 + assert "not found" in _parse(resp)["error"].lower() + + async def test_db_error_returns_500(self): + stmt = make_stmt() + stmt.bind.return_value.first.side_effect = Exception("DB down") + env = _make_env_with_r2(db=MockDB([stmt])) + resp = await materials.list_materials(ACTIVITY_ID, self._req(), env) + assert resp.status == 500 + + async def test_materials_query_error_returns_500(self): + act_stmt = make_stmt(first=MockRow(id=ACTIVITY_ID)) + mat_stmt = make_stmt() + mat_stmt.bind.return_value.all.side_effect = Exception("Query failed") + env = _make_env_with_r2(db=MockDB([act_stmt, mat_stmt])) + resp = await materials.list_materials(ACTIVITY_ID, self._req(), env) + assert resp.status == 500 + + async def test_response_fields_present(self): + row = _make_material_row() + env = _make_env_with_r2(db=MockDB([ + make_stmt(first=MockRow(id=ACTIVITY_ID)), + make_stmt(all_results=[row]), + ])) + resp = await materials.list_materials(ACTIVITY_ID, self._req(), env) + mat = _parse(resp)["materials"][0] + for field in ("id", "activity_id", "title", "description", "uploaded_by", "created_at"): + assert field in mat, f"Missing field: {field}" + + async def test_file_key_not_exposed_in_list(self): + """R2 file_key must never appear in the list response.""" + row = _make_material_row() + env = _make_env_with_r2(db=MockDB([ + make_stmt(first=MockRow(id=ACTIVITY_ID)), + make_stmt(all_results=[row]), + ])) + resp = await materials.list_materials(ACTIVITY_ID, self._req(), env) + mat = _parse(resp)["materials"][0] + assert "file_key" not in mat + + async def test_multiple_materials_returned(self): + rows = [_make_material_row(id=f"mat-{i}", title=f"Material {i}") for i in range(3)] + env = _make_env_with_r2(db=MockDB([ + make_stmt(first=MockRow(id=ACTIVITY_ID)), + make_stmt(all_results=rows), + ])) + resp = await materials.list_materials(ACTIVITY_ID, self._req(), env) + data = _parse(resp) + assert data["count"] == 3 + assert len(data["materials"]) == 3 + + +# --------------------------------------------------------------------------- +# upload_material +# --------------------------------------------------------------------------- + +class TestUploadMaterial: + """POST /api/activities/:activity_id/materials""" + + def _req(self, activity_id=ACTIVITY_ID, headers=None, body=None): + h = {"Content-Type": "application/json"} + if headers: + h.update(headers) + return MockRequest( + method="POST", + url=f"http://localhost/api/activities/{activity_id}/materials", + headers=h, + body=json.dumps(body) if body is not None else None, + ) + + def _upload_req(self, title="Slides", description="", filename="slides.pdf", + file_bytes=b"%PDF-1.4 test", activity_id=ACTIVITY_ID, uid=USER_ID): + """Build a JSON-body request that exercises the JSON fallback path in _parse_multipart.""" + body = { + "title": title, + "description": description, + "filename": filename, + "file": { + "filename": filename, + "bytes": list(file_bytes), # JSON-serialisable + "content_type": "application/pdf", + }, + } + return MockRequest( + method="POST", + url=f"http://localhost/api/activities/{activity_id}/materials", + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {_token(uid)}", + }, + body=json.dumps(body), + ) + + async def test_no_auth_returns_401(self): + env = _make_env_with_r2() + resp = await materials.upload_material(ACTIVITY_ID, self._req(), env) + assert resp.status == 401 + + async def test_activity_not_found_returns_404(self): + env = _make_env_with_r2(db=MockDB([make_stmt(first=None)])) + req = self._upload_req() + resp = await materials.upload_material(ACTIVITY_ID, req, env) + assert resp.status == 404 + + async def test_non_host_upload_returns_403(self): + env = _make_env_with_r2(db=MockDB([make_stmt(first=MockRow(id=ACTIVITY_ID, host_id=OTHER_USER_ID))])) + req = self._upload_req(uid=USER_ID) + resp = await materials.upload_material(ACTIVITY_ID, req, env) + assert resp.status == 403 + + async def test_missing_title_returns_400(self): + env = _make_env_with_r2(db=MockDB([make_stmt(first=MockRow(id=ACTIVITY_ID, host_id=USER_ID))])) + body = {"description": "no title", "file": {"filename": "f.pdf", "bytes": [1, 2, 3]}} + req = MockRequest( + method="POST", + url=f"http://localhost/api/activities/{ACTIVITY_ID}/materials", + headers={"Content-Type": "application/json", "Authorization": f"Bearer {_token()}"}, + body=json.dumps(body), + ) + resp = await materials.upload_material(ACTIVITY_ID, req, env) + assert resp.status == 400 + assert "title" in _parse(resp)["error"].lower() + + async def test_missing_file_returns_400(self): + env = _make_env_with_r2(db=MockDB([make_stmt(first=MockRow(id=ACTIVITY_ID))])) + body = {"title": "Slides"} + req = MockRequest( + method="POST", + url=f"http://localhost/api/activities/{ACTIVITY_ID}/materials", + headers={"Content-Type": "application/json", "Authorization": f"Bearer {_token()}"}, + body=json.dumps(body), + ) + resp = await materials.upload_material(ACTIVITY_ID, req, env) + assert resp.status == 400 + assert "file" in _parse(resp)["error"].lower() + + async def test_successful_upload_returns_200(self): + r2 = MockR2Bucket() + env = _make_env_with_r2( + db=MockDB([ + make_stmt(first=MockRow(id=ACTIVITY_ID)), # activity check + make_stmt(), # INSERT material + ]), + r2=r2, + ) + req = self._upload_req() + resp = await materials.upload_material(ACTIVITY_ID, req, env) + assert resp.status == 200 + data = _parse(resp) + assert data["success"] is True + assert "id" in data["data"] + assert data["data"]["title"] == "Slides" + assert data["data"]["activity_id"] == ACTIVITY_ID + + async def test_r2_put_called_on_success(self): + r2 = MockR2Bucket() + env = _make_env_with_r2( + db=MockDB([ + make_stmt(first=MockRow(id=ACTIVITY_ID)), + make_stmt(), + ]), + r2=r2, + ) + await materials.upload_material(ACTIVITY_ID, self._upload_req(), env) + r2.put.assert_called_once() + + async def test_r2_key_format(self): + """R2 key must start with materials/{activity_id}/.""" + r2 = MockR2Bucket() + env = _make_env_with_r2( + db=MockDB([ + make_stmt(first=MockRow(id=ACTIVITY_ID)), + make_stmt(), + ]), + r2=r2, + ) + await materials.upload_material(ACTIVITY_ID, self._upload_req(), env) + call_args = r2.put.call_args + key = call_args[0][0] + assert key.startswith(f"materials/{ACTIVITY_ID}/") + + async def test_file_key_not_exposed_in_response(self): + """file_key is returned in upload response for internal use but raw R2 path is opaque.""" + r2 = MockR2Bucket() + env = _make_env_with_r2( + db=MockDB([ + make_stmt(first=MockRow(id=ACTIVITY_ID)), + make_stmt(), + ]), + r2=r2, + ) + resp = await materials.upload_material(ACTIVITY_ID, self._upload_req(), env) + data = _parse(resp) + # file_key is present in upload response (internal reference) but must not be a raw URL + key = data["data"].get("file_key", "") + assert not key.startswith("http"), "file_key must not be a public URL" + + async def test_r2_upload_failure_returns_500(self): + r2 = MockR2Bucket() + r2.put.side_effect = Exception("R2 unavailable") + env = _make_env_with_r2( + db=MockDB([make_stmt(first=MockRow(id=ACTIVITY_ID))]), + r2=r2, + ) + resp = await materials.upload_material(ACTIVITY_ID, self._upload_req(), env) + assert resp.status == 500 + + async def test_db_insert_failure_deletes_r2_object(self): + """If D1 insert fails after R2 upload, the orphaned R2 object must be deleted.""" + r2 = MockR2Bucket() + insert_stmt = make_stmt() + insert_stmt.bind.return_value.run.side_effect = Exception("D1 write error") + env = _make_env_with_r2( + db=MockDB([ + make_stmt(first=MockRow(id=ACTIVITY_ID)), + insert_stmt, + ]), + r2=r2, + ) + resp = await materials.upload_material(ACTIVITY_ID, self._upload_req(), env) + assert resp.status == 500 + # Compensating delete must have been called + r2.delete.assert_called_once() + + async def test_empty_file_bytes_returns_400(self): + env = _make_env_with_r2(db=MockDB([make_stmt(first=MockRow(id=ACTIVITY_ID))])) + body = {"title": "Empty", "file": {"filename": "empty.pdf", "bytes": []}} + req = MockRequest( + method="POST", + url=f"http://localhost/api/activities/{ACTIVITY_ID}/materials", + headers={"Content-Type": "application/json", "Authorization": f"Bearer {_token()}"}, + body=json.dumps(body), + ) + resp = await materials.upload_material(ACTIVITY_ID, req, env) + assert resp.status == 400 + + async def test_invalid_json_returns_400(self): + req = MockRequest( + method="POST", + url=f"http://localhost/api/activities/{ACTIVITY_ID}/materials", + headers={"Content-Type": "application/json", "Authorization": f"Bearer {_token()}"}, + body="not-json", + ) + env = _make_env_with_r2(db=MockDB([make_stmt(first=MockRow(id=ACTIVITY_ID))])) + resp = await materials.upload_material(ACTIVITY_ID, req, env) + assert resp.status == 400 + + async def test_description_is_optional(self): + r2 = MockR2Bucket() + env = _make_env_with_r2( + db=MockDB([ + make_stmt(first=MockRow(id=ACTIVITY_ID)), + make_stmt(), + ]), + r2=r2, + ) + req = self._upload_req(description="") + resp = await materials.upload_material(ACTIVITY_ID, req, env) + assert resp.status == 200 + + +# --------------------------------------------------------------------------- +# delete_material +# --------------------------------------------------------------------------- + +class TestDeleteMaterial: + """DELETE /api/activities/:activity_id/materials/:mid""" + + def _req(self, activity_id=ACTIVITY_ID, mid=MATERIAL_ID, uid=USER_ID): + return MockRequest( + method="DELETE", + url=f"http://localhost/api/activities/{activity_id}/materials/{mid}", + headers={"Authorization": f"Bearer {_token(uid)}"}, + ) + + async def test_no_auth_returns_401(self): + env = _make_env_with_r2() + req = MockRequest(method="DELETE", + url=f"http://localhost/api/activities/{ACTIVITY_ID}/materials/{MATERIAL_ID}") + resp = await materials.delete_material(ACTIVITY_ID, MATERIAL_ID, req, env) + assert resp.status == 401 + + async def test_material_not_found_returns_404(self): + env = _make_env_with_r2(db=MockDB([make_stmt(first=None)])) + resp = await materials.delete_material(ACTIVITY_ID, MATERIAL_ID, self._req(), env) + assert resp.status == 404 + + async def test_uploader_can_delete(self): + r2 = MockR2Bucket() + mat_row = _make_material_row(uploaded_by=USER_ID) + env = _make_env_with_r2( + db=MockDB([ + make_stmt(first=mat_row), # material lookup + make_stmt(), # DELETE + ]), + r2=r2, + ) + resp = await materials.delete_material(ACTIVITY_ID, MATERIAL_ID, self._req(uid=USER_ID), env) + assert resp.status == 200 + data = _parse(resp) + assert data["success"] is True + assert data["data"]["id"] == MATERIAL_ID + + async def test_host_can_delete_others_material(self): + """Activity host should be able to delete any material in their activity.""" + r2 = MockR2Bucket() + mat_row = _make_material_row(uploaded_by=OTHER_USER_ID) + host_act_row = MockRow(host_id=USER_ID) + env = _make_env_with_r2( + db=MockDB([ + make_stmt(first=mat_row), # material lookup + make_stmt(first=host_act_row), # activity host check + make_stmt(), # DELETE + ]), + r2=r2, + ) + resp = await materials.delete_material(ACTIVITY_ID, MATERIAL_ID, self._req(uid=USER_ID), env) + assert resp.status == 200 + + async def test_non_owner_non_host_returns_403(self): + mat_row = _make_material_row(uploaded_by=OTHER_USER_ID) + act_row = MockRow(host_id=OTHER_USER_ID) # different host + env = _make_env_with_r2( + db=MockDB([ + make_stmt(first=mat_row), + make_stmt(first=act_row), + ]), + ) + resp = await materials.delete_material(ACTIVITY_ID, MATERIAL_ID, self._req(uid=USER_ID), env) + assert resp.status == 403 + + async def test_r2_delete_called_after_db_delete(self): + r2 = MockR2Bucket() + mat_row = _make_material_row(uploaded_by=USER_ID) + env = _make_env_with_r2( + db=MockDB([ + make_stmt(first=mat_row), + make_stmt(), + ]), + r2=r2, + ) + await materials.delete_material(ACTIVITY_ID, MATERIAL_ID, self._req(), env) + r2.delete.assert_called_once_with(mat_row.file_key) + + async def test_db_delete_failure_returns_500(self): + mat_row = _make_material_row(uploaded_by=USER_ID) + del_stmt = make_stmt() + del_stmt.bind.return_value.run.side_effect = Exception("DB error") + env = _make_env_with_r2( + db=MockDB([ + make_stmt(first=mat_row), + del_stmt, + ]), + ) + resp = await materials.delete_material(ACTIVITY_ID, MATERIAL_ID, self._req(), env) + assert resp.status == 500 + + async def test_r2_delete_error_does_not_fail_request(self): + """R2 delete errors are swallowed – the DB record is already gone.""" + r2 = MockR2Bucket() + r2.delete.side_effect = Exception("R2 error") + mat_row = _make_material_row(uploaded_by=USER_ID) + env = _make_env_with_r2( + db=MockDB([ + make_stmt(first=mat_row), + make_stmt(), + ]), + r2=r2, + ) + # Should still return 200 even though R2 delete raised + resp = await materials.delete_material(ACTIVITY_ID, MATERIAL_ID, self._req(), env) + assert resp.status == 200 + + async def test_activity_id_scoping(self): + """Material must belong to the given activity_id (enforced by WHERE clause).""" + # Simulate the DB returning None because activity_id doesn't match + env = _make_env_with_r2(db=MockDB([make_stmt(first=None)])) + resp = await materials.delete_material("wrong-activity", MATERIAL_ID, self._req(), env) + assert resp.status == 404 + + +# --------------------------------------------------------------------------- +# download_material +# --------------------------------------------------------------------------- + +class TestDownloadMaterial: + """GET /api/activities/:activity_id/materials/:mid/download""" + + def _req(self, activity_id=ACTIVITY_ID, mid=MATERIAL_ID, uid=USER_ID): + return MockRequest( + method="GET", + url=f"http://localhost/api/activities/{activity_id}/materials/{mid}/download", + headers={"Authorization": f"Bearer {_token(uid)}"}, + ) + + async def test_no_auth_returns_401(self): + env = _make_env_with_r2() + req = MockRequest( + method="GET", + url=f"http://localhost/api/activities/{ACTIVITY_ID}/materials/{MATERIAL_ID}/download", + ) + resp = await materials.download_material(ACTIVITY_ID, MATERIAL_ID, req, env) + assert resp.status == 401 + + async def test_material_not_found_returns_404(self): + env = _make_env_with_r2(db=MockDB([make_stmt(first=None)])) + resp = await materials.download_material(ACTIVITY_ID, MATERIAL_ID, self._req(), env) + assert resp.status == 404 + + async def test_returns_signed_url(self): + signed_url = "https://r2.example.com/signed?token=xyz&expires=3600" + r2 = MockR2Bucket(presigned_url=signed_url) + mat_row = MockRow(id=MATERIAL_ID, title="Slides", + file_key=f"materials/{ACTIVITY_ID}/uuid_slides.pdf") + env = _make_env_with_r2(db=MockDB([make_stmt(first=mat_row)]), r2=r2) + resp = await materials.download_material(ACTIVITY_ID, MATERIAL_ID, self._req(), env) + assert resp.status == 200 + data = _parse(resp) + assert data["success"] is True + assert data["data"]["download_url"] == signed_url + assert data["data"]["id"] == MATERIAL_ID + assert data["data"]["title"] == "Slides" + + async def test_expires_in_field_present(self): + r2 = MockR2Bucket() + mat_row = MockRow(id=MATERIAL_ID, title="Slides", + file_key=f"materials/{ACTIVITY_ID}/uuid_slides.pdf") + env = _make_env_with_r2(db=MockDB([make_stmt(first=mat_row)]), r2=r2) + resp = await materials.download_material(ACTIVITY_ID, MATERIAL_ID, self._req(), env) + data = _parse(resp) + assert "expires_in" in data["data"] + assert data["data"]["expires_in"] == 3600 + + async def test_raw_r2_key_not_in_response(self): + """The raw R2 file_key must never appear in the download response.""" + r2 = MockR2Bucket() + file_key = f"materials/{ACTIVITY_ID}/uuid_slides.pdf" + mat_row = MockRow(id=MATERIAL_ID, title="Slides", file_key=file_key) + env = _make_env_with_r2(db=MockDB([make_stmt(first=mat_row)]), r2=r2) + resp = await materials.download_material(ACTIVITY_ID, MATERIAL_ID, self._req(), env) + body_str = resp.body + assert file_key not in body_str + + async def test_presigned_url_fallback_when_r2_method_missing(self): + """When createPresignedUrl is unavailable, fall back to /api/r2/:key proxy URL.""" + r2 = MockR2Bucket() + r2.createPresignedUrl.side_effect = Exception("Method not available") + file_key = f"materials/{ACTIVITY_ID}/uuid_slides.pdf" + mat_row = MockRow(id=MATERIAL_ID, title="Slides", file_key=file_key) + env = _make_env_with_r2(db=MockDB([make_stmt(first=mat_row)]), r2=r2) + resp = await materials.download_material(ACTIVITY_ID, MATERIAL_ID, self._req(), env) + assert resp.status == 200 + data = _parse(resp) + # Fallback URL should be the internal proxy path + assert data["data"]["download_url"] == f"/api/r2/{file_key}" + + async def test_db_error_returns_500(self): + stmt = make_stmt() + stmt.bind.return_value.first.side_effect = Exception("DB down") + env = _make_env_with_r2(db=MockDB([stmt])) + resp = await materials.download_material(ACTIVITY_ID, MATERIAL_ID, self._req(), env) + assert resp.status == 500 + + async def test_activity_id_scoping(self): + """Material must belong to the given activity_id.""" + env = _make_env_with_r2(db=MockDB([make_stmt(first=None)])) + resp = await materials.download_material("wrong-activity", MATERIAL_ID, self._req(), env) + assert resp.status == 404 + + +# --------------------------------------------------------------------------- +# update_material +# --------------------------------------------------------------------------- + + +class TestUpdateMaterial: + """PATCH /api/activities/:activity_id/materials/:mid""" + + def _req(self, activity_id=ACTIVITY_ID, mid=MATERIAL_ID, uid=USER_ID, body=None): + return MockRequest( + method="PATCH", + url=f"http://localhost/api/activities/{activity_id}/materials/{mid}", + headers={"Authorization": f"Bearer {_token(uid)}", "Content-Type": "application/json"}, + body=json.dumps(body) if body is not None else json.dumps({}), + ) + + async def test_no_auth_returns_401(self): + req = MockRequest( + method="PATCH", + url=f"http://localhost/api/activities/{ACTIVITY_ID}/materials/{MATERIAL_ID}", + headers={"Content-Type": "application/json"}, + body=json.dumps({"title": "New", "description": "Updated"}), + ) + env = _make_env_with_r2(db=MockDB([])) + resp = await materials.update_material(ACTIVITY_ID, MATERIAL_ID, req, env) + assert resp.status == 401 + + async def test_material_not_found_returns_404(self): + env = _make_env_with_r2(db=MockDB([ + make_stmt(first=None), # material lookup + ])) + resp = await materials.update_material(ACTIVITY_ID, MATERIAL_ID, self._req(body={"title": "New"}), env) + assert resp.status == 404 + + async def test_non_owner_non_host_returns_403(self): + mat_row = _make_material_row(uploaded_by=OTHER_USER_ID) + act_row = MockRow(host_id=OTHER_USER_ID) + env = _make_env_with_r2(db=MockDB([ + make_stmt(first=mat_row), # material lookup + make_stmt(first=act_row), # host lookup + ])) + resp = await materials.update_material(ACTIVITY_ID, MATERIAL_ID, self._req(uid=USER_ID, body={"title": "New"}), env) + assert resp.status == 403 + + async def test_missing_title_returns_400(self): + mat_row = _make_material_row(uploaded_by=USER_ID) + env = _make_env_with_r2(db=MockDB([ + make_stmt(first=mat_row), + ])) + # Empty title should be rejected + resp = await materials.update_material(ACTIVITY_ID, MATERIAL_ID, self._req(body={"title": " "}), env) + assert resp.status == 400 + assert "title" in _parse(resp)["error"].lower() + + async def test_uploader_can_update(self): + mat_row = _make_material_row(uploaded_by=USER_ID) + update_stmt = make_stmt() + env = _make_env_with_r2(db=MockDB([ + make_stmt(first=mat_row), # material lookup + update_stmt, # UPDATE + ])) + resp = await materials.update_material(ACTIVITY_ID, MATERIAL_ID, self._req(body={"title": "New title", "description": "Updated"}), env) + assert resp.status == 200 + data = _parse(resp) + assert data["success"] is True + assert data["data"]["id"] == MATERIAL_ID + assert data["data"]["title"] == "New title" + + async def test_host_can_update_others_material(self): + mat_row = _make_material_row(uploaded_by=OTHER_USER_ID) + host_act_row = MockRow(host_id=USER_ID) + update_stmt = make_stmt() + env = _make_env_with_r2(db=MockDB([ + make_stmt(first=mat_row), # material lookup + make_stmt(first=host_act_row), # host lookup + update_stmt, # UPDATE + ])) + resp = await materials.update_material(ACTIVITY_ID, MATERIAL_ID, self._req(body={"title": "New"}), env) + assert resp.status == 200 + + async def test_db_error_returns_500(self): + mat_row = _make_material_row(uploaded_by=USER_ID) + bad_stmt = make_stmt() + bad_stmt.bind.return_value.run.side_effect = Exception("DB down") + env = _make_env_with_r2(db=MockDB([ + make_stmt(first=mat_row), + bad_stmt, + ])) + resp = await materials.update_material(ACTIVITY_ID, MATERIAL_ID, self._req(body={"title": "New"}), env) + assert resp.status == 500 + + +# --------------------------------------------------------------------------- +# Helper unit tests +# --------------------------------------------------------------------------- + +class TestHelpers: + """Unit tests for internal helper functions.""" + + def test_new_id_is_uuid_format(self): + uid = materials._new_id() + parts = uid.split("-") + assert len(parts) == 5 + assert len(uid) == 36 + + def test_new_id_uniqueness(self): + ids = {materials._new_id() for _ in range(100)} + assert len(ids) == 100 + + def test_r2_key_format(self): + key = materials._r2_key("act-123", "lecture.pdf") + assert key.startswith("materials/act-123/") + assert key.endswith("lecture.pdf") + + def test_r2_key_sanitises_special_chars(self): + key = materials._r2_key("act-1", "my file (1).pdf") + # Spaces and parentheses should be replaced with underscores + assert " " not in key + assert "(" not in key + assert ")" not in key + + def test_r2_key_caps_filename_length(self): + long_name = "a" * 300 + ".pdf" + key = materials._r2_key("act-1", long_name) + # The filename portion after the UUID_ prefix should be ≤ 128 chars + filename_part = key.split("/")[-1].split("_", 1)[-1] + assert len(filename_part) <= 128 + + def test_r2_key_unique_per_call(self): + k1 = materials._r2_key("act-1", "file.pdf") + k2 = materials._r2_key("act-1", "file.pdf") + assert k1 != k2