185 lines
5.6 KiB
Rust
185 lines
5.6 KiB
Rust
use std::sync::Arc;
|
|
|
|
use axum::extract::{Path, State};
|
|
use axum::http::{header, StatusCode};
|
|
use axum::response::{Html, IntoResponse, Response};
|
|
use axum::Json;
|
|
use rand::Rng;
|
|
use serde::{Deserialize, Serialize};
|
|
use tracing::warn;
|
|
|
|
use crate::pocketbase::get_superuser_token;
|
|
use crate::state::SharedState;
|
|
|
|
const CODE_LEN: usize = 8;
|
|
const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
|
|
|
|
fn generate_code() -> String {
|
|
let mut rng = rand::rng();
|
|
(0..CODE_LEN)
|
|
.map(|_| CHARSET[rng.random_range(0..CHARSET.len())] as char)
|
|
.collect()
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct ShortenRequest {
|
|
params: String,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct ShortenResponse {
|
|
code: String,
|
|
url: String,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct PbRecord {
|
|
code: String,
|
|
params: String,
|
|
}
|
|
|
|
pub async fn post_shorten(
|
|
State(shared): State<Arc<SharedState>>,
|
|
Json(req): Json<ShortenRequest>,
|
|
) -> Response {
|
|
let state = shared.load_state();
|
|
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
|
|
|
let token = match get_superuser_token(&state).await {
|
|
Ok(t) => t,
|
|
Err(err) => {
|
|
warn!("PocketBase superuser auth failed: {err}");
|
|
return StatusCode::BAD_GATEWAY.into_response();
|
|
}
|
|
};
|
|
|
|
let code = generate_code();
|
|
|
|
let record = PbRecord {
|
|
code: code.clone(),
|
|
params: req.params,
|
|
};
|
|
|
|
let res = state
|
|
.http_client
|
|
.post(format!("{pb_url}/api/collections/short_urls/records"))
|
|
.header("Authorization", format!("Bearer {token}"))
|
|
.json(&record)
|
|
.send()
|
|
.await;
|
|
|
|
match res {
|
|
Ok(resp) if resp.status().is_success() => {
|
|
let body = ShortenResponse {
|
|
url: format!("/s/{code}"),
|
|
code,
|
|
};
|
|
Json(body).into_response()
|
|
}
|
|
Ok(resp) => {
|
|
let status = resp.status();
|
|
let text = resp.text().await.unwrap_or_default();
|
|
warn!("PocketBase create failed ({status}): {text}");
|
|
StatusCode::BAD_GATEWAY.into_response()
|
|
}
|
|
Err(err) => {
|
|
warn!("PocketBase request error: {err}");
|
|
StatusCode::BAD_GATEWAY.into_response()
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn get_short_url(
|
|
State(shared): State<Arc<SharedState>>,
|
|
Path(code): Path<String>,
|
|
) -> Response {
|
|
let state = shared.load_state();
|
|
|
|
if code.is_empty() || code.len() > 20 || !code.bytes().all(|b| b.is_ascii_alphanumeric()) {
|
|
return StatusCode::BAD_REQUEST.into_response();
|
|
}
|
|
|
|
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
|
|
|
let token = match get_superuser_token(&state).await {
|
|
Ok(t) => t,
|
|
Err(err) => {
|
|
warn!("PocketBase superuser auth failed: {err}");
|
|
return StatusCode::BAD_GATEWAY.into_response();
|
|
}
|
|
};
|
|
|
|
let filter = format!("code=\"{code}\"");
|
|
let url = format!(
|
|
"{pb_url}/api/collections/short_urls/records?filter={}&perPage=1",
|
|
urlencoding::encode(&filter)
|
|
);
|
|
|
|
let res = state
|
|
.http_client
|
|
.get(&url)
|
|
.header("Authorization", format!("Bearer {token}"))
|
|
.send()
|
|
.await;
|
|
|
|
match res {
|
|
Ok(resp) if resp.status().is_success() => {
|
|
let json: serde_json::Value = match resp.json().await {
|
|
Ok(v) => v,
|
|
Err(err) => {
|
|
warn!("Failed to parse PocketBase response: {err}");
|
|
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
|
}
|
|
};
|
|
|
|
let params = json["items"]
|
|
.as_array()
|
|
.and_then(|items| items.first())
|
|
.and_then(|item| item["params"].as_str());
|
|
|
|
match params {
|
|
Some(params) => {
|
|
let redirect_url = format!("/dashboard?{params}");
|
|
let og_image_url = format!("{}/api/screenshot?og=1&{params}", state.public_url);
|
|
let og_url = format!("{}/s/{code}", state.public_url);
|
|
let og_title = "Perfect Postcode | Every neighbourhood in England";
|
|
let og_description = "Explore property prices, energy ratings, crime stats, school ratings, and more across England on one interactive map.";
|
|
|
|
let html = format!(
|
|
r#"<!DOCTYPE html>
|
|
<html><head>
|
|
<meta charset="utf-8" />
|
|
<meta property="og:title" content="{og_title}" />
|
|
<meta property="og:description" content="{og_description}" />
|
|
<meta property="og:type" content="website" />
|
|
<meta property="og:url" content="{og_url}" />
|
|
<meta property="og:image" content="{og_image_url}" />
|
|
<meta property="og:image:width" content="1200" />
|
|
<meta property="og:image:height" content="630" />
|
|
<meta name="twitter:card" content="summary_large_image" />
|
|
<meta name="twitter:title" content="{og_title}" />
|
|
<meta name="twitter:description" content="{og_description}" />
|
|
<meta http-equiv="refresh" content="0;url={redirect_url}" />
|
|
<title>{og_title}</title>
|
|
</head><body></body></html>"#
|
|
);
|
|
(
|
|
[(header::CACHE_CONTROL, "public, max-age=86400")],
|
|
Html(html),
|
|
)
|
|
.into_response()
|
|
}
|
|
None => StatusCode::NOT_FOUND.into_response(),
|
|
}
|
|
}
|
|
Ok(resp) => {
|
|
let status = resp.status();
|
|
warn!("PocketBase lookup failed ({status})");
|
|
StatusCode::BAD_GATEWAY.into_response()
|
|
}
|
|
Err(err) => {
|
|
warn!("PocketBase request error: {err}");
|
|
StatusCode::BAD_GATEWAY.into_response()
|
|
}
|
|
}
|
|
}
|