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 => {
59            Err(anyhow!("domain_add: server error; check fn0-control logs"))
60        }
61    }
62}
63
64#[derive(Serialize)]
65struct DomainProjectInput<'a> {
66    project_id: &'a str,
67}
68
69#[derive(Deserialize)]
70#[serde(tag = "t", rename_all_fields = "camelCase")]
71enum DomainRemove {
72    Ok { removed_domain: String },
73    NotLoggedIn,
74    NotFound,
75    NoDomain,
76    InternalError,
77}
78
79pub async fn domain_remove(project_id: &str) -> Result<()> {
80    let creds = crate::credentials::require()?;
81    let client = reqwest::Client::new();
82    let url = format!(
83        "{}/__forte_action/domain_remove",
84        creds.control_url.trim_end_matches('/')
85    );
86    let resp = client
87        .post(&url)
88        .bearer_auth(&creds.token)
89        .json(&DomainProjectInput { project_id })
90        .send()
91        .await?
92        .error_for_status()?;
93    let raw: DomainRemove = resp.json().await?;
94    match raw {
95        DomainRemove::Ok { removed_domain } => {
96            println!("domain '{removed_domain}' detached from project '{project_id}'");
97            println!("Cloudflare hostname removal is queued.");
98            Ok(())
99        }
100        DomainRemove::NotLoggedIn => Err(anyhow!("control rejected token; run `fn0 login` again.")),
101        DomainRemove::NotFound => Err(anyhow!(
102            "project '{project_id}' not found or not owned by you."
103        )),
104        DomainRemove::NoDomain => Err(anyhow!(
105            "no custom domain attached to project '{project_id}'."
106        )),
107        DomainRemove::InternalError => Err(anyhow!(
108            "domain_remove: server error; check fn0-control logs"
109        )),
110    }
111}
112
113#[derive(Deserialize)]
114#[serde(tag = "t", rename_all_fields = "camelCase")]
115pub enum DomainStatus {
116    NotConfigured,
117    Configured {
118        domain: String,
119        cloudflare_status: CloudflareStatus,
120    },
121    NotLoggedIn,
122    NotFound,
123    InternalError,
124}
125
126#[derive(Deserialize)]
127#[serde(tag = "t", rename_all_fields = "camelCase")]
128pub enum CloudflareStatus {
129    Active,
130    Pending,
131    Missing,
132    Other { value: String },
133}
134
135pub async fn fetch_domain_status(
136    creds: &crate::credentials::Credentials,
137    project_id: &str,
138) -> Result<DomainStatus> {
139    let client = reqwest::Client::new();
140    let url = format!(
141        "{}/__forte_action/domain_status",
142        creds.control_url.trim_end_matches('/')
143    );
144    let resp = client
145        .post(&url)
146        .bearer_auth(&creds.token)
147        .json(&DomainProjectInput { project_id })
148        .send()
149        .await?
150        .error_for_status()?;
151    Ok(resp.json().await?)
152}
153
154pub async fn domain_status(project_id: &str) -> Result<()> {
155    let creds = crate::credentials::require()?;
156    match fetch_domain_status(&creds, project_id).await? {
157        DomainStatus::NotConfigured => {
158            println!("project '{project_id}' has no custom domain configured.");
159            Ok(())
160        }
161        DomainStatus::Configured {
162            domain,
163            cloudflare_status,
164        } => {
165            println!("project '{project_id}' custom domain: {domain}");
166            println!(
167                "cloudflare status: {}",
168                format_cloudflare_status(&cloudflare_status)
169            );
170            Ok(())
171        }
172        DomainStatus::NotLoggedIn => Err(anyhow!("control rejected token; run `fn0 login` again.")),
173        DomainStatus::NotFound => Err(anyhow!(
174            "project '{project_id}' not found or not owned by you."
175        )),
176        DomainStatus::InternalError => Err(anyhow!(
177            "domain_status: server error; check fn0-control logs"
178        )),
179    }
180}
181
182pub(crate) fn format_cloudflare_status(status: &CloudflareStatus) -> String {
183    match status {
184        CloudflareStatus::Active => "active".to_string(),
185        CloudflareStatus::Pending => "pending (waiting for DV verification)".to_string(),
186        CloudflareStatus::Missing => {
187            "missing on Cloudflare (registration may still be in progress)".to_string()
188        }
189        CloudflareStatus::Other { value } => format!("other: {value}"),
190    }
191}