From 2245c213c74cff19befbc4dfdeae77f6065fe49f Mon Sep 17 00:00:00 2001 From: Ghanshyam Singh Date: Mon, 8 Jun 2026 22:05:27 +0530 Subject: [PATCH 1/4] feat:user-profile-directory --- migrations/0004_add_user_profiles.sql | 21 ++ public/js/layout.js | 103 +++++-- public/public-profile.html | 266 +++++++++++++++++ public/users.html | 232 +++++++++++++++ schema.sql | 30 +- src/worker.py | 315 +++++++++++++++++++- tests/test_api_activities.py | 11 +- tests/test_api_admin.py | 3 +- tests/test_api_auth.py | 1 + tests/test_api_profile.py | 407 ++++++++++++++++++++++++++ wrangler.toml | 6 + 11 files changed, 1344 insertions(+), 51 deletions(-) create mode 100644 migrations/0004_add_user_profiles.sql create mode 100644 public/public-profile.html create mode 100644 public/users.html create mode 100644 tests/test_api_profile.py diff --git a/migrations/0004_add_user_profiles.sql b/migrations/0004_add_user_profiles.sql new file mode 100644 index 0000000..c5233ba --- /dev/null +++ b/migrations/0004_add_user_profiles.sql @@ -0,0 +1,21 @@ +-- Migration 0004: Add user profile fields and avatars table + +ALTER TABLE users ADD COLUMN bio TEXT; +ALTER TABLE users ADD COLUMN avatar_url TEXT; +ALTER TABLE users ADD COLUMN is_teacher INTEGER NOT NULL DEFAULT 0; +ALTER TABLE users ADD COLUMN github_username TEXT; +ALTER TABLE users ADD COLUMN discord_username TEXT; +ALTER TABLE users ADD COLUMN slack_username TEXT; +ALTER TABLE users ADD COLUMN expertise TEXT; +ALTER TABLE users ADD COLUMN is_profile_public INTEGER NOT NULL DEFAULT 0; + +CREATE TABLE IF NOT EXISTS avatars ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + avatar_url TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_avatars_user ON avatars(user_id); +CREATE INDEX IF NOT EXISTS idx_users_public ON users(is_profile_public); diff --git a/public/js/layout.js b/public/js/layout.js index 2c6656a..72ecca6 100644 --- a/public/js/layout.js +++ b/public/js/layout.js @@ -99,7 +99,10 @@ function initializeDarkMode() { } // ── Layout injection ────────────────────────────────────────────────── -async function inject(id, file, callback) { +const _navbarPromise = fetch('/partials/navbar.html'); +const _footerPromise = fetch('/partials/footer.html'); + +async function inject(id, fetchPromise, callback) { const el = document.getElementById(id); if (!el) return; if (el.dataset.inline === 'true') { @@ -109,13 +112,11 @@ async function inject(id, file, callback) { return; } try { - const res = await fetch(file); + const res = await fetchPromise; el.innerHTML = await res.text(); - if (callback && typeof callback === 'function') { - callback(); - } - } catch (err) { - console.error(`Failed to load ${file}:`, err); + if (typeof callback === 'function') callback(); + } catch (e) { + console.error('Layout inject error:', e); } } @@ -128,6 +129,41 @@ window.toggleProfileDropdown = function () { }; // ── Update auth section with profile ───────────────────────────────── +// ── Avatar helpers ─────────────────────────────────────────────────── +function _setNavAvatar(initialsId, imgId, name, username, avatarUrl) { + const initials = (name || username || '?').split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase(); + const initialsEl = document.getElementById(initialsId); + const imgEl = document.getElementById(imgId); + if (!initialsEl) return; + if (avatarUrl) { + initialsEl.classList.add('hidden'); + if (imgEl) { + imgEl.src = avatarUrl; + imgEl.classList.remove('hidden'); + imgEl.onerror = function () { + this.classList.add('hidden'); + initialsEl.classList.remove('hidden'); + }; + } + } else { + initialsEl.textContent = initials; + initialsEl.classList.remove('hidden'); + if (imgEl) imgEl.classList.add('hidden'); + } +} + +// Keep the old profile-avatar element (button circle) in sync too +function _setNavButtonAvatar(avatarElId, name, username, avatarUrl) { + const el = document.getElementById(avatarElId); + if (!el) return; + const initials = (name || username || '?').split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase(); + if (avatarUrl) { + el.innerHTML = ``; + } else { + el.textContent = initials; + } +} + function updateAuthSection() { const { token, user } = getAuth(); const notLoggedInDiv = document.getElementById('auth-not-logged-in'); @@ -136,11 +172,11 @@ function updateAuthSection() { const mobileLoggedIn = document.getElementById('mobile-auth-logged-in'); if (token && user) { - // User is logged in - const firstName = user.name ? user.name.split(' ')[0] : user.username; - const firstLetter = firstName.charAt(0).toUpperCase(); + const firstName = user.name ? user.name.split(' ')[0] : user.username; + const avatarUrl = user.avatar_url || ''; + const role = user.role || ''; - // Desktop view + // Desktop if (notLoggedInDiv) notLoggedInDiv.classList.add('hidden'); if (loggedInDiv) loggedInDiv.classList.remove('hidden'); @@ -154,24 +190,45 @@ function updateAuthSection() { const notifLink = document.getElementById('notif-bell-link'); if (notifLink) notifLink.classList.remove('hidden'); - // Mobile view + // Trigger button circle (existing id="profile-avatar") + _setNavButtonAvatar('profile-avatar', user.name, user.username, avatarUrl); + + const greetingEl = document.getElementById('profile-greeting'); + const usernameEl = document.getElementById('dropdown-username'); + const roleEl = document.getElementById('dropdown-user-role'); + + if (greetingEl) greetingEl.textContent = `Hi, ${firstName}`; + if (usernameEl) usernameEl.textContent = `@${user.username}`; + if (roleEl) roleEl.textContent = role; + + // Dropdown avatar (new elements) + _setNavAvatar('dropdown-avatar-initials', 'dropdown-avatar-img', user.name, user.username, avatarUrl); + + // "My Public Profile" deep link + const profileLink = document.getElementById('dropdown-view-profile-link'); + if (profileLink) profileLink.href = `/public-profile.html?username=${encodeURIComponent(user.username)}`; + + // Mobile if (mobileNotLoggedIn) mobileNotLoggedIn.classList.add('hidden'); - if (mobileLoggedIn) mobileLoggedIn.classList.remove('hidden'); - - const mobileAvatarEl = document.getElementById('mobile-profile-avatar'); + if (mobileLoggedIn) mobileLoggedIn.classList.remove('hidden'); + + _setNavAvatar('mobile-avatar-initials', 'mobile-avatar-img', user.name, user.username, avatarUrl); + const mobileGreetingEl = document.getElementById('mobile-profile-greeting'); - - if (mobileAvatarEl) mobileAvatarEl.textContent = firstLetter; + const mobileRoleEl = document.getElementById('mobile-user-role'); if (mobileGreetingEl) mobileGreetingEl.textContent = `Hi, ${firstName}`; + if (mobileRoleEl) mobileRoleEl.textContent = role; + + const mobileProfileLink = document.getElementById('mobile-view-profile-link'); + if (mobileProfileLink) mobileProfileLink.href = `/public-profile.html?username=${encodeURIComponent(user.username)}`; runLayoutIdle(startUnreadPolling, 1200); runLayoutIdle(refreshCartBadge, 1200); } else { - // User is not logged in if (notLoggedInDiv) notLoggedInDiv.classList.remove('hidden'); - if (loggedInDiv) loggedInDiv.classList.add('hidden'); + if (loggedInDiv) loggedInDiv.classList.add('hidden'); if (mobileNotLoggedIn) mobileNotLoggedIn.classList.remove('hidden'); - if (mobileLoggedIn) mobileLoggedIn.classList.add('hidden'); + if (mobileLoggedIn) mobileLoggedIn.classList.add('hidden'); const badge = document.getElementById('notif-unread-badge'); if (badge) badge.classList.add('hidden'); @@ -330,8 +387,10 @@ async function initLayout() { if (!layoutInitPromise) { layoutInitPromise = (async () => { initializeDarkMode(); - await inject('site-navbar', '/partials/navbar.html', updateAuthSection); - await inject('site-footer', '/partials/footer.html'); + await Promise.all([ + inject('site-navbar', _navbarPromise, updateAuthSection), + inject('site-footer', _footerPromise), + ]); updateDarkModeIcon(); })(); } diff --git a/public/public-profile.html b/public/public-profile.html new file mode 100644 index 0000000..af893f2 --- /dev/null +++ b/public/public-profile.html @@ -0,0 +1,266 @@ + + + + + + + Profile - Alpha One Labs + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
+
+ + + + + + + + + + diff --git a/public/users.html b/public/users.html new file mode 100644 index 0000000..152bb1a --- /dev/null +++ b/public/users.html @@ -0,0 +1,232 @@ + + + + + + + Community Directory - Alpha One Labs + + + + + + + + + + + + + + + +
+ +
+

