Files
cursor-byok/server_backup/src/control/ads.rs
T
2026-08-29 22:05:04 +08:00

210 lines
5.9 KiB
Rust

//! Advertisement service contract and desktop HTTP handler.
use axum::{
extract::{Path, State},
http::{HeaderMap, StatusCode},
Json,
};
use serde::{Deserialize, Serialize};
use url::Url;
use crate::{Error, Result};
use super::ControlService;
// 此广告拉取不涉及用户隐私,用户id随机产生
// 开源项目广告为作者唯一收入来源,PR时请勿去除此广告
pub(super) const ADS_ENDPOINT: &str = "https://tab.leokun.cn/api/v1/ads?placement=menu";
pub(super) const DEVICE_ID_HEADER: &str = "X-Cursor-Assistant-Device-ID";
pub(super) const OS_HEADER: &str = "X-Cursor-Assistant-OS";
pub(super) const APP_VERSION_HEADER: &str = "X-Cursor-Assistant-Version";
pub(super) const DISABLED_AD_IDS_HEADER: &str = "disable-ad-ids";
pub(super) const LANGUAGE_HEADER: &str = "accept-language";
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct AdRuntime {
pub slots: Vec<AdSlot>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AdSlot {
pub id: String,
pub enabled: bool,
pub placement: AdPlacement,
pub target: AdTarget,
pub content: AdContent,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AdPlacement {
Menu,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AdTarget {
pub title: String,
pub description: String,
pub image_url: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AdContent {
pub title: String,
pub description: String,
pub image_url: String,
pub details: Vec<AdDetail>,
pub button: AdButton,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct AdDetail {
pub label: String,
pub value: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct AdButton {
pub label: String,
pub action: AdAction,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct AdAction {
#[serde(rename = "type")]
pub action_type: AdActionType,
pub url: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct AdDismissalInput {
pub reason: String,
}
#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AdActionType {
OpenBrowser,
}
impl AdRuntime {
pub(super) fn into_menu_slots(mut self) -> Result<Self> {
self.slots
.retain(|slot| slot.enabled && slot.placement == AdPlacement::Menu);
for slot in &self.slots {
validate_http_url(&slot.target.image_url, "target.imageUrl")?;
validate_http_url(&slot.content.image_url, "content.imageUrl")?;
validate_http_url(&slot.content.button.action.url, "content.button.action.url")?;
}
Ok(self)
}
}
fn validate_http_url(value: &str, field: &str) -> Result<()> {
let url = Url::parse(value)
.map_err(|error| Error::Provider(format!("advertisement {field} is invalid: {error}")))?;
if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
return Err(Error::Provider(format!(
"advertisement {field} must be an absolute HTTP or HTTPS URL"
)));
}
Ok(())
}
pub async fn get(
State(service): State<ControlService>,
headers: HeaderMap,
) -> Result<Json<AdRuntime>> {
let disabled_ad_ids = headers
.get(DISABLED_AD_IDS_HEADER)
.and_then(|value| value.to_str().ok());
Ok(Json(
service.ads(disabled_ad_ids, ad_language(&headers)).await?,
))
}
fn ad_language(headers: &HeaderMap) -> &'static str {
match headers
.get(LANGUAGE_HEADER)
.and_then(|value| value.to_str().ok())
{
Some(value) if value.eq_ignore_ascii_case("zh-CN") => "zh-CN",
_ => "en-US",
}
}
pub async fn dismiss(
State(service): State<ControlService>,
Path(ad_id): Path<String>,
Json(input): Json<AdDismissalInput>,
) -> Result<StatusCode> {
service.dismiss_ad(&ad_id, &input).await?;
Ok(StatusCode::NO_CONTENT)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_supported_ad_language_and_falls_back_to_english() {
let mut headers = HeaderMap::new();
headers.insert(LANGUAGE_HEADER, "zh-CN".parse().unwrap());
assert_eq!(ad_language(&headers), "zh-CN");
headers.insert(LANGUAGE_HEADER, "fr-FR".parse().unwrap());
assert_eq!(ad_language(&headers), "en-US");
}
#[test]
fn filters_disabled_slots_without_limiting_menu_ads() {
let slot = |id: &str, enabled| AdSlot {
id: id.into(),
enabled,
placement: AdPlacement::Menu,
target: AdTarget {
title: id.into(),
description: String::new(),
image_url: "https://example.com/target.png".into(),
},
content: AdContent {
title: id.into(),
description: String::new(),
image_url: "https://example.com/content.png".into(),
details: Vec::new(),
button: AdButton {
label: "Open".into(),
action: AdAction {
action_type: AdActionType::OpenBrowser,
url: "https://example.com".into(),
},
},
},
};
let runtime = AdRuntime {
slots: vec![
slot("one", true),
slot("disabled", false),
slot("two", true),
slot("three", true),
slot("four", true),
],
}
.into_menu_slots()
.unwrap();
assert_eq!(
runtime
.slots
.iter()
.map(|slot| slot.id.as_str())
.collect::<Vec<_>>(),
["one", "two", "three", "four"]
);
}
}