diff --git a/src/api_activities.py b/src/api_activities.py new file mode 100644 index 0000000..8314079 --- /dev/null +++ b/src/api_activities.py @@ -0,0 +1,430 @@ +""" +Activity, enrollment, dashboard, session, and tag API handlers live here. +""" + +from urllib.parse import parse_qs, urlparse + +from http_utils import capture_exception, err, json_resp, ok, parse_json_object +from security_utils import decrypt, encrypt, new_id, verify_token + + +async def api_list_activities(req, env): + parsed = urlparse(req.url) + params = parse_qs(parsed.query) + atype = (params.get("type") or [None])[0] + fmt = (params.get("format") or [None])[0] + search = (params.get("q") or [None])[0] + tag = (params.get("tag") or [None])[0] + enc = env.ENCRYPTION_KEY + + base_q = ( + "SELECT a.id,a.title,a.description,a.type,a.format,a.schedule_type," + "a.created_at,u.name AS host_name_enc," + "(SELECT COUNT(*) FROM enrollments WHERE activity_id=a.id AND status='active')" + " AS participant_count," + "(SELECT COUNT(*) FROM sessions WHERE activity_id=a.id) AS session_count" + " FROM activities a JOIN users u ON a.host_id=u.id" + ) + + if tag: + tag_row = await env.DB.prepare( + "SELECT id FROM tags WHERE name=?" + ).bind(tag).first() + if not tag_row: + return json_resp({"activities": []}) + res = await env.DB.prepare( + base_q + + " JOIN activity_tags at2 ON at2.activity_id=a.id" + " WHERE at2.tag_id=? ORDER BY a.created_at DESC" + ).bind(tag_row.id).all() + elif atype and fmt: + res = await env.DB.prepare( + base_q + " WHERE a.type=? AND a.format=? ORDER BY a.created_at DESC" + ).bind(atype, fmt).all() + elif atype: + res = await env.DB.prepare( + base_q + " WHERE a.type=? ORDER BY a.created_at DESC" + ).bind(atype).all() + elif fmt: + res = await env.DB.prepare( + base_q + " WHERE a.format=? ORDER BY a.created_at DESC" + ).bind(fmt).all() + else: + res = await env.DB.prepare( + base_q + " ORDER BY a.created_at DESC" + ).all() + + activities = [] + for row in res.results or []: + desc = decrypt(row.description or "", enc) + host_name = decrypt(row.host_name_enc or "", enc) + if search and ( + search.lower() not in row.title.lower() + and search.lower() not in desc.lower() + ): + continue + + t_res = await env.DB.prepare( + "SELECT t.name FROM tags t" + " JOIN activity_tags at2 ON at2.tag_id=t.id" + " WHERE at2.activity_id=?" + ).bind(row.id).all() + + activities.append({ + "id": row.id, + "title": row.title, + "description": desc, + "type": row.type, + "format": row.format, + "schedule_type": row.schedule_type, + "host_name": host_name, + "participant_count": row.participant_count, + "session_count": row.session_count, + "tags": [t.name for t in (t_res.results or [])], + "created_at": row.created_at, + }) + + return json_resp({"activities": activities}) + + +async def api_create_activity(req, env): + user = verify_token(req.headers.get("Authorization"), env.JWT_SECRET) + if not user: + return err("Authentication required", 401) + + body, bad_resp = await parse_json_object(req) + if bad_resp: + return bad_resp + + title = (body.get("title") or "").strip() + description = (body.get("description") or "").strip() + atype = (body.get("type") or "course").strip() + fmt = (body.get("format") or "self_paced").strip() + schedule_type = (body.get("schedule_type") or "ongoing").strip() + + if not title: + return err("title is required") + if atype not in ("course", "meetup", "workshop", "seminar", "other"): + atype = "course" + if fmt not in ("live", "self_paced", "hybrid"): + fmt = "self_paced" + if schedule_type not in ("one_time", "multi_session", "recurring", "ongoing"): + schedule_type = "ongoing" + + enc = env.ENCRYPTION_KEY + act_id = new_id() + try: + await env.DB.prepare( + "INSERT INTO activities " + "(id,title,description,type,format,schedule_type,host_id)" + " VALUES (?,?,?,?,?,?,?)" + ).bind( + act_id, title, + encrypt(description, enc) if description else "", + atype, fmt, schedule_type, user["id"] + ).run() + except Exception as e: + capture_exception(e, req, env, "api_create_activity.insert_activity") + return err("Failed to create activity — please try again", 500) + + for tag_name in (body.get("tags") or []): + tag_name = tag_name.strip() + if not tag_name: + continue + t_row = await env.DB.prepare( + "SELECT id FROM tags WHERE name=?" + ).bind(tag_name).first() + if t_row: + tag_id = t_row.id + else: + tag_id = new_id() + try: + await env.DB.prepare( + "INSERT INTO tags (id,name) VALUES (?,?)" + ).bind(tag_id, tag_name).run() + except Exception as e: + capture_exception(e, req, env, f"api_create_activity.insert_tag: tag_name={tag_name}, tag_id={tag_id}, act_id={act_id}") + continue + try: + await env.DB.prepare( + "INSERT OR IGNORE INTO activity_tags (activity_id,tag_id) VALUES (?,?)" + ).bind(act_id, tag_id).run() + except Exception as e: + capture_exception(e, req, env, f"api_create_activity.insert_activity_tags: tag_name={tag_name}, tag_id={tag_id}, act_id={act_id}") + pass + + return ok({"id": act_id, "title": title}, "Activity created") + + +async def api_get_activity(act_id: str, req, env): + user = verify_token(req.headers.get("Authorization") or "", env.JWT_SECRET) + enc = env.ENCRYPTION_KEY + + act = await env.DB.prepare( + "SELECT a.*,u.name AS host_name_enc,u.id AS host_uid" + " FROM activities a JOIN users u ON a.host_id=u.id" + " WHERE a.id=?" + ).bind(act_id).first() + if not act: + return err("Activity not found", 404) + + enrollment = None + is_enrolled = False + if user: + enrollment = await env.DB.prepare( + "SELECT id,role,status FROM enrollments" + " WHERE activity_id=? AND user_id=?" + ).bind(act_id, user["id"]).first() + is_enrolled = enrollment is not None + + is_host = bool(user and act.host_uid == user["id"]) + + ses_res = await env.DB.prepare( + "SELECT id,title,description,start_time,end_time,location,created_at" + " FROM sessions WHERE activity_id=? ORDER BY start_time" + ).bind(act_id).all() + + sessions = [] + for s in ses_res.results or []: + sessions.append({ + "id": s.id, + "title": s.title, + "description": decrypt(s.description or "", enc) if (is_enrolled or is_host) else None, + "start_time": s.start_time, + "end_time": s.end_time, + "location": decrypt(s.location or "", enc) if (is_enrolled or is_host) else None, + }) + + t_res = await env.DB.prepare( + "SELECT t.name FROM tags t" + " JOIN activity_tags at2 ON at2.tag_id=t.id" + " WHERE at2.activity_id=?" + ).bind(act_id).all() + + count_row = await env.DB.prepare( + "SELECT COUNT(*) AS cnt FROM enrollments WHERE activity_id=? AND status='active'" + ).bind(act_id).first() + + return json_resp({ + "activity": { + "id": act.id, + "title": act.title, + "description": decrypt(act.description or "", enc), + "type": act.type, + "format": act.format, + "schedule_type": act.schedule_type, + "host_name": decrypt(act.host_name_enc or "", enc), + "participant_count": count_row.cnt if count_row else 0, + "tags": [t.name for t in (t_res.results or [])], + "created_at": act.created_at, + }, + "sessions": sessions, + "is_enrolled": is_enrolled, + "is_host": is_host, + "enrollment": { + "role": enrollment.role, + "status": enrollment.status, + } if enrollment else None, + }) + + +async def api_join(req, env): + user = verify_token(req.headers.get("Authorization"), env.JWT_SECRET) + if not user: + return err("Authentication required", 401) + + body, bad_resp = await parse_json_object(req) + if bad_resp: + return bad_resp + + act_id = body.get("activity_id") + role = (body.get("role") or "participant").strip() + + if not act_id: + return err("activity_id is required") + if role not in ("participant", "instructor", "organizer"): + role = "participant" + + act = await env.DB.prepare( + "SELECT id FROM activities WHERE id=?" + ).bind(act_id).first() + if not act: + return err("Activity not found", 404) + + enr_id = new_id() + try: + await env.DB.prepare( + "INSERT OR IGNORE INTO enrollments (id,activity_id,user_id,role)" + " VALUES (?,?,?,?)" + ).bind(enr_id, act_id, user["id"], role).run() + except Exception as e: + capture_exception(e, req, env, "api_join.insert_enrollment") + return err("Failed to join activity — please try again", 500) + + return ok(None, "Joined activity successfully") + + +async def api_dashboard(req, env): + user = verify_token(req.headers.get("Authorization"), env.JWT_SECRET) + if not user: + return err("Authentication required", 401) + + enc = env.ENCRYPTION_KEY + + res = await env.DB.prepare( + "SELECT a.id,a.title,a.type,a.format,a.schedule_type,a.created_at," + "(SELECT COUNT(*) FROM enrollments WHERE activity_id=a.id AND status='active')" + " AS participant_count," + "(SELECT COUNT(*) FROM sessions WHERE activity_id=a.id) AS session_count" + " FROM activities a WHERE a.host_id=? ORDER BY a.created_at DESC" + ).bind(user["id"]).all() + + hosted = [] + for r in res.results or []: + t_res = await env.DB.prepare( + "SELECT t.name FROM tags t JOIN activity_tags at2 ON at2.tag_id=t.id" + " WHERE at2.activity_id=?" + ).bind(r.id).all() + hosted.append({ + "id": r.id, + "title": r.title, + "type": r.type, + "format": r.format, + "schedule_type": r.schedule_type, + "participant_count": r.participant_count, + "session_count": r.session_count, + "tags": [t.name for t in (t_res.results or [])], + "created_at": r.created_at, + }) + + res2 = await env.DB.prepare( + "SELECT a.id,a.title,a.type,a.format,a.schedule_type," + "e.role AS enr_role,e.status AS enr_status,e.created_at AS joined_at," + "u.name AS host_name_enc" + " FROM enrollments e" + " JOIN activities a ON e.activity_id=a.id" + " JOIN users u ON a.host_id=u.id" + " WHERE e.user_id=? ORDER BY e.created_at DESC" + ).bind(user["id"]).all() + + joined = [] + for r in res2.results or []: + t_res = await env.DB.prepare( + "SELECT t.name FROM tags t JOIN activity_tags at2 ON at2.tag_id=t.id" + " WHERE at2.activity_id=?" + ).bind(r.id).all() + joined.append({ + "id": r.id, + "title": r.title, + "type": r.type, + "format": r.format, + "schedule_type": r.schedule_type, + "enr_role": r.enr_role, + "enr_status": r.enr_status, + "host_name": decrypt(r.host_name_enc or "", enc), + "tags": [t.name for t in (t_res.results or [])], + "joined_at": r.joined_at, + }) + + return json_resp({"user": user, "hosted_activities": hosted, "joined_activities": joined}) + + +async def api_create_session(req, env): + user = verify_token(req.headers.get("Authorization"), env.JWT_SECRET) + if not user: + return err("Authentication required", 401) + + body, bad_resp = await parse_json_object(req) + if bad_resp: + return bad_resp + + act_id = body.get("activity_id") + title = (body.get("title") or "").strip() + description = (body.get("description") or "").strip() + start_time = (body.get("start_time") or "").strip() + end_time = (body.get("end_time") or "").strip() + location = (body.get("location") or "").strip() + + if not act_id or not title: + return err("activity_id and title are required") + + owned = await env.DB.prepare( + "SELECT id FROM activities WHERE id=? AND host_id=?" + ).bind(act_id, user["id"]).first() + if not owned: + return err("Activity not found or access denied", 404) + + enc = env.ENCRYPTION_KEY + sid = new_id() + try: + await env.DB.prepare( + "INSERT INTO sessions " + "(id,activity_id,title,description,start_time,end_time,location)" + " VALUES (?,?,?,?,?,?,?)" + ).bind( + sid, act_id, title, + encrypt(description, enc) if description else "", + start_time, end_time, + encrypt(location, enc) if location else "", + ).run() + except Exception as e: + capture_exception(e, req, env, "api_create_session.insert_session") + return err("Failed to create session — please try again", 500) + + return ok({"id": sid}, "Session created") + + +async def api_list_tags(_req, env): + res = await env.DB.prepare("SELECT id,name FROM tags ORDER BY name").all() + tags = [{"id": r.id, "name": r.name} for r in (res.results or [])] + return json_resp({"tags": tags}) + + +async def api_add_activity_tags(req, env): + user = verify_token(req.headers.get("Authorization"), env.JWT_SECRET) + if not user: + return err("Authentication required", 401) + + body, bad_resp = await parse_json_object(req) + if bad_resp: + return bad_resp + + act_id = body.get("activity_id") + tags = body.get("tags") or [] + + if not act_id: + return err("activity_id is required") + + owned = await env.DB.prepare( + "SELECT id FROM activities WHERE id=? AND host_id=?" + ).bind(act_id, user["id"]).first() + if not owned: + return err("Activity not found or access denied", 404) + + for tag_name in tags: + tag_name = tag_name.strip() + if not tag_name: + continue + t_row = await env.DB.prepare( + "SELECT id FROM tags WHERE name=?" + ).bind(tag_name).first() + if t_row: + tag_id = t_row.id + else: + tag_id = new_id() + try: + await env.DB.prepare( + "INSERT INTO tags (id,name) VALUES (?,?)" + ).bind(tag_id, tag_name).run() + except Exception as e: + capture_exception(e, req, env, f"api_add_activity_tags.insert_tag: tag_name={tag_name}, tag_id={tag_id}, act_id={act_id}") + continue + try: + await env.DB.prepare( + "INSERT OR IGNORE INTO activity_tags (activity_id,tag_id) VALUES (?,?)" + ).bind(act_id, tag_id).run() + except Exception as e: + capture_exception(e, req, env, f"api_add_activity_tags.insert_activity_tags: tag_name={tag_name}, tag_id={tag_id}, act_id={act_id}") + pass + + return ok(None, "Tags updated") \ No newline at end of file diff --git a/src/api_admin.py b/src/api_admin.py new file mode 100644 index 0000000..4233f2a --- /dev/null +++ b/src/api_admin.py @@ -0,0 +1,24 @@ +""" +Admin-only API handlers live here. +""" + +from http_utils import is_basic_auth_valid, json_resp, unauthorized_basic + +async def api_admin_table_counts(req, env): + if not is_basic_auth_valid(req, env): + return unauthorized_basic() + + tables_res = await env.DB.prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name" + ).all() + + counts = [] + for row in tables_res.results or []: + table_name = row.name + # Table names come from sqlite_master and are quoted to avoid SQL injection. + count_row = await env.DB.prepare( + f'SELECT COUNT(*) AS cnt FROM "{table_name.replace(chr(34), chr(34) + chr(34))}"' + ).first() + counts.append({"table": table_name, "count": count_row.cnt if count_row else 0}) + + return json_resp({"tables": counts}) \ No newline at end of file diff --git a/src/api_auth.py b/src/api_auth.py new file mode 100644 index 0000000..590561e --- /dev/null +++ b/src/api_auth.py @@ -0,0 +1,106 @@ +""" +Authentication-related API handlers live here. +""" + +from http_utils import capture_exception, err, ok, parse_json_object +from security_utils import ( + blind_index, + create_token, + decrypt, + encrypt, + hash_password, + new_id, + verify_password, +) + +# --------------------------------------------------------------------------- +# API handlers +# --------------------------------------------------------------------------- + +async def api_register(req, env): + body, bad_resp = await parse_json_object(req) + if bad_resp: + return bad_resp + + username = (body.get("username") or "").strip() + email = (body.get("email") or "").strip() + password = (body.get("password") or "") + name = (body.get("name") or username).strip() + + if not username or not email or not password: + return err("username, email, and password are required") + if len(password) < 8: + return err("Password must be at least 8 characters") + + role = "member" + + enc = env.ENCRYPTION_KEY + uid = new_id() + try: + await env.DB.prepare( + "INSERT INTO users " + "(id,username_hash,email_hash,name,username,email,password_hash,role)" + " VALUES (?,?,?,?,?,?,?,?)" + ).bind( + uid, + blind_index(username, enc), + blind_index(email, enc), + encrypt(name, enc), + encrypt(username, enc), + encrypt(email, enc), + hash_password(password, username), + encrypt(role, enc), + ).run() + except Exception as e: + if "UNIQUE" in str(e): + return err("Username or email already registered", 409) + capture_exception(e, req, env, "api_register.insert_user") + return err("Registration failed — please try again", 500) + + token = create_token(uid, username, role, env.JWT_SECRET) + return ok( + {"token": token, + "user": {"id": uid, "username": username, "name": name, "role": role}}, + "Registration successful", + ) + + +async def api_login(req, env): + body, bad_resp = await parse_json_object(req) + if bad_resp: + return bad_resp + + username = (body.get("username") or "").strip() + password = (body.get("password") or "") + + if not username or not password: + return err("username and password are required") + + enc = env.ENCRYPTION_KEY + u_hash = blind_index(username, enc) + row = await env.DB.prepare( + "SELECT id,password_hash,role,name,username FROM users WHERE username_hash=?" + ).bind(u_hash).first() + + if not row: + return err("Invalid username or password", 401) + + password_hash = row.password_hash + user_id = row.id + role_enc = row.role + name_enc = row.name + username_enc = row.username + stored_username = decrypt(username_enc, enc) + + if not verify_password(password, password_hash, stored_username): + return err("Invalid username or password", 401) + + real_role = decrypt(role_enc, enc) + real_name = decrypt(name_enc, enc) + token = create_token(user_id, stored_username, real_role, env.JWT_SECRET) + return ok( + {"token": token, + "user": {"id": user_id, "username": stored_username, + "name": real_name, "role": real_role}}, + "Login successful", + ) diff --git a/src/db_utils.py b/src/db_utils.py new file mode 100644 index 0000000..479f20a --- /dev/null +++ b/src/db_utils.py @@ -0,0 +1,275 @@ +""" +Database schema and seed helpers live here. +""" + +from security_utils import blind_index, encrypt, hash_password + +# --------------------------------------------------------------------------- +# DDL - full schema (mirrors schema.sql) +# --------------------------------------------------------------------------- + +_DDL = [ + # Users - all PII encrypted; HMAC blind indexes for O(1) lookups + """CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username_hash TEXT NOT NULL UNIQUE, + email_hash TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + username TEXT NOT NULL, + email TEXT NOT NULL, + password_hash TEXT NOT NULL, + role TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )""", + # Activities + """CREATE TABLE IF NOT EXISTS activities ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + description TEXT, + type TEXT NOT NULL DEFAULT 'course', + format TEXT NOT NULL DEFAULT 'self_paced', + schedule_type TEXT NOT NULL DEFAULT 'ongoing', + host_id TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (host_id) REFERENCES users(id) + )""", + # Sessions + """CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + activity_id TEXT NOT NULL, + title TEXT, + description TEXT, + start_time TEXT, + end_time TEXT, + location TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (activity_id) REFERENCES activities(id) + )""", + # Enrollments + """CREATE TABLE IF NOT EXISTS enrollments ( + id TEXT PRIMARY KEY, + activity_id TEXT NOT NULL, + user_id TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'participant', + status TEXT NOT NULL DEFAULT 'active', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (activity_id, user_id), + FOREIGN KEY (activity_id) REFERENCES activities(id), + FOREIGN KEY (user_id) REFERENCES users(id) + )""", + # Session attendance + """CREATE TABLE IF NOT EXISTS session_attendance ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + user_id TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'registered', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (session_id, user_id), + FOREIGN KEY (session_id) REFERENCES sessions(id), + FOREIGN KEY (user_id) REFERENCES users(id) + )""", + # Tags + """CREATE TABLE IF NOT EXISTS tags ( + id TEXT PRIMARY KEY, + name TEXT UNIQUE NOT NULL + )""", + # Activity-tag junction + """CREATE TABLE IF NOT EXISTS activity_tags ( + activity_id TEXT NOT NULL, + tag_id TEXT NOT NULL, + PRIMARY KEY (activity_id, tag_id), + FOREIGN KEY (activity_id) REFERENCES activities(id), + FOREIGN KEY (tag_id) REFERENCES tags(id) + )""", + # Indexes + "CREATE INDEX IF NOT EXISTS idx_activities_host ON activities(host_id)", + "CREATE INDEX IF NOT EXISTS idx_enrollments_activity ON enrollments(activity_id)", + "CREATE INDEX IF NOT EXISTS idx_enrollments_user ON enrollments(user_id)", + "CREATE INDEX IF NOT EXISTS idx_sessions_activity ON sessions(activity_id)", + "CREATE INDEX IF NOT EXISTS idx_sa_session ON session_attendance(session_id)", + "CREATE INDEX IF NOT EXISTS idx_sa_user ON session_attendance(user_id)", + "CREATE INDEX IF NOT EXISTS idx_at_activity ON activity_tags(activity_id)", +] + + +async def init_db(env): + for sql in _DDL: + await env.DB.prepare(sql).run() + + +# --------------------------------------------------------------------------- +# Sample-data seeding +# --------------------------------------------------------------------------- + +async def seed_db(env, enc_key: str): + # ---- users --------------------------------------------------------------- + seed_users = [ + ("alice", "alice@example.com", "password123", "host", "Alice Chen"), + ("bob", "bob@example.com", "password123", "host", "Bob Martinez"), + ("charlie", "charlie@example.com", "password123", "member", "Charlie Kim"), + ("diana", "diana@example.com", "password123", "member", "Diana Patel"), + ] + uid_map = {} + for uname, email, pw, role, display in seed_users: + uid = f"usr-{uname}" + uid_map[uname] = uid + try: + await env.DB.prepare( + "INSERT INTO users " + "(id,username_hash,email_hash,name,username,email,password_hash,role)" + " VALUES (?,?,?,?,?,?,?,?)" + ).bind( + uid, + blind_index(uname, enc_key), + blind_index(email, enc_key), + encrypt(display, enc_key), + encrypt(uname, enc_key), + encrypt(email, enc_key), + hash_password(pw, uname), + encrypt(role, enc_key), + ).run() + except Exception: + pass # already seeded + + aid = uid_map["alice"] + bid = uid_map["bob"] + cid = uid_map["charlie"] + did = uid_map["diana"] + + # ---- tags ---------------------------------------------------------------- + tag_rows = [ + ("tag-python", "Python"), + ("tag-js", "JavaScript"), + ("tag-data", "Data Science"), + ("tag-ml", "Machine Learning"), + ("tag-webdev", "Web Development"), + ("tag-db", "Databases"), + ("tag-cloud", "Cloud"), + ] + for tid, tname in tag_rows: + try: + await env.DB.prepare( + "INSERT INTO tags (id,name) VALUES (?,?)" + ).bind(tid, tname).run() + except Exception: + pass + + # ---- activities ---------------------------------------------------------- + act_rows = [ + ( + "act-py-begin", "Python for Beginners", + "Learn Python programming from scratch. Master variables, loops, " + "functions, and object-oriented design in this hands-on course.", + "course", "self_paced", "ongoing", aid, + ["tag-python"], + ), + ( + "act-js-meetup", "JavaScript Developers Meetup", + "Monthly meetup for JavaScript enthusiasts. Share projects, " + "discuss new frameworks, and network with fellow devs.", + "meetup", "live", "recurring", bid, + ["tag-js", "tag-webdev"], + ), + ( + "act-ds-workshop", "Data Science Workshop", + "Hands-on workshop covering data wrangling with pandas, " + "visualisation with matplotlib, and intro to machine learning.", + "workshop", "live", "multi_session", aid, + ["tag-data", "tag-python"], + ), + ( + "act-ml-study", "Machine Learning Study Group", + "Collaborative study group working through ML concepts, " + "reading papers, and implementing algorithms together.", + "course", "hybrid", "recurring", bid, + ["tag-ml", "tag-python"], + ), + ( + "act-webdev", "Web Dev Fundamentals", + "Build modern responsive websites with HTML5, CSS3, and JavaScript. " + "Covers Flexbox, Grid, fetch API, and accessible design.", + "course", "self_paced", "ongoing", aid, + ["tag-webdev", "tag-js"], + ), + ( + "act-db-design", "Database Design & SQL", + "Design normalised relational schemas, write complex SQL queries, " + "use indexes for speed, and understand transactions.", + "workshop", "live", "one_time", bid, + ["tag-db"], + ), + ] + for act_id, title, desc, atype, fmt, sched, host_id, tags in act_rows: + try: + await env.DB.prepare( + "INSERT INTO activities " + "(id,title,description,type,format,schedule_type,host_id)" + " VALUES (?,?,?,?,?,?,?)" + ).bind( + act_id, title, encrypt(desc, enc_key), + atype, fmt, sched, host_id + ).run() + except Exception: + pass + for tag_id in tags: + try: + await env.DB.prepare( + "INSERT OR IGNORE INTO activity_tags (activity_id,tag_id)" + " VALUES (?,?)" + ).bind(act_id, tag_id).run() + except Exception: + pass + + # ---- sessions for live/recurring activities ------------------------------ + ses_rows = [ + ("ses-js-1", "act-js-meetup", + "April Meetup", "Q1 retro and React 19 deep-dive", + "2024-04-15 18:00", "2024-04-15 21:00", "Tech Hub, 123 Main St, SF"), + ("ses-js-2", "act-js-meetup", + "May Meetup", "TypeScript 5.4 and what's new in Node 22", + "2024-05-20 18:00", "2024-05-20 21:00", "Tech Hub, 123 Main St, SF"), + ("ses-ds-1", "act-ds-workshop", + "Session 1 - Data Wrangling", + "Introduction to pandas DataFrames and data cleaning", + "2024-06-01 10:00", "2024-06-01 14:00", "Online via Zoom"), + ("ses-ds-2", "act-ds-workshop", + "Session 2 - Visualisation", + "matplotlib, seaborn, and plotly for data storytelling", + "2024-06-08 10:00", "2024-06-08 14:00", "Online via Zoom"), + ("ses-ds-3", "act-ds-workshop", + "Session 3 - Intro to ML", + "scikit-learn: regression, classification, evaluation", + "2024-06-15 10:00", "2024-06-15 14:00", "Online via Zoom"), + ] + for sid, act_id, title, desc, start, end, loc in ses_rows: + try: + await env.DB.prepare( + "INSERT INTO sessions " + "(id,activity_id,title,description,start_time,end_time,location)" + " VALUES (?,?,?,?,?,?,?)" + ).bind( + sid, act_id, title, + encrypt(desc, enc_key), + start, end, + encrypt(loc, enc_key), + ).run() + except Exception: + pass + + # ---- enrollments --------------------------------------------------------- + enr_rows = [ + ("enr-c-py", "act-py-begin", cid, "participant"), + ("enr-c-js", "act-js-meetup", cid, "participant"), + ("enr-c-ds", "act-ds-workshop", cid, "participant"), + ("enr-d-py", "act-py-begin", did, "participant"), + ("enr-d-webdev", "act-webdev", did, "participant"), + ("enr-b-py", "act-py-begin", bid, "instructor"), + ] + for eid, act_id, uid, role in enr_rows: + try: + await env.DB.prepare( + "INSERT OR IGNORE INTO enrollments (id,activity_id,user_id,role)" + " VALUES (?,?,?,?)" + ).bind(eid, act_id, uid, role).run() + except Exception: + pass diff --git a/src/http_utils.py b/src/http_utils.py new file mode 100644 index 0000000..402aa72 --- /dev/null +++ b/src/http_utils.py @@ -0,0 +1,111 @@ +import base64 +import hmac as _hmac +import json +import re +import traceback +from urllib.parse import urlparse + +from workers import Response + +def capture_exception(exc: Exception, req=None, _env=None, where: str = ""): + """Best-effort exception logging with full traceback and request context.""" + try: + payload = { + "level": "error", + "where": where or "unknown", + "error_type": type(exc).__name__, + "error": str(exc), + "traceback": "".join(traceback.format_exception(type(exc), exc, exc.__traceback__)), + } + if req: + payload["request"] = { + "method": req.method, + "url": req.url, + "path": urlparse(req.url).path, + } + print(json.dumps(payload)) + except Exception: + pass + + +CORS = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", +} + + +def json_resp(data, status: int = 200): + return Response( + json.dumps(data), + status=status, + headers={"Content-Type": "application/json", **CORS}, + ) + + +def ok(data=None, msg: str = "OK"): + body = {"success": True, "message": msg} + if data is not None: + body["data"] = data + return json_resp(body, 200) + + +def err(msg: str, status: int = 400): + return json_resp({"error": msg}, status) + + +async def parse_json_object(req): + """Parse request JSON and ensure payload is an object/dict.""" + try: + text = await req.text() + body = json.loads(text) + except Exception: + return None, err("Invalid JSON body") + + if not isinstance(body, dict): + return None, err("JSON body must be an object", 400) + + return body, None + + +def clean_path(value: str, default: str = "/admin") -> str: + """Normalize an env-provided path into a safe absolute URL path.""" + raw = (value or "").strip() + if not raw: + return default + parsed = urlparse(raw) + path = (parsed.path or raw).strip() + if not path.startswith("/"): + path = "/" + path + path = re.sub(r"/+", "/", path) + if len(path) > 1 and path.endswith("/"): + path = path[:-1] + return path or default + + +def unauthorized_basic(realm: str = "Alpha One Labs Admin"): + return Response( + "Authentication required", + status=401, + headers={"WWW-Authenticate": f'Basic realm="{realm}"', **CORS}, + ) + + +def is_basic_auth_valid(req, env) -> bool: + username = (getattr(env, "ADMIN_BASIC_USER", "") or "").strip() + password = (getattr(env, "ADMIN_BASIC_PASS", "") or "").strip() + if not username or not password: + return False + + auth = req.headers.get("Authorization") or "" + if not auth.lower().startswith("basic "): + return False + + try: + raw = auth.split(" ", 1)[1].strip() + decoded = base64.b64decode(raw).decode("utf-8") + user, pwd = decoded.split(":", 1) + except Exception: + return False + + return _hmac.compare_digest(user, username) and _hmac.compare_digest(pwd, password) diff --git a/src/security_utils.py b/src/security_utils.py new file mode 100644 index 0000000..fef8fdd --- /dev/null +++ b/src/security_utils.py @@ -0,0 +1,127 @@ +import base64 +import hashlib +import hmac as _hmac +import json +import os + +def new_id() -> str: + """Generate a random UUID v4 using os.urandom.""" + 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:]}" + + +# --------------------------------------------------------------------------- +# Encryption helpers +# --------------------------------------------------------------------------- + +def _derive_key(secret: str) -> bytes: + """Derive a 32-byte key from an arbitrary secret string via SHA-256.""" + return hashlib.sha256(secret.encode("utf-8")).digest() + + +def encrypt(plaintext: str, secret: str) -> str: + """ + XOR stream-cipher encryption. + + Key is SHA-256 of secret, XOR'd byte-by-byte against plaintext. + Result is Base64-encoded for safe TEXT storage in D1. + + XOR stream cipher - demonstration only. Replace with AES-GCM for production. + """ + if not plaintext: + return "" + key = _derive_key(secret) + data = plaintext.encode("utf-8") + ks = (key * (len(data) // len(key) + 1))[: len(data)] + return base64.b64encode(bytes(a ^ b for a, b in zip(data, ks))).decode("ascii") + + +def decrypt(ciphertext: str, secret: str) -> str: + """Reverse of encrypt(). XOR is self-inverse.""" + if not ciphertext: + return "" + try: + key = _derive_key(secret) + raw = base64.b64decode(ciphertext) + ks = (key * (len(raw) // len(key) + 1))[: len(raw)] + return bytes(a ^ b for a, b in zip(raw, ks)).decode("utf-8") + except Exception: + return "[decryption error]" + + +def blind_index(value: str, secret: str) -> str: + """ + HMAC-SHA256 deterministic hash of value used as a blind index. + + Allows finding a row by plaintext value without decrypting every row. + The value is lower-cased before hashing so lookups are case-insensitive. + """ + return _hmac.new( + secret.encode("utf-8"), value.lower().encode("utf-8"), hashlib.sha256 + ).hexdigest() + + +# --------------------------------------------------------------------------- +# Password hashing +# --------------------------------------------------------------------------- + +# ⚠️ For production, derive the pepper from a secret stored via +# `wrangler secret put PEPPER` and pass it to _user_salt() at runtime. +# Rotating the pepper requires re-hashing all stored passwords. +_PEPPER = b"edu-platform-cf-pepper-2024" +_PBKDF2_IT = 100_000 + + +def _user_salt(username: str) -> bytes: + """Per-user PBKDF2 salt = SHA-256(pepper || username).""" + return hashlib.sha256(_PEPPER + username.encode("utf-8")).digest() + + +def hash_password(password: str, username: str) -> str: + """PBKDF2-SHA256 with per-user derived salt.""" + dk = hashlib.pbkdf2_hmac( + "sha256", password.encode("utf-8"), _user_salt(username), _PBKDF2_IT + ) + return base64.b64encode(dk).decode("ascii") + + +def verify_password(password: str, stored: str, username: str) -> bool: + return hash_password(password, username) == stored + + +# --------------------------------------------------------------------------- +# Auth tokens (HMAC-SHA256 signed, stateless JWT-lite) +# --------------------------------------------------------------------------- + +def create_token(uid: str, username: str, role: str, secret: str) -> str: + payload = base64.b64encode( + json.dumps({"id": uid, "username": username, "role": role}).encode() + ).decode("ascii") + sig = _hmac.new( + secret.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256 + ).hexdigest() + return f"{payload}.{sig}" + + +def verify_token(raw: str, secret: str): + """Return decoded payload dict or None if invalid/missing.""" + if not raw: + return None + try: + token = raw.removeprefix("Bearer ").strip() + dot = token.rfind(".") + if dot == -1: + return None + p, sig = token[:dot], token[dot + 1:] + exp = _hmac.new( + secret.encode("utf-8"), p.encode("utf-8"), hashlib.sha256 + ).hexdigest() + if not _hmac.compare_digest(sig, exp): + return None + padding = (4 - len(p) % 4) % 4 + return json.loads(base64.b64decode(p + "=" * padding).decode("utf-8")) + except Exception: + return None diff --git a/src/static_utils.py b/src/static_utils.py new file mode 100644 index 0000000..9807670 --- /dev/null +++ b/src/static_utils.py @@ -0,0 +1,45 @@ +from workers import Response + +from http_utils import CORS + +_MIME = { + "html": "text/html; charset=utf-8", + "css": "text/css; charset=utf-8", + "js": "application/javascript; charset=utf-8", + "json": "application/json", + "png": "image/png", + "jpg": "image/jpeg", + "svg": "image/svg+xml", + "ico": "image/x-icon", +} + + +async def serve_static(path: str, env): + if path in ("/", ""): + key = "index.html" + else: + key = path.lstrip("/") + if "." not in key.split("/")[-1]: + key += ".html" + + try: + content = await env.__STATIC_CONTENT.get(key, "text") + except Exception: + content = None + + if content is None: + try: + content = await env.__STATIC_CONTENT.get("index.html", "text") + except Exception: + content = None + + if content is None: + return Response( + "

