Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,19 @@
"statusBar.background": "#dd0531",
"statusBar.foreground": "#e7e7e7",
"statusBarItem.hoverBackground": "#fa1b49",
"statusBarItem.remoteBackground": "#dd0531",
"statusBarItem.remoteBackground": "#0e3e01",
"statusBarItem.remoteForeground": "#e7e7e7",
"titleBar.activeBackground": "#dd0531",
"titleBar.activeForeground": "#e7e7e7",
"titleBar.inactiveBackground": "#dd053199",
"titleBar.inactiveForeground": "#e7e7e799"
"titleBar.inactiveForeground": "#e7e7e799",
"activityBarTop.activeBackground": "#fa1b49",
"activityBarTop.background": "#fa1b49",
"activityBarTop.foreground": "#e7e7e7",
"activityBarTop.inactiveForeground": "#e7e7e799",
"commandCenter.foreground": "#e7e7e7",
"statusBar.debuggingBackground": "#dd0531",
"statusBar.debuggingForeground": "#e7e7e7"
},
"peacock.color": "#dd0531"
}
17 changes: 17 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,3 +174,20 @@ Config store for the Slack praise-bot (repo `ohack-slack-bot/praise-bot`): the b
- Doc types in one collection: fixed doc id `global` (dry_run/llm_enabled/timezone), `github_watcher` (source `mode: hackathon|repos`, digest + optional rollup crons), `calendar_reminder`, and singleton `community` (intro matchmaker + weekly digest). Audit fields `created_at/updated_at/updated_by` on every doc.
- `GET /api/praise-bot/config` — bot-facing, authed via `X-Api-Key` against `BACKEND_BOT_CONFIG_TOKEN` (falls back to `BACKEND_PRAISE_TOKEN`) through the shared `common/utils/api_key.py:check_api_key` (hmac.compare_digest; new code should use this instead of the inline checks in messages_views). Returns `configured: false` when the collection is empty → bot uses its env defaults.
- `GET/POST /api/praise-bot/admin/config`, `PATCH/DELETE /api/praise-bot/admin/config/<doc_id>` — `volunteer.admin`-gated. Validation is whitelist-per-type (`_ALLOWED_KEYS` — the enforcement point that keeps secrets out of docs); crons validated as 5 fields (bot re-validates with `cron.validate()`); repos normalized to `owner/repo`; `global` is upsert-only (no DELETE); `community` is a singleton (POST 400s if one exists). `source.orgs` is accepted/stored but ignored by the bot until org-watching ships. 15s TTL cache on the assembled config, cleared on mutation. Tests: `api/praisebot/tests/` (mockfirestore, run with `ENVIRONMENT=test`).

## Public portfolio (profile → portfolio, Aug 2026)

The public profile payload (`GET /api/users/<id_or_slug>/profile/public`) is now the "portfolio" payload. Load-bearing contracts:

