Skip to main content

fn0_deploy/
domain.rs

1use anyhow::{Result, anyhow};
2use serde::{Deserialize, Serialize};
3
4#[derive(Serialize)]
5struct DomainAddInput<'a> {
6    project_id: &'a str,
7    domain: &'a str,
8}
9
10#[derive(Deserialize)]
11#[serde(tag = "t", rename_all_fields = "camelCase")]
12enum DomainAdd {
13    Ok,
14    NotLoggedIn,
15    NotFound,
16    InvalidDomain { message: String },
17    DomainTaken { existing_project_id: String },
18    AlreadyHasDomain { current_domain: String },
19    InternalError,
20}
21
22pub async fn domain_add(project_id: &str, domain: &str) -> Result<()> {
23    let creds = crate::credentials::require()?;
24    let client = reqwest::Client::new();
25    let url = format!(
26        "{}/__forte_action/domain_add",
27        creds.control_url.trim_end_matches('/')
28    );
29    let resp = client
30        .post(&url)
31        .bearer_auth(&creds.token)
32        .json(&DomainAddInput { project_id, domain })
33        .send()
34        .await?
35        .error_for_status()?;
36    let raw: DomainAdd = resp.json().await?;
37    match raw {
38        DomainAdd::Ok => {
39            println!("domain '{domain}' attached to project '{project_id}'");
40            println!(
41                "Cloudflare hostname registration is queued; run `fn0 domain status` to check."
42            );
43            Ok(())
44        }
45        DomainAdd::NotLoggedIn => Err(anyhow!("control rejected token; run `fn0 login` again.")),
46        DomainAdd::NotFound => Err(anyhow!(
47            "project '{project_id}' not found or not owned by you."
48        )),
49        DomainAdd::InvalidDomain { message } => Err(anyhow!("invalid domain: {message}")),
50        DomainAdd::DomainTaken {
51            existing_project_id,
52        } => Err(anyhow!(
53            "domain '{domain}' already in use by project '{existing_project_id}'"
54        )),
55        DomainAdd::AlreadyHasDomain { current_domain } => Err(anyhow!(
56            "project '{project_id}' already has domain '{current_domain}'; remove it first"
57        )),
58        DomainAdd::InternalError => Err(anyhow!("domain_add: server error; check fn0-control logs")),
59    }
60}
61
62#[derive(Serialize)]
63struct DomainProjectInput<'a> {
64    project_id: &'a str,
65}
66
67#[derive(Deserialize)]
68#[serde(tag = "t", rename_all_fields = "camelCase")]
69enum DomainRemove {
70    Ok { removed_domain: String },
71    NotLoggedIn,
72    NotFound,
73    NoDomain,
74    InternalError,
75}
76
77pub async fn domain_remove(project_id: &str) -> Result<()> {
78    let creds = crate::credentials::require()?;
79    let client = reqwest::Client::new();
80    let url = format!(
81        "{}/__forte_action/domain_remove",
82        creds.control_url.trim_end_matches('/')
83    );
84    let resp = client
85        .post(&url)
86        .bearer_auth(&creds.token)
87        .json(&DomainProjectInput { project_id })
88        .send()
89        .await?
90        .error_for_status()?;
91    let raw: DomainRemove = resp.json().await?;
92    match raw {
93        DomainRemove::Ok { removed_domain } => {
94            println!("domain '{removed_domain}' detached from project '{project_id}'");
95            println!("Cloudflare hostname removal is queued.");
96            Ok(())
97        }
98        DomainRemove::NotLoggedIn => Err(anyhow!("control rejected token; run `fn0 login` again.")),
99        DomainRemove::NotFound => Err(anyhow!(
100            "project '{project_id}' not found or not owned by you."
101        )),
102        DomainRemove::NoDomain => Err(anyhow!(
103            "no custom domain attached to project '{project_id}'."
104        )),
105        DomainRemove::InternalError => Err(anyhow!(
106            "domain_remove: server error; check fn0-control logs"
107        )),
108    }
109}
110
111#[derive(Deserialize)]
112#[serde(tag = "t", rename_all_fields = "camelCase")]
113enum DomainStatus {
114    NotConfigured,
115    Configured {
116        domain: String,
117        cloudflare_status: CloudflareStatus,
118    },
119    NotLoggedIn,
120    NotFound,
121    InternalError,
122}
123
124#[derive(Deserialize)]
125#[serde(tag = "t", rename_all_fields = "camelCase")]
126enum CloudflareStatus {
127    Active,
128    Pending,
129    Missing,
130    Other { value: String },
131}
132
133pub async fn domain_status(project_id: &str) -> Result<()> {
134    let creds = crate::credentials::require()?;
135    let client = reqwest::Client::new();
136    let url = format!(
137        "{}/__forte_action/domain_status",
138        creds.control_url.trim_end_matches('/')
139    );
140    let resp = client
141        .post(&url)
142        .bearer_auth(&creds.token)
143        .json(&DomainProjectInput { project_id })
144        .send()
145        .await?
146        .error_for_status()?;
147    let raw: DomainStatus = resp.json().await?;
148    match raw {
149        DomainStatus::NotConfigured => {
150            println!("project '{project_id}' has no custom domain configured.");
151            Ok(())
152        }
153        DomainStatus::Configured {
154            domain,
155            cloudflare_status,
156        } => {
157            println!("project '{project_id}' custom domain: {domain}");
158            println!(
159                "cloudflare status: {}",
160                format_cloudflare_status(&cloudflare_status)
161            );
162            Ok(())
163        }
164        DomainStatus::NotLoggedIn => Err(anyhow!("control rejected token; run `fn0 login` again.")),
165        DomainStatus::NotFound => Err(anyhow!(
166            "project '{project_id}' not found or not owned by you."
167        )),
168        DomainStatus::InternalError => Err(anyhow!(
169            "domain_status: server error; check fn0-control logs"
170        )),
171    }
172}
173
174fn format_cloudflare_status(status: &CloudflareStatus) -> String {
175    match status {
176        CloudflareStatus::Active => "active".to_string(),
177        CloudflareStatus::Pending => "pending (waiting for DV verification)".to_string(),
178        CloudflareStatus::Missing => {
179            "missing on Cloudflare (registration may still be in progress)".to_string()
180        }
181        CloudflareStatus::Other { value } => format!("other: {value}"),
182    }
183}