404 - Not Found

", + status=404, + headers={"Content-Type": "text/html"}, + ) + + ext = key.rsplit(".", 1)[-1] if "." in key else "html" + mime = _MIME.get(ext, "text/plain") + return Response(content, headers={"Content-Type": mime, **CORS}) diff --git a/src/worker.py b/src/worker.py index 9656277..cadd14c 100644 --- a/src/worker.py +++ b/src/worker.py @@ -29,1105 +29,34 @@ Static HTML pages (public/) are served via Workers Sites (KV binding). """ -import base64 -import hashlib -import hmac as _hmac -import json -import os import re -import traceback -from urllib.parse import urlparse, parse_qs +from urllib.parse import urlparse from workers import Response - -def capture_exception(exc: Exception, req=None, _env=None, where: str = ""): - """Best-effort exception logging with full traceback and request context.""" - try: - payload = { - "level": "error", - "where": where or "unknown", - "error_type": type(exc).__name__, - "error": str(exc), - "traceback": "".join(traceback.format_exception(type(exc), exc, exc.__traceback__)), - } - if req: - payload["request"] = { - "method": req.method, - "url": req.url, - "path": urlparse(req.url).path, - } - print(json.dumps(payload)) - except Exception: - pass - - -# --------------------------------------------------------------------------- -# ID generation -# --------------------------------------------------------------------------- - -def new_id() -> str: - """Generate a random UUID v4 using os.urandom.""" - 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:]}" - - -# --------------------------------------------------------------------------- -# Encryption helpers -# --------------------------------------------------------------------------- - -def _derive_key(secret: str) -> bytes: - """Derive a 32-byte key from an arbitrary secret string via SHA-256.""" - return hashlib.sha256(secret.encode("utf-8")).digest() - - -def encrypt(plaintext: str, secret: str) -> str: - """ - XOR stream-cipher encryption. - - Key is SHA-256 of secret, XOR'd byte-by-byte against plaintext. - Result is Base64-encoded for safe TEXT storage in D1. - - XOR stream cipher - demonstration only. Replace with AES-GCM for production. - """ - if not plaintext: - return "" - key = _derive_key(secret) - data = plaintext.encode("utf-8") - ks = (key * (len(data) // len(key) + 1))[: len(data)] - return base64.b64encode(bytes(a ^ b for a, b in zip(data, ks))).decode("ascii") - - -def decrypt(ciphertext: str, secret: str) -> str: - """Reverse of encrypt(). XOR is self-inverse.""" - if not ciphertext: - return "" - try: - key = _derive_key(secret) - raw = base64.b64decode(ciphertext) - ks = (key * (len(raw) // len(key) + 1))[: len(raw)] - return bytes(a ^ b for a, b in zip(raw, ks)).decode("utf-8") - except Exception: - return "[decryption error]" - - -def blind_index(value: str, secret: str) -> str: - """ - HMAC-SHA256 deterministic hash of value used as a blind index. - - Allows finding a row by plaintext value without decrypting every row. - The value is lower-cased before hashing so lookups are case-insensitive. - """ - return _hmac.new( - secret.encode("utf-8"), value.lower().encode("utf-8"), hashlib.sha256 - ).hexdigest() - - -# --------------------------------------------------------------------------- -# Password hashing -# --------------------------------------------------------------------------- - -# ⚠️ For production, derive the pepper from a secret stored via -# `wrangler secret put PEPPER` and pass it to _user_salt() at runtime. -# Rotating the pepper requires re-hashing all stored passwords. -_PEPPER = b"edu-platform-cf-pepper-2024" -_PBKDF2_IT = 100_000 - - -def _user_salt(username: str) -> bytes: - """Per-user PBKDF2 salt = SHA-256(pepper || username).""" - return hashlib.sha256(_PEPPER + username.encode("utf-8")).digest() - - -def hash_password(password: str, username: str) -> str: - """PBKDF2-SHA256 with per-user derived salt.""" - dk = hashlib.pbkdf2_hmac( - "sha256", password.encode("utf-8"), _user_salt(username), _PBKDF2_IT - ) - return base64.b64encode(dk).decode("ascii") - - -def verify_password(password: str, stored: str, username: str) -> bool: - return hash_password(password, username) == stored - - -# --------------------------------------------------------------------------- -# Auth tokens (HMAC-SHA256 signed, stateless JWT-lite) -# --------------------------------------------------------------------------- - -def create_token(uid: str, username: str, role: str, secret: str) -> str: - payload = base64.b64encode( - json.dumps({"id": uid, "username": username, "role": role}).encode() - ).decode("ascii") - sig = _hmac.new( - secret.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256 - ).hexdigest() - return f"{payload}.{sig}" - - -def verify_token(raw: str, secret: str): - """Return decoded payload dict or None if invalid/missing.""" - if not raw: - return None - try: - token = raw.removeprefix("Bearer ").strip() - dot = token.rfind(".") - if dot == -1: - return None - p, sig = token[:dot], token[dot + 1:] - exp = _hmac.new( - secret.encode("utf-8"), p.encode("utf-8"), hashlib.sha256 - ).hexdigest() - if not _hmac.compare_digest(sig, exp): - return None - padding = (4 - len(p) % 4) % 4 - return json.loads(base64.b64decode(p + "=" * padding).decode("utf-8")) - except Exception: - return None - - -# --------------------------------------------------------------------------- -# Response helpers -# --------------------------------------------------------------------------- - -_CORS = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization", -} - - -def json_resp(data, status: int = 200): - return Response( - json.dumps(data), - status=status, - headers={"Content-Type": "application/json", **_CORS}, - ) - - -def ok(data=None, msg: str = "OK"): - body = {"success": True, "message": msg} - if data is not None: - body["data"] = data - return json_resp(body, 200) - - -def err(msg: str, status: int = 400): - return json_resp({"error": msg}, status) - - -async def parse_json_object(req): - """Parse request JSON and ensure payload is an object/dict.""" - try: - text = await req.text() - body = json.loads(text) - except Exception: - return None, err("Invalid JSON body") - - if not isinstance(body, dict): - return None, err("JSON body must be an object", 400) - - return body, None - - -def _clean_path(value: str, default: str = "/admin") -> str: - """Normalize an env-provided path into a safe absolute URL path.""" - raw = (value or "").strip() - if not raw: - return default - parsed = urlparse(raw) - path = (parsed.path or raw).strip() - if not path.startswith("/"): - path = "/" + path - path = re.sub(r"/+", "/", path) - if len(path) > 1 and path.endswith("/"): - path = path[:-1] - return path or default - - -def _unauthorized_basic(realm: str = "Alpha One Labs Admin"): - return Response( - "Authentication required", - status=401, - headers={"WWW-Authenticate": f'Basic realm="{realm}"', **_CORS}, - ) - - -def _is_basic_auth_valid(req, env) -> bool: - username = (getattr(env, "ADMIN_BASIC_USER", "") or "").strip() - password = (getattr(env, "ADMIN_BASIC_PASS", "") or "").strip() - if not username or not password: - return False - - auth = req.headers.get("Authorization") or "" - if not auth.lower().startswith("basic "): - return False - - try: - raw = auth.split(" ", 1)[1].strip() - decoded = base64.b64decode(raw).decode("utf-8") - user, pwd = decoded.split(":", 1) - except Exception: - return False - - return _hmac.compare_digest(user, username) and _hmac.compare_digest(pwd, password) - - -# --------------------------------------------------------------------------- -# DDL - full schema (mirrors schema.sql) -# --------------------------------------------------------------------------- - -_DDL = [ - # Users - all PII encrypted; HMAC blind indexes for O(1) lookups - """CREATE TABLE IF NOT EXISTS users ( - id TEXT PRIMARY KEY, - username_hash TEXT NOT NULL UNIQUE, - email_hash TEXT NOT NULL UNIQUE, - name TEXT NOT NULL, - username TEXT NOT NULL, - email TEXT NOT NULL, - password_hash TEXT NOT NULL, - role TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT (datetime('now')) - )""", - # Activities - """CREATE TABLE IF NOT EXISTS activities ( - id TEXT PRIMARY KEY, - title TEXT NOT NULL, - description TEXT, - type TEXT NOT NULL DEFAULT 'course', - format TEXT NOT NULL DEFAULT 'self_paced', - schedule_type TEXT NOT NULL DEFAULT 'ongoing', - host_id TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - FOREIGN KEY (host_id) REFERENCES users(id) - )""", - # Sessions - """CREATE TABLE IF NOT EXISTS sessions ( - id TEXT PRIMARY KEY, - activity_id TEXT NOT NULL, - title TEXT, - description TEXT, - start_time TEXT, - end_time TEXT, - location TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - FOREIGN KEY (activity_id) REFERENCES activities(id) - )""", - # Enrollments - """CREATE TABLE IF NOT EXISTS enrollments ( - id TEXT PRIMARY KEY, - activity_id TEXT NOT NULL, - user_id TEXT NOT NULL, - role TEXT NOT NULL DEFAULT 'participant', - status TEXT NOT NULL DEFAULT 'active', - created_at TEXT NOT NULL DEFAULT (datetime('now')), - UNIQUE (activity_id, user_id), - FOREIGN KEY (activity_id) REFERENCES activities(id), - FOREIGN KEY (user_id) REFERENCES users(id) - )""", - # Session attendance - """CREATE TABLE IF NOT EXISTS session_attendance ( - id TEXT PRIMARY KEY, - session_id TEXT NOT NULL, - user_id TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'registered', - created_at TEXT NOT NULL DEFAULT (datetime('now')), - UNIQUE (session_id, user_id), - FOREIGN KEY (session_id) REFERENCES sessions(id), - FOREIGN KEY (user_id) REFERENCES users(id) - )""", - # Tags - """CREATE TABLE IF NOT EXISTS tags ( - id TEXT PRIMARY KEY, - name TEXT UNIQUE NOT NULL - )""", - # Activity-tag junction - """CREATE TABLE IF NOT EXISTS activity_tags ( - activity_id TEXT NOT NULL, - tag_id TEXT NOT NULL, - PRIMARY KEY (activity_id, tag_id), - FOREIGN KEY (activity_id) REFERENCES activities(id), - FOREIGN KEY (tag_id) REFERENCES tags(id) - )""", - # Indexes - "CREATE INDEX IF NOT EXISTS idx_activities_host ON activities(host_id)", - "CREATE INDEX IF NOT EXISTS idx_enrollments_activity ON enrollments(activity_id)", - "CREATE INDEX IF NOT EXISTS idx_enrollments_user ON enrollments(user_id)", - "CREATE INDEX IF NOT EXISTS idx_sessions_activity ON sessions(activity_id)", - "CREATE INDEX IF NOT EXISTS idx_sa_session ON session_attendance(session_id)", - "CREATE INDEX IF NOT EXISTS idx_sa_user ON session_attendance(user_id)", - "CREATE INDEX IF NOT EXISTS idx_at_activity ON activity_tags(activity_id)", -] - - -async def init_db(env): - for sql in _DDL: - await env.DB.prepare(sql).run() - - -# --------------------------------------------------------------------------- -# Sample-data seeding -# --------------------------------------------------------------------------- - -async def seed_db(env, enc_key: str): - # ---- users --------------------------------------------------------------- - seed_users = [ - ("alice", "alice@example.com", "password123", "host", "Alice Chen"), - ("bob", "bob@example.com", "password123", "host", "Bob Martinez"), - ("charlie", "charlie@example.com", "password123", "member", "Charlie Kim"), - ("diana", "diana@example.com", "password123", "member", "Diana Patel"), - ] - uid_map = {} - for uname, email, pw, role, display in seed_users: - uid = f"usr-{uname}" - uid_map[uname] = uid - try: - await env.DB.prepare( - "INSERT INTO users " - "(id,username_hash,email_hash,name,username,email,password_hash,role)" - " VALUES (?,?,?,?,?,?,?,?)" - ).bind( - uid, - blind_index(uname, enc_key), - blind_index(email, enc_key), - encrypt(display, enc_key), - encrypt(uname, enc_key), - encrypt(email, enc_key), - hash_password(pw, uname), - encrypt(role, enc_key), - ).run() - except Exception: - pass # already seeded - - aid = uid_map["alice"] - bid = uid_map["bob"] - cid = uid_map["charlie"] - did = uid_map["diana"] - - # ---- tags ---------------------------------------------------------------- - tag_rows = [ - ("tag-python", "Python"), - ("tag-js", "JavaScript"), - ("tag-data", "Data Science"), - ("tag-ml", "Machine Learning"), - ("tag-webdev", "Web Development"), - ("tag-db", "Databases"), - ("tag-cloud", "Cloud"), - ] - for tid, tname in tag_rows: - try: - await env.DB.prepare( - "INSERT INTO tags (id,name) VALUES (?,?)" - ).bind(tid, tname).run() - except Exception: - pass - - # ---- activities ---------------------------------------------------------- - act_rows = [ - ( - "act-py-begin", "Python for Beginners", - "Learn Python programming from scratch. Master variables, loops, " - "functions, and object-oriented design in this hands-on course.", - "course", "self_paced", "ongoing", aid, - ["tag-python"], - ), - ( - "act-js-meetup", "JavaScript Developers Meetup", - "Monthly meetup for JavaScript enthusiasts. Share projects, " - "discuss new frameworks, and network with fellow devs.", - "meetup", "live", "recurring", bid, - ["tag-js", "tag-webdev"], - ), - ( - "act-ds-workshop", "Data Science Workshop", - "Hands-on workshop covering data wrangling with pandas, " - "visualisation with matplotlib, and intro to machine learning.", - "workshop", "live", "multi_session", aid, - ["tag-data", "tag-python"], - ), - ( - "act-ml-study", "Machine Learning Study Group", - "Collaborative study group working through ML concepts, " - "reading papers, and implementing algorithms together.", - "course", "hybrid", "recurring", bid, - ["tag-ml", "tag-python"], - ), - ( - "act-webdev", "Web Dev Fundamentals", - "Build modern responsive websites with HTML5, CSS3, and JavaScript. " - "Covers Flexbox, Grid, fetch API, and accessible design.", - "course", "self_paced", "ongoing", aid, - ["tag-webdev", "tag-js"], - ), - ( - "act-db-design", "Database Design & SQL", - "Design normalised relational schemas, write complex SQL queries, " - "use indexes for speed, and understand transactions.", - "workshop", "live", "one_time", bid, - ["tag-db"], - ), - ] - for act_id, title, desc, atype, fmt, sched, host_id, tags in act_rows: - try: - await env.DB.prepare( - "INSERT INTO activities " - "(id,title,description,type,format,schedule_type,host_id)" - " VALUES (?,?,?,?,?,?,?)" - ).bind( - act_id, title, encrypt(desc, enc_key), - atype, fmt, sched, host_id - ).run() - except Exception: - pass - for tag_id in tags: - try: - await env.DB.prepare( - "INSERT OR IGNORE INTO activity_tags (activity_id,tag_id)" - " VALUES (?,?)" - ).bind(act_id, tag_id).run() - except Exception: - pass - - # ---- sessions for live/recurring activities ------------------------------ - ses_rows = [ - ("ses-js-1", "act-js-meetup", - "April Meetup", "Q1 retro and React 19 deep-dive", - "2024-04-15 18:00", "2024-04-15 21:00", "Tech Hub, 123 Main St, SF"), - ("ses-js-2", "act-js-meetup", - "May Meetup", "TypeScript 5.4 and what's new in Node 22", - "2024-05-20 18:00", "2024-05-20 21:00", "Tech Hub, 123 Main St, SF"), - ("ses-ds-1", "act-ds-workshop", - "Session 1 - Data Wrangling", - "Introduction to pandas DataFrames and data cleaning", - "2024-06-01 10:00", "2024-06-01 14:00", "Online via Zoom"), - ("ses-ds-2", "act-ds-workshop", - "Session 2 - Visualisation", - "matplotlib, seaborn, and plotly for data storytelling", - "2024-06-08 10:00", "2024-06-08 14:00", "Online via Zoom"), - ("ses-ds-3", "act-ds-workshop", - "Session 3 - Intro to ML", - "scikit-learn: regression, classification, evaluation", - "2024-06-15 10:00", "2024-06-15 14:00", "Online via Zoom"), - ] - for sid, act_id, title, desc, start, end, loc in ses_rows: - try: - await env.DB.prepare( - "INSERT INTO sessions " - "(id,activity_id,title,description,start_time,end_time,location)" - " VALUES (?,?,?,?,?,?,?)" - ).bind( - sid, act_id, title, - encrypt(desc, enc_key), - start, end, - encrypt(loc, enc_key), - ).run() - except Exception: - pass - - # ---- enrollments --------------------------------------------------------- - enr_rows = [ - ("enr-c-py", "act-py-begin", cid, "participant"), - ("enr-c-js", "act-js-meetup", cid, "participant"), - ("enr-c-ds", "act-ds-workshop", cid, "participant"), - ("enr-d-py", "act-py-begin", did, "participant"), - ("enr-d-webdev", "act-webdev", did, "participant"), - ("enr-b-py", "act-py-begin", bid, "instructor"), - ] - for eid, act_id, uid, role in enr_rows: - try: - await env.DB.prepare( - "INSERT OR IGNORE INTO enrollments (id,activity_id,user_id,role)" - " VALUES (?,?,?,?)" - ).bind(eid, act_id, uid, role).run() - except Exception: - pass - - -# --------------------------------------------------------------------------- -# API handlers -# --------------------------------------------------------------------------- - -async def api_register(req, env): - body, bad_resp = await parse_json_object(req) - if bad_resp: - return bad_resp - - username = (body.get("username") or "").strip() - email = (body.get("email") or "").strip() - password = (body.get("password") or "") - name = (body.get("name") or username).strip() - - if not username or not email or not password: - return err("username, email, and password are required") - if len(password) < 8: - return err("Password must be at least 8 characters") - - role = "member" - - enc = env.ENCRYPTION_KEY - uid = new_id() - try: - await env.DB.prepare( - "INSERT INTO users " - "(id,username_hash,email_hash,name,username,email,password_hash,role)" - " VALUES (?,?,?,?,?,?,?,?)" - ).bind( - uid, - blind_index(username, enc), - blind_index(email, enc), - encrypt(name, enc), - encrypt(username, enc), - encrypt(email, enc), - hash_password(password, username), - encrypt(role, enc), - ).run() - except Exception as e: - if "UNIQUE" in str(e): - return err("Username or email already registered", 409) - capture_exception(e, req, env, "api_register.insert_user") - return err("Registration failed — please try again", 500) - - token = create_token(uid, username, role, env.JWT_SECRET) - return ok( - {"token": token, - "user": {"id": uid, "username": username, "name": name, "role": role}}, - "Registration successful", - ) - - -async def api_login(req, env): - body, bad_resp = await parse_json_object(req) - if bad_resp: - return bad_resp - - username = (body.get("username") or "").strip() - password = (body.get("password") or "") - - if not username or not password: - return err("username and password are required") - - enc = env.ENCRYPTION_KEY - u_hash = blind_index(username, enc) - row = await env.DB.prepare( - "SELECT id,password_hash,role,name,username FROM users WHERE username_hash=?" - ).bind(u_hash).first() - - if not row: - return err("Invalid username or password", 401) - - password_hash = row.password_hash - user_id = row.id - role_enc = row.role - name_enc = row.name - username_enc = row.username - stored_username = decrypt(username_enc, enc) - - if not verify_password(password, password_hash, stored_username): - return err("Invalid username or password", 401) - - real_role = decrypt(role_enc, enc) - real_name = decrypt(name_enc, enc) - token = create_token(user_id, stored_username, real_role, env.JWT_SECRET) - return ok( - {"token": token, - "user": {"id": user_id, "username": stored_username, - "name": real_name, "role": real_role}}, - "Login successful", - ) - - -async def api_list_activities(req, env): - parsed = urlparse(req.url) - params = parse_qs(parsed.query) - atype = (params.get("type") or [None])[0] - fmt = (params.get("format") or [None])[0] - search = (params.get("q") or [None])[0] - tag = (params.get("tag") or [None])[0] - enc = env.ENCRYPTION_KEY - - base_q = ( - "SELECT a.id,a.title,a.description,a.type,a.format,a.schedule_type," - "a.created_at,u.name AS host_name_enc," - "(SELECT COUNT(*) FROM enrollments WHERE activity_id=a.id AND status='active')" - " AS participant_count," - "(SELECT COUNT(*) FROM sessions WHERE activity_id=a.id) AS session_count" - " FROM activities a JOIN users u ON a.host_id=u.id" - ) - - if tag: - tag_row = await env.DB.prepare( - "SELECT id FROM tags WHERE name=?" - ).bind(tag).first() - if not tag_row: - return json_resp({"activities": []}) - res = await env.DB.prepare( - base_q - + " JOIN activity_tags at2 ON at2.activity_id=a.id" - " WHERE at2.tag_id=? ORDER BY a.created_at DESC" - ).bind(tag_row.id).all() - elif atype and fmt: - res = await env.DB.prepare( - base_q + " WHERE a.type=? AND a.format=? ORDER BY a.created_at DESC" - ).bind(atype, fmt).all() - elif atype: - res = await env.DB.prepare( - base_q + " WHERE a.type=? ORDER BY a.created_at DESC" - ).bind(atype).all() - elif fmt: - res = await env.DB.prepare( - base_q + " WHERE a.format=? ORDER BY a.created_at DESC" - ).bind(fmt).all() - else: - res = await env.DB.prepare( - base_q + " ORDER BY a.created_at DESC" - ).all() - - activities = [] - for row in res.results or []: - desc = decrypt(row.description or "", enc) - host_name = decrypt(row.host_name_enc or "", enc) - if search and ( - search.lower() not in row.title.lower() - and search.lower() not in desc.lower() - ): - continue - - t_res = await env.DB.prepare( - "SELECT t.name FROM tags t" - " JOIN activity_tags at2 ON at2.tag_id=t.id" - " WHERE at2.activity_id=?" - ).bind(row.id).all() - - activities.append({ - "id": row.id, - "title": row.title, - "description": desc, - "type": row.type, - "format": row.format, - "schedule_type": row.schedule_type, - "host_name": host_name, - "participant_count": row.participant_count, - "session_count": row.session_count, - "tags": [t.name for t in (t_res.results or [])], - "created_at": row.created_at, - }) - - return json_resp({"activities": activities}) - - -async def api_create_activity(req, env): - user = verify_token(req.headers.get("Authorization"), env.JWT_SECRET) - if not user: - return err("Authentication required", 401) - - body, bad_resp = await parse_json_object(req) - if bad_resp: - return bad_resp - - title = (body.get("title") or "").strip() - description = (body.get("description") or "").strip() - atype = (body.get("type") or "course").strip() - fmt = (body.get("format") or "self_paced").strip() - schedule_type = (body.get("schedule_type") or "ongoing").strip() - - if not title: - return err("title is required") - if atype not in ("course", "meetup", "workshop", "seminar", "other"): - atype = "course" - if fmt not in ("live", "self_paced", "hybrid"): - fmt = "self_paced" - if schedule_type not in ("one_time", "multi_session", "recurring", "ongoing"): - schedule_type = "ongoing" - - enc = env.ENCRYPTION_KEY - act_id = new_id() - try: - await env.DB.prepare( - "INSERT INTO activities " - "(id,title,description,type,format,schedule_type,host_id)" - " VALUES (?,?,?,?,?,?,?)" - ).bind( - act_id, title, - encrypt(description, enc) if description else "", - atype, fmt, schedule_type, user["id"] - ).run() - except Exception as e: - capture_exception(e, req, env, "api_create_activity.insert_activity") - return err("Failed to create activity — please try again", 500) - - for tag_name in (body.get("tags") or []): - tag_name = tag_name.strip() - if not tag_name: - continue - t_row = await env.DB.prepare( - "SELECT id FROM tags WHERE name=?" - ).bind(tag_name).first() - if t_row: - tag_id = t_row.id - else: - tag_id = new_id() - try: - await env.DB.prepare( - "INSERT INTO tags (id,name) VALUES (?,?)" - ).bind(tag_id, tag_name).run() - except Exception as e: - capture_exception(e, req, env, f"api_create_activity.insert_tag: tag_name={tag_name}, tag_id={tag_id}, act_id={act_id}") - continue - try: - await env.DB.prepare( - "INSERT OR IGNORE INTO activity_tags (activity_id,tag_id) VALUES (?,?)" - ).bind(act_id, tag_id).run() - except Exception as e: - capture_exception(e, req, env, f"api_create_activity.insert_activity_tags: tag_name={tag_name}, tag_id={tag_id}, act_id={act_id}") - pass - - return ok({"id": act_id, "title": title}, "Activity created") - - -async def api_get_activity(act_id: str, req, env): - user = verify_token(req.headers.get("Authorization") or "", env.JWT_SECRET) - enc = env.ENCRYPTION_KEY - - act = await env.DB.prepare( - "SELECT a.*,u.name AS host_name_enc,u.id AS host_uid" - " FROM activities a JOIN users u ON a.host_id=u.id" - " WHERE a.id=?" - ).bind(act_id).first() - if not act: - return err("Activity not found", 404) - - enrollment = None - is_enrolled = False - if user: - enrollment = await env.DB.prepare( - "SELECT id,role,status FROM enrollments" - " WHERE activity_id=? AND user_id=?" - ).bind(act_id, user["id"]).first() - is_enrolled = enrollment is not None - - is_host = bool(user and act.host_uid == user["id"]) - - ses_res = await env.DB.prepare( - "SELECT id,title,description,start_time,end_time,location,created_at" - " FROM sessions WHERE activity_id=? ORDER BY start_time" - ).bind(act_id).all() - - sessions = [] - for s in ses_res.results or []: - sessions.append({ - "id": s.id, - "title": s.title, - "description": decrypt(s.description or "", enc) if (is_enrolled or is_host) else None, - "start_time": s.start_time, - "end_time": s.end_time, - "location": decrypt(s.location or "", enc) if (is_enrolled or is_host) else None, - }) - - t_res = await env.DB.prepare( - "SELECT t.name FROM tags t" - " JOIN activity_tags at2 ON at2.tag_id=t.id" - " WHERE at2.activity_id=?" - ).bind(act_id).all() - - count_row = await env.DB.prepare( - "SELECT COUNT(*) AS cnt FROM enrollments WHERE activity_id=? AND status='active'" - ).bind(act_id).first() - - return json_resp({ - "activity": { - "id": act.id, - "title": act.title, - "description": decrypt(act.description or "", enc), - "type": act.type, - "format": act.format, - "schedule_type": act.schedule_type, - "host_name": decrypt(act.host_name_enc or "", enc), - "participant_count": count_row.cnt if count_row else 0, - "tags": [t.name for t in (t_res.results or [])], - "created_at": act.created_at, - }, - "sessions": sessions, - "is_enrolled": is_enrolled, - "is_host": is_host, - "enrollment": { - "role": enrollment.role, - "status": enrollment.status, - } if enrollment else None, - }) - - -async def api_join(req, env): - user = verify_token(req.headers.get("Authorization"), env.JWT_SECRET) - if not user: - return err("Authentication required", 401) - - body, bad_resp = await parse_json_object(req) - if bad_resp: - return bad_resp - - act_id = body.get("activity_id") - role = (body.get("role") or "participant").strip() - - if not act_id: - return err("activity_id is required") - if role not in ("participant", "instructor", "organizer"): - role = "participant" - - act = await env.DB.prepare( - "SELECT id FROM activities WHERE id=?" - ).bind(act_id).first() - if not act: - return err("Activity not found", 404) - - enr_id = new_id() - try: - await env.DB.prepare( - "INSERT OR IGNORE INTO enrollments (id,activity_id,user_id,role)" - " VALUES (?,?,?,?)" - ).bind(enr_id, act_id, user["id"], role).run() - except Exception as e: - capture_exception(e, req, env, "api_join.insert_enrollment") - return err("Failed to join activity — please try again", 500) - - return ok(None, "Joined activity successfully") - - -async def api_dashboard(req, env): - user = verify_token(req.headers.get("Authorization"), env.JWT_SECRET) - if not user: - return err("Authentication required", 401) - - enc = env.ENCRYPTION_KEY - - res = await env.DB.prepare( - "SELECT a.id,a.title,a.type,a.format,a.schedule_type,a.created_at," - "(SELECT COUNT(*) FROM enrollments WHERE activity_id=a.id AND status='active')" - " AS participant_count," - "(SELECT COUNT(*) FROM sessions WHERE activity_id=a.id) AS session_count" - " FROM activities a WHERE a.host_id=? ORDER BY a.created_at DESC" - ).bind(user["id"]).all() - - hosted = [] - for r in res.results or []: - t_res = await env.DB.prepare( - "SELECT t.name FROM tags t JOIN activity_tags at2 ON at2.tag_id=t.id" - " WHERE at2.activity_id=?" - ).bind(r.id).all() - hosted.append({ - "id": r.id, - "title": r.title, - "type": r.type, - "format": r.format, - "schedule_type": r.schedule_type, - "participant_count": r.participant_count, - "session_count": r.session_count, - "tags": [t.name for t in (t_res.results or [])], - "created_at": r.created_at, - }) - - res2 = await env.DB.prepare( - "SELECT a.id,a.title,a.type,a.format,a.schedule_type," - "e.role AS enr_role,e.status AS enr_status,e.created_at AS joined_at," - "u.name AS host_name_enc" - " FROM enrollments e" - " JOIN activities a ON e.activity_id=a.id" - " JOIN users u ON a.host_id=u.id" - " WHERE e.user_id=? ORDER BY e.created_at DESC" - ).bind(user["id"]).all() - - joined = [] - for r in res2.results or []: - t_res = await env.DB.prepare( - "SELECT t.name FROM tags t JOIN activity_tags at2 ON at2.tag_id=t.id" - " WHERE at2.activity_id=?" - ).bind(r.id).all() - joined.append({ - "id": r.id, - "title": r.title, - "type": r.type, - "format": r.format, - "schedule_type": r.schedule_type, - "enr_role": r.enr_role, - "enr_status": r.enr_status, - "host_name": decrypt(r.host_name_enc or "", enc), - "tags": [t.name for t in (t_res.results or [])], - "joined_at": r.joined_at, - }) - - return json_resp({"user": user, "hosted_activities": hosted, "joined_activities": joined}) - - -async def api_create_session(req, env): - user = verify_token(req.headers.get("Authorization"), env.JWT_SECRET) - if not user: - return err("Authentication required", 401) - - body, bad_resp = await parse_json_object(req) - if bad_resp: - return bad_resp - - act_id = body.get("activity_id") - title = (body.get("title") or "").strip() - description = (body.get("description") or "").strip() - start_time = (body.get("start_time") or "").strip() - end_time = (body.get("end_time") or "").strip() - location = (body.get("location") or "").strip() - - if not act_id or not title: - return err("activity_id and title are required") - - owned = await env.DB.prepare( - "SELECT id FROM activities WHERE id=? AND host_id=?" - ).bind(act_id, user["id"]).first() - if not owned: - return err("Activity not found or access denied", 404) - - enc = env.ENCRYPTION_KEY - sid = new_id() - try: - await env.DB.prepare( - "INSERT INTO sessions " - "(id,activity_id,title,description,start_time,end_time,location)" - " VALUES (?,?,?,?,?,?,?)" - ).bind( - sid, act_id, title, - encrypt(description, enc) if description else "", - start_time, end_time, - encrypt(location, enc) if location else "", - ).run() - except Exception as e: - capture_exception(e, req, env, "api_create_session.insert_session") - return err("Failed to create session — please try again", 500) - - return ok({"id": sid}, "Session created") - - -async def api_list_tags(_req, env): - res = await env.DB.prepare("SELECT id,name FROM tags ORDER BY name").all() - tags = [{"id": r.id, "name": r.name} for r in (res.results or [])] - return json_resp({"tags": tags}) - - -async def api_add_activity_tags(req, env): - user = verify_token(req.headers.get("Authorization"), env.JWT_SECRET) - if not user: - return err("Authentication required", 401) - - body, bad_resp = await parse_json_object(req) - if bad_resp: - return bad_resp - - act_id = body.get("activity_id") - tags = body.get("tags") or [] - - if not act_id: - return err("activity_id is required") - - owned = await env.DB.prepare( - "SELECT id FROM activities WHERE id=? AND host_id=?" - ).bind(act_id, user["id"]).first() - if not owned: - return err("Activity not found or access denied", 404) - - for tag_name in tags: - tag_name = tag_name.strip() - if not tag_name: - continue - t_row = await env.DB.prepare( - "SELECT id FROM tags WHERE name=?" - ).bind(tag_name).first() - if t_row: - tag_id = t_row.id - else: - tag_id = new_id() - try: - await env.DB.prepare( - "INSERT INTO tags (id,name) VALUES (?,?)" - ).bind(tag_id, tag_name).run() - except Exception as e: - capture_exception(e, req, env, f"api_add_activity_tags.insert_tag: tag_name={tag_name}, tag_id={tag_id}, act_id={act_id}") - continue - try: - await env.DB.prepare( - "INSERT OR IGNORE INTO activity_tags (activity_id,tag_id) VALUES (?,?)" - ).bind(act_id, tag_id).run() - except Exception as e: - capture_exception(e, req, env, f"api_add_activity_tags.insert_activity_tags: tag_name={tag_name}, tag_id={tag_id}, act_id={act_id}") - pass - - return ok(None, "Tags updated") - - -async def api_admin_table_counts(req, env): - if not _is_basic_auth_valid(req, env): - return _unauthorized_basic() - - tables_res = await env.DB.prepare( - "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name" - ).all() - - counts = [] - for row in tables_res.results or []: - table_name = row.name - # Table names come from sqlite_master and are quoted to avoid SQL injection. - count_row = await env.DB.prepare( - f'SELECT COUNT(*) AS cnt FROM "{table_name.replace(chr(34), chr(34) + chr(34))}"' - ).first() - counts.append({"table": table_name, "count": count_row.cnt if count_row else 0}) - - return json_resp({"tables": counts}) - - -# --------------------------------------------------------------------------- -# Static-asset serving (Workers Sites / __STATIC_CONTENT KV) -# --------------------------------------------------------------------------- - -_MIME = { - "html": "text/html; charset=utf-8", - "css": "text/css; charset=utf-8", - "js": "application/javascript; charset=utf-8", - "json": "application/json", - "png": "image/png", - "jpg": "image/jpeg", - "svg": "image/svg+xml", - "ico": "image/x-icon", -} - - -async def serve_static(path: str, env): - if path in ("/", ""): - key = "index.html" - else: - key = path.lstrip("/") - if "." not in key.split("/")[-1]: - key += ".html" - - try: - content = await env.__STATIC_CONTENT.get(key, "text") - except Exception: - content = None - - if content is None: - try: - content = await env.__STATIC_CONTENT.get("index.html", "text") - except Exception: - content = None - - if content is None: - return Response( - "

