headless_lms_models/library/agent_changesets/preview/
token.rs1use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
2use chrono::Duration;
3use rand::Rng;
4use sha2::{Digest, Sha256};
5
6use crate::{error::missing_model_error, prelude::*};
7
8const PREVIEW_TOKEN_LIFETIME_MINUTES: i64 = 120;
11
12const PREVIEW_TOKEN_BYTES: usize = 16;
13
14#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct PreviewClaim {
18 pub course_id: Uuid,
19 pub changeset_id: Option<Uuid>,
21 pub page_id: Option<Uuid>,
24 pub user_id: Uuid,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct PreviewToken {
30 pub token: String,
31 pub expires_at: DateTime<Utc>,
32}
33
34fn hash_token(token: &str) -> Vec<u8> {
35 Sha256::digest(token.as_bytes()).to_vec()
36}
37
38pub async fn mint_preview_token(
42 conn: &mut PgConnection,
43 course_id: Uuid,
44 changeset_id: Option<Uuid>,
45 page_id: Option<Uuid>,
46 user_id: Uuid,
47) -> ModelResult<PreviewToken> {
48 let mut bytes = [0u8; PREVIEW_TOKEN_BYTES];
49 rand::rng().fill_bytes(&mut bytes);
50 let token = URL_SAFE_NO_PAD.encode(bytes);
51 let expires_at = Utc::now() + Duration::minutes(PREVIEW_TOKEN_LIFETIME_MINUTES);
52
53 sqlx::query!("DELETE FROM agent_changeset_preview_links WHERE expires_at < now()")
54 .execute(&mut *conn)
55 .await?;
56 sqlx::query!(
57 "
58INSERT INTO agent_changeset_preview_links (token_hash, course_id, changeset_id, page_id, user_id, expires_at)
59VALUES ($1, $2, $3, $4, $5, $6)
60",
61 hash_token(&token),
62 course_id,
63 changeset_id,
64 page_id,
65 user_id,
66 expires_at,
67 )
68 .execute(conn)
69 .await?;
70 Ok(PreviewToken { token, expires_at })
71}
72
73pub fn preview_path(token: &str) -> String {
76 format!("/agent-preview/{token}")
77}
78
79pub async fn validate_preview_token(
81 conn: &mut PgConnection,
82 token: &str,
83) -> ModelResult<PreviewClaim> {
84 sqlx::query_as!(
85 PreviewClaim,
86 "
87SELECT course_id, changeset_id, page_id, user_id
88FROM agent_changeset_preview_links
89WHERE token_hash = $1
90 AND expires_at > now()
91",
92 hash_token(token),
93 )
94 .fetch_optional(conn)
95 .await?
96 .ok_or_else(missing_model_error(
97 ModelErrorType::PreconditionFailed,
98 "The preview link is invalid or has expired",
99 ))
100}