Skip to main content

codoseo_web/routes/
rankorg.rs

1//! `/go/rankorg`: counts a click and sends the visitor to RankOrg with their domain and top
2//! pages. Links on the audit preview and the explorer point here instead of straight at
3//! RankOrg, so every click lands in the funnel. Cloud only.
4
5use axum::Router;
6use axum::extract::{OriginalUri, Query, State};
7use axum::response::{IntoResponse, Redirect, Response};
8use axum::routing::get;
9use codoseo_store::events::{self, EventKind};
10use serde::Deserialize;
11use serde_json::json;
12use uuid::Uuid;
13
14use super::quick::require_cloud;
15use crate::auth::{CurrentUser, urlencode};
16use crate::error::AppError;
17use crate::rankorg;
18use crate::state::AppState;
19
20/// How many pages go to RankOrg.
21const TOP_PAGES: i64 = 10;
22
23pub fn routes() -> Router<AppState> {
24    Router::new().route("/go/rankorg", get(go))
25}
26
27#[derive(Deserialize)]
28pub struct GoQuery {
29    src: Option<String>,
30    audit: Option<String>,
31    site: Option<String>,
32}
33
34fn parse_id(raw: Option<&str>) -> Result<Uuid, AppError> {
35    raw.and_then(|r| Uuid::parse_str(r).ok())
36        .ok_or(AppError::NotFound)
37}
38
39async fn go(
40    State(state): State<AppState>,
41    user: Option<CurrentUser>,
42    OriginalUri(uri): OriginalUri,
43    Query(q): Query<GoQuery>,
44) -> Result<Response, AppError> {
45    require_cloud(&state)?;
46    let pool = &state.pool;
47    let (domain, pages, medium, account_id, site_id, payload) = match q.src.as_deref() {
48        // The public audit preview: anyone with the report's link.
49        Some("audit") => {
50            let id = parse_id(q.audit.as_deref())?;
51            let audit = codoseo_store::quick::get(pool, id)
52                .await?
53                .ok_or(AppError::NotFound)?;
54            let pages = codoseo_store::reports::top_pages_by_inlinks(pool, id, TOP_PAGES).await?;
55            (
56                audit.domain,
57                pages,
58                "audit_preview",
59                None,
60                Some(audit.site_id),
61                json!({ "src": "audit", "crawl_id": id }),
62            )
63        }
64        // The explorer: the owner of the site only.
65        Some("explorer") => {
66            let id = parse_id(q.site.as_deref())?;
67            let Some(user) = user else {
68                let back = uri.path_and_query().map_or("/", |p| p.as_str());
69                return Ok(
70                    Redirect::to(&format!("/login?next={}", urlencode(back))).into_response()
71                );
72            };
73            let site = codoseo_store::sites::get_for_account(pool, user.id(), id)
74                .await?
75                .ok_or(AppError::NotFound)?;
76            let pages = match codoseo_store::crawls::latest_done(pool, site.id).await? {
77                Some(crawl) => {
78                    codoseo_store::reports::top_pages_by_inlinks(pool, crawl.id, TOP_PAGES).await?
79                }
80                None => Vec::new(),
81            };
82            (
83                site.domain,
84                pages,
85                "explorer",
86                Some(user.id()),
87                Some(site.id),
88                json!({ "src": "explorer" }),
89            )
90        }
91        _ => return Err(AppError::NotFound),
92    };
93
94    events::record(
95        pool,
96        EventKind::RankorgClick,
97        account_id,
98        site_id,
99        Some(payload),
100    )
101    .await?;
102    let to = rankorg::link(&state.config.rankorg_url, &domain, &pages, medium);
103    Ok(Redirect::to(to.as_str()).into_response())
104}