404 - Not Found

", - status=404, - headers={"Content-Type": "text/html"}, - ) - - ext = key.rsplit(".", 1)[-1] if "." in key else "html" - mime = _MIME.get(ext, "text/plain") - return Response(content, headers={"Content-Type": mime, **_CORS}) - +from api_activities import ( + api_add_activity_tags, + api_create_activity, + api_create_session, + api_dashboard, + api_get_activity, + api_join, + api_list_activities, + api_list_tags, +) +from api_admin import api_admin_table_counts +from api_auth import api_login, api_register +from db_utils import init_db, seed_db +from http_utils import ( + CORS, + capture_exception, + clean_path, + err, + is_basic_auth_valid, + ok, + unauthorized_basic, +) +from static_utils import serve_static # --------------------------------------------------------------------------- # Main dispatcher @@ -1136,14 +65,14 @@ async def serve_static(path: str, env): async def _dispatch(request, env): path = urlparse(request.url).path method = request.method.upper() - admin_path = _clean_path(getattr(env, "ADMIN_URL", "")) + admin_path = clean_path(getattr(env, "ADMIN_URL", "")) if method == "OPTIONS": - return Response("", status=204, headers=_CORS) + return Response("", status=204, headers=CORS) if path == admin_path and method == "GET": - if not _is_basic_auth_valid(request, env): - return _unauthorized_basic() + if not is_basic_auth_valid(request, env): + return unauthorized_basic() return await serve_static("/admin.html", env) if path.startswith("/api/"):