+ Community +

+

Member Directory

+

Discover teachers, learners, and experts in our community.

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

Loading…

+
+ + +
+
+
+
+
+ + +
+ + + + + + + +
+ + + + + + + diff --git a/schema.sql b/schema.sql index 9d2d2e6..0bb92e0 100644 --- a/schema.sql +++ b/schema.sql @@ -6,16 +6,24 @@ -- username_hash and email_hash are HMAC-SHA256 blind indexes used for O(1) -- lookups so no plaintext ever needs to be stored in an indexed column. CREATE TABLE IF NOT EXISTS users ( - id TEXT PRIMARY KEY, - username_hash TEXT NOT NULL UNIQUE, -- HMAC(username) for lookups - email_hash TEXT NOT NULL UNIQUE, -- HMAC(email) for lookups - name TEXT NOT NULL, -- encrypt(display_name) - username TEXT NOT NULL, -- encrypt(login_username) - email TEXT NOT NULL, -- encrypt(email) - password_hash TEXT NOT NULL, -- PBKDF2-SHA256, per-user salt - role TEXT NOT NULL, -- encrypt('host' | 'member') - email_verified INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (datetime('now')) + id TEXT PRIMARY KEY, + username_hash TEXT NOT NULL UNIQUE, -- HMAC(username) for lookups + email_hash TEXT NOT NULL UNIQUE, -- HMAC(email) for lookups + name TEXT NOT NULL, -- encrypt(display_name) + username TEXT NOT NULL, -- encrypt(login_username) + email TEXT NOT NULL, -- encrypt(email) + password_hash TEXT NOT NULL, -- PBKDF2-SHA256, per-user salt + role TEXT NOT NULL, -- encrypt('host' | 'member') + email_verified INTEGER NOT NULL DEFAULT 0, + bio TEXT, -- encrypt(bio) + avatar_url TEXT, + is_teacher INTEGER NOT NULL DEFAULT 0, + github_username TEXT, -- encrypt(github_username) + discord_username TEXT, -- encrypt(discord_username) + slack_username TEXT, -- encrypt(slack_username) + expertise TEXT, -- encrypt(expertise) + is_profile_public INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) ); -- ACTIVITIES (courses, meetups, workshops, seminars, etc.) @@ -162,4 +170,4 @@ 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); \ No newline at end of file +CREATE INDEX IF NOT EXISTS idx_message_requests_activity ON message_requests(activity_id); diff --git a/src/worker.py b/src/worker.py index ac8a0c9..fe4c1f8 100644 --- a/src/worker.py +++ b/src/worker.py @@ -947,16 +947,24 @@ async def send_password_reset_email(to_email: str, _username: str, token: str, e _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, - email_verified INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (datetime('now')) + 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, + email_verified INTEGER NOT NULL DEFAULT 0, + bio TEXT, + avatar_url TEXT, + is_teacher INTEGER NOT NULL DEFAULT 0, + github_username TEXT, + discord_username TEXT, + slack_username TEXT, + expertise TEXT, + is_profile_public INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) )""", # Activities """CREATE TABLE IF NOT EXISTS activities ( @@ -1712,7 +1720,7 @@ async def api_login(req, env): enc = env.ENCRYPTION_KEY u_hash = blind_index(username, enc) row = await env.DB.prepare( - "SELECT id,password_hash,role,name,username,email_verified FROM users WHERE username_hash=?" + "SELECT id,password_hash,role,name,username,email_verified,avatar_url FROM users WHERE username_hash=?" ).bind(u_hash).first() if not row: e_hash = blind_index(username, enc) @@ -1750,7 +1758,8 @@ async def api_login(req, env): return ok( {"token": token, "user": {"id": user_id, "username": stored_username, - "name": real_name, "role": real_role}}, + "name": real_name, "role": real_role, + "avatar_url": row.avatar_url or ""}}, "Login successful", ) @@ -7101,6 +7110,288 @@ async def api_get_thread_messages(req, env, thread_id: str): }) +# --------------------------------------------------------------------------- +# Profile & user-directory API handlers +# --------------------------------------------------------------------------- + +async def api_get_profile(req, env): + """GET /api/profile — return the authenticated user's full profile.""" + user = verify_token(req.headers.get("Authorization"), env.JWT_SECRET) + if not user: + return err("Authentication required", 401) + + enc = env.ENCRYPTION_KEY + row = await env.DB.prepare( + "SELECT id, name, username, bio, expertise, github_username, " + "discord_username, slack_username, avatar_url, is_teacher, " + "is_profile_public, created_at FROM users WHERE id=?" + ).bind(user["id"]).first() + + if not row: + return err("User not found", 404) + + async def _d(val): + return await decrypt_aes(val, enc) if val else "" + + return ok({ + "id": row.id, + "username": await _d(row.username), + "name": await _d(row.name), + "bio": await _d(row.bio), + "expertise": await _d(row.expertise), + "github_username": await _d(row.github_username), + "discord_username": await _d(row.discord_username), + "slack_username": await _d(row.slack_username), + "avatar_url": row.avatar_url or "", + "is_teacher": row.is_teacher or 0, + "is_profile_public": row.is_profile_public or 0, + "created_at": row.created_at, + }) + + +async def api_patch_profile(req, env): + """PATCH /api/profile — update the authenticated user's editable profile fields.""" + 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 + + enc = env.ENCRYPTION_KEY + _encrypted = {"name", "bio", "expertise", "github_username", "discord_username", "slack_username"} + _bools = {"is_teacher", "is_profile_public"} + _allowed = _encrypted | _bools + + set_parts, binds = [], [] + for field in _allowed: + if field not in body: + continue + val = body[field] + if field in _encrypted: + val = await encrypt_aes(str(val or "").strip(), enc) + else: + val = 1 if val else 0 + set_parts.append(f"{field}=?") + binds.append(val) + + if not set_parts: + return err("No valid fields to update") + + binds.append(user["id"]) + try: + await env.DB.prepare( + f"UPDATE users SET {', '.join(set_parts)} WHERE id=?" + ).bind(*binds).run() + except Exception as e: + await capture_exception(e, req, env, "api_patch_profile") + return err("Profile update failed — please try again", 500) + + return ok(None, "Profile updated") + + +async def api_upload_avatar(req, env): + """POST /api/profile/avatar — upload a new avatar image (base64 JSON body).""" + 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 + + image_data = (body.get("image_data") or "").strip() + image_type = (body.get("image_type") or "").lower().strip() + + _allowed_types = {"image/jpeg", "image/jpg", "image/png", "image/webp"} + if image_type not in _allowed_types: + return err("Only jpg, jpeg, png, and webp images are allowed") + + if not image_data: + return err("image_data is required") + + # Strip data-URL prefix if the browser included it + if "," in image_data: + image_data = image_data.split(",", 1)[1] + + try: + img_bytes = base64.b64decode(image_data) + except Exception: + return err("Invalid image data — expected base64-encoded content") + + if len(img_bytes) > 5 * 1024 * 1024: + return err("Image must be 5 MB or smaller") + + r2 = getattr(env, "R2", None) + + if r2: + # Upload to R2 and store the public URL + ext = image_type.split("/")[-1].replace("jpeg", "jpg") + key = f"avatars/{user['id']}/{new_id()}.{ext}" + try: + await r2.put(key, to_js(img_bytes)) + except Exception as e: + await capture_exception(e, req, env, "api_upload_avatar.r2_put") + return err("Avatar upload failed — please try again", 500) + r2_public_url = (getattr(env, "R2_PUBLIC_URL", "") or "").rstrip("/") + avatar_url = f"{r2_public_url}/{key}" if r2_public_url else f"/r2/{key}" + else: + # R2 not configured — store the image as a base64 data-URL directly. + # The client is expected to pre-resize to ≤256×256 so this stays small. + b64 = base64.b64encode(img_bytes).decode() + avatar_url = f"data:{image_type};base64,{b64}" + + try: + await env.DB.prepare("UPDATE users SET avatar_url=? WHERE id=?").bind(avatar_url, user["id"]).run() + await env.DB.prepare( + "INSERT INTO avatars (id, user_id, avatar_url) VALUES (?,?,?)" + ).bind(new_id(), user["id"], avatar_url).run() + except Exception as e: + await capture_exception(e, req, env, "api_upload_avatar.db") + return err("Avatar saved but database update failed", 500) + + return ok({"avatar_url": avatar_url}, "Avatar uploaded successfully") + + +async def api_remove_avatar(req, env): + """DELETE /api/profile/avatar — clear the authenticated user's avatar.""" + user = verify_token(req.headers.get("Authorization"), env.JWT_SECRET) + if not user: + return err("Authentication required", 401) + + try: + await env.DB.prepare( + "UPDATE users SET avatar_url=NULL WHERE id=?" + ).bind(user["id"]).run() + except Exception as e: + await capture_exception(e, req, env, "api_remove_avatar") + return err("Failed to remove avatar", 500) + + return ok(None, "Avatar removed") + + + + +async def api_delete_account(req, env): + """DELETE /api/account — permanently delete the authenticated user's account.""" + 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 + + password = body.get("password") or "" + if not password: + return err("Password is required to confirm account deletion") + + enc = env.ENCRYPTION_KEY + row = await env.DB.prepare( + "SELECT password_hash, username FROM users WHERE id=?" + ).bind(user["id"]).first() + + if not row: + return err("Account not found", 404) + + stored_username = await decrypt_aes(row.username or "", enc) + if not verify_password(password, row.password_hash, stored_username): + return err("Incorrect password", 401) + + uid = user["id"] + try: + # Clean up activities and their dependents (no ON DELETE CASCADE on activities) + acts = await env.DB.prepare("SELECT id FROM activities WHERE host_id=?").bind(uid).all() + for act in (acts.results or []): + await env.DB.prepare("DELETE FROM activity_tags WHERE activity_id=?").bind(act.id).run() + await env.DB.prepare("DELETE FROM session_attendance WHERE session_id IN " + "(SELECT id FROM sessions WHERE activity_id=?)").bind(act.id).run() + await env.DB.prepare("DELETE FROM sessions WHERE activity_id=?").bind(act.id).run() + await env.DB.prepare("DELETE FROM enrollments WHERE activity_id=?").bind(act.id).run() + await env.DB.prepare("DELETE FROM activities WHERE host_id=?").bind(uid).run() + + # Remove this user's enrollments in other activities + await env.DB.prepare("DELETE FROM enrollments WHERE user_id=?").bind(uid).run() + + # Delete user row — ON DELETE CASCADE handles: notifications, notification_preferences, + # email_verification_tokens, password_reset_tokens, avatars + await env.DB.prepare("DELETE FROM users WHERE id=?").bind(uid).run() + except Exception as e: + await capture_exception(e, req, env, "api_delete_account") + return err("Account deletion failed — please try again", 500) + + return ok(None, "Account deleted successfully") + + +async def api_get_public_profile(username: str, _req, env): + """GET /api/users/:username — return a user's public profile (only if is_profile_public=1).""" + enc = env.ENCRYPTION_KEY + u_hash = blind_index(username, enc) + + row = await env.DB.prepare( + "SELECT id, name, username, bio, expertise, github_username, " + "discord_username, slack_username, avatar_url, is_teacher, " + "is_profile_public, created_at FROM users WHERE username_hash=?" + ).bind(u_hash).first() + + if not row: + return err("User not found", 404) + + if not row.is_profile_public: + return err("This profile is private", 403) + + async def _d(val): + return await decrypt_aes(val, enc) if val else "" + + # Fetch public hosted activities for this user + acts_res = await env.DB.prepare( + "SELECT id, title, type, format FROM activities WHERE host_id=? ORDER BY created_at DESC LIMIT 10" + ).bind(row.id).all() + activities = [ + {"id": a.id, "title": a.title, "type": a.type, "format": a.format} + for a in (acts_res.results or []) + ] + + return ok({ + "username": await _d(row.username), + "name": await _d(row.name), + "bio": await _d(row.bio), + "expertise": await _d(row.expertise), + "github_username": await _d(row.github_username), + "discord_username": await _d(row.discord_username), + "slack_username": await _d(row.slack_username), + "avatar_url": row.avatar_url or "", + "is_teacher": row.is_teacher or 0, + "member_since": row.created_at, + "activities": activities, + }) + + +async def api_list_users(_req, env): + """GET /api/users — return all users who have set their profile to public.""" + enc = env.ENCRYPTION_KEY + + rows = await env.DB.prepare( + "SELECT id, name, username, bio, expertise, avatar_url, is_teacher " + "FROM users WHERE is_profile_public=1 ORDER BY created_at DESC" + ).all() + + users_list = [] + for r in rows.results or []: + bio_plain = await decrypt_aes(r.bio, enc) if r.bio else "" + users_list.append({ + "username": await decrypt_aes(r.username, enc) if r.username else "", + "name": await decrypt_aes(r.name, enc) if r.name else "", + "bio": bio_plain[:200], + "expertise": await decrypt_aes(r.expertise, enc) if r.expertise else "", + "avatar_url": r.avatar_url or "", + "is_teacher": r.is_teacher or 0, + }) + + return ok({"users": users_list, "total": len(users_list)}) + + # --------------------------------------------------------------------------- # Main dispatcher # --------------------------------------------------------------------------- diff --git a/tests/test_api_activities.py b/tests/test_api_activities.py index 2f639cd..c777af9 100644 --- a/tests/test_api_activities.py +++ b/tests/test_api_activities.py @@ -168,12 +168,13 @@ async def test_missing_table_initializes_schema_and_retries(self): ddl_count = len(worker._DDL) env = make_env(db=MockDB( - [failing_stmt] # first list query fails - + [make_stmt() for _ in range(ddl_count)] # init_db DDL statements - + [make_stmt(), make_stmt()] # init_db migration: ALTER TABLE + UPDATE + [failing_stmt] # first list query fails + + [make_stmt() for _ in range(ddl_count)] # init_db DDL statements + + [make_stmt(), make_stmt()] # init_db: email_verified ALTER + UPDATE + + [make_stmt() for _ in range(8)] # init_db: profile-fields ALTERs (0004) + [ - make_stmt(all_results=[row]), # retried list query succeeds - make_stmt(all_results=[]), # tags query + make_stmt(all_results=[row]), # retried list query succeeds + make_stmt(all_results=[]), # tags query ] )) diff --git a/tests/test_api_admin.py b/tests/test_api_admin.py index 33f2aca..8c5ef72 100644 --- a/tests/test_api_admin.py +++ b/tests/test_api_admin.py @@ -86,7 +86,8 @@ async def test_missing_table_initializes_schema_and_retries(self): failing_count_stmt, # initial users count fails ] + [make_stmt() for _ in range(ddl_count)] # init_db DDL statements - + [make_stmt(), make_stmt()] # init_db migration: ALTER TABLE + UPDATE + + [make_stmt(), make_stmt()] # init_db: email_verified ALTER + UPDATE + + [make_stmt() for _ in range(8)] # init_db: profile-fields ALTERs (0004) + [ make_stmt(all_results=[tables_row]), # retried sqlite_master query make_stmt(first=count_row), # retried users count succeeds diff --git a/tests/test_api_auth.py b/tests/test_api_auth.py index f5afadb..0ae2f3f 100644 --- a/tests/test_api_auth.py +++ b/tests/test_api_auth.py @@ -192,6 +192,7 @@ def _make_user_row(self, username="alice", password="password123", role="member" name=_enc(name), username=_enc(username), email_verified=1, + avatar_url=None, ) async def test_missing_username_returns_400(self): diff --git a/tests/test_api_profile.py b/tests/test_api_profile.py new file mode 100644 index 0000000..56bc3ef --- /dev/null +++ b/tests/test_api_profile.py @@ -0,0 +1,407 @@ +""" +Tests for Week-2 profile & user-directory API endpoints: + GET /api/profile + PATCH /api/profile + POST /api/profile/avatar + DELETE /api/account + GET /api/users + GET /api/users/:username +""" + +import base64 +import json + +from tests.helpers import ( + load_worker, MockRequest, MockRow, MockDB, make_env, make_stmt, json_request +) + +worker = load_worker() + +SECRET = "test-encryption-key" +JWT_SEC = "test-jwt-secret" + + +def _parse(resp): + return json.loads(resp.body) + + +def _enc(val: str) -> str: + """Match the stub encrypt_aes output: 'v1:' + base64(12_zero_iv + plaintext).""" + return "v1:" + base64.b64encode(b"\x00" * 12 + val.encode()).decode() + + +def _make_token(uid="uid-alice", username="alice", role="member"): + return worker.create_token(uid, username, role, JWT_SEC) + + +def _auth(uid="uid-alice", username="alice", role="member"): + return {"Authorization": f"Bearer {_make_token(uid, username, role)}"} + + +def _full_profile_row(**overrides): + defaults = dict( + id="uid-alice", + name=_enc("Alice Smith"), + username=_enc("alice"), + bio=None, + expertise=None, + github_username=None, + discord_username=None, + slack_username=None, + avatar_url=None, + is_teacher=0, + is_profile_public=0, + created_at="2024-01-01 00:00:00", + ) + defaults.update(overrides) + return MockRow(**defaults) + + +# --------------------------------------------------------------------------- +# GET /api/profile +# --------------------------------------------------------------------------- + +class TestGetProfile: + def _req(self): + return MockRequest(method="GET", url="http://localhost/api/profile", + headers=_auth()) + + async def test_no_token_returns_401(self): + env = make_env() + r = await worker.api_get_profile( + MockRequest(method="GET", url="http://localhost/api/profile"), env + ) + assert r.status == 401 + + async def test_user_not_found_returns_404(self): + env = make_env(db=MockDB([make_stmt(first=None)])) + r = await worker.api_get_profile(self._req(), env) + assert r.status == 404 + + async def test_returns_profile_fields(self): + row = _full_profile_row( + bio=_enc("Love coding!"), + expertise=_enc("Python, Django"), + github_username=_enc("alice-gh"), + ) + env = make_env(db=MockDB([make_stmt(first=row)])) + r = await worker.api_get_profile(self._req(), env) + assert r.status == 200 + d = _parse(r)["data"] + assert d["username"] == "alice" + assert d["name"] == "Alice Smith" + assert d["bio"] == "Love coding!" + assert d["expertise"] == "Python, Django" + assert d["github_username"] == "alice-gh" + assert d["is_teacher"] == 0 + assert d["is_profile_public"] == 0 + + async def test_null_optional_fields_return_empty_string(self): + row = _full_profile_row() + env = make_env(db=MockDB([make_stmt(first=row)])) + r = await worker.api_get_profile(self._req(), env) + d = _parse(r)["data"] + assert d["bio"] == "" + assert d["expertise"] == "" + assert d["github_username"] == "" + assert d["discord_username"] == "" + assert d["slack_username"] == "" + assert d["avatar_url"] == "" + + async def test_does_not_expose_email_or_password(self): + row = _full_profile_row() + env = make_env(db=MockDB([make_stmt(first=row)])) + r = await worker.api_get_profile(self._req(), env) + d = _parse(r)["data"] + assert "email" not in d + assert "password_hash" not in d + assert "email_verified" not in d + + +# --------------------------------------------------------------------------- +# PATCH /api/profile +# --------------------------------------------------------------------------- + +class TestPatchProfile: + def _req(self, payload): + return json_request("/api/profile", payload, headers=_auth(), method="PATCH") + + async def test_no_token_returns_401(self): + env = make_env() + r = await worker.api_patch_profile( + json_request("/api/profile", {"bio": "x"}, method="PATCH"), env + ) + assert r.status == 401 + + async def test_empty_body_returns_400(self): + env = make_env() + r = await worker.api_patch_profile(self._req({}), env) + assert r.status == 400 + + async def test_unknown_fields_ignored_returns_400(self): + env = make_env() + r = await worker.api_patch_profile(self._req({"hacked_column": "x"}), env) + assert r.status == 400 + + async def test_valid_update_returns_200(self): + env = make_env(db=MockDB([make_stmt()])) + r = await worker.api_patch_profile( + self._req({"bio": "Hello world", "is_profile_public": True}), env + ) + assert r.status == 200 + assert _parse(r)["success"] is True + + async def test_is_teacher_coerced_to_int(self): + # Ensure the handler runs without errors when toggling boolean fields + env = make_env(db=MockDB([make_stmt()])) + r = await worker.api_patch_profile( + self._req({"is_teacher": True, "is_profile_public": False}), env + ) + assert r.status == 200 + + async def test_partial_update_only_allowed_fields(self): + # Only known fields should survive; unknown fields are stripped silently + env = make_env(db=MockDB([make_stmt()])) + r = await worker.api_patch_profile( + self._req({"bio": "ok", "role": "host"}), env + ) + assert r.status == 200 + + +# --------------------------------------------------------------------------- +# DELETE /api/account +# --------------------------------------------------------------------------- + +class TestDeleteAccount: + def _req(self, payload): + return json_request("/api/account", payload, + headers=_auth(), method="DELETE") + + def _user_row(self, username="alice", password="password123"): + return MockRow( + id="uid-alice", + password_hash=worker.hash_password(password, username), + username=_enc(username), + ) + + async def test_no_token_returns_401(self): + env = make_env() + r = await worker.api_delete_account( + json_request("/api/account", {"password": "x"}, method="DELETE"), env + ) + assert r.status == 401 + + async def test_missing_password_returns_400(self): + env = make_env() + r = await worker.api_delete_account(self._req({}), env) + assert r.status == 400 + + async def test_wrong_password_returns_401(self): + row = self._user_row() + env = make_env(db=MockDB([make_stmt(first=row)])) + r = await worker.api_delete_account(self._req({"password": "wrongpass"}), env) + assert r.status == 401 + + async def test_correct_password_returns_200(self): + row = self._user_row() + acts = make_stmt(all_results=[]) # no owned activities + # Sequence: lookup user, SELECT activities, DELETE user + env = make_env(db=MockDB([make_stmt(first=row), acts, make_stmt()])) + r = await worker.api_delete_account(self._req({"password": "password123"}), env) + assert r.status == 200 + assert _parse(r)["success"] is True + + async def test_user_not_found_returns_404(self): + env = make_env(db=MockDB([make_stmt(first=None)])) + r = await worker.api_delete_account(self._req({"password": "pw"}), env) + assert r.status == 404 + + +# --------------------------------------------------------------------------- +# GET /api/users (public directory) +# --------------------------------------------------------------------------- + +class TestListUsers: + def _req(self): + return MockRequest(method="GET", url="http://localhost/api/users") + + async def test_returns_200_with_empty_list(self): + env = make_env(db=MockDB([make_stmt(all_results=[])])) + r = await worker.api_list_users(self._req(), env) + assert r.status == 200 + d = _parse(r)["data"] + assert d["users"] == [] + assert d["total"] == 0 + + async def test_only_public_users_returned(self): + # The SQL query WHERE is_profile_public=1 is handled by the DB; + # here we verify the response shape for a returned public user. + public_row = MockRow( + id="uid-bob", + username=_enc("bob"), + name=_enc("Bob"), + bio=_enc("I teach Python"), + expertise=_enc("Python"), + avatar_url=None, + is_teacher=1, + ) + env = make_env(db=MockDB([make_stmt(all_results=[public_row])])) + r = await worker.api_list_users(self._req(), env) + assert r.status == 200 + users = _parse(r)["data"]["users"] + assert len(users) == 1 + assert users[0]["username"] == "bob" + assert users[0]["name"] == "Bob" + assert users[0]["is_teacher"] == 1 + + async def test_bio_excerpt_max_200_chars(self): + long_bio = "x" * 400 + row = MockRow(id="u1", username=_enc("u"), name=_enc("U"), + bio=_enc(long_bio), expertise=None, avatar_url=None, is_teacher=0) + env = make_env(db=MockDB([make_stmt(all_results=[row])])) + r = await worker.api_list_users(self._req(), env) + bio = _parse(r)["data"]["users"][0]["bio"] + assert len(bio) <= 200 + + async def test_response_does_not_include_private_fields(self): + row = MockRow(id="u1", username=_enc("u"), name=_enc("U"), + bio=None, expertise=None, avatar_url=None, is_teacher=0) + env = make_env(db=MockDB([make_stmt(all_results=[row])])) + r = await worker.api_list_users(self._req(), env) + user = _parse(r)["data"]["users"][0] + assert "email" not in user + assert "password_hash" not in user + assert "is_profile_public" not in user + + +# --------------------------------------------------------------------------- +# GET /api/users/:username (public profile) +# --------------------------------------------------------------------------- + +class TestGetPublicProfile: + def _req(self): + return MockRequest(method="GET", url="http://localhost/api/users/alice") + + def _public_row(self, **overrides): + defaults = dict( + id="uid-alice", + name=_enc("Alice Smith"), + username=_enc("alice"), + bio=_enc("Hello!"), + expertise=_enc("Python"), + github_username=None, + discord_username=None, + slack_username=None, + avatar_url=None, + is_teacher=0, + is_profile_public=1, + created_at="2024-01-01 00:00:00", + ) + defaults.update(overrides) + return MockRow(**defaults) + + async def test_user_not_found_returns_404(self): + env = make_env(db=MockDB([make_stmt(first=None)])) + r = await worker.api_get_public_profile("nobody", self._req(), env) + assert r.status == 404 + + async def test_private_profile_returns_403(self): + row = self._public_row(is_profile_public=0) + # first() for user lookup; all() for activities + env = make_env(db=MockDB([make_stmt(first=row)])) + r = await worker.api_get_public_profile("alice", self._req(), env) + assert r.status == 403 + + async def test_public_profile_returns_200(self): + row = self._public_row() + acts = make_stmt(all_results=[]) + env = make_env(db=MockDB([make_stmt(first=row), acts])) + r = await worker.api_get_public_profile("alice", self._req(), env) + assert r.status == 200 + d = _parse(r)["data"] + assert d["username"] == "alice" + assert d["name"] == "Alice Smith" + assert d["bio"] == "Hello!" + + async def test_public_profile_never_exposes_email(self): + row = self._public_row() + acts = make_stmt(all_results=[]) + env = make_env(db=MockDB([make_stmt(first=row), acts])) + r = await worker.api_get_public_profile("alice", self._req(), env) + d = _parse(r)["data"] + assert "email" not in d + assert "password_hash" not in d + assert "email_verified" not in d + + async def test_public_profile_includes_activities(self): + row = self._public_row() + act_row = MockRow(id="act-1", title="Python 101", type="course", format="self_paced") + acts = make_stmt(all_results=[act_row]) + env = make_env(db=MockDB([make_stmt(first=row), acts])) + r = await worker.api_get_public_profile("alice", self._req(), env) + d = _parse(r)["data"] + assert len(d["activities"]) == 1 + assert d["activities"][0]["title"] == "Python 101" + + async def test_teacher_flag_exposed(self): + row = self._public_row(is_teacher=1) + acts = make_stmt(all_results=[]) + env = make_env(db=MockDB([make_stmt(first=row), acts])) + r = await worker.api_get_public_profile("alice", self._req(), env) + d = _parse(r)["data"] + assert d["is_teacher"] == 1 + + +# --------------------------------------------------------------------------- +# POST /api/profile/avatar (avatar upload validation) +# --------------------------------------------------------------------------- + +class TestUploadAvatar: + def _req(self, payload, headers=None): + h = {**_auth(), **(headers or {})} + return json_request("/api/profile/avatar", payload, headers=h) + + async def test_no_token_returns_401(self): + env = make_env() + r = await worker.api_upload_avatar( + json_request("/api/profile/avatar", {"image_data": "x", "image_type": "image/png"}), env + ) + assert r.status == 401 + + async def test_invalid_type_returns_400(self): + env = make_env() + r = await worker.api_upload_avatar( + self._req({"image_data": "abc", "image_type": "image/gif"}), env + ) + assert r.status == 400 + assert "jpg" in _parse(r)["error"].lower() or "jpeg" in _parse(r)["error"].lower() \ + or "png" in _parse(r)["error"].lower() or "webp" in _parse(r)["error"].lower() + + async def test_missing_image_data_returns_400(self): + env = make_env() + r = await worker.api_upload_avatar( + self._req({"image_type": "image/png"}), env + ) + assert r.status == 400 + + async def test_oversized_image_returns_400(self): + big_bytes = b"\xff" * (5 * 1024 * 1024 + 1) + big_b64 = base64.b64encode(big_bytes).decode() + env = make_env() + r = await worker.api_upload_avatar( + self._req({"image_data": big_b64, "image_type": "image/png"}), env + ) + assert r.status == 400 + assert "5" in _parse(r)["error"] + + async def test_no_r2_falls_back_to_base64(self): + # When R2 is not configured the avatar is stored as a data-URL (no 503) + small_b64 = base64.b64encode(b"\x89PNG fake").decode() + env = make_env(db=MockDB([make_stmt(), make_stmt()])) + del env.R2 # ensure R2 is absent + r = await worker.api_upload_avatar( + self._req({"image_data": small_b64, "image_type": "image/png"}), env + ) + assert r.status == 200 + url = _parse(r)["data"]["avatar_url"] + assert url.startswith("data:image/png;base64,") diff --git a/wrangler.toml b/wrangler.toml index d5267fe..dc68ec1 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -19,6 +19,12 @@ database_id = "a0021f2e-a8cc-4e20-8910-3c7290ba47a6" migrations_dir = "migrations" +# Create bucket: wrangler r2 bucket create learn-avatars +# [[r2_buckets]] +# binding = "R2" +# bucket_name = "learn-avatars" + + # Virtual Classroom Durable Object [[durable_objects.bindings]] name = "CLASSROOM_DO" From ac1c1800381d3154f4456d4cf5ad2f8d0ba938b7 Mon Sep 17 00:00:00 2001 From: Ghanshyam Singh Date: Mon, 8 Jun 2026 22:31:54 +0530 Subject: [PATCH 2/4] fix --- public/js/layout.js | 9 ++++++++- public/public-profile.html | 17 ++++++++++++----- public/users.html | 3 ++- tests/test_api_profile.py | 17 +++++++++++++++++ wrangler.toml | 7 ++++++- 5 files changed, 45 insertions(+), 8 deletions(-) diff --git a/public/js/layout.js b/public/js/layout.js index 72ecca6..a3e00b9 100644 --- a/public/js/layout.js +++ b/public/js/layout.js @@ -158,7 +158,14 @@ function _setNavButtonAvatar(avatarElId, name, username, avatarUrl) { if (!el) return; const initials = (name || username || '?').split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase(); if (avatarUrl) { - el.innerHTML = ``; + // Build element programmatically — avoids XSS via inline onerror with user-derived initials + const img = document.createElement('img'); + img.src = avatarUrl; + img.className = 'w-full h-full object-cover rounded-full'; + img.alt = ''; + img.addEventListener('error', () => { el.textContent = initials; }); + el.innerHTML = ''; + el.appendChild(img); } else { el.textContent = initials; } diff --git a/public/public-profile.html b/public/public-profile.html index af893f2..a0eab6f 100644 --- a/public/public-profile.html +++ b/public/public-profile.html @@ -188,13 +188,20 @@

} // Avatar + const _initials = (p.name || p.username || '?').split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase(); + const _imgEl = document.getElementById('profile-avatar-img'); + const _initialsEl = document.getElementById('profile-initials'); if (p.avatar_url) { - document.getElementById('profile-avatar-img').src = p.avatar_url; - document.getElementById('profile-avatar-img').classList.remove('hidden'); - document.getElementById('profile-initials').classList.add('hidden'); + _imgEl.src = p.avatar_url; + _imgEl.classList.remove('hidden'); + _initialsEl.classList.add('hidden'); + _imgEl.addEventListener('error', () => { + _imgEl.classList.add('hidden'); + _initialsEl.textContent = _initials; + _initialsEl.classList.remove('hidden'); + }); } else { - const initials = (p.name || p.username || '?').split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase(); - document.getElementById('profile-initials').textContent = initials; + _initialsEl.textContent = _initials; } // Bio diff --git a/public/users.html b/public/users.html index 152bb1a..678f24e 100644 --- a/public/users.html +++ b/public/users.html @@ -53,10 +53,11 @@

