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 %}
-
+
+
+
+
+
+
+
+
My Profile
+
+
@username
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Your Avatar
+
+
+
+
?
+
+
+
+
+
+
Upload a photo to use as your profile picture.
+
+ Upload Photo
+ Remove Photo
+
+
JPG, PNG, or WebP · Max 5 MB · Resized to 256×256
+
+
+
+
+
+
+
+
Profile Visibility
+
Public profile visible
+
Public profiles share your bio, expertise, and username. Real name and email remain private.
+
+
+
+
+
+
+
+
+
+ How did you hear about us?
+ I teach or host activities
+
+ Update Profile
+
+
+
+
+
+
+
+
+
+
+
+
{% 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 %}
+
+
+
🔒
+
Profile Not Found
+
This profile is private or does not exist.
+
+ Browse Community
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
?
+
+
+
+
+
+
+ Teacher
+
+
@username
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% 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
+
+
Discover teachers, learners, and experts in our community.
+
+
+
+
+
+
+
+ Teachers Only
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
No members found
+
Try a different search or check back later.
+
+
+
+
+{% 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"
Connect
+ +