-
diff --git a/public/profile.html b/public/profile.html
new file mode 100644
index 0000000..d37ddb8
--- /dev/null
+++ b/public/profile.html
@@ -0,0 +1,680 @@
+
+
+
+
+
+
+
My Profile - Alpha One Labs
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
My Profile
+
+
@username
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Your Avatar
+
+
+
+
+
+
+
?
+
![avatar]()
+
+
+
+
+
+
+
Upload a photo to use as your profile picture.
+
+
+
+
+
JPG, PNG, or WebP · Max 5 MB · Will be resized to 256×256
+
+
+
+
+
+
+
+
+
+
+ About Me
+
+
+
+
+
+
+
+
+
+
+ @
+
+
+
Username cannot be changed.
+
+
+
+
+
Email is private and never shared.
+
+
+
+
+
Comma-separated — shown as skill chips on your public profile.
+
+
+
+
+
+
+
+
+
+
+
+ Social & Community
+
+
+
+
+
+
+ @
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Account Settings
+
+
+
+
+
I am a teacher / instructor
+
Shows a teacher badge on your public profile.
+
+
+
+
+
+
Profile Visibility
+
Public profiles share your bio, expertise and username. Real name and email remain private.
+
+
+
+
+
+
+
+
+
+
Changes are saved to your account immediately.
+
+
+
+
+
+
+ Account Management
+
+
+ Permanently delete your account and all data. This cannot be undone.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
My Badges
+
+
+
You haven't earned any badges yet.
+
+
+
+
+
+
+
+
+
+
+
+ Delete Account
+
+
+ This will permanently erase your account, all activities you created, and every piece of your data. Enter your password to confirm.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/public-profile.html b/public/public-profile.html
new file mode 100644
index 0000000..a0eab6f
--- /dev/null
+++ b/public/public-profile.html
@@ -0,0 +1,273 @@
+
+
+
+
+
+
+
Profile - Alpha One Labs
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
🔒
+
Profile Not Found
+
This profile is private or does not exist.
+
+ Browse Community
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
?
+
![avatar]()
+
+
+
+
+
+
+ Teacher
+
+
+
@username
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Expertise & Skills
+
+
+
+
+
+
+
+
+
+
+ Hosted Activities
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/users.html b/public/users.html
new file mode 100644
index 0000000..678f24e
--- /dev/null
+++ b/public/users.html
@@ -0,0 +1,233 @@
+
+
+
+
+
+
+
Community Directory - Alpha One Labs
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Community
+
+
+
Discover teachers, learners, and experts in our community.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
No members found
+
Try a different search or check back later.
+
+
+
+
+
+
+
+
+
+
diff --git a/schema.sql b/schema.sql
index 2c58c6b..f899d83 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.)
@@ -146,3 +154,15 @@ CREATE TABLE IF NOT EXISTS password_reset_tokens (
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_prtoken_user ON password_reset_tokens(user_id);
+
+-- AVATARS (history of uploaded avatars per user)
+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/src/worker.py b/src/worker.py
index f274a07..e0bab6a 100644
--- a/src/worker.py
+++ b/src/worker.py
@@ -597,16 +597,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 (
@@ -720,6 +728,16 @@ async def send_password_reset_email(to_email: str, _username: str, token: str, e
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)""",
"CREATE INDEX IF NOT EXISTS idx_prtoken_user ON password_reset_tokens(user_id)",
+ # Avatars (history of uploaded avatars per user)
+ """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)",
]
@@ -738,6 +756,23 @@ async def init_db(env):
except Exception as e:
print(f"[init_db] email_verified migration skipped (likely already exists): {e}")
+ # Idempotent migration: add profile fields (0004).
+ _profile_alters = [
+ "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",
+ ]
+ for alter in _profile_alters:
+ try:
+ await env.DB.prepare(alter).run()
+ except Exception:
+ pass
+
_NO_SUCH_TABLE_RE = re.compile(r"\bno such table\b", re.IGNORECASE)
@@ -1027,7 +1062,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:
@@ -1060,7 +1095,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",
)
@@ -2584,6 +2620,288 @@ def _clamp_01(value):
return 0.5
+# ---------------------------------------------------------------------------
+# 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
# ---------------------------------------------------------------------------
@@ -2708,6 +3026,23 @@ async def _dispatch(request, env):
if path == "/api/notification-preferences" and method == "PATCH":
return await api_patch_notification_preferences(request, env)
+ # Profile & user directory
+ if path == "/api/profile" and method == "GET":
+ return await api_get_profile(request, env)
+ if path == "/api/profile" and method == "PATCH":
+ return await api_patch_profile(request, env)
+ if path == "/api/profile/avatar" and method == "POST":
+ return await api_upload_avatar(request, env)
+ if path == "/api/profile/avatar" and method == "DELETE":
+ return await api_remove_avatar(request, env)
+ if path == "/api/account" and method == "DELETE":
+ return await api_delete_account(request, env)
+ if path == "/api/users" and method == "GET":
+ return await api_list_users(request, env)
+ m_user = re.fullmatch(r"/api/users/([A-Za-z0-9_.-]+)", path)
+ if m_user and method == "GET":
+ return await api_get_public_profile(m_user.group(1), request, env)
+
return err("API endpoint not found", 404)
return await serve_static(path, 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 a7e3c86..3a5e79b 100644
--- a/tests/test_api_auth.py
+++ b/tests/test_api_auth.py
@@ -147,6 +147,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..1d7e9c0
--- /dev/null
+++ b/tests/test_api_profile.py
@@ -0,0 +1,424 @@
+"""
+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,")
+
+ 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 761e130..382f62e 100644
--- a/wrangler.toml
+++ b/wrangler.toml
@@ -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 +
+ +