Member Directory

- diff --git a/tests/test_api_profile.py b/tests/test_api_profile.py index 56bc3ef..1d7e9c0 100644 --- a/tests/test_api_profile.py +++ b/tests/test_api_profile.py @@ -405,3 +405,20 @@ async def test_no_r2_falls_back_to_base64(self): assert r.status == 200 url = _parse(r)["data"]["avatar_url"] assert url.startswith("data:image/png;base64,") + + async def test_r2_upload_returns_public_url(self): + from unittest.mock import AsyncMock, MagicMock + small_b64 = base64.b64encode(b"\x89PNG fake").decode() + env = make_env(db=MockDB([make_stmt(), make_stmt()])) + mock_r2 = MagicMock() + mock_r2.put = AsyncMock(return_value=None) + env.R2 = mock_r2 + env.R2_PUBLIC_URL = "https://pub-abc123.r2.dev" + r = await worker.api_upload_avatar( + self._req({"image_data": small_b64, "image_type": "image/png"}), env + ) + assert r.status == 200 + url = _parse(r)["data"]["avatar_url"] + assert url.startswith("https://pub-abc123.r2.dev/avatars/") + assert url.endswith(".png") + mock_r2.put.assert_awaited_once() diff --git a/wrangler.toml b/wrangler.toml index dc68ec1..78df7d2 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -19,7 +19,12 @@ database_id = "a0021f2e-a8cc-4e20-8910-3c7290ba47a6" migrations_dir = "migrations" -# Create bucket: wrangler r2 bucket create learn-avatars +# To enable R2 avatar storage: +# 1. Create the bucket: wrangler r2 bucket create learn-avatars +# 2. Enable public access in the Cloudflare dashboard for the bucket +# 3. Add R2_PUBLIC_URL as an environment variable (Workers → Settings → Variables) +# e.g. https://pub-xxxxx.r2.dev or your custom domain +# Without R2_PUBLIC_URL, the worker falls back to base64 data-URLs stored in D1. # [[r2_buckets]] # binding = "R2" # bucket_name = "learn-avatars" From 10675db29f38816e0d45461e7fc9eea401e5acf5 Mon Sep 17 00:00:00 2001 From: Ghanshyam Singh Date: Wed, 29 Jul 2026 13:37:30 +0530 Subject: [PATCH 3/4] fix: profile page and schema --- migrations/0004_add_user_profiles.sql | 21 -- public/js/layout.js | 9 +- public/partials/navbar.html | 23 +- public/profile.html | 343 +++++++++++++++++-- public/public-profile.html | 457 +++++++++++--------------- public/users.html | 401 ++++++++++------------ schema.sql | 8 - src/worker.py | 201 +++-------- tests/test_api_auth.py | 9 +- tests/test_api_profile.py | 305 +++++++++-------- wrangler.toml | 2 +- 11 files changed, 899 insertions(+), 880 deletions(-) delete mode 100644 migrations/0004_add_user_profiles.sql diff --git a/migrations/0004_add_user_profiles.sql b/migrations/0004_add_user_profiles.sql deleted file mode 100644 index c5233ba..0000000 --- a/migrations/0004_add_user_profiles.sql +++ /dev/null @@ -1,21 +0,0 @@ --- Migration 0004: Add user profile fields and avatars table - -ALTER TABLE users ADD COLUMN bio TEXT; -ALTER TABLE users ADD COLUMN avatar_url TEXT; -ALTER TABLE users ADD COLUMN is_teacher INTEGER NOT NULL DEFAULT 0; -ALTER TABLE users ADD COLUMN github_username TEXT; -ALTER TABLE users ADD COLUMN discord_username TEXT; -ALTER TABLE users ADD COLUMN slack_username TEXT; -ALTER TABLE users ADD COLUMN expertise TEXT; -ALTER TABLE users ADD COLUMN is_profile_public INTEGER NOT NULL DEFAULT 0; - -CREATE TABLE IF NOT EXISTS avatars ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - avatar_url TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -); - -CREATE INDEX IF NOT EXISTS idx_avatars_user ON avatars(user_id); -CREATE INDEX IF NOT EXISTS idx_users_public ON users(is_profile_public); diff --git a/public/js/layout.js b/public/js/layout.js index a3e00b9..3e7ac54 100644 --- a/public/js/layout.js +++ b/public/js/layout.js @@ -186,14 +186,7 @@ function updateAuthSection() { // Desktop if (notLoggedInDiv) notLoggedInDiv.classList.add('hidden'); if (loggedInDiv) loggedInDiv.classList.remove('hidden'); - - const avatarEl = document.getElementById('profile-avatar'); - const greetingEl = document.getElementById('profile-greeting'); - const usernameEl = document.getElementById('dropdown-username'); - - if (avatarEl) avatarEl.textContent = firstLetter; - if (greetingEl) greetingEl.textContent = `Hi, ${firstName}`; - if (usernameEl) usernameEl.textContent = user.username; + const notifLink = document.getElementById('notif-bell-link'); if (notifLink) notifLink.classList.remove('hidden'); diff --git a/public/partials/navbar.html b/public/partials/navbar.html index 6042ea3..fda8d4c 100644 --- a/public/partials/navbar.html +++ b/public/partials/navbar.html @@ -57,11 +57,19 @@ diff --git a/public/profile.html b/public/profile.html index 18e73bc..31f780a 100644 --- a/public/profile.html +++ b/public/profile.html @@ -1,37 +1,316 @@ {% extends "base.html" %} -{% block title %}Profile - Alpha One Labs{% endblock %} -{% block meta_description %}Alpha One Labs - Activity Platform{% endblock %} -{% block body_class %}min-h-screen flex flex-col bg-white text-gray-900 dark:bg-black dark:text-gray-100{% endblock %} +{% block title %}My Profile - Alpha One Labs{% endblock %} +{% block meta_description %}Manage your Alpha One Labs profile, avatar, and account settings{% endblock %} +{% block body_class %}min-h-screen flex flex-col bg-white text-gray-900 dark:bg-black dark:text-gray-100 transition-colors duration-300 overflow-x-hidden{% endblock %} {% block content %} -
- -

