irongit

Git hosting and a container registry in one Rust binary (axum + Astro)

Use the Google profile photo: import before the first page, adopt it for existing accounts without a picture; revalidate bare avatar URLs

huncholanehuncholaneauthored
parent dd59a20commit 649719e3cca373b6bbe4830813ec28bcfb445bd0Browse files

2 files changed, +69 -41

+30-9backend/src/web/account.rs
@@ -475,6 +475,7 @@ pub async fn google_callback(ctx: Ctx, headers: HeaderMap, jar: CookieJar, Query
475475 .execute(db)
476476 .await?;
477477 audit(db, Some(link_user), "google.link", info.email.as_deref().unwrap_or(""), json!({}), ip.as_deref()).await;
478+ adopt_google_picture(&ctx.state, link_user, info.picture.as_deref()).await;
478479 analytics::track(&ctx.state, "google_linked", ctx.viewer.as_ref().map(|v| v.name.as_str()), "/settings/security", json!({}));
479480 return Ok((jar, Redirect::to("/settings/security?google=linked")).into_response());
480481 }
@@ -484,6 +485,7 @@ pub async fn google_callback(ctx: Ctx, headers: HeaderMap, jar: CookieJar, Query
484485 return Ok((jar, fail("suspended")).into_response());
485486 }
486487 sqlx::query("update oauth_identities set last_used_at = now() where provider = 'google' and subject = $1").bind(&info.sub).execute(db).await?;
488+ adopt_google_picture(&ctx.state, user_id, info.picture.as_deref()).await;
487489 return sign_in(&ctx.state, jar, &headers, user_id, &username, &oauth.next).await;
488490 }
489491
@@ -513,6 +515,7 @@ pub async fn google_callback(ctx: Ctx, headers: HeaderMap, jar: CookieJar, Query
513515 .execute(db)
514516 .await?;
515517 audit(db, Some(user_id), "google.link", &email, json!({ "auto": true }), ip.as_deref()).await;
518+ adopt_google_picture(&ctx.state, user_id, info.picture.as_deref()).await;
516519 return sign_in(&ctx.state, jar, &headers, user_id, &username, &oauth.next).await;
517520 }
518521
@@ -564,6 +567,31 @@ async fn sign_in(state: &AppState, jar: CookieJar, headers: &HeaderMap, user_id:
564567 Ok((set_session(jar, state, token), Redirect::to(next)).into_response())
565568 }
566569
570+/// Uses the Google profile photo as the avatar when the account has none.
571+/// A picture someone uploaded is never replaced. Bounded so a slow fetch
572+/// cannot hold up sign-in; failures are logged and otherwise ignored.
573+async fn adopt_google_picture(state: &AppState, user_id: i64, picture: Option<&str>) {
574+ let Some(picture) = picture.filter(|p| !p.is_empty()) else { return };
575+ let has_avatar: bool = match sqlx::query_scalar("select avatar_key is not null from accounts where id = $1").bind(user_id).fetch_one(&state.db).await {
576+ Ok(has) => has,
577+ Err(error) => {
578+ tracing::warn!(%error, user_id, "could not check avatar before google import");
579+ return;
580+ }
581+ };
582+ if has_avatar {
583+ return;
584+ }
585+ match tokio::time::timeout(std::time::Duration::from_secs(5), avatars::import_from_url(state, user_id, picture)).await {
586+ Ok(Ok(())) => {
587+ tracing::info!(user_id, "google profile photo imported as avatar");
588+ analytics::track(state, "avatar_imported_google", None, "/login/google", json!({}));
589+ }
590+ Ok(Err(error)) => tracing::warn!(?error, user_id, "could not import google avatar"),
591+ Err(_) => tracing::warn!(user_id, "google avatar import timed out"),
592+ }
593+}
594+
567595 fn pending_signup(state: &AppState, jar: &CookieJar) -> Option<PendingSignup> {
568596 jar.get(SIGNUP_COOKIE).and_then(|c| signed::verify(&state.config.secret_key, "google-signup", c.value()))
569597 }
@@ -648,15 +676,8 @@ pub async fn google_finish_submit(ctx: Ctx, headers: HeaderMap, jar: CookieJar,
648676 .bind(&pending.email)
649677 .execute(&ctx.state.db)
650678 .await?;
651- // Bring the Google profile photo along; people like their avatars.
652- if let Some(picture) = pending.picture.clone() {
653- let state = ctx.state.clone();
654- tokio::spawn(async move {
655- if let Err(error) = avatars::import_from_url(&state, user_id, &picture).await {
656- tracing::warn!(?error, "could not import google avatar");
657- }
658- });
659- }
679+ // Bring the Google profile photo along before the first page renders.
680+ adopt_google_picture(&ctx.state, user_id, pending.picture.as_deref()).await;
660681 let username = form.username.trim().to_ascii_lowercase();
661682 let jar = jar.remove(Cookie::build(SIGNUP_COOKIE).path("/").build());
662683 let next = if pending.next == "/" { format!("/{username}?welcome=1") } else { pending.next.clone() };
+39-32backend/src/web/avatars.rs
@@ -1,14 +1,25 @@
11 //! /avatars/{name}: the uploaded picture from R2, or a generated pattern.
2+//!
3+//! Pages that know the avatar key link to `/avatars/{name}?v={version}`, which
4+//! never changes for a given picture and is cached for a year. The bare
5+//! `/avatars/{name}` (used where the key is not at hand) revalidates on every
6+//! use through an ETag, so a new picture shows up immediately everywhere.
27
38 use axum::{
4- extract::{Path, State},
5- http::{StatusCode, header},
9+ extract::{Path, Query, State},
10+ http::{HeaderMap, HeaderValue, StatusCode, header},
611 response::{IntoResponse, Response},
712 };
13+use serde::Deserialize;
814
915 use crate::{models::Account, state::AppState, web::ui};
1016
11-pub async fn serve(State(state): State<AppState>, Path(name): Path<String>) -> Response {
17+#[derive(Deserialize, Default)]
18+pub struct AvatarQuery {
19+ v: Option<String>,
20+}
21+
22+pub async fn serve(State(state): State<AppState>, Path(name): Path<String>, Query(query): Query<AvatarQuery>, headers: HeaderMap) -> Response {
1223 let account = match Account::by_name(&state.db, &name).await {
1324 Ok(account) => account,
1425 Err(error) => {
@@ -16,37 +27,33 @@ pub async fn serve(State(state): State<AppState>, Path(name): Path<String>) -> R
1627 None
1728 }
1829 };
19- if let Some(key) = account.as_ref().and_then(|a| a.avatar_key.as_deref()) {
20- // Pictures are small and change rarely: proxy them with long caching
21- // (the URL carries a version) instead of redirecting to R2.
22- match state.storage.get_bytes(key).await {
23- Ok(Some(bytes)) => {
24- let mime = if bytes.starts_with(b"\x89PNG") {
25- "image/png"
26- } else if bytes.starts_with(b"RIFF") {
27- "image/webp"
28- } else if bytes.starts_with(b"GIF8") {
29- "image/gif"
30- } else {
31- "image/jpeg"
32- };
33- return (
34- StatusCode::OK,
35- [(header::CONTENT_TYPE, mime), (header::CACHE_CONTROL, "public, max-age=86400")],
36- bytes,
37- )
38- .into_response();
30+ let key = account.as_ref().and_then(|a| a.avatar_key.clone());
31+ let etag = format!("\"{}\"", key.as_deref().and_then(|k| k.rsplit('/').next()).unwrap_or("pattern"));
32+ let cache = if query.v.is_some() { "public, max-age=31536000, immutable" } else { "public, no-cache" };
33+ if headers.get(header::IF_NONE_MATCH).and_then(|v| v.to_str().ok()) == Some(etag.as_str()) {
34+ return (StatusCode::NOT_MODIFIED, [(header::ETAG, etag.as_str()), (header::CACHE_CONTROL, cache)]).into_response();
35+ }
36+
37+ let (mime, body): (&str, axum::body::Body) = match key.as_deref() {
38+ Some(key) => match state.storage.get_bytes(key).await {
39+ Ok(Some(bytes)) => (sniff_image(&bytes).unwrap_or("image/jpeg"), bytes.into()),
40+ Ok(None) => {
41+ tracing::warn!(key, "avatar object missing in R2");
42+ ("image/svg+xml", ui::identicon_svg(&name).into())
3943 }
40- Ok(None) => tracing::warn!(key, "avatar object missing in R2"),
41- Err(error) => tracing::error!(%error, key, "avatar fetch failed"),
42- }
44+ Err(error) => {
45+ tracing::error!(%error, key, "avatar fetch failed");
46+ // Do not let a transient R2 error get cached as the pattern.
47+ return (StatusCode::SERVICE_UNAVAILABLE, [(header::CACHE_CONTROL, "no-store")]).into_response();
48+ }
49+ },
50+ None => ("image/svg+xml", ui::identicon_svg(&name).into()),
51+ };
52+ let mut response = (StatusCode::OK, [(header::CONTENT_TYPE, mime), (header::CACHE_CONTROL, cache)], body).into_response();
53+ if let Ok(value) = HeaderValue::from_str(&etag) {
54+ response.headers_mut().insert(header::ETAG, value);
4355 }
44- (
45- StatusCode::OK,
46- [(header::CONTENT_TYPE, "image/svg+xml"), (header::CACHE_CONTROL, "public, max-age=3600")],
47- ui::identicon_svg(&name),
48- )
49- .into_response()
56+ response
5057 }
5158
5259 const MAX_AVATAR_BYTES: usize = 2 * 1024 * 1024;