Skip to main content

headless_lms_models/library/agent_changesets/preview/
token.rs

1use 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
8/// How long a preview link works. It may end up in agent transcripts, so it is bounded; publishing
9/// or discarding the changeset ends it earlier.
10const PREVIEW_TOKEN_LIFETIME_MINUTES: i64 = 120;
11
12const PREVIEW_TOKEN_BYTES: usize = 16;
13
14/// What a valid preview token lets its holder read without logging in: one page of a course, or
15/// all of its pages, as `changeset_id` stages them or as published.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct PreviewClaim {
18    pub course_id: Uuid,
19    /// `None` for the published state of the course.
20    pub changeset_id: Option<Uuid>,
21    /// `None` for a token of all the changeset's changed pages, or all the course's pages when
22    /// published.
23    pub page_id: Option<Uuid>,
24    pub user_id: Uuid,
25}
26
27/// A preview link's token and when it stops working.
28#[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
38/// Mints a short-lived preview token for the page `page_id` of the course, or for all its pages
39/// with `None`, as `changeset_id` stages them or published without one. Also deletes expired
40/// tokens.
41pub 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
73/// Path of the main frontend page that previews the staged changeset, for a token from
74/// [`mint_preview_token`].
75pub fn preview_path(token: &str) -> String {
76    format!("/agent-preview/{token}")
77}
78
79/// Looks up an unexpired token. Errors with `PreconditionFailed` for an unknown or expired one.
80pub 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}