Profile Visibility

Public profiles share your bio, expertise, and username. Real name and email remain private.

-
-
-
-
-
- -

-
-
-

Delete Account

-

This anonymizes your login, removes profile data, preferences, notifications, enrollments, carts, and learning requests, and archives activities you hosted.

- - - -

-
-
-
+ +
+ +
+

My Profile

+

Loading…

+

@username

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

Your Avatar

+
+
+
+ ? + +
+ +
+
+

Upload a photo to use as your profile picture.

+
+ + +
+

JPG, PNG, or WebP · Max 5 MB · Resized to 256×256

+
+
+
+ + +
+
+

Profile Visibility

+ +

Public profiles share your bio, expertise, and username. Real name and email remain private.

+ +
+ +
+
+
+ +
@
+
+
+

Comma-separated — shown as skill chips on your public profile.

+

0/500

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

+
+ + +
+

Delete Account

+

This anonymizes your login, removes profile data, preferences, notifications, enrollments, carts, and learning requests, and archives activities you hosted.

+ + + +

+
+
+ + +
+ + + +
+

Stats

+
+
+

+

Enrollments

+
+
+

+

Hosting

+
+
+
+ +
+

My Enrollments

+
Loading…
+ Browse Activities +
+ +
+
+
+ {% endblock %} diff --git a/public/public-profile.html b/public/public-profile.html index a0eab6f..9e9b990 100644 --- a/public/public-profile.html +++ b/public/public-profile.html @@ -1,273 +1,208 @@ - - - - - - - Profile - Alpha One Labs - - - - - - - - - - - - - - - - @@ -146,7 +146,7 @@

