diff --git a/public/js/layout.js b/public/js/layout.js index 2c6656a..1ce05be 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').then(res => res.text()).catch(e => { console.error('Layout fetch error:', e); return null; }); +const _footerPromise = fetch('/partials/footer.html').then(res => res.text()).catch(e => { console.error('Layout fetch error:', e); return null; }); + +async function inject(id, textPromise, callback) { const el = document.getElementById(id); if (!el) return; if (el.dataset.inline === 'true') { @@ -109,13 +112,12 @@ async function inject(id, file, callback) { return; } try { - const res = await fetch(file); - el.innerHTML = await res.text(); - if (callback && typeof callback === 'function') { - callback(); - } - } catch (err) { - console.error(`Failed to load ${file}:`, err); + const html = await textPromise; + if (html === null) return; + el.innerHTML = html; + if (typeof callback === 'function') callback(); + } catch (e) { + console.error('Layout inject error:', e); } } @@ -128,6 +130,49 @@ 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.textContent = initials; + initialsEl.classList.add('hidden'); + if (imgEl) { + imgEl.onerror = function () { + this.classList.add('hidden'); + initialsEl.classList.remove('hidden'); + }; + imgEl.src = avatarUrl; + imgEl.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) { + // 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; + } +} + function updateAuthSection() { const { token, user } = getAuth(); const notLoggedInDiv = document.getElementById('auth-not-logged-in'); @@ -136,42 +181,58 @@ 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'); - - 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'); + const cartLink = document.getElementById('cart-nav-link'); + if (cartLink) cartLink.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'); @@ -257,6 +318,7 @@ async function refreshCartBadge() { if (!res.ok) return; const body = await res.json(); const count = (body.items || []).reduce((sum, item) => sum + Number(item.quantity || 1), 0); + if (link) link.classList.remove('hidden'); if (count > 0) { badge.textContent = count > 99 ? '99+' : String(count); badge.classList.remove('hidden'); @@ -330,8 +392,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/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..3111e81 100644 --- a/public/profile.html +++ b/public/profile.html @@ -1,37 +1,324 @@ {% 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 new file mode 100644 index 0000000..aa4a20a --- /dev/null +++ b/public/public-profile.html @@ -0,0 +1,209 @@ +{% extends "base.html" %} +{% block title %}Profile - Alpha One Labs{% endblock %} +{% block meta_description %}View a community member's public profile on Alpha One Labs{% endblock %} +{% block body_class %}min-h-screen flex flex-col bg-gray-50 dark:bg-black text-gray-900 dark:text-gray-100 transition-colors{% endblock %} +{% block content %} + + + +
+
+
+
+
+
+
+
+ + + + +{% endblock %} diff --git a/public/users.html b/public/users.html new file mode 100644 index 0000000..3b202a9 --- /dev/null +++ b/public/users.html @@ -0,0 +1,195 @@ +{% extends "base.html" %} +{% block title %}Community Directory - Alpha One Labs{% endblock %} +{% block meta_description %}Discover teachers, learners, and experts in the Alpha One Labs community{% endblock %} +{% block body_class %}min-h-screen flex flex-col bg-gray-50 dark:bg-black text-gray-900 dark:text-gray-100 transition-colors{% endblock %} +{% block head %} + +{% endblock %} +{% block content %} +
+ +
+

Community

+

Member Directory

+

Discover teachers, learners, and experts in our community.

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

Loading…

+
+ +
+
+
+
+
+ + +
+ + + + +
+ + +{% endblock %} diff --git a/schema.sql b/schema.sql index 9d2d2e6..f9c7c94 100644 --- a/schema.sql +++ b/schema.sql @@ -6,16 +6,16 @@ -- 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, + created_at TEXT NOT NULL DEFAULT (datetime('now')) ); -- ACTIVITIES (courses, meetups, workshops, seminars, etc.) @@ -162,4 +162,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..ff788e0 100644 --- a/src/worker.py +++ b/src/worker.py @@ -947,16 +947,16 @@ 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, + created_at TEXT NOT NULL DEFAULT (datetime('now')) )""", # Activities """CREATE TABLE IF NOT EXISTS activities ( @@ -1747,10 +1747,14 @@ async def api_login(req, env): if not real_role or real_role == "[decryption error]": return err("Account data corrupted — please contact support", 500) token = create_token(user_id, stored_username, real_role, env.JWT_SECRET) + profile_row = await env.DB.prepare( + "SELECT avatar_url FROM user_profiles WHERE user_id=?" + ).bind(user_id).first() 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": (getattr(profile_row, "avatar_url", "") or "") if profile_row else ""}}, "Login successful", ) @@ -3628,6 +3632,8 @@ async def run(): "/notification-preferences": "/notification-preferences.html", "/notifications": "/notifications.html", "/profile": "/profile.html", + "/users": "/users.html", + "/public-profile": "/public-profile.html", "/referral-leaderboard": "/referral-leaderboard.html", "/reset-password": "/reset-password.html", "/status": "/status.html", @@ -7101,6 +7107,176 @@ async def api_get_thread_messages(req, env, thread_id: str): }) +# --------------------------------------------------------------------------- +# Profile & user-directory API handlers +# --------------------------------------------------------------------------- + +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, validate=True) + 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) + r2_key = "" + + if r2: + # Upload to R2 and store the public URL + ext = image_type.split("/")[-1].replace("jpeg", "jpg") + r2_key = f"avatars/{user['id']}/{new_id()}.{ext}" + try: + 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) + r2_public_url = (getattr(env, "R2_PUBLIC_URL", "") or "").rstrip("/") + avatar_url = f"{r2_public_url}/{r2_key}" if r2_public_url else f"/r2/{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}" + + enc = env.ENCRYPTION_KEY + await _ensure_user_profile(env, user["id"], enc) + try: + await env.DB.prepare( + "UPDATE user_profiles SET avatar_url=?,avatar_r2_key=?,updated_at=datetime('now') WHERE user_id=?" + ).bind(avatar_url, r2_key, user["id"]).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 user_profiles SET avatar_url=NULL,avatar_r2_key=NULL WHERE user_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_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 u.id,u.name,u.username,p.bio,p.expertise,p.github_username," + "p.discord_username,p.slack_username,p.avatar_url,p.is_teacher," + "p.is_profile_public,u.created_at" + " FROM users u LEFT JOIN user_profiles p ON p.user_id=u.id WHERE u.username_hash=?" + ).bind(u_hash).first() + + if not row: + return err("User not found", 404) + + if not getattr(row, "is_profile_public", 0): + 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(getattr(row, "bio", "")), + "expertise": await _d(getattr(row, "expertise", "")), + "github_username": await _d(getattr(row, "github_username", "")), + "discord_username": await _d(getattr(row, "discord_username", "")), + "slack_username": await _d(getattr(row, "slack_username", "")), + "avatar_url": getattr(row, "avatar_url", "") or "", + "is_teacher": getattr(row, "is_teacher", 0) or 0, + "member_since": row.created_at, + "activities": activities, + }) + + +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 LIMIT ? OFFSET ?" + ).bind(limit, offset).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 # --------------------------------------------------------------------------- @@ -7252,6 +7428,16 @@ async def _dispatch(request, env): if path == "/api/profile" and method in ("GET", "PATCH", "DELETE"): return await (api_delete_account(request, env) if method == "DELETE" else api_profile(request, env)) + if path == "/api/profile/avatar" and method in ("POST", "DELETE"): + return await (api_upload_avatar(request, env) if method == "POST" else api_remove_avatar(request, env)) + + if path == "/api/users" and method == "GET": + return await api_list_users(request, env) + + m_public_user = re.fullmatch(r"/api/users/([^/]+)", path) + if m_public_user and method == "GET": + return await api_get_public_profile(unquote(m_public_user.group(1)), request, env) + if path == "/api/feedback" and method == "POST": return await api_feedback(request, env) 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..488737a 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): @@ -275,12 +276,9 @@ async def test_login_is_rate_limited_per_ip(self): async def test_login_rate_limit_resets_after_window(self, monkeypatch): row = self._make_user_row() env = self._rate_limited_env() - env.DB = MockDB([ - make_stmt(first=row), - make_stmt(first=row), - make_stmt(first=row), - make_stmt(first=row), - ]) + # Each successful login issues two DB reads: the user row lookup and + # the user_profiles avatar_url lookup. Four logins in this test = 8 stmts. + env.DB = MockDB([make_stmt(first=row) for _ in range(8)]) worker._AUTH_RATE_LIMIT_STATE.clear() monkeypatch.setattr(worker.time, "time", lambda: 1000) diff --git a/tests/test_api_profile.py b/tests/test_api_profile.py new file mode 100644 index 0000000..b47fe23 --- /dev/null +++ b/tests/test_api_profile.py @@ -0,0 +1,413 @@ +""" +Tests for profile & user-directory API endpoints: + GET /api/profile + PATCH /api/profile + DELETE /api/profile (anonymizing account deletion) + POST /api/profile/avatar + DELETE /api/profile/avatar + 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 _ensure_profile_stmts(referred_by_user_id=""): + """The two DB calls `_ensure_user_profile` makes when a profile row already exists.""" + existing = MockRow( + referral_code=_enc("REF123"), + referral_code_hash="hash", + referral_earnings_cents=0, + referred_by_user_id=referred_by_user_id, + ) + return [make_stmt(first=existing), make_stmt()] + + +def _profile_row(**overrides): + defaults = dict( + id="uid-alice", + name=_enc("Alice Smith"), + username=_enc("alice"), + email=_enc("alice@example.com"), + role=_enc("member"), + bio=None, + expertise=None, + avatar_url=None, + discord_username=None, + slack_username=None, + github_username=None, + is_teacher=0, + is_profile_public=0, + how_did_you_hear_about_us=None, + referral_code=_enc("REF123"), + referral_earnings_cents=0, + ) + 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_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([*_ensure_profile_stmts(), make_stmt(first=None)])) + r = await worker.api_profile(self._req(), env) + assert r.status == 404 + + async def test_returns_profile_fields(self): + row = _profile_row( + bio=_enc("Love coding!"), + expertise=_enc("Python, Django"), + github_username=_enc("alice-gh"), + ) + env = make_env(db=MockDB([*_ensure_profile_stmts(), make_stmt(first=row)])) + r = await worker.api_profile(self._req(), env) + assert r.status == 200 + d = _parse(r)["profile"] + 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"] is False + assert d["is_profile_public"] is False + + async def test_null_optional_fields_return_empty_string(self): + row = _profile_row() + env = make_env(db=MockDB([*_ensure_profile_stmts(), make_stmt(first=row)])) + r = await worker.api_profile(self._req(), env) + d = _parse(r)["profile"] + assert d["bio"] == "" + assert d["expertise"] == "" + assert d["github_username"] == "" + assert d["discord_username"] == "" + assert d["slack_username"] == "" + assert d["avatar_url"] == "" + + +# --------------------------------------------------------------------------- +# 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_profile( + json_request("/api/profile", {"bio": "x"}, method="PATCH"), env + ) + assert r.status == 401 + + async def test_valid_update_returns_200(self): + env = make_env(db=MockDB([*_ensure_profile_stmts(), make_stmt()])) + r = await worker.api_profile( + self._req({"bio": "Hello world", "is_profile_public": True}), env + ) + assert r.status == 200 + assert _parse(r)["success"] is True + + async def test_name_update_issues_users_table_write(self): + env = make_env(db=MockDB([*_ensure_profile_stmts(), make_stmt(), make_stmt()])) + r = await worker.api_profile(self._req({"name": "New Name"}), env) + assert r.status == 200 + + async def test_is_teacher_coerced_to_int(self): + env = make_env(db=MockDB([*_ensure_profile_stmts(), make_stmt()])) + r = await worker.api_profile( + self._req({"is_teacher": True, "is_profile_public": False}), env + ) + assert r.status == 200 + + +# --------------------------------------------------------------------------- +# DELETE /api/profile (anonymizing account deletion) +# --------------------------------------------------------------------------- + +class TestDeleteAccount: + def _req(self, payload): + return json_request("/api/profile", payload, headers=_auth(), method="DELETE") + + async def test_no_token_returns_401(self): + env = make_env() + r = await worker.api_delete_account( + json_request("/api/profile", {"confirmation": "DELETE"}, method="DELETE"), env + ) + assert r.status == 401 + + async def test_missing_confirmation_returns_400(self): + env = make_env() + r = await worker.api_delete_account(self._req({}), env) + assert r.status == 400 + + async def test_wrong_confirmation_returns_400(self): + env = make_env() + r = await worker.api_delete_account(self._req({"confirmation": "delete me"}), env) + assert r.status == 400 + + async def test_correct_confirmation_returns_200(self): + cart_rows = make_stmt(all_results=[]) + cleanup_stmts = [make_stmt() for _ in range(13)] # 13 cleanup DELETE statements + archive_stmt = make_stmt() + final_update = make_stmt() + env = make_env(db=MockDB([cart_rows, *cleanup_stmts, archive_stmt, final_update])) + r = await worker.api_delete_account(self._req({"confirmation": "DELETE"}), env) + assert r.status == 200 + assert _parse(r)["success"] is True + + +# --------------------------------------------------------------------------- +# POST/DELETE /api/profile/avatar +# --------------------------------------------------------------------------- + +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): + small_b64 = base64.b64encode(b"\x89PNG fake").decode() + env = make_env(db=MockDB([*_ensure_profile_stmts(), make_stmt()])) + del env.R2 + 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,") + + 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([*_ensure_profile_stmts(), 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() + + +class TestRemoveAvatar: + async def test_no_token_returns_401(self): + env = make_env() + r = await worker.api_remove_avatar( + MockRequest(method="DELETE", url="http://localhost/api/profile/avatar"), env + ) + assert r.status == 401 + + async def test_removes_avatar_returns_200(self): + env = make_env(db=MockDB([make_stmt()])) + r = await worker.api_remove_avatar( + MockRequest(method="DELETE", url="http://localhost/api/profile/avatar", headers=_auth()), env + ) + assert r.status == 200 + + +# --------------------------------------------------------------------------- +# 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): + 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) + 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 diff --git a/wrangler.toml b/wrangler.toml index d5267fe..d61d894 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -6,7 +6,7 @@ compatibility_flags = ["python_workers"] [assets] directory = "./public" binding = "ASSETS" -run_worker_first = ["/api/*", "/media/*", "/admin", "/admin/*", "/admin-*", "/ref/*", "/activity/*", "/blog/*", "/en/*", "/es/*", "/fr/*", "/de/*", "/zh/*", "/*.html", "/", "/activity", "/classes-map", "/requests", "/waiting-rooms", "/cart", "/checkout-success", "/dashboard", "/donate", "/feedback", "/forgot-password", "/login", "/notification-preferences", "/notifications", "/profile", "/referral-leaderboard", "/reset-password", "/status", "/teach", "/verify-email", "/virtual-classroom", "/whiteboard", "/calculator", "/contributors", "/gsoc", "/about", "/terms", "/privacy", "/cookies", "/429", "/500", "/features", "/legacy-features", "/forum", "/blog", "/study-groups", "/quizzes", "/surveys", "/challenges", "/progress", "/grade-links", "/calendar", "/memes", "/success-stories", "/feature-votes", "/messages"] +run_worker_first = ["/api/*", "/media/*", "/admin", "/admin/*", "/admin-*", "/ref/*", "/activity/*", "/blog/*", "/en/*", "/es/*", "/fr/*", "/de/*", "/zh/*", "/*.html", "/", "/activity", "/classes-map", "/requests", "/waiting-rooms", "/cart", "/checkout-success", "/dashboard", "/donate", "/feedback", "/forgot-password", "/login", "/notification-preferences", "/notifications", "/profile", "/users", "/public-profile", "/referral-leaderboard", "/reset-password", "/status", "/teach", "/verify-email", "/virtual-classroom", "/whiteboard", "/calculator", "/contributors", "/gsoc", "/about", "/terms", "/privacy", "/cookies", "/429", "/500", "/features", "/legacy-features", "/forum", "/blog", "/study-groups", "/quizzes", "/surveys", "/challenges", "/progress", "/grade-links", "/calendar", "/memes", "/success-stories", "/feature-votes", "/messages"] html_handling = "auto-trailing-slash" not_found_handling = "404-page" @@ -19,6 +19,17 @@ database_id = "a0021f2e-a8cc-4e20-8910-3c7290ba47a6" migrations_dir = "migrations" +# 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" + + # Virtual Classroom Durable Object [[durable_objects.bindings]] name = "CLASSROOM_DO"