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
Generated
+2
@@ -346,8 +346,10 @@ dependencies = [
|
|||||||
name = "cardclaws-wallet"
|
name = "cardclaws-wallet"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
"cardclaws-types",
|
"cardclaws-types",
|
||||||
"image",
|
"image",
|
||||||
|
"jsonwebtoken",
|
||||||
"openssl",
|
"openssl",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
@@ -63,3 +63,4 @@ uuid = { workspace = true }
|
|||||||
sqlx = { workspace = true }
|
sqlx = { workspace = true }
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
zip = { workspace = true }
|
zip = { workspace = true }
|
||||||
|
base64 = { workspace = true }
|
||||||
|
|||||||
@@ -3,5 +3,6 @@ pub mod assets;
|
|||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod cards;
|
pub mod cards;
|
||||||
pub mod health;
|
pub mod health;
|
||||||
|
pub mod profile;
|
||||||
pub mod share;
|
pub mod share;
|
||||||
pub mod wallet;
|
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::extract::{Path, State};
|
||||||
use axum::http::header;
|
use axum::http::header;
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use axum::Json;
|
||||||
|
use serde_json::{json, Value};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::error::ApiResult;
|
use crate::error::ApiResult;
|
||||||
@@ -29,3 +31,13 @@ pub async fn apple_pass(
|
|||||||
)
|
)
|
||||||
.into_response())
|
.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_config::{Config, EnvSecretSource, SecretSource};
|
||||||
use cardclaws_wallet::apple::signer::PassSigner;
|
use cardclaws_wallet::apple::signer::PassSigner;
|
||||||
use cardclaws_wallet::apple::BrandAssets;
|
use cardclaws_wallet::apple::BrandAssets;
|
||||||
|
use cardclaws_wallet::google::jwt_signer::{FakeGoogleSigner, GoogleWalletSigner, Rs256Signer};
|
||||||
use cardclaws_wallet::strip_renderer;
|
use cardclaws_wallet::strip_renderer;
|
||||||
|
|
||||||
type BoxError = Box<dyn std::error::Error + Send + Sync>;
|
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 cache = RedisCache::connect(&config.redis_url).await?;
|
||||||
let assets = R2Store::new(&config.r2)?;
|
let assets = R2Store::new(&config.r2)?;
|
||||||
let pass_signer = build_pass_signer(&secrets).await?;
|
let pass_signer = build_pass_signer(&secrets).await?;
|
||||||
|
let google_signer = build_google_signer(&secrets).await;
|
||||||
let geo = build_geo_resolver(&secrets).await;
|
let geo = build_geo_resolver(&secrets).await;
|
||||||
|
|
||||||
// Brand glyphs bundled into every pass. Solid-fill placeholders for now;
|
// 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(),
|
ip_hash_secret: config.ip_hash_secret.clone(),
|
||||||
wallet: config.wallet.clone(),
|
wallet: config.wallet.clone(),
|
||||||
pass_signer,
|
pass_signer,
|
||||||
|
google_signer,
|
||||||
brand: Arc::new(brand),
|
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
|
/// Build the geo resolver. With `geoip` enabled and `MAXMIND_DB_PATH` set, uses
|
||||||
/// the MaxMind City database; otherwise geo is unresolved (analytics still work).
|
/// the MaxMind City database; otherwise geo is unresolved (analytics still work).
|
||||||
#[cfg(feature = "geoip")]
|
#[cfg(feature = "geoip")]
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
use axum::routing::{get, post};
|
use axum::routing::{get, post};
|
||||||
use axum::Router;
|
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::middleware::cors;
|
||||||
use crate::state::AppState;
|
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/duplicate", post(cards::duplicate_card))
|
||||||
.route("/cards/:id/export/vcf", get(cards::export_vcf))
|
.route("/cards/:id/export/vcf", get(cards::export_vcf))
|
||||||
.route("/cards/:id/wallet/apple", post(wallet::apple_pass))
|
.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/handle/:handle", get(cards::get_card_by_handle))
|
||||||
.route("/cards/:id/analytics", get(analytics::summary))
|
.route("/cards/:id/analytics", get(analytics::summary))
|
||||||
.route("/cards/:id/analytics/feed", get(analytics::feed))
|
.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", post(share::create_share))
|
||||||
.route("/cards/:id/share-links", get(share::list_shares))
|
.route("/cards/:id/share-links", get(share::list_shares))
|
||||||
.route("/s/:token", get(share::resolve_share))
|
.route("/s/:token", get(share::resolve_share))
|
||||||
|
.route("/profile/:handle/contact", post(profile::submit_contact))
|
||||||
.route("/analytics/event", post(analytics::ingest_event))
|
.route("/analytics/event", post(analytics::ingest_event))
|
||||||
.route("/assets/upload", post(assets::presign_upload))
|
.route("/assets/upload", post(assets::presign_upload))
|
||||||
.route("/assets/*key", axum::routing::delete(assets::delete_asset));
|
.route("/assets/*key", axum::routing::delete(assets::delete_asset));
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
pub mod analytics_service;
|
pub mod analytics_service;
|
||||||
pub mod auth_service;
|
pub mod auth_service;
|
||||||
pub mod card_service;
|
pub mod card_service;
|
||||||
|
pub mod profile_service;
|
||||||
pub mod share_service;
|
pub mod share_service;
|
||||||
pub mod vcard;
|
pub mod vcard;
|
||||||
pub mod wallet_service;
|
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_db::queries::users;
|
||||||
use cardclaws_types::AppError;
|
use cardclaws_types::AppError;
|
||||||
use cardclaws_wallet::apple::{build_pkpass, PassInput};
|
use cardclaws_wallet::apple::{build_pkpass, PassInput};
|
||||||
|
use cardclaws_wallet::google::{build_save_link, GoogleInput};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::error::SqlxResultExt;
|
use crate::error::SqlxResultExt;
|
||||||
@@ -42,6 +43,35 @@ pub async fn apple_pkpass(
|
|||||||
.map_err(|e| AppError::Internal(e.to_string()))
|
.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
|
/// Extract the face background color (solid) from the definition, defaulting to
|
||||||
/// the CardClaws dark base when absent or non-solid.
|
/// the CardClaws dark base when absent or non-solid.
|
||||||
fn background_hex(definition: &serde_json::Value) -> String {
|
fn background_hex(definition: &serde_json::Value) -> String {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use cardclaws_config::WalletConfig;
|
|||||||
use cardclaws_db::Db;
|
use cardclaws_db::Db;
|
||||||
use cardclaws_wallet::apple::signer::PassSigner;
|
use cardclaws_wallet::apple::signer::PassSigner;
|
||||||
use cardclaws_wallet::apple::BrandAssets;
|
use cardclaws_wallet::apple::BrandAssets;
|
||||||
|
use cardclaws_wallet::google::jwt_signer::GoogleWalletSigner;
|
||||||
|
|
||||||
use crate::assets::ObjectStore;
|
use crate::assets::ObjectStore;
|
||||||
use crate::cache::Cache;
|
use crate::cache::Cache;
|
||||||
@@ -34,5 +35,6 @@ pub struct AppState {
|
|||||||
/// Apple Wallet pass identity + signer + bundled brand assets.
|
/// Apple Wallet pass identity + signer + bundled brand assets.
|
||||||
pub wallet: WalletConfig,
|
pub wallet: WalletConfig,
|
||||||
pub pass_signer: Arc<dyn PassSigner>,
|
pub pass_signer: Arc<dyn PassSigner>,
|
||||||
|
pub google_signer: Arc<dyn GoogleWalletSigner>,
|
||||||
pub brand: Arc<BrandAssets>,
|
pub brand: Arc<BrandAssets>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ use cardclaws_auth::JwtKeys;
|
|||||||
use cardclaws_config::WalletConfig;
|
use cardclaws_config::WalletConfig;
|
||||||
use cardclaws_wallet::apple::signer::FakePassSigner;
|
use cardclaws_wallet::apple::signer::FakePassSigner;
|
||||||
use cardclaws_wallet::apple::BrandAssets;
|
use cardclaws_wallet::apple::BrandAssets;
|
||||||
|
use cardclaws_wallet::google::jwt_signer::FakeGoogleSigner;
|
||||||
use cardclaws_wallet::strip_renderer;
|
use cardclaws_wallet::strip_renderer;
|
||||||
|
|
||||||
/// Apple provider that never returns a usable key — fine for tests that don't
|
/// Apple provider that never returns a usable key — fine for tests that don't
|
||||||
@@ -92,8 +93,11 @@ pub async fn try_setup() -> Option<TestApp> {
|
|||||||
apple_pass_type_id: "pass.com.cardclaws.test".into(),
|
apple_pass_type_id: "pass.com.cardclaws.test".into(),
|
||||||
apple_team_id: "TEST123".into(),
|
apple_team_id: "TEST123".into(),
|
||||||
organization_name: "CardClaws".into(),
|
organization_name: "CardClaws".into(),
|
||||||
|
google_issuer_id: "3388000000000000000".into(),
|
||||||
|
google_service_account_email: "[email protected]".into(),
|
||||||
},
|
},
|
||||||
pass_signer: Arc::new(FakePassSigner),
|
pass_signer: Arc::new(FakePassSigner),
|
||||||
|
google_signer: Arc::new(FakeGoogleSigner),
|
||||||
brand: Arc::new(BrandAssets {
|
brand: Arc::new(BrandAssets {
|
||||||
icon_png: strip_renderer::render_solid(58, 58, "#ff3b30").unwrap(),
|
icon_png: strip_renderer::render_solid(58, 58, "#ff3b30").unwrap(),
|
||||||
logo_png: strip_renderer::render_solid(160, 50, "#ffffff").unwrap(),
|
logo_png: strip_renderer::render_solid(160, 50, "#ffffff").unwrap(),
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
//! Integration tests for the public profile contact form (PRD §11.4, §13.6).
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
use common::{unique_handle, TestApp};
|
||||||
|
|
||||||
|
async fn published_card(app: &TestApp, token: &str) -> String {
|
||||||
|
let handle = unique_handle();
|
||||||
|
let def = json!({
|
||||||
|
"face": { "layers": [], "background": { "type": "solid", "value": "#101014" } },
|
||||||
|
"back": { "layers": [] }
|
||||||
|
});
|
||||||
|
let (_, created) = app
|
||||||
|
.request(
|
||||||
|
"POST",
|
||||||
|
"/v1/cards",
|
||||||
|
Some(token),
|
||||||
|
Some(json!({"handle": handle, "definition": def})),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let id = created["id"].as_str().unwrap().to_string();
|
||||||
|
app.request(
|
||||||
|
"POST",
|
||||||
|
&format!("/v1/cards/{id}/publish"),
|
||||||
|
Some(token),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
handle
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn contact_form_emails_the_owner() {
|
||||||
|
let app = require_app!();
|
||||||
|
let token = app.register_and_token().await;
|
||||||
|
let handle = published_card(&app, &token).await;
|
||||||
|
|
||||||
|
let (status, _) = app
|
||||||
|
.request(
|
||||||
|
"POST",
|
||||||
|
&format!("/v1/profile/{handle}/contact"),
|
||||||
|
None,
|
||||||
|
Some(json!({ "name": "Visitor", "email": "[email protected]", "message": "Loved your card!" })),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
|
||||||
|
let sent = app.email.sent.lock().unwrap();
|
||||||
|
let msg = sent.last().expect("an email was sent");
|
||||||
|
assert!(msg.subject.contains("contact form"));
|
||||||
|
assert!(msg.html.contains("Loved your card!"));
|
||||||
|
assert!(msg.html.contains("[email protected]"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn contact_form_rejects_bad_input() {
|
||||||
|
let app = require_app!();
|
||||||
|
let token = app.register_and_token().await;
|
||||||
|
let handle = published_card(&app, &token).await;
|
||||||
|
|
||||||
|
let bad_email: Value = json!({ "name": "V", "email": "not-an-email", "message": "hi" });
|
||||||
|
let (s1, b1) = app
|
||||||
|
.request(
|
||||||
|
"POST",
|
||||||
|
&format!("/v1/profile/{handle}/contact"),
|
||||||
|
None,
|
||||||
|
Some(bad_email),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(s1, StatusCode::BAD_REQUEST);
|
||||||
|
assert_eq!(b1["code"], "validation");
|
||||||
|
|
||||||
|
let empty_name = json!({ "name": " ", "email": "[email protected]", "message": "hi" });
|
||||||
|
let (s2, _) = app
|
||||||
|
.request(
|
||||||
|
"POST",
|
||||||
|
&format!("/v1/profile/{handle}/contact"),
|
||||||
|
None,
|
||||||
|
Some(empty_name),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(s2, StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn contact_form_unknown_handle_is_404() {
|
||||||
|
let app = require_app!();
|
||||||
|
let (status, _) = app
|
||||||
|
.request(
|
||||||
|
"POST",
|
||||||
|
"/v1/profile/nobody-here/contact",
|
||||||
|
None,
|
||||||
|
Some(json!({ "name": "V", "email": "[email protected]", "message": "hi" })),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
@@ -72,6 +72,59 @@ async fn apple_pass_endpoint_returns_valid_pkpass() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn google_pass_endpoint_returns_save_url_with_object() {
|
||||||
|
use base64::Engine;
|
||||||
|
|
||||||
|
let app = require_app!();
|
||||||
|
let token = app.register_and_token().await;
|
||||||
|
|
||||||
|
let handle = unique_handle();
|
||||||
|
let definition = json!({
|
||||||
|
"face": { "layers": [], "background": { "type": "solid", "value": "#202028" } },
|
||||||
|
"back": { "layers": [
|
||||||
|
{ "type": "contact", "fields": { "title": "Founder", "company": "RedClaw" } }
|
||||||
|
]}
|
||||||
|
});
|
||||||
|
let (_, created) = app
|
||||||
|
.request(
|
||||||
|
"POST",
|
||||||
|
"/v1/cards",
|
||||||
|
Some(&token),
|
||||||
|
Some(json!({"handle": handle, "definition": definition})),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let id = created["id"].as_str().unwrap();
|
||||||
|
|
||||||
|
let (status, body) = app
|
||||||
|
.request(
|
||||||
|
"POST",
|
||||||
|
&format!("/v1/cards/{id}/wallet/google"),
|
||||||
|
Some(&token),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
let save_url = body["saveUrl"].as_str().unwrap();
|
||||||
|
assert!(save_url.starts_with("https://pay.google.com/gp/v/save/"));
|
||||||
|
|
||||||
|
// Decode the JWT payload and confirm it carries our object + QR barcode.
|
||||||
|
let jwt = save_url.trim_start_matches("https://pay.google.com/gp/v/save/");
|
||||||
|
let parts: Vec<&str> = jwt.split('.').collect();
|
||||||
|
assert_eq!(parts.len(), 3);
|
||||||
|
let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||||
|
.decode(parts[1])
|
||||||
|
.unwrap();
|
||||||
|
let claims: serde_json::Value = serde_json::from_slice(&payload).unwrap();
|
||||||
|
assert_eq!(claims["typ"], "savetowallet");
|
||||||
|
let obj = &claims["payload"]["genericObjects"][0];
|
||||||
|
assert_eq!(
|
||||||
|
obj["barcode"]["value"],
|
||||||
|
format!("https://cardclaws.test/{handle}")
|
||||||
|
);
|
||||||
|
assert_eq!(obj["header"]["defaultValue"]["value"], "Card Owner");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn apple_pass_requires_auth() {
|
async fn apple_pass_requires_auth() {
|
||||||
let app = require_app!();
|
let app = require_app!();
|
||||||
|
|||||||
@@ -64,6 +64,10 @@ pub struct WalletConfig {
|
|||||||
pub apple_pass_type_id: String,
|
pub apple_pass_type_id: String,
|
||||||
pub apple_team_id: String,
|
pub apple_team_id: String,
|
||||||
pub organization_name: String,
|
pub organization_name: String,
|
||||||
|
/// Google Wallet issuer id (the numeric issuer account).
|
||||||
|
pub google_issuer_id: String,
|
||||||
|
/// Service account email used as the save-JWT `iss`.
|
||||||
|
pub google_service_account_email: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cloudflare R2 (S3-compatible) object storage config. Defaults are dev
|
/// Cloudflare R2 (S3-compatible) object storage config. Defaults are dev
|
||||||
@@ -112,6 +116,13 @@ impl Config {
|
|||||||
.await,
|
.await,
|
||||||
apple_team_id: optional(src, "APPLE_TEAM_ID", "TEAMID0000").await,
|
apple_team_id: optional(src, "APPLE_TEAM_ID", "TEAMID0000").await,
|
||||||
organization_name: optional(src, "ORGANIZATION_NAME", "CardClaws").await,
|
organization_name: optional(src, "ORGANIZATION_NAME", "CardClaws").await,
|
||||||
|
google_issuer_id: optional(src, "GOOGLE_ISSUER_ID", "3388000000000000000").await,
|
||||||
|
google_service_account_email: optional(
|
||||||
|
src,
|
||||||
|
"GOOGLE_SA_EMAIL",
|
||||||
|
"[email protected]",
|
||||||
|
)
|
||||||
|
.await,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ sha1 = { workspace = true }
|
|||||||
zip = { workspace = true }
|
zip = { workspace = true }
|
||||||
image = { workspace = true }
|
image = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
|
# Google Wallet "save" JWT (RS256). jsonwebtoken is pure-Rust, so this is always
|
||||||
|
# compiled (unlike the openssl-backed Apple signer).
|
||||||
|
jsonwebtoken = { workspace = true }
|
||||||
|
base64 = { workspace = true }
|
||||||
openssl = { version = "0.10", features = ["vendored"], optional = true }
|
openssl = { version = "0.10", features = ["vendored"], optional = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
//! Signs the Google Wallet `savetowallet` JWT (RS256) with the issuer service
|
||||||
|
//! account key. A fake signer (test double) lets the link-assembly logic be
|
||||||
|
//! verified without a real key.
|
||||||
|
|
||||||
|
use base64::Engine;
|
||||||
|
use jsonwebtoken::{Algorithm, EncodingKey, Header};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::error::WalletError;
|
||||||
|
|
||||||
|
pub trait GoogleWalletSigner: Send + Sync {
|
||||||
|
/// Sign the claims into a compact JWT string.
|
||||||
|
fn sign(&self, claims: &Value) -> Result<String, WalletError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Production signer: RS256 over the service account private key (PEM).
|
||||||
|
pub struct Rs256Signer {
|
||||||
|
key: EncodingKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Rs256Signer {
|
||||||
|
pub fn from_pem(pem: &[u8]) -> Result<Self, WalletError> {
|
||||||
|
let key =
|
||||||
|
EncodingKey::from_rsa_pem(pem).map_err(|e| WalletError::Signing(e.to_string()))?;
|
||||||
|
Ok(Self { key })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GoogleWalletSigner for Rs256Signer {
|
||||||
|
fn sign(&self, claims: &Value) -> Result<String, WalletError> {
|
||||||
|
jsonwebtoken::encode(&Header::new(Algorithm::RS256), claims, &self.key)
|
||||||
|
.map_err(|e| WalletError::Signing(e.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test double: emits a structurally-valid `header.payload.signature` token with
|
||||||
|
/// a non-cryptographic signature, so tests can decode and assert the payload.
|
||||||
|
pub struct FakeGoogleSigner;
|
||||||
|
|
||||||
|
impl GoogleWalletSigner for FakeGoogleSigner {
|
||||||
|
fn sign(&self, claims: &Value) -> Result<String, WalletError> {
|
||||||
|
let b64 = |bytes: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes);
|
||||||
|
let header = b64(br#"{"alg":"RS256","typ":"JWT"}"#);
|
||||||
|
let payload =
|
||||||
|
b64(&serde_json::to_vec(claims).map_err(|e| WalletError::Signing(e.to_string()))?);
|
||||||
|
Ok(format!("{header}.{payload}.fakesig"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fake_signer_payload_is_decodable() {
|
||||||
|
let claims = json!({ "aud": "google", "typ": "savetowallet" });
|
||||||
|
let jwt = FakeGoogleSigner.sign(&claims).unwrap();
|
||||||
|
let parts: Vec<&str> = jwt.split('.').collect();
|
||||||
|
assert_eq!(parts.len(), 3);
|
||||||
|
|
||||||
|
let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||||
|
.decode(parts[1])
|
||||||
|
.unwrap();
|
||||||
|
let decoded: Value = serde_json::from_slice(&payload).unwrap();
|
||||||
|
assert_eq!(decoded["aud"], "google");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
//! Google Wallet generic pass (PRD §10.3). The "Add to Google Wallet" button
|
||||||
|
//! deep-links to `https://pay.google.com/gp/v/save/{JWT}`, where the JWT is an
|
||||||
|
//! RS256-signed `savetowallet` token carrying the GenericObject inline (so no
|
||||||
|
//! Google Wallet API round-trip is needed to mint the link).
|
||||||
|
|
||||||
|
pub mod jwt_signer;
|
||||||
|
pub mod object_builder;
|
||||||
|
|
||||||
|
use crate::error::WalletError;
|
||||||
|
use jwt_signer::GoogleWalletSigner;
|
||||||
|
|
||||||
|
const SAVE_URL_PREFIX: &str = "https://pay.google.com/gp/v/save/";
|
||||||
|
|
||||||
|
/// Inputs for one Google Wallet object (mirrors the Apple `PassInput`).
|
||||||
|
pub struct GoogleInput {
|
||||||
|
/// Service account email (the JWT `iss`).
|
||||||
|
pub issuer_email: String,
|
||||||
|
/// Fully-qualified class id, e.g. `3388000000022195611.cardclaws_generic`.
|
||||||
|
pub class_id: String,
|
||||||
|
/// Fully-qualified object id, e.g. `3388000000022195611.<card-id>`.
|
||||||
|
pub object_id: String,
|
||||||
|
pub holder_name: String,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub company: Option<String>,
|
||||||
|
pub profile_url: String,
|
||||||
|
pub background_hex: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the full "Add to Google Wallet" save URL.
|
||||||
|
pub fn build_save_link(
|
||||||
|
input: &GoogleInput,
|
||||||
|
signer: &dyn GoogleWalletSigner,
|
||||||
|
) -> Result<String, WalletError> {
|
||||||
|
let claims = object_builder::build_claims(input);
|
||||||
|
let jwt = signer.sign(&claims)?;
|
||||||
|
Ok(format!("{SAVE_URL_PREFIX}{jwt}"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
//! Builds the `savetowallet` JWT claims, including the inline GenericObject.
|
||||||
|
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
use super::GoogleInput;
|
||||||
|
|
||||||
|
/// Build the JWT claims for the save link. The GenericObject is embedded under
|
||||||
|
/// `payload.genericObjects` so the link is self-contained.
|
||||||
|
pub fn build_claims(input: &GoogleInput) -> Value {
|
||||||
|
json!({
|
||||||
|
"iss": input.issuer_email,
|
||||||
|
"aud": "google",
|
||||||
|
"typ": "savetowallet",
|
||||||
|
"origins": [],
|
||||||
|
"payload": {
|
||||||
|
"genericObjects": [ build_object(input) ]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_object(input: &GoogleInput) -> Value {
|
||||||
|
let subheader = match (&input.title, &input.company) {
|
||||||
|
(Some(t), Some(c)) => format!("{t} · {c}"),
|
||||||
|
(Some(t), None) => t.clone(),
|
||||||
|
(None, Some(c)) => c.clone(),
|
||||||
|
(None, None) => String::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
json!({
|
||||||
|
"id": input.object_id,
|
||||||
|
"classId": input.class_id,
|
||||||
|
"state": "ACTIVE",
|
||||||
|
"hexBackgroundColor": input.background_hex,
|
||||||
|
"cardTitle": {
|
||||||
|
"defaultValue": { "language": "en", "value": "CardClaws" }
|
||||||
|
},
|
||||||
|
"header": {
|
||||||
|
"defaultValue": { "language": "en", "value": input.holder_name }
|
||||||
|
},
|
||||||
|
"subheader": {
|
||||||
|
"defaultValue": { "language": "en", "value": subheader }
|
||||||
|
},
|
||||||
|
"barcode": {
|
||||||
|
"type": "QR_CODE",
|
||||||
|
"value": input.profile_url,
|
||||||
|
"alternateText": input.profile_url
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn input() -> GoogleInput {
|
||||||
|
GoogleInput {
|
||||||
|
issuer_email: "[email protected]".into(),
|
||||||
|
class_id: "338800.cardclaws_generic".into(),
|
||||||
|
object_id: "338800.card-abc".into(),
|
||||||
|
holder_name: "Omar Sobh".into(),
|
||||||
|
title: Some("Founder".into()),
|
||||||
|
company: Some("RedClaw".into()),
|
||||||
|
profile_url: "https://cardclaws.com/omar".into(),
|
||||||
|
background_hex: "#101014".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn claims_have_savetowallet_shape() {
|
||||||
|
let c = build_claims(&input());
|
||||||
|
assert_eq!(c["aud"], "google");
|
||||||
|
assert_eq!(c["typ"], "savetowallet");
|
||||||
|
assert_eq!(c["iss"], "[email protected]");
|
||||||
|
let obj = &c["payload"]["genericObjects"][0];
|
||||||
|
assert_eq!(obj["id"], "338800.card-abc");
|
||||||
|
assert_eq!(obj["classId"], "338800.cardclaws_generic");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn barcode_is_qr_to_profile() {
|
||||||
|
let c = build_claims(&input());
|
||||||
|
let barcode = &c["payload"]["genericObjects"][0]["barcode"];
|
||||||
|
assert_eq!(barcode["type"], "QR_CODE");
|
||||||
|
assert_eq!(barcode["value"], "https://cardclaws.com/omar");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn header_is_holder_and_subheader_combines_title_company() {
|
||||||
|
let c = build_claims(&input());
|
||||||
|
let obj = &c["payload"]["genericObjects"][0];
|
||||||
|
assert_eq!(obj["header"]["defaultValue"]["value"], "Omar Sobh");
|
||||||
|
assert_eq!(
|
||||||
|
obj["subheader"]["defaultValue"]["value"],
|
||||||
|
"Founder · RedClaw"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
pub mod apple;
|
pub mod apple;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
pub mod google;
|
||||||
pub mod strip_renderer;
|
pub mod strip_renderer;
|
||||||
|
|
||||||
pub use error::WalletError;
|
pub use error::WalletError;
|
||||||
|
|||||||
Reference in New Issue
Block a user