${esc(msg)}`; + el.innerHTML = `${esc(msg)}`; el.classList.remove('hidden'); clearTimeout(el._t); el._t = setTimeout(() => el.classList.add('hidden'), 5000); @@ -175,15 +175,19 @@

Member Directory

grid.innerHTML = users.map(u => { const initials = (u.name || u.username || '?').split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase(); const avatarEl = u.avatar_url - ? `` + ? `` : `${esc(initials)}`; const teacherBadge = u.is_teacher @@ -181,6 +181,15 @@

Member Directory

} } +document.getElementById('users-grid').addEventListener('error', (e) => { + const img = e.target; + if (!img.matches('img[data-avatar-initials]')) return; + const span = document.createElement('span'); + span.className = 'text-white text-xl font-bold'; + span.textContent = img.dataset.avatarInitials; + img.replaceWith(span); +}, true); + document.addEventListener('DOMContentLoaded', loadUsers); {% endblock %} diff --git a/src/worker.py b/src/worker.py index b22182b..ff788e0 100644 --- a/src/worker.py +++ b/src/worker.py @@ -7136,7 +7136,7 @@ async def api_upload_avatar(req, env): image_data = image_data.split(",", 1)[1] try: - img_bytes = base64.b64decode(image_data) + img_bytes = base64.b64decode(image_data, validate=True) except Exception: return err("Invalid image data — expected base64-encoded content") @@ -7151,7 +7151,11 @@ async def api_upload_avatar(req, env): ext = image_type.split("/")[-1].replace("jpeg", "jpg") r2_key = f"avatars/{user['id']}/{new_id()}.{ext}" try: - await r2.put(r2_key, to_js(img_bytes)) + put_opts = to_js( + {"httpMetadata": {"contentType": image_type}}, + dict_converter=js.Object.fromEntries, + ) + await r2.put(r2_key, to_js(img_bytes, create_pyproxies=False), put_opts) except Exception as e: await capture_exception(e, req, env, "api_upload_avatar.r2_put") return err("Avatar upload failed — please try again", 500) @@ -7238,15 +7242,25 @@ async def _d(val): }) -async def api_list_users(_req, env): - """GET /api/users — return all users who have set their profile to public.""" +async def api_list_users(req, env): + """GET /api/users — return public-profile users, paginated.""" enc = env.ENCRYPTION_KEY + params = parse_qs(urlparse(req.url).query) + try: + limit = max(1, min(100, int((params.get("limit") or ["50"])[0]))) + except Exception: + limit = 50 + try: + offset = max(0, int((params.get("offset") or ["0"])[0])) + except Exception: + offset = 0 + rows = await env.DB.prepare( "SELECT u.id,u.name,u.username,p.bio,p.expertise,p.avatar_url,p.is_teacher" " FROM users u JOIN user_profiles p ON p.user_id=u.id WHERE p.is_profile_public=1" - " ORDER BY u.created_at DESC" - ).all() + " ORDER BY u.created_at DESC LIMIT ? OFFSET ?" + ).bind(limit, offset).all() users_list = [] for r in rows.results or []: