Phase 2 backend: Google Wallet pass + profile contact form
- cardclaws-wallet::google — inline GenericObject + RS256 `savetowallet` JWT
(Rs256Signer via jsonwebtoken; FakeGoogleSigner test double)
- POST /v1/cards/{id}/wallet/google → { saveUrl }
- POST /v1/profile/{handle}/contact — validate, rate-limit, record
contact_form_submission, email the owner (HTML-escaped, Resend)
- WalletConfig gains google issuer id + service account email
84 backend tests; fmt + clippy clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
4770efd861
commit
8ae1d8f263
@@ -3,5 +3,6 @@ pub mod assets;
|
||||
pub mod auth;
|
||||
pub mod cards;
|
||||
pub mod health;
|
||||
pub mod profile;
|
||||
pub mod share;
|
||||
pub mod wallet;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
//! Public profile handlers (PRD §13.6).
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::Json;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::error::ApiResult;
|
||||
use crate::services::profile_service;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ContactFormRequest {
|
||||
pub name: String,
|
||||
pub email: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
pub async fn submit_contact(
|
||||
State(state): State<AppState>,
|
||||
Path(handle): Path<String>,
|
||||
Json(req): Json<ContactFormRequest>,
|
||||
) -> ApiResult<Json<Value>> {
|
||||
profile_service::submit_contact(&state, &handle, &req.name, &req.email, &req.message).await?;
|
||||
Ok(Json(json!({ "status": "sent" })))
|
||||
}
|
||||
@@ -4,6 +4,8 @@
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::header;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use serde_json::{json, Value};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::ApiResult;
|
||||
@@ -29,3 +31,13 @@ pub async fn apple_pass(
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
/// Returns the "Add to Google Wallet" save URL (PRD §13.3).
|
||||
pub async fn google_pass(
|
||||
State(state): State<AppState>,
|
||||
user: AuthUser,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> ApiResult<Json<Value>> {
|
||||
let save_url = wallet_service::google_save_link(&state, id, user.user_id).await?;
|
||||
Ok(Json(json!({ "saveUrl": save_url })))
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ use cardclaws_auth::JwtKeys;
|
||||
use cardclaws_config::{Config, EnvSecretSource, SecretSource};
|
||||
use cardclaws_wallet::apple::signer::PassSigner;
|
||||
use cardclaws_wallet::apple::BrandAssets;
|
||||
use cardclaws_wallet::google::jwt_signer::{FakeGoogleSigner, GoogleWalletSigner, Rs256Signer};
|
||||
use cardclaws_wallet::strip_renderer;
|
||||
|
||||
type BoxError = Box<dyn std::error::Error + Send + Sync>;
|
||||
@@ -47,6 +48,7 @@ async fn main() -> Result<(), BoxError> {
|
||||
let cache = RedisCache::connect(&config.redis_url).await?;
|
||||
let assets = R2Store::new(&config.r2)?;
|
||||
let pass_signer = build_pass_signer(&secrets).await?;
|
||||
let google_signer = build_google_signer(&secrets).await;
|
||||
let geo = build_geo_resolver(&secrets).await;
|
||||
|
||||
// Brand glyphs bundled into every pass. Solid-fill placeholders for now;
|
||||
@@ -69,6 +71,7 @@ async fn main() -> Result<(), BoxError> {
|
||||
ip_hash_secret: config.ip_hash_secret.clone(),
|
||||
wallet: config.wallet.clone(),
|
||||
pass_signer,
|
||||
google_signer,
|
||||
brand: Arc::new(brand),
|
||||
};
|
||||
|
||||
@@ -97,6 +100,23 @@ fn spawn_rollup_task(db: cardclaws_db::Db) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Build the Google Wallet signer. Uses the service account RSA key from
|
||||
/// `GOOGLE_SA_KEY` (PEM) when present; otherwise a fake signer (dev) that
|
||||
/// produces structurally-valid but unverifiable save links, with a warning.
|
||||
async fn build_google_signer(secrets: &dyn SecretSource) -> Arc<dyn GoogleWalletSigner> {
|
||||
if let Some(pem) = secrets.get("GOOGLE_SA_KEY").await {
|
||||
match Rs256Signer::from_pem(pem.as_bytes()) {
|
||||
Ok(s) => {
|
||||
tracing::info!("google wallet signing enabled (RS256)");
|
||||
return Arc::new(s);
|
||||
}
|
||||
Err(e) => tracing::warn!(error = %e, "invalid GOOGLE_SA_KEY; google wallet disabled"),
|
||||
}
|
||||
}
|
||||
tracing::warn!("GOOGLE_SA_KEY not set: google wallet save links are unsigned (dev only)");
|
||||
Arc::new(FakeGoogleSigner)
|
||||
}
|
||||
|
||||
/// Build the geo resolver. With `geoip` enabled and `MAXMIND_DB_PATH` set, uses
|
||||
/// the MaxMind City database; otherwise geo is unresolved (analytics still work).
|
||||
#[cfg(feature = "geoip")]
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
use axum::routing::{get, post};
|
||||
use axum::Router;
|
||||
|
||||
use crate::handlers::{analytics, assets, auth, cards, health, share, wallet};
|
||||
use crate::handlers::{analytics, assets, auth, cards, health, profile, share, wallet};
|
||||
use crate::middleware::cors;
|
||||
use crate::state::AppState;
|
||||
|
||||
@@ -32,6 +32,7 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.route("/cards/:id/duplicate", post(cards::duplicate_card))
|
||||
.route("/cards/:id/export/vcf", get(cards::export_vcf))
|
||||
.route("/cards/:id/wallet/apple", post(wallet::apple_pass))
|
||||
.route("/cards/:id/wallet/google", post(wallet::google_pass))
|
||||
.route("/cards/handle/:handle", get(cards::get_card_by_handle))
|
||||
.route("/cards/:id/analytics", get(analytics::summary))
|
||||
.route("/cards/:id/analytics/feed", get(analytics::feed))
|
||||
@@ -39,6 +40,7 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.route("/cards/:id/share", post(share::create_share))
|
||||
.route("/cards/:id/share-links", get(share::list_shares))
|
||||
.route("/s/:token", get(share::resolve_share))
|
||||
.route("/profile/:handle/contact", post(profile::submit_contact))
|
||||
.route("/analytics/event", post(analytics::ingest_event))
|
||||
.route("/assets/upload", post(assets::presign_upload))
|
||||
.route("/assets/*key", axum::routing::delete(assets::delete_asset));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod analytics_service;
|
||||
pub mod auth_service;
|
||||
pub mod card_service;
|
||||
pub mod profile_service;
|
||||
pub mod share_service;
|
||||
pub mod vcard;
|
||||
pub mod wallet_service;
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
//! Public profile actions — currently the contact form (PRD §11.4). A visitor
|
||||
//! submits name/email/message; we validate, record an analytics event, and email
|
||||
//! the card owner. Always rate-limited per handle to prevent abuse.
|
||||
|
||||
use cardclaws_db::queries::users;
|
||||
use cardclaws_types::AppError;
|
||||
|
||||
use crate::error::SqlxResultExt;
|
||||
use crate::middleware::rate_limit;
|
||||
use crate::services::{analytics_service, card_service};
|
||||
use crate::state::AppState;
|
||||
|
||||
pub async fn submit_contact(
|
||||
state: &AppState,
|
||||
handle: &str,
|
||||
name: &str,
|
||||
email: &str,
|
||||
message: &str,
|
||||
) -> Result<(), AppError> {
|
||||
if name.trim().is_empty() {
|
||||
return Err(AppError::Validation("name is required".into()));
|
||||
}
|
||||
crate::validation::validate_email(email)?;
|
||||
if message.trim().is_empty() || message.chars().count() > 1000 {
|
||||
return Err(AppError::Validation(
|
||||
"message must be 1–1000 characters".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// 5 submissions / minute / handle.
|
||||
rate_limit::check(state.cache.as_ref(), &format!("contact:{handle}"), 5, 60).await?;
|
||||
|
||||
let card = card_service::get_public_by_handle(state, handle).await?;
|
||||
let owner = users::find_by_id(&state.db, card.owner_id)
|
||||
.await
|
||||
.map_db()?
|
||||
.ok_or_else(|| AppError::Internal("card owner missing".into()))?;
|
||||
|
||||
// Record the submission (best-effort) and email the owner.
|
||||
let _ = analytics_service::record(state, card.id, "contact_form_submission", None, None, None)
|
||||
.await;
|
||||
|
||||
let html = format!(
|
||||
"<p>New message via your CardClaws profile from <strong>{}</strong> ({}):</p><blockquote>{}</blockquote>",
|
||||
escape_html(name),
|
||||
escape_html(email),
|
||||
escape_html(message),
|
||||
);
|
||||
let _ = state
|
||||
.email
|
||||
.send(&owner.email, "New CardClaws contact form message", &html)
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Minimal HTML escaping so the visitor's text can't inject markup into the email.
|
||||
fn escape_html(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::escape_html;
|
||||
|
||||
#[test]
|
||||
fn escapes_markup() {
|
||||
assert_eq!(escape_html("<b>&hi</b>"), "<b>&hi</b>");
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
use cardclaws_db::queries::users;
|
||||
use cardclaws_types::AppError;
|
||||
use cardclaws_wallet::apple::{build_pkpass, PassInput};
|
||||
use cardclaws_wallet::google::{build_save_link, GoogleInput};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::SqlxResultExt;
|
||||
@@ -42,6 +43,35 @@ pub async fn apple_pkpass(
|
||||
.map_err(|e| AppError::Internal(e.to_string()))
|
||||
}
|
||||
|
||||
/// Build the "Add to Google Wallet" save URL for a card (PRD §10.3).
|
||||
pub async fn google_save_link(
|
||||
state: &AppState,
|
||||
card_id: Uuid,
|
||||
user_id: Uuid,
|
||||
) -> Result<String, AppError> {
|
||||
let card = card_service::get_owned(state, card_id, user_id).await?;
|
||||
let owner = users::find_by_id(&state.db, card.owner_id)
|
||||
.await
|
||||
.map_db()?
|
||||
.ok_or_else(|| AppError::Internal("card owner missing".into()))?;
|
||||
|
||||
let contact = vcard::extract_contact(&card.definition);
|
||||
let issuer = &state.wallet.google_issuer_id;
|
||||
let input = GoogleInput {
|
||||
issuer_email: state.wallet.google_service_account_email.clone(),
|
||||
class_id: format!("{issuer}.cardclaws_generic"),
|
||||
object_id: format!("{issuer}.{}", card.id),
|
||||
holder_name: owner.display_name,
|
||||
title: contact.title,
|
||||
company: contact.company,
|
||||
profile_url: format!("{}/{}", state.profile_base_url, card.handle),
|
||||
background_hex: background_hex(&card.definition),
|
||||
};
|
||||
|
||||
build_save_link(&input, state.google_signer.as_ref())
|
||||
.map_err(|e| AppError::Internal(e.to_string()))
|
||||
}
|
||||
|
||||
/// Extract the face background color (solid) from the definition, defaulting to
|
||||
/// the CardClaws dark base when absent or non-solid.
|
||||
fn background_hex(definition: &serde_json::Value) -> String {
|
||||
|
||||
@@ -8,6 +8,7 @@ use cardclaws_config::WalletConfig;
|
||||
use cardclaws_db::Db;
|
||||
use cardclaws_wallet::apple::signer::PassSigner;
|
||||
use cardclaws_wallet::apple::BrandAssets;
|
||||
use cardclaws_wallet::google::jwt_signer::GoogleWalletSigner;
|
||||
|
||||
use crate::assets::ObjectStore;
|
||||
use crate::cache::Cache;
|
||||
@@ -34,5 +35,6 @@ pub struct AppState {
|
||||
/// Apple Wallet pass identity + signer + bundled brand assets.
|
||||
pub wallet: WalletConfig,
|
||||
pub pass_signer: Arc<dyn PassSigner>,
|
||||
pub google_signer: Arc<dyn GoogleWalletSigner>,
|
||||
pub brand: Arc<BrandAssets>,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user