Phase 1 foundation: backend, web profile, and mobile app
Greenfield implementation of CardClaws Phase 1 across three surfaces. Backend (Rust/Axum workspace, 67 tests): - cardclaws-types/config/db/auth/api/wallet crates - Auth: register, login + lockout, magic link, refresh rotation, Apple verify - Cards: CRUD, tier-limited publish, duplicate, public handle lookup - Assets: R2 presigned uploads; vCard export - Apple Wallet .pkpass pipeline (PKCS#7 signer behind apple-signing feature) - Analytics ingest + summary with daily-salted IP hashing - Migrations 0001 (incl. cardclaws_sessions) + 0002 analytics Web profile (Astro SSR): cardclaws.com/[handle] hero + flip, contact actions, client-built vCard, visit attribution. Verified end-to-end. Mobile (Expo SDK 51): auth, card list/create, builder v1 (bg/text/logo, palette, undo/redo), Skia/Reanimated viewer (flip + ambient). 22 logic tests. CI: policy/backend/profile/mobile jobs; LOC + no-placeholder lint. Review fixes baked in: `back` (not `cardclaws`) key; strip image is a bundled manifest file (not a URL); sessions table added; NFC reframed; test doubles allowed for external services. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
//! Analytics ingestion + summary (PRD §18). IPs are hashed with a per-day
|
||||
//! rotating salt before storage — never persisted in plaintext (§18.3).
|
||||
|
||||
use cardclaws_db::models::analytics::AnalyticsSummary;
|
||||
use cardclaws_db::queries::analytics;
|
||||
use cardclaws_types::AppError;
|
||||
use sha2::{Digest, Sha256};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::SqlxResultExt;
|
||||
use crate::middleware::rate_limit;
|
||||
use crate::services::card_service;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Client-ingestible event types (PRD §12.2). Server-originated types
|
||||
/// (`profile_visit`) are recorded internally and not accepted from clients.
|
||||
const CLIENT_EVENT_TYPES: &[&str] = &["qr_scan", "contact_save", "link_click", "wallet_add"];
|
||||
|
||||
/// All recordable event types (client + server-originated).
|
||||
const ALL_EVENT_TYPES: &[&str] = &[
|
||||
"profile_visit",
|
||||
"qr_scan",
|
||||
"nfc_tap",
|
||||
"contact_save",
|
||||
"link_click",
|
||||
"wallet_add",
|
||||
"contact_form_submission",
|
||||
"card_share_event",
|
||||
];
|
||||
|
||||
/// Record a client-reported event (PRD §18.1 secondary path). Rate-limited per
|
||||
/// card. Rejects server-only event types.
|
||||
pub async fn ingest_client_event(
|
||||
state: &AppState,
|
||||
card_id: Uuid,
|
||||
event_type: &str,
|
||||
ip: Option<&str>,
|
||||
user_agent: Option<&str>,
|
||||
) -> Result<(), AppError> {
|
||||
if !CLIENT_EVENT_TYPES.contains(&event_type) {
|
||||
return Err(AppError::Validation(format!(
|
||||
"event_type '{event_type}' is not client-ingestible"
|
||||
)));
|
||||
}
|
||||
// 100 events/min per card (§20.4).
|
||||
rate_limit::check(
|
||||
state.cache.as_ref(),
|
||||
&format!("analytics:ingest:{card_id}"),
|
||||
100,
|
||||
60,
|
||||
)
|
||||
.await?;
|
||||
|
||||
record(state, card_id, event_type, ip, user_agent).await
|
||||
}
|
||||
|
||||
/// Record any event type internally (used for server-originated `profile_visit`).
|
||||
/// Errors are swallowed by callers that treat analytics as best-effort.
|
||||
pub async fn record(
|
||||
state: &AppState,
|
||||
card_id: Uuid,
|
||||
event_type: &str,
|
||||
ip: Option<&str>,
|
||||
user_agent: Option<&str>,
|
||||
) -> Result<(), AppError> {
|
||||
debug_assert!(ALL_EVENT_TYPES.contains(&event_type));
|
||||
let ip_hash = ip.map(|raw| hash_ip(&state.ip_hash_secret, raw));
|
||||
analytics::insert_event(
|
||||
&state.db,
|
||||
analytics::NewEvent {
|
||||
card_id,
|
||||
event_type,
|
||||
ip_hash: ip_hash.as_deref(),
|
||||
user_agent,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_db()
|
||||
}
|
||||
|
||||
/// Owner-only metrics summary for a card.
|
||||
pub async fn summary(
|
||||
state: &AppState,
|
||||
card_id: Uuid,
|
||||
user_id: Uuid,
|
||||
) -> Result<AnalyticsSummary, AppError> {
|
||||
card_service::get_owned(state, card_id, user_id).await?;
|
||||
analytics::summary(&state.db, card_id).await.map_db()
|
||||
}
|
||||
|
||||
/// SHA-256 of `date:secret:ip`. The date component rotates the salt daily so a
|
||||
/// hash cannot be correlated across days, while same-day uniqueness is
|
||||
/// preserved for unique-visitor counting (§18.3).
|
||||
fn hash_ip(secret: &str, ip: &str) -> String {
|
||||
let day = chrono::Utc::now().format("%Y-%m-%d");
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(format!("{day}:{secret}:{ip}").as_bytes());
|
||||
hasher
|
||||
.finalize()
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ip_hash_is_stable_within_day_and_ip_specific() {
|
||||
let a = hash_ip("seed", "1.2.3.4");
|
||||
let b = hash_ip("seed", "1.2.3.4");
|
||||
let c = hash_ip("seed", "5.6.7.8");
|
||||
assert_eq!(a, b);
|
||||
assert_ne!(a, c);
|
||||
assert_eq!(a.len(), 64);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
//! Authentication business logic. Handlers stay thin (PRD §9.2) and delegate
|
||||
//! here. This module wires the `cardclaws-auth` primitives to the database and
|
||||
//! cache.
|
||||
|
||||
use chrono::{Duration, Utc};
|
||||
use rand::Rng;
|
||||
|
||||
use cardclaws_auth::token::{generate_opaque_token, hash_token};
|
||||
use cardclaws_auth::{apple, jwt, magic_link, password};
|
||||
use cardclaws_db::queries::{sessions, users};
|
||||
use cardclaws_types::auth::*;
|
||||
use cardclaws_types::{AppError, User};
|
||||
|
||||
use crate::error::SqlxResultExt;
|
||||
use crate::middleware::rate_limit;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Refresh-token lifetime (30 days, PRD §6.8.1).
|
||||
const REFRESH_TTL_DAYS: i64 = 30;
|
||||
|
||||
// ---- Public flows ---------------------------------------------------------
|
||||
|
||||
pub async fn register(state: &AppState, req: RegisterRequest) -> Result<TokenPair, AppError> {
|
||||
crate::validation::validate_email(&req.email)?;
|
||||
crate::validation::validate_password(&req.password)?;
|
||||
crate::validation::validate_handle(&req.handle)?;
|
||||
|
||||
if users::email_or_handle_taken(&state.db, &req.email, &req.handle)
|
||||
.await
|
||||
.map_db()?
|
||||
{
|
||||
return Err(AppError::Conflict("email or handle already in use".into()));
|
||||
}
|
||||
|
||||
let hash = password::hash_password(&req.password)
|
||||
.map_err(|_| AppError::Internal("password hashing failed".into()))?;
|
||||
|
||||
let user = users::insert(
|
||||
&state.db,
|
||||
users::NewUser {
|
||||
email: &req.email,
|
||||
handle: &req.handle,
|
||||
display_name: &req.display_name,
|
||||
password_hash: Some(&hash),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(map_unique_violation)?;
|
||||
|
||||
issue_tokens(state, &user, None).await
|
||||
}
|
||||
|
||||
pub async fn login(
|
||||
state: &AppState,
|
||||
req: LoginRequest,
|
||||
device_fingerprint: Option<&str>,
|
||||
) -> Result<TokenPair, AppError> {
|
||||
// Lockout: 10 failed attempts / 5 min per email (§20.1).
|
||||
let lock_key = format!("login:fail:{}", req.email);
|
||||
|
||||
let user = users::find_by_email(&state.db, &req.email).await.map_db()?;
|
||||
let Some(user) = user else {
|
||||
// Count the attempt even for unknown emails to avoid timing/enumeration.
|
||||
bump_login_failures(state, &lock_key).await?;
|
||||
return Err(AppError::Unauthorized);
|
||||
};
|
||||
|
||||
let Some(stored) = &user.password_hash else {
|
||||
// OAuth/magic-link-only account: no password to check.
|
||||
return Err(AppError::Unauthorized);
|
||||
};
|
||||
|
||||
let ok = password::verify_password(&req.password, stored).unwrap_or(false);
|
||||
if !ok {
|
||||
bump_login_failures(state, &lock_key).await?;
|
||||
return Err(AppError::Unauthorized);
|
||||
}
|
||||
|
||||
issue_tokens(state, &user, device_fingerprint).await
|
||||
}
|
||||
|
||||
/// Always returns Ok(()) regardless of whether the email exists, to avoid
|
||||
/// account enumeration. Only sends a link if the account is real.
|
||||
pub async fn magic_link_request(state: &AppState, req: MagicLinkRequest) -> Result<(), AppError> {
|
||||
crate::validation::validate_email(&req.email)?;
|
||||
|
||||
if let Some(user) = users::find_by_email(&state.db, &req.email).await.map_db()? {
|
||||
let minted = magic_link::mint();
|
||||
let key = format!("magic:{}", minted.hash);
|
||||
state
|
||||
.cache
|
||||
.set_ex(&key, &user.email, magic_link::MAGIC_LINK_TTL_SECS)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(e.0))?;
|
||||
|
||||
let link = format!(
|
||||
"{}/auth/magic?token={}",
|
||||
state.profile_base_url, minted.token
|
||||
);
|
||||
let html = format!(
|
||||
"<p>Tap to sign in to CardClaws:</p><p><a href=\"{link}\">Sign in</a></p>\
|
||||
<p>This link expires in 15 minutes.</p>"
|
||||
);
|
||||
// Best-effort: a transient email failure should not leak account state.
|
||||
let _ = state
|
||||
.email
|
||||
.send(&user.email, "Your CardClaws sign-in link", &html)
|
||||
.await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn magic_link_verify(
|
||||
state: &AppState,
|
||||
req: MagicLinkVerifyRequest,
|
||||
) -> Result<TokenPair, AppError> {
|
||||
let hash = hash_token(&req.token);
|
||||
let key = format!("magic:{hash}");
|
||||
let email = state
|
||||
.cache
|
||||
.get_del(&key)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(e.0))?
|
||||
.ok_or(AppError::Unauthorized)?;
|
||||
|
||||
let user = users::find_by_email(&state.db, &email)
|
||||
.await
|
||||
.map_db()?
|
||||
.ok_or(AppError::Unauthorized)?;
|
||||
|
||||
issue_tokens(state, &user, None).await
|
||||
}
|
||||
|
||||
pub async fn oauth_apple(state: &AppState, req: AppleOAuthRequest) -> Result<TokenPair, AppError> {
|
||||
let claims = apple::verify_identity_token(
|
||||
state.apple.as_ref(),
|
||||
&req.identity_token,
|
||||
&state.apple_audience,
|
||||
)
|
||||
.await
|
||||
.map_err(|_| AppError::Unauthorized)?;
|
||||
|
||||
let email = claims.email.ok_or_else(|| {
|
||||
AppError::BadRequest("Apple did not provide an email for this account".into())
|
||||
})?;
|
||||
|
||||
if let Some(user) = users::find_by_email(&state.db, &email).await.map_db()? {
|
||||
return issue_tokens(state, &user, None).await;
|
||||
}
|
||||
|
||||
// First sign-in: provision an account with a generated unique handle.
|
||||
let display_name = req
|
||||
.display_name
|
||||
.filter(|n| !n.trim().is_empty())
|
||||
.unwrap_or_else(|| email.split('@').next().unwrap_or("user").to_string());
|
||||
let handle = generate_unique_handle(state, &email).await?;
|
||||
|
||||
let user = users::insert(
|
||||
&state.db,
|
||||
users::NewUser {
|
||||
email: &email,
|
||||
handle: &handle,
|
||||
display_name: &display_name,
|
||||
password_hash: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(map_unique_violation)?;
|
||||
|
||||
issue_tokens(state, &user, None).await
|
||||
}
|
||||
|
||||
pub async fn refresh(state: &AppState, req: RefreshRequest) -> Result<TokenPair, AppError> {
|
||||
let hash = hash_token(&req.refresh_token);
|
||||
let session = sessions::find_live_by_hash(&state.db, &hash)
|
||||
.await
|
||||
.map_db()?
|
||||
.ok_or(AppError::Unauthorized)?;
|
||||
|
||||
let user = users::find_by_id(&state.db, session.user_id)
|
||||
.await
|
||||
.map_db()?
|
||||
.ok_or(AppError::Unauthorized)?;
|
||||
|
||||
// Rotate: invalidate the presented token, then mint a fresh pair.
|
||||
sessions::delete(&state.db, session.id).await.map_db()?;
|
||||
issue_tokens(state, &user, session.device_fingerprint.as_deref()).await
|
||||
}
|
||||
|
||||
pub async fn logout(state: &AppState, refresh_token: &str) -> Result<(), AppError> {
|
||||
let hash = hash_token(refresh_token);
|
||||
if let Some(session) = sessions::find_live_by_hash(&state.db, &hash)
|
||||
.await
|
||||
.map_db()?
|
||||
{
|
||||
sessions::delete(&state.db, session.id).await.map_db()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---- Helpers --------------------------------------------------------------
|
||||
|
||||
/// Mint an access JWT + a fresh refresh token, persisting the refresh session.
|
||||
async fn issue_tokens(
|
||||
state: &AppState,
|
||||
user: &User,
|
||||
device_fingerprint: Option<&str>,
|
||||
) -> Result<TokenPair, AppError> {
|
||||
let access_token = state
|
||||
.jwt
|
||||
.encode_access(user.id, user.tier)
|
||||
.map_err(|_| AppError::Internal("token encoding failed".into()))?;
|
||||
|
||||
let refresh_token = generate_opaque_token();
|
||||
let refresh_hash = hash_token(&refresh_token);
|
||||
let expires_at = Utc::now() + Duration::days(REFRESH_TTL_DAYS);
|
||||
|
||||
sessions::insert(
|
||||
&state.db,
|
||||
sessions::NewSession {
|
||||
user_id: user.id,
|
||||
refresh_token_hash: &refresh_hash,
|
||||
device_fingerprint,
|
||||
expires_at,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_db()?;
|
||||
|
||||
Ok(TokenPair {
|
||||
access_token,
|
||||
refresh_token,
|
||||
expires_in: jwt::ACCESS_TOKEN_TTL_SECS,
|
||||
user: AuthUserInfo {
|
||||
id: user.id,
|
||||
email: user.email.clone(),
|
||||
handle: user.handle.clone(),
|
||||
display_name: user.display_name.clone(),
|
||||
tier: user.tier,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async fn bump_login_failures(state: &AppState, key: &str) -> Result<(), AppError> {
|
||||
let count = rate_limit::check(
|
||||
state.cache.as_ref(),
|
||||
key,
|
||||
rate_limit::LOGIN_MAX_FAILS,
|
||||
rate_limit::LOGIN_WINDOW_SECS,
|
||||
)
|
||||
.await;
|
||||
// `check` returns RateLimited once the threshold is crossed; surface that as
|
||||
// an explicit lockout so the client can message it.
|
||||
match count {
|
||||
Err(AppError::RateLimited) => Err(AppError::RateLimited),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a unique handle from an email local part plus random digits on
|
||||
/// collision. Sanitizes to the handle charset and pads short names.
|
||||
async fn generate_unique_handle(state: &AppState, email: &str) -> Result<String, AppError> {
|
||||
let mut base: String = email
|
||||
.split('@')
|
||||
.next()
|
||||
.unwrap_or("user")
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric() || *c == '-')
|
||||
.map(|c| c.to_ascii_lowercase())
|
||||
.collect();
|
||||
if base.chars().count() < 3 {
|
||||
base = format!("user{base}");
|
||||
}
|
||||
base.truncate(24);
|
||||
|
||||
if !users::handle_taken(&state.db, &base).await.map_db()? && !is_reserved(&base) {
|
||||
return Ok(base);
|
||||
}
|
||||
for _ in 0..10 {
|
||||
let suffix: u32 = rand::thread_rng().gen_range(1000..9999);
|
||||
let candidate = format!("{base}{suffix}");
|
||||
if !users::handle_taken(&state.db, &candidate).await.map_db()? {
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
Err(AppError::Internal(
|
||||
"could not allocate a unique handle".into(),
|
||||
))
|
||||
}
|
||||
|
||||
fn is_reserved(handle: &str) -> bool {
|
||||
crate::validation::validate_handle(handle).is_err()
|
||||
}
|
||||
|
||||
/// Map a Postgres unique-violation into a clean 409.
|
||||
fn map_unique_violation(e: sqlx::Error) -> AppError {
|
||||
if let sqlx::Error::Database(db_err) = &e {
|
||||
if db_err.is_unique_violation() {
|
||||
return AppError::Conflict("email or handle already in use".into());
|
||||
}
|
||||
}
|
||||
AppError::Internal(format!("db: {e}"))
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
//! Card CRUD business logic (PRD §13.2). Ownership is enforced on every
|
||||
//! mutating/owned-read path; non-owned access returns `NotFound` rather than
|
||||
//! `Forbidden` so card existence is not leaked.
|
||||
|
||||
use cardclaws_db::models::card::CardRow;
|
||||
use cardclaws_db::queries::{cards, users};
|
||||
use cardclaws_types::{AppError, Tier};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::SqlxResultExt;
|
||||
use crate::services::vcard;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Load a card the caller owns, or `NotFound`.
|
||||
pub async fn get_owned(state: &AppState, id: Uuid, user_id: Uuid) -> Result<CardRow, AppError> {
|
||||
let card = cards::find_by_id(&state.db, id)
|
||||
.await
|
||||
.map_db()?
|
||||
.filter(|c| c.owner_id == user_id)
|
||||
.ok_or_else(|| AppError::NotFound("card".into()))?;
|
||||
Ok(card)
|
||||
}
|
||||
|
||||
pub async fn list(state: &AppState, user_id: Uuid) -> Result<Vec<CardRow>, AppError> {
|
||||
cards::list_by_owner(&state.db, user_id).await.map_db()
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
state: &AppState,
|
||||
user_id: Uuid,
|
||||
handle: &str,
|
||||
definition: &serde_json::Value,
|
||||
) -> Result<CardRow, AppError> {
|
||||
crate::validation::validate_handle(handle)?;
|
||||
require_object(definition)?;
|
||||
|
||||
cards::insert(
|
||||
&state.db,
|
||||
cards::NewCard {
|
||||
owner_id: user_id,
|
||||
handle,
|
||||
definition,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(map_handle_conflict)
|
||||
}
|
||||
|
||||
pub async fn replace(
|
||||
state: &AppState,
|
||||
id: Uuid,
|
||||
user_id: Uuid,
|
||||
definition: &serde_json::Value,
|
||||
) -> Result<CardRow, AppError> {
|
||||
get_owned(state, id, user_id).await?;
|
||||
require_object(definition)?;
|
||||
cards::update_definition(&state.db, id, definition)
|
||||
.await
|
||||
.map_db()
|
||||
}
|
||||
|
||||
/// Shallow-merge the provided top-level keys into the existing definition.
|
||||
pub async fn patch(
|
||||
state: &AppState,
|
||||
id: Uuid,
|
||||
user_id: Uuid,
|
||||
partial: &serde_json::Value,
|
||||
) -> Result<CardRow, AppError> {
|
||||
let existing = get_owned(state, id, user_id).await?;
|
||||
let mut merged = existing.definition.clone();
|
||||
let (Some(base), Some(patch)) = (merged.as_object_mut(), partial.as_object()) else {
|
||||
return Err(AppError::BadRequest(
|
||||
"definition and patch must be JSON objects".into(),
|
||||
));
|
||||
};
|
||||
for (k, v) in patch {
|
||||
base.insert(k.clone(), v.clone());
|
||||
}
|
||||
cards::update_definition(&state.db, id, &merged)
|
||||
.await
|
||||
.map_db()
|
||||
}
|
||||
|
||||
/// Archive (soft-delete): status -> archived. Never destroys the row (§15.2).
|
||||
pub async fn archive(state: &AppState, id: Uuid, user_id: Uuid) -> Result<CardRow, AppError> {
|
||||
get_owned(state, id, user_id).await?;
|
||||
cards::set_status(&state.db, id, "archived").await.map_db()
|
||||
}
|
||||
|
||||
/// Publish: enforce the tier's active-card limit, then status -> active.
|
||||
pub async fn publish(
|
||||
state: &AppState,
|
||||
id: Uuid,
|
||||
user_id: Uuid,
|
||||
tier: Tier,
|
||||
) -> Result<CardRow, AppError> {
|
||||
let card = get_owned(state, id, user_id).await?;
|
||||
|
||||
// Already-active cards re-publish freely; only a draft/archived card
|
||||
// becoming active consumes a slot.
|
||||
if card.status != "active" {
|
||||
if let Some(limit) = tier.active_card_limit() {
|
||||
let active = cards::count_active_for_owner(&state.db, user_id)
|
||||
.await
|
||||
.map_db()?;
|
||||
if active >= limit {
|
||||
return Err(AppError::TierLimit(format!(
|
||||
"your plan allows {limit} active card(s); archive one or upgrade"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
require_object(&card.definition)?;
|
||||
cards::set_status(&state.db, id, "active").await.map_db()
|
||||
}
|
||||
|
||||
pub async fn duplicate(state: &AppState, id: Uuid, user_id: Uuid) -> Result<CardRow, AppError> {
|
||||
let src = get_owned(state, id, user_id).await?;
|
||||
let new_handle = format!("{}-copy-{}", src.handle, short_id());
|
||||
let new_handle = truncate_handle(&new_handle);
|
||||
|
||||
cards::insert(
|
||||
&state.db,
|
||||
cards::NewCard {
|
||||
owner_id: user_id,
|
||||
handle: &new_handle,
|
||||
definition: &src.definition,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(map_handle_conflict)
|
||||
}
|
||||
|
||||
/// Public, unauthenticated lookup — active cards only (§6.6, review A2).
|
||||
pub async fn get_public_by_handle(state: &AppState, handle: &str) -> Result<CardRow, AppError> {
|
||||
cards::find_active_by_handle(&state.db, handle)
|
||||
.await
|
||||
.map_db()?
|
||||
.ok_or_else(|| AppError::NotFound("card".into()))
|
||||
}
|
||||
|
||||
/// Public profile response: the active card plus the owner's display name (the
|
||||
/// name the web profile renders in the hero). Used by the Astro profile.
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PublicProfile {
|
||||
#[serde(flatten)]
|
||||
pub card: CardRow,
|
||||
pub owner_display_name: String,
|
||||
}
|
||||
|
||||
pub async fn public_profile(state: &AppState, handle: &str) -> Result<PublicProfile, AppError> {
|
||||
let card = 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()))?;
|
||||
Ok(PublicProfile {
|
||||
owner_display_name: owner.display_name,
|
||||
card,
|
||||
})
|
||||
}
|
||||
|
||||
/// Render the card's contact data as an RFC 6350 vCard (PRD §13.2, §17.3).
|
||||
pub async fn export_vcf(state: &AppState, id: Uuid, user_id: Uuid) -> Result<String, AppError> {
|
||||
let card = get_owned(state, 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);
|
||||
Ok(vcard::build_vcard(&owner.display_name, &contact))
|
||||
}
|
||||
|
||||
// ---- Helpers --------------------------------------------------------------
|
||||
|
||||
fn require_object(definition: &serde_json::Value) -> Result<(), AppError> {
|
||||
if definition.is_object() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AppError::Validation(
|
||||
"card definition must be a JSON object".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn map_handle_conflict(e: sqlx::Error) -> AppError {
|
||||
if let sqlx::Error::Database(db_err) = &e {
|
||||
if db_err.is_unique_violation() {
|
||||
return AppError::Conflict("handle already in use".into());
|
||||
}
|
||||
}
|
||||
AppError::Internal(format!("db: {e}"))
|
||||
}
|
||||
|
||||
fn short_id() -> String {
|
||||
Uuid::new_v4().simple().to_string()[..8].to_string()
|
||||
}
|
||||
|
||||
fn truncate_handle(handle: &str) -> String {
|
||||
handle.chars().take(30).collect()
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod analytics_service;
|
||||
pub mod auth_service;
|
||||
pub mod card_service;
|
||||
pub mod vcard;
|
||||
pub mod wallet_service;
|
||||
@@ -0,0 +1,149 @@
|
||||
//! vCard 3.0 (RFC 6350) generation from a card's contact data (PRD §17.3).
|
||||
//!
|
||||
//! The card `definition` is stored as opaque JSON, so we extract contact fields
|
||||
//! from the first `contact`-type layer found on either side, falling back to the
|
||||
//! account display name for the formatted name.
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ContactInfo {
|
||||
pub phone: Option<String>,
|
||||
pub email: Option<String>,
|
||||
pub website: Option<String>,
|
||||
pub linkedin: Option<String>,
|
||||
pub company: Option<String>,
|
||||
pub title: Option<String>,
|
||||
}
|
||||
|
||||
/// Pull contact fields out of a card definition by scanning face/back layers for
|
||||
/// the first `{"type":"contact","fields":{...}}` layer.
|
||||
pub fn extract_contact(definition: &serde_json::Value) -> ContactInfo {
|
||||
for side in ["face", "back"] {
|
||||
if let Some(layers) = definition
|
||||
.get(side)
|
||||
.and_then(|s| s.get("layers"))
|
||||
.and_then(|l| l.as_array())
|
||||
{
|
||||
for layer in layers {
|
||||
if layer.get("type").and_then(|t| t.as_str()) == Some("contact") {
|
||||
if let Some(fields) = layer.get("fields") {
|
||||
if let Ok(info) = serde_json::from_value::<ContactInfo>(fields.clone()) {
|
||||
return info;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ContactInfo::default()
|
||||
}
|
||||
|
||||
/// Build an RFC 6350 vCard 3.0 string. `display_name` becomes FN; the structured
|
||||
/// N field is a best-effort split on the first space.
|
||||
pub fn build_vcard(display_name: &str, contact: &ContactInfo) -> String {
|
||||
let (first, last) = split_name(display_name);
|
||||
let mut lines = vec![
|
||||
"BEGIN:VCARD".to_string(),
|
||||
"VERSION:3.0".to_string(),
|
||||
format!("FN:{}", escape(display_name)),
|
||||
format!("N:{};{};;;", escape(&last), escape(&first)),
|
||||
];
|
||||
if let Some(org) = &contact.company {
|
||||
lines.push(format!("ORG:{}", escape(org)));
|
||||
}
|
||||
if let Some(title) = &contact.title {
|
||||
lines.push(format!("TITLE:{}", escape(title)));
|
||||
}
|
||||
if let Some(phone) = &contact.phone {
|
||||
lines.push(format!("TEL;TYPE=CELL:{}", escape(phone)));
|
||||
}
|
||||
if let Some(email) = &contact.email {
|
||||
lines.push(format!("EMAIL:{}", escape(email)));
|
||||
}
|
||||
if let Some(url) = &contact.website {
|
||||
lines.push(format!("URL:{}", escape(url)));
|
||||
}
|
||||
if let Some(linkedin) = &contact.linkedin {
|
||||
lines.push(format!(
|
||||
"X-SOCIALPROFILE;TYPE=linkedin:{}",
|
||||
escape(linkedin)
|
||||
));
|
||||
}
|
||||
lines.push("END:VCARD".to_string());
|
||||
// vCard uses CRLF line endings.
|
||||
lines.join("\r\n") + "\r\n"
|
||||
}
|
||||
|
||||
fn split_name(display_name: &str) -> (String, String) {
|
||||
match display_name.split_once(' ') {
|
||||
Some((first, last)) => (first.to_string(), last.to_string()),
|
||||
None => (display_name.to_string(), String::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Escape the characters that are special in vCard property values.
|
||||
fn escape(value: &str) -> String {
|
||||
value
|
||||
.replace('\\', "\\\\")
|
||||
.replace(';', "\\;")
|
||||
.replace(',', "\\,")
|
||||
.replace('\n', "\\n")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn extracts_contact_from_back_layer() {
|
||||
let def = json!({
|
||||
"face": { "layers": [] },
|
||||
"back": { "layers": [
|
||||
{ "type": "text", "text": "Omar" },
|
||||
{ "type": "contact", "fields": {
|
||||
"phone": "+15551234567",
|
||||
"email": "[email protected]",
|
||||
"company": "RedClaw",
|
||||
"title": "Founder"
|
||||
}}
|
||||
]}
|
||||
});
|
||||
let c = extract_contact(&def);
|
||||
assert_eq!(c.email.as_deref(), Some("[email protected]"));
|
||||
assert_eq!(c.company.as_deref(), Some("RedClaw"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_valid_vcard_structure() {
|
||||
let contact = ContactInfo {
|
||||
phone: Some("+15551234567".into()),
|
||||
email: Some("[email protected]".into()),
|
||||
company: Some("RedClaw".into()),
|
||||
title: Some("Founder".into()),
|
||||
website: Some("https://redclaw.dev".into()),
|
||||
linkedin: None,
|
||||
};
|
||||
let vcf = build_vcard("Omar Sobh", &contact);
|
||||
assert!(vcf.starts_with("BEGIN:VCARD\r\nVERSION:3.0\r\n"));
|
||||
assert!(vcf.contains("FN:Omar Sobh\r\n"));
|
||||
assert!(vcf.contains("N:Sobh;Omar;;;\r\n"));
|
||||
assert!(vcf.contains("ORG:RedClaw\r\n"));
|
||||
assert!(vcf.contains("TITLE:Founder\r\n"));
|
||||
assert!(vcf.contains("TEL;TYPE=CELL:+15551234567\r\n"));
|
||||
assert!(vcf.contains("EMAIL:[email protected]\r\n"));
|
||||
assert!(vcf.trim_end().ends_with("END:VCARD"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escapes_special_characters() {
|
||||
let contact = ContactInfo {
|
||||
company: Some("Red;Claw, Inc".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let vcf = build_vcard("Solo", &contact);
|
||||
assert!(vcf.contains("ORG:Red\\;Claw\\, Inc"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//! Builds an Apple Wallet `.pkpass` for a card (PRD §10.1). Pulls contact data
|
||||
//! out of the card definition (reusing the vCard extractor) and the holder name
|
||||
//! from the account.
|
||||
|
||||
use cardclaws_db::queries::users;
|
||||
use cardclaws_types::AppError;
|
||||
use cardclaws_wallet::apple::{build_pkpass, PassInput};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::SqlxResultExt;
|
||||
use crate::services::{card_service, vcard};
|
||||
use crate::state::AppState;
|
||||
|
||||
pub async fn apple_pkpass(
|
||||
state: &AppState,
|
||||
card_id: Uuid,
|
||||
user_id: Uuid,
|
||||
) -> Result<Vec<u8>, 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 input = PassInput {
|
||||
serial_number: card.id.to_string(),
|
||||
pass_type_id: state.wallet.apple_pass_type_id.clone(),
|
||||
team_id: state.wallet.apple_team_id.clone(),
|
||||
organization_name: state.wallet.organization_name.clone(),
|
||||
holder_name: owner.display_name,
|
||||
title: contact.title,
|
||||
company: contact.company,
|
||||
email: contact.email,
|
||||
phone: contact.phone,
|
||||
website: contact.website,
|
||||
profile_url: format!("{}/{}", state.profile_base_url, card.handle),
|
||||
background_hex: background_hex(&card.definition),
|
||||
};
|
||||
|
||||
build_pkpass(&input, &state.brand, state.pass_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 {
|
||||
definition
|
||||
.get("face")
|
||||
.and_then(|f| f.get("background"))
|
||||
.and_then(|b| {
|
||||
if b.get("type").and_then(|t| t.as_str()) == Some("solid") {
|
||||
b.get("value").and_then(|v| v.as_str())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or("#101014")
|
||||
.to_string()
|
||||
}
|
||||
Reference in New Issue
Block a user