use axum::Json;
use axum::extract::{Path, State};
use axum::http::{HeaderMap, StatusCode};
use serde::Deserialize;
use super::common::{
ApiResult, bad_payload, edit_token_header, err, fetch_token_hash, require_token,
};
use crate::cloud_server::auth::{AppState, sha256_hex};
use crate::cloud_server::helpers::internal_error;
const LINK_CODE_TTL_MINUTES: i32 = 10;
const MAX_ACTIVE_LINK_CODES_PER_CARD: i64 = 3;
pub(in crate::cloud_server) async fn link_start(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
) -> ApiResult<Json<serde_json::Value>> {
let token =
edit_token_header(&headers).ok_or_else(|| err(StatusCode::FORBIDDEN, "forbidden"))?;
let client = state.pool.get().await.map_err(internal_error)?;
let stored = fetch_token_hash(&client, &id).await?;
require_token(&token, &stored)?;
client
.execute(
"DELETE FROM wrapped_link_codes WHERE expires_at < now()",
&[],
)
.await
.map_err(internal_error)?;
let active: i64 = client
.query_one(
"SELECT count(*) FROM wrapped_link_codes WHERE card_id = $1",
&[&id],
)
.await
.map_err(internal_error)?
.get(0);
if active >= MAX_ACTIVE_LINK_CODES_PER_CARD {
return Err(err(StatusCode::TOO_MANY_REQUESTS, "rate_limited"));
}
let code = generate_link_code();
client
.execute(
"INSERT INTO wrapped_link_codes (code_hash, card_id, expires_at) \
VALUES ($1, $2, now() + make_interval(mins => $3))",
&[&sha256_hex(&code), &id, &LINK_CODE_TTL_MINUTES],
)
.await
.map_err(internal_error)?;
Ok(Json(serde_json::json!({
"code": code,
"expires_in_secs": i64::from(LINK_CODE_TTL_MINUTES) * 60,
})))
}
#[derive(Deserialize)]
pub(in crate::cloud_server) struct LinkCompleteBody {
pub code: String,
}
pub(in crate::cloud_server) async fn link_complete(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
Json(body): Json<LinkCompleteBody>,
) -> ApiResult<Json<serde_json::Value>> {
let token =
edit_token_header(&headers).ok_or_else(|| err(StatusCode::FORBIDDEN, "forbidden"))?;
let client = state.pool.get().await.map_err(internal_error)?;
let stored = fetch_token_hash(&client, &id).await?;
require_token(&token, &stored)?;
let code = body.code.trim().to_uppercase().replace('-', "");
if code.is_empty() || code.len() > 32 {
return Err(bad_payload());
}
let row = client
.query_opt(
"DELETE FROM wrapped_link_codes \
WHERE code_hash = $1 AND expires_at >= now() \
RETURNING card_id",
&[&sha256_hex(&format_link_code(&code))],
)
.await
.map_err(internal_error)?;
let Some(row) = row else {
return Err(err(StatusCode::NOT_FOUND, "code_invalid_or_expired"));
};
let initiator_id: String = row.get(0);
if initiator_id == id {
return Err(bad_payload());
}
let group: String = client
.query_one(
"SELECT COALESCE(link_group, publisher_id, id) FROM wrapped_cards WHERE id = $1",
&[&initiator_id],
)
.await
.map_err(internal_error)?
.get(0);
let joined = client
.execute(
"UPDATE wrapped_cards \
SET link_group = $1 \
WHERE id IN ($2, $3) \
OR link_group IN (SELECT link_group FROM wrapped_cards \
WHERE id IN ($2, $3) AND link_group IS NOT NULL) \
OR (publisher_id IS NOT NULL AND publisher_id IN \
(SELECT publisher_id FROM wrapped_cards \
WHERE id IN ($2, $3) AND publisher_id IS NOT NULL))",
&[&group, &initiator_id, &id],
)
.await
.map_err(internal_error)?;
tracing::info!(group, joined, "wrapped cards linked (login-less)");
Ok(Json(
serde_json::json!({ "linked": true, "group_size": joined }),
))
}
pub(in crate::cloud_server) fn generate_link_code() -> String {
const ALPHABET: &[u8] = b"ABCDEFGHJKMNPQRSTUVWXYZ23456789";
let bytes: [u8; 8] = rand::random();
let chars: String = bytes
.iter()
.map(|b| ALPHABET[*b as usize % ALPHABET.len()] as char)
.collect();
format!("{}-{}", &chars[..4], &chars[4..])
}
pub(in crate::cloud_server) fn format_link_code(bare: &str) -> String {
if bare.len() == 8 {
format!("{}-{}", &bare[..4], &bare[4..])
} else {
bare.to_string()
}
}