- **`model/user.py` is the control surface.** `metadata_list` += `bio`, `headline`, `portfolio_links` (savable via the generic profile POSTs — BOTH `api/users` and the legacy `api/messages` paths, which each call `_sanitize_portfolio_metadata` from users_service: bio ≤2000, headline ≤80, links ≤10 × {label ≤40, url} with https autoprefix + whitespace-URL rejection since `validate_url` lets spaces through). `privacy_fields` += `bio`, `bio_video_url`, `portfolio_links`, `teams`, `certificates`, `github_history`, `hearts` — **all default private**; `get_privacy_settings()` backfills lazily. `safe_public_fields` += `id`, `profile_slug` (they ARE the public URLs). `headline` rides the `bio` privacy toggle. `profile_visibility` (`private`|`public`, `DEFAULT_PROFILE_VISIBILITY="private"`) is always emitted; **private does NOT strip content** — it only tells the frontend to noindex (old links keep today's per-field rendering; `public` = indexable + sitemap and **requires a claimed slug**, enforced in `set_profile_visibility`).
- **Profile fields have ONE source of truth: `PROFILE_FIELD_SPECS` in `model/user.py`** (Aug 2026 profile-stack retirement). Adding a flat profile field = one spec entry `(name, default, owner_editable, persisted)` (+ a `privacy_fields` entry if privacy-gated). The registry generates `metadata_list`, `OWNER_EDITABLE_FIELDS` (what POST /profile accepts), `PROFILE_PERSISTED_FIELDS` (what the generic upsert writes), and `serialize_profile_fields()`; `users_service.build_profile_response(user)` is THE canonical own-profile response (hackathons attendance-derived, NOT the deprecated users.hackathons refs). Guards in `api/users/tests/`: `test_field_registry.py` fails CI when any hand-list (privacy/pii/safe, `_ADMIN_PROFILE_LEAN_FIELDS`) drifts; `test_profile_roundtrip.py` asserts every editable field survives POST→GET. `volunteering` is deliberately NOT in the generic write set (dedicated `update_user_volunteering` — a profile save racing a volunteering log must not clobber it); `propel_id`/`bio_video_url`/`profile_slug`/`profile_visibility` are never owner-editable via metadata. Profile get/save resolve identity via `_resolve_and_ensure_user` (3-tier; OAuth outage can't 404 the profile). The legacy `/api/messages/profile*` routes are THIN DELEGATES onto this stack (parity-tested in `api/messages/tests/test_profile_delegates.py`) pending final deletion once the migrated frontend deploy is confirmed live — the old hand-built `_old` bodies are gone; don't add fields to delegates.
- **Dedicated writers only** for `profile_slug` (`POST /api/users/profile/slug`, check at `GET .../slug/check/<slug>`), `profile_visibility` (`PATCH /api/users/profile/visibility`), `bio_video_url` (`POST /api/users/profile/bio-video`) — never add these to `metadata_list` (uniqueness/validation would be bypassed via merge writes).
- **Slugs** (`services/user_slug_service.py`): `user_slugs/{slug}` pointer collection, slug IS the doc id → uniqueness via `DocumentReference.create()` (MockFirestore fallback: get+set). Old slugs stay as `is_primary: False` aliases forever (links never break, no slug-jacking); rename throttled 1/24h via pointer `created_at`, max 5 pointers/user; `RESERVED_SLUGS` + regex `^[a-z0-9](?:[a-z0-9-]{1,28}[a-z0-9])?$` + reject 32-hex (db-id shadowing). Resolution: `_get_user_profile_by_db_id_or_slug` tries the doc id first (db-id always wins), then the pointer — wired into profile/public, privacy-settings, and praises getters.
- **Attach functions** in users_service (same try/except pattern as `_attach_hackathon_history`): `_attach_teams` (raw-doc team refs → `db.get_all` — deserialize drops `teams`; allowlisted team fields + trimmed `event` via `get_hackathon_by_event_id`; `@redis_cached("portfolio:teams", 900)`), `_attach_certificates` (heart certs from `history.certificates` + git-fame via `get_certificates_by_github_username`; **never expose `author_email`**), `_attach_github_contributions`, `_attach_hearts` (`hearts_service.get_hearts_summary` = what+how sum only, matches frontend HeartGauge; tiers mirror `src/lib/heartTiers.js` — keep in lockstep).
- **Caching**: whole payload `@redis_cached("portfolio:profile", 300)` (`get_portfolio_profile`); `clear_portfolio_caches()` (pattern-clears portfolio:* prefixes) is called from profile save (both paths), privacy PATCH, visibility PATCH, slug claim, bio-video set, and `give_hearts_to_user`. `redis_cached` never caches `None` and silently skips non-JSON-serializable results.
- **Certificates by user**: cert docs now stamped with `github_username` at generation (`_extract_github_username`: noreply-email regex, fallback bare author_name, lowercased). `GET /api/certificates?github=<username>` (bare prefix route; sort/dedupe in Python — no order_by, avoids a composite index). **Run `scripts/backfill_certificate_github_usernames.py --apply` once** so pre-existing certs match.
- **GitHub contributions**: `get_github_contributions_for_user` no longer hardcodes the 2025 org — collection-group query on `login` only. **Requires the `github_contributors.login` COLLECTION_GROUP fieldOverride in firestore.indexes.json — deploy with `firebase deploy --only firestore:indexes` BEFORE shipping.** Timestamps ISO-ified for redis; `get_github_profile` TTL 10s→1h.
- **Bio video**: never proxy bytes through Flask. `POST /api/users/profile/bio-video/upload-url` mints a V4 signed GCS PUT URL (`common/utils/cdn.py::generate_signed_upload_url`, `x-goog-content-length-range` enforces the 100MB cap server-side; client must echo `required_headers`). The setter accepts own-CDN `users/{db_id}/…` URLs (blob verified via `get_blob_metadata`) or YouTube/Vimeo/Loom hosts; replaces best-effort-delete the previous own-CDN blob. **One-time ops: set bucket CORS to allow PUT from www.ohack.dev/ohack.dev/localhost** (`gcloud storage buckets update gs://$GCLOUD_CDN_BUCKET --cors-file=…`).
- **Sitemap feed**: `GET /api/users/portfolio/sitemap` (public, `@redis_cached("portfolio:sitemap", 3600)`) → `[{slug, last_login}]` for `profile_visibility == "public"` users; consumed by the frontend `server-sitemap.xml.js`.
- **Security fixes shipped with this**: `get_profile_by_db_id` now returns only `safe_public_fields` (was leaking `propel_id` + privacy-ignoring `github`); `get_praises_about_user` privacy-gates the raw-Slack-id praise route inside the service (messages_views is frozen); `POST /api/certificates/generate` requires login, and its `slack_channel` batch mode requires `volunteer.admin` (checked manually via `org_id_to_org_member_info` — pattern from hackathon_planning_service.is_admin).
- Tests: `api/users/tests/test_portfolio.py` (slug validation/claims/throttle, visibility gating, privacy matrix, sanitizer) + `test/services/test_portfolio_helpers.py` (hearts summary/tiers, cert username extraction). Run per-directory (`pytest api/users/tests test/…`) — running `pytest api/` wholesale hits a pre-existing `tests`-package name collision.
52 changes: 50 additions & 2 deletions api/certificates/certificate_service.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import uuid
import os
import re

from PIL import ImageFont
from os import getenv, path, remove
Expand All @@ -14,7 +15,8 @@
import resend

# Import get_team_by_slack_channel
from common.utils.firebase import get_team_by_slack_channel, save_certificate, get_certficate_by_file_id, get_recent_certs_from_db
from common.utils.firebase import get_team_by_slack_channel, save_certificate, get_certficate_by_file_id, get_recent_certs_from_db, fetch_certificates_by_github_username
from common.utils.redis_cache import redis_cached
from common.utils.cdn import upload_to_cdn
from common.utils.slack import async_send_slack
from common.log import get_logger
Expand Down Expand Up @@ -43,6 +45,45 @@
# Organizer names/usernames excluded from certificate generation (lowercase)
EXCLUDED_AUTHORS = {"gregv", "greg v"}

# GitHub noreply emails look like 123456+login@users.noreply.github.com
GITHUB_NOREPLY_PATTERN = re.compile(r"^(?:\d+\+)?(?P<login>[^@+]+)@users\.noreply\.github\.com$", re.IGNORECASE)


def _extract_github_username(author_email, author_name):
"""Best-effort GitHub login for a cert, lowercased.

Prefers the noreply-email encoding; falls back to author_name when it looks
like a bare login (no spaces). Returns None when neither works — the
backfill script logs those.
"""
if author_email:
m = GITHUB_NOREPLY_PATTERN.match(author_email.strip())
if m:
return m.group("login").lower()
if author_name and " " not in author_name.strip():
return author_name.strip().lower()
return None


@redis_cached(prefix="certs:by_github", ttl=900)
def get_certificates_by_github_username(github_username):
"""Public list of a user's git-fame certificates, newest first."""
if not github_username:
return []
certs = fetch_certificates_by_github_username(github_username.strip().lower())
# Sort + dedupe in Python — an order_by alongside the where() would force a
# composite index.
certs.sort(key=lambda c: c.get("date") or "", reverse=True)
deduped = []
seen_file_ids = set()
for cert in certs:
file_id = cert.get("file_id")
if file_id in seen_file_ids:
continue
seen_file_ids.add(file_id)
deduped.append(cert)
return deduped


def get_cert_info(id):
return get_certficate_by_file_id(id)
Expand Down Expand Up @@ -334,15 +375,22 @@ def generate_certificate(repositoryURL: str, username: str) -> str:
"certificate_url" : file_url,
"author_name": username,
"author_email" : authorWithEmailData.author,
# Stamped so certs can be queried per-user (portfolio page); the
# backfill script computes this for pre-existing docs.
"github_username": _extract_github_username(authorWithEmailData.author, username),
"stats": stats_json,
"totals": totals_json,
"file_id": file_id,
"file_id_hash": file_id_hash,
"date": iso_date,
"repository_url": repositoryURL
"repository_url": repositoryURL
}

save_certificate(result)
try:
get_certificates_by_github_username.cache_clear()
except Exception:
pass
return result

def validateCertificate(certificateBase64Str: str) -> bool:
Expand Down
51 changes: 45 additions & 6 deletions api/certificates/certificate_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,48 @@

from flask import Blueprint
from flask import request
from api.certificates.certificate_service import generate_certificate, validateCertificate, generate_certificate_from_slack, get_cert_info, get_recent_certs
from api.certificates.certificate_service import generate_certificate, validateCertificate, generate_certificate_from_slack, get_cert_info, get_recent_certs, get_certificates_by_github_username
from common.auth import auth, auth_user

bp_name = "api-certificates"
bp_url_prefix = "/api/certificates"
bp = Blueprint(bp_name, __name__, url_prefix=bp_url_prefix)

ADMIN_PERMISSION = "volunteer.admin"


def _user_has_admin_permission(propel_user):
"""True when the logged-in user holds volunteer.admin in any org.

Same pattern as services/hackathon_planning_service.is_admin — OHack has a
single org, so any-org membership is sufficient.
"""
if not propel_user or not getattr(propel_user, "user_id", None):
return False
org_id_to_org_member_info = (
getattr(propel_user, "org_id_to_org_member_info", None) or {}
)
for org_info in org_id_to_org_member_info.values():
try:
if org_info.user_has_permission(ADMIN_PERMISSION):
return True
except Exception:
permissions = getattr(org_info, "user_permissions", None) or []
if ADMIN_PERMISSION in permissions:
return True
return False


@bp.route("/generate", methods=["POST"])
def generateCertificate():
@auth.require_user
def generateCertificate():
form = request.get_json()
if "slack_channel" in form:
return {"images": generate_certificate_from_slack(slack_channel=form["slack_channel"])}
if "slack_channel" in form:
# Batch mode clones every team repo and notifies people via Slack +
# email — admin only.
if not _user_has_admin_permission(auth_user):
return {"error": "Admin permission required for batch generation"}, 403
return {"images": generate_certificate_from_slack(slack_channel=form["slack_channel"])}


if ("repoURL" not in form or "username" not in form): return {}
Expand All @@ -32,7 +63,15 @@ def getCert(id):
return get_cert_info(id)

@bp.route("/recent", methods=["GET"])
def getRecentCerts():
def getRecentCerts():
return {
"certs": get_recent_certs()
}
}

@bp.route("", methods=["GET"])
def getCertsByGithub():
"""GET /api/certificates?github=<username> — a user's certificates."""
github = request.args.get("github", "").strip()
if not github:
return {"error": "github query param is required"}, 400
return {"certs": get_certificates_by_github_username(github)}
Loading
Loading