mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-10-05 04:07:36 +08:00
341 lines
12 KiB
Rust
341 lines
12 KiB
Rust
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::Result;
|
|
|
|
use super::{now_ms, Store};
|
|
|
|
const PORT_SETTINGS_KEY: &str = "network_ports";
|
|
const PROXY_SETTINGS_KEY: &str = "outbound_proxy";
|
|
const TAB_SETTINGS_KEY: &str = "cursor_tab";
|
|
const INSTALLATION_ID_KEY: &str = "installation_id";
|
|
|
|
pub const PUBLIC_TAB_SERVICE_URL: &str = "https://tab.leokun.cn";
|
|
|
|
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq, Serialize)]
|
|
pub struct PortSettings {
|
|
pub proxy_port: u16,
|
|
pub service_port: u16,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq, Serialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum ProxyMode {
|
|
#[default]
|
|
System,
|
|
Custom,
|
|
}
|
|
|
|
impl ProxyMode {
|
|
pub fn is_custom(self) -> bool {
|
|
self == Self::Custom
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq, Serialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum TabMode {
|
|
#[default]
|
|
Public,
|
|
Direct,
|
|
Custom,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq, Serialize)]
|
|
pub struct TabSettings {
|
|
pub mode: TabMode,
|
|
pub address: String,
|
|
}
|
|
|
|
impl TabSettings {
|
|
pub fn service_url(&self) -> Option<&str> {
|
|
match self.mode {
|
|
TabMode::Public => Some(PUBLIC_TAB_SERVICE_URL),
|
|
TabMode::Direct => None,
|
|
TabMode::Custom => Some(&self.address),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq, Serialize)]
|
|
pub struct ProxySettingsInput {
|
|
pub mode: ProxyMode,
|
|
pub address: String,
|
|
pub auth_enabled: bool,
|
|
pub username: String,
|
|
pub password: Option<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
|
|
pub struct ProxySettings {
|
|
pub mode: ProxyMode,
|
|
pub address: String,
|
|
pub auth_enabled: bool,
|
|
pub username: String,
|
|
pub has_password: bool,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
|
pub(crate) struct ProxySettingsSecret {
|
|
pub mode: ProxyMode,
|
|
pub address: String,
|
|
pub auth_enabled: bool,
|
|
pub username: String,
|
|
pub password: String,
|
|
}
|
|
|
|
impl Store {
|
|
pub(crate) async fn installation_id(&self) -> Result<String> {
|
|
let generated = uuid::Uuid::new_v4().to_string();
|
|
sqlx::query(
|
|
"INSERT INTO service_settings(setting_key, value_json, updated_at_ms) VALUES (?, ?, ?) ON CONFLICT(setting_key) DO NOTHING",
|
|
)
|
|
.bind(INSTALLATION_ID_KEY)
|
|
.bind(serde_json::to_string(&generated)?)
|
|
.bind(now_ms())
|
|
.execute(&self.pool)
|
|
.await?;
|
|
let value = sqlx::query_scalar::<_, String>(
|
|
"SELECT value_json FROM service_settings WHERE setting_key = ?",
|
|
)
|
|
.bind(INSTALLATION_ID_KEY)
|
|
.fetch_one(&self.pool)
|
|
.await?;
|
|
let installation_id = serde_json::from_str::<String>(&value)?;
|
|
uuid::Uuid::parse_str(&installation_id).map_err(|error| {
|
|
crate::Error::Store(format!("invalid persisted installation ID: {error}"))
|
|
})?;
|
|
Ok(installation_id)
|
|
}
|
|
|
|
pub(crate) async fn proxy_settings_secret(&self) -> Result<ProxySettingsSecret> {
|
|
let value = sqlx::query_scalar::<_, String>(
|
|
"SELECT value_json FROM service_settings WHERE setting_key = ?",
|
|
)
|
|
.bind(PROXY_SETTINGS_KEY)
|
|
.fetch_optional(&self.pool)
|
|
.await?;
|
|
value
|
|
.map(|value| serde_json::from_str(&value).map_err(Into::into))
|
|
.unwrap_or_else(|| Ok(ProxySettingsSecret::default()))
|
|
}
|
|
|
|
pub async fn proxy_settings(&self) -> Result<ProxySettings> {
|
|
let settings = self.proxy_settings_secret().await?;
|
|
Ok(ProxySettings {
|
|
mode: settings.mode,
|
|
address: settings.address,
|
|
auth_enabled: settings.auth_enabled,
|
|
username: settings.username,
|
|
has_password: !settings.password.is_empty(),
|
|
})
|
|
}
|
|
|
|
pub async fn set_proxy_settings(&self, input: ProxySettingsInput) -> Result<ProxySettings> {
|
|
let existing = self.proxy_settings_secret().await?;
|
|
let address = input.address.trim().to_owned();
|
|
if input.mode.is_custom() {
|
|
let parsed = url::Url::parse(&address)
|
|
.map_err(|error| crate::Error::Config(format!("invalid proxy address: {error}")))?;
|
|
if !matches!(parsed.scheme(), "http" | "https" | "socks5" | "socks5h") {
|
|
return Err(crate::Error::Config(
|
|
"proxy address must use http, https, socks5, or socks5h".into(),
|
|
));
|
|
}
|
|
reqwest::Proxy::all(&address)?;
|
|
}
|
|
let password = if input.auth_enabled {
|
|
input
|
|
.password
|
|
.filter(|password| !password.is_empty())
|
|
.unwrap_or(existing.password)
|
|
} else {
|
|
String::new()
|
|
};
|
|
let settings = ProxySettingsSecret {
|
|
mode: input.mode,
|
|
address,
|
|
auth_enabled: input.auth_enabled,
|
|
username: input.username.trim().to_owned(),
|
|
password,
|
|
};
|
|
sqlx::query("INSERT INTO service_settings(setting_key, value_json, updated_at_ms) VALUES (?, ?, ?) ON CONFLICT(setting_key) DO UPDATE SET value_json = excluded.value_json, updated_at_ms = excluded.updated_at_ms")
|
|
.bind(PROXY_SETTINGS_KEY)
|
|
.bind(serde_json::to_string(&settings)?)
|
|
.bind(now_ms())
|
|
.execute(&self.pool)
|
|
.await?;
|
|
self.proxy_settings().await
|
|
}
|
|
|
|
pub async fn tab_settings(&self) -> Result<TabSettings> {
|
|
let value = sqlx::query_scalar::<_, String>(
|
|
"SELECT value_json FROM service_settings WHERE setting_key = ?",
|
|
)
|
|
.bind(TAB_SETTINGS_KEY)
|
|
.fetch_optional(&self.pool)
|
|
.await?;
|
|
value
|
|
.map(|value| serde_json::from_str(&value).map_err(Into::into))
|
|
.unwrap_or_else(|| Ok(TabSettings::default()))
|
|
}
|
|
|
|
pub async fn set_tab_settings(&self, mut settings: TabSettings) -> Result<TabSettings> {
|
|
settings.address = settings.address.trim().trim_end_matches('/').to_owned();
|
|
if settings.mode == TabMode::Custom {
|
|
let parsed = url::Url::parse(&settings.address).map_err(|error| {
|
|
crate::Error::Config(format!("invalid TAB service address: {error}"))
|
|
})?;
|
|
if !matches!(parsed.scheme(), "http" | "https") {
|
|
return Err(crate::Error::Config(
|
|
"TAB service address must use http or https".into(),
|
|
));
|
|
}
|
|
if parsed.host_str().is_none()
|
|
|| parsed.query().is_some()
|
|
|| parsed.fragment().is_some()
|
|
{
|
|
return Err(crate::Error::Config(
|
|
"TAB service address must be a base URL without a query or fragment".into(),
|
|
));
|
|
}
|
|
}
|
|
sqlx::query("INSERT INTO service_settings(setting_key, value_json, updated_at_ms) VALUES (?, ?, ?) ON CONFLICT(setting_key) DO UPDATE SET value_json = excluded.value_json, updated_at_ms = excluded.updated_at_ms")
|
|
.bind(TAB_SETTINGS_KEY)
|
|
.bind(serde_json::to_string(&settings)?)
|
|
.bind(now_ms())
|
|
.execute(&self.pool)
|
|
.await?;
|
|
Ok(settings)
|
|
}
|
|
|
|
pub async fn port_settings(&self) -> Result<PortSettings> {
|
|
let value = sqlx::query_scalar::<_, String>(
|
|
"SELECT value_json FROM service_settings WHERE setting_key = ?",
|
|
)
|
|
.bind(PORT_SETTINGS_KEY)
|
|
.fetch_optional(&self.pool)
|
|
.await?;
|
|
value
|
|
.map(|value| serde_json::from_str(&value).map_err(Into::into))
|
|
.unwrap_or_else(|| Ok(PortSettings::default()))
|
|
}
|
|
|
|
pub async fn set_port_settings(&self, settings: PortSettings) -> Result<()> {
|
|
sqlx::query(
|
|
"INSERT INTO service_settings(setting_key, value_json, updated_at_ms) VALUES (?, ?, ?) ON CONFLICT(setting_key) DO UPDATE SET value_json = excluded.value_json, updated_at_ms = excluded.updated_at_ms",
|
|
)
|
|
.bind(PORT_SETTINGS_KEY)
|
|
.bind(serde_json::to_string(&settings)?)
|
|
.bind(now_ms())
|
|
.execute(&self.pool)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn set_service_port(&self, port: u16) -> Result<()> {
|
|
let mut settings = self.port_settings().await?;
|
|
settings.service_port = port;
|
|
self.set_port_settings(settings).await
|
|
}
|
|
|
|
pub async fn set_proxy_port(&self, port: u16) -> Result<()> {
|
|
let mut settings = self.port_settings().await?;
|
|
settings.proxy_port = port;
|
|
self.set_port_settings(settings).await
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[tokio::test]
|
|
async fn installation_id_is_a_persisted_random_uuid() {
|
|
let directory = tempfile::tempdir().unwrap();
|
|
let database = directory.path().join("installation.db");
|
|
let url = format!("sqlite://{}", database.display());
|
|
let first_store = Store::connect(&url).await.unwrap();
|
|
let first = first_store.installation_id().await.unwrap();
|
|
drop(first_store);
|
|
let second_store = Store::connect(&url).await.unwrap();
|
|
let second = second_store.installation_id().await.unwrap();
|
|
|
|
assert_eq!(first, second);
|
|
assert_eq!(uuid::Uuid::parse_str(&first).unwrap().get_version_num(), 4);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn port_settings_default_to_zero_and_round_trip() {
|
|
let directory = tempfile::tempdir().unwrap();
|
|
let database = directory.path().join("settings.db");
|
|
let store = Store::connect(&format!("sqlite://{}", database.display()))
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
store.port_settings().await.unwrap(),
|
|
PortSettings::default()
|
|
);
|
|
let settings = PortSettings {
|
|
proxy_port: 18_080,
|
|
service_port: 18_081,
|
|
};
|
|
store.set_port_settings(settings).await.unwrap();
|
|
assert_eq!(store.port_settings().await.unwrap(), settings);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn proxy_settings_are_write_only_and_preserve_an_unchanged_password() {
|
|
let store = Store::connect("sqlite::memory:").await.unwrap();
|
|
let saved = store
|
|
.set_proxy_settings(ProxySettingsInput {
|
|
mode: ProxyMode::Custom,
|
|
address: "socks5h://127.0.0.1:1080".into(),
|
|
auth_enabled: true,
|
|
username: "user".into(),
|
|
password: Some("secret".into()),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert!(saved.has_password);
|
|
store
|
|
.set_proxy_settings(ProxySettingsInput {
|
|
mode: ProxyMode::Custom,
|
|
address: "http://127.0.0.1:8080".into(),
|
|
auth_enabled: true,
|
|
username: "user".into(),
|
|
password: None,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
store.proxy_settings_secret().await.unwrap().password,
|
|
"secret"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn tab_settings_default_to_public_and_validate_custom_urls() {
|
|
let store = Store::connect("sqlite::memory:").await.unwrap();
|
|
assert_eq!(store.tab_settings().await.unwrap(), TabSettings::default());
|
|
|
|
let saved = store
|
|
.set_tab_settings(TabSettings {
|
|
mode: TabMode::Custom,
|
|
address: " https://tab.example.com/base/ ".into(),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(saved.address, "https://tab.example.com/base");
|
|
assert_eq!(store.tab_settings().await.unwrap(), saved);
|
|
|
|
assert!(store
|
|
.set_tab_settings(TabSettings {
|
|
mode: TabMode::Custom,
|
|
address: "file:///tmp/tab".into(),
|
|
})
|
|
.await
|
|
.is_err());
|
|
}
|
|
}
|