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
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 | ||
| 475 | 475 | .execute(db) |
| 476 | 476 | .await?; |
| 477 | 477 | 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; | |
| 478 | 479 | analytics::track(&ctx.state, "google_linked", ctx.viewer.as_ref().map(|v| v.name.as_str()), "/settings/security", json!({})); |
| 479 | 480 | return Ok((jar, Redirect::to("/settings/security?google=linked")).into_response()); |
| 480 | 481 | } |
| @@ -484,6 +485,7 @@ pub async fn google_callback(ctx: Ctx, headers: HeaderMap, jar: CookieJar, Query | ||
| 484 | 485 | return Ok((jar, fail("suspended")).into_response()); |
| 485 | 486 | } |
| 486 | 487 | 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; | |
| 487 | 489 | return sign_in(&ctx.state, jar, &headers, user_id, &username, &oauth.next).await; |
| 488 | 490 | } |
| 489 | 491 | |
| @@ -513,6 +515,7 @@ pub async fn google_callback(ctx: Ctx, headers: HeaderMap, jar: CookieJar, Query | ||
| 513 | 515 | .execute(db) |
| 514 | 516 | .await?; |
| 515 | 517 | 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; | |
| 516 | 519 | return sign_in(&ctx.state, jar, &headers, user_id, &username, &oauth.next).await; |
| 517 | 520 | } |
| 518 | 521 | |
| @@ -564,6 +567,31 @@ async fn sign_in(state: &AppState, jar: CookieJar, headers: &HeaderMap, user_id: | ||
| 564 | 567 | Ok((set_session(jar, state, token), Redirect::to(next)).into_response()) |
| 565 | 568 | } |
| 566 | 569 | |
| 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 | + | |
| 567 | 595 | fn pending_signup(state: &AppState, jar: &CookieJar) -> Option<PendingSignup> { |
| 568 | 596 | jar.get(SIGNUP_COOKIE).and_then(|c| signed::verify(&state.config.secret_key, "google-signup", c.value())) |
| 569 | 597 | } |
| @@ -648,15 +676,8 @@ pub async fn google_finish_submit(ctx: Ctx, headers: HeaderMap, jar: CookieJar, | ||
| 648 | 676 | .bind(&pending.email) |
| 649 | 677 | .execute(&ctx.state.db) |
| 650 | 678 | .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; | |
| 660 | 681 | let username = form.username.trim().to_ascii_lowercase(); |
| 661 | 682 | let jar = jar.remove(Cookie::build(SIGNUP_COOKIE).path("/").build()); |
| 662 | 683 | let next = if pending.next == "/" { format!("/{username}?welcome=1") } else { pending.next.clone() }; |
+39-32backend/src/web/avatars.rs
| @@ -1,14 +1,25 @@ | ||
| 1 | 1 | //! /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. | |
| 2 | 7 | |
| 3 | 8 | use axum::{ |
| 4 | - extract::{Path, State}, | |
| 5 | - http::{StatusCode, header}, | |
| 9 | + extract::{Path, Query, State}, | |
| 10 | + http::{HeaderMap, HeaderValue, StatusCode, header}, | |
| 6 | 11 | response::{IntoResponse, Response}, |
| 7 | 12 | }; |
| 13 | +use serde::Deserialize; | |
| 8 | 14 | |
| 9 | 15 | use crate::{models::Account, state::AppState, web::ui}; |
| 10 | 16 | |
| 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 { | |
| 12 | 23 | let account = match Account::by_name(&state.db, &name).await { |
| 13 | 24 | Ok(account) => account, |
| 14 | 25 | Err(error) => { |
| @@ -16,37 +27,33 @@ pub async fn serve(State(state): State<AppState>, Path(name): Path<String>) -> R | ||
| 16 | 27 | None |
| 17 | 28 | } |
| 18 | 29 | }; |
| 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()) | |
| 39 | 43 | } |
| 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); | |
| 43 | 55 | } |
| 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 | |
| 50 | 57 | } |
| 51 | 58 | |
| 52 | 59 | const MAX_AVATAR_BYTES: usize = 2 * 1024 * 1024; |