File size: 4,609 Bytes
1295969
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
//! BTFS upload module — multipart POST to BTFS daemon /api/v0/add.
//!
//! SECURITY:
//! - Set BTFS_API_KEY env var to authenticate to your BTFS node.
//!   Every request carries `X-API-Key: {BTFS_API_KEY}` header.
//! - Set BTFS_API_URL to a private internal URL; never expose port 5001 publicly.
//! - The pin() function now propagates errors — a failed pin is treated as
//!   a data loss condition and must be investigated.
use shared::types::{BtfsCid, Isrc};
use tracing::{debug, info, instrument};

/// Build a reqwest client with a 120-second timeout and the BTFS API key header.
///
/// TLS enforcement: in production (`RETROSYNC_ENV=production`), BTFS_API_URL
/// must use HTTPS.  Configure a TLS-terminating reverse proxy (nginx/HAProxy)
/// in front of the BTFS daemon (which only speaks HTTP natively).
/// Example nginx config:
///   server {
///     listen 443 ssl;
///     location / { proxy_pass http://127.0.0.1:5001; }
///   }
fn btfs_client() -> anyhow::Result<(reqwest::Client, Option<String>)> {
    let api = std::env::var("BTFS_API_URL").unwrap_or_else(|_| "http://127.0.0.1:5001".into());
    let env = std::env::var("RETROSYNC_ENV").unwrap_or_default();

    if env == "production" && !api.starts_with("https://") {
        anyhow::bail!(
            "SECURITY: BTFS_API_URL must use HTTPS in production (got: {api}). \
             Configure a TLS reverse proxy in front of the BTFS node. \
             See: https://docs.btfs.io/docs/tls-setup"
        );
    }
    if !api.starts_with("https://") {
        tracing::warn!(
            url=%api,
            "BTFS_API_URL uses plaintext HTTP — traffic is unencrypted. \
             Configure HTTPS for production (set BTFS_API_URL=https://...)."
        );
    }

    let client = reqwest::Client::builder()
        .timeout(std::time::Duration::from_secs(120))
        .build()?;
    let api_key = std::env::var("BTFS_API_KEY").ok();
    Ok((client, api_key))
}

/// Attach BTFS API key to a request builder if BTFS_API_KEY is set.
fn with_api_key(
    builder: reqwest::RequestBuilder,
    api_key: Option<&str>,
) -> reqwest::RequestBuilder {
    match api_key {
        Some(key) => builder.header("X-API-Key", key),
        None => builder,
    }
}

#[instrument(skip(audio_bytes), fields(bytes = audio_bytes.len()))]
pub async fn upload(audio_bytes: &[u8], title: &str, isrc: &Isrc) -> anyhow::Result<BtfsCid> {
    let api = std::env::var("BTFS_API_URL").unwrap_or_else(|_| "http://127.0.0.1:5001".into());
    let url = format!("{api}/api/v0/add");
    let filename = format!("{}.bin", isrc.0.replace('/', "-"));

    let (client, api_key) = btfs_client()?;

    let part = reqwest::multipart::Part::bytes(audio_bytes.to_vec())
        .file_name(filename)
        .mime_str("application/octet-stream")?;
    let form = reqwest::multipart::Form::new().part("file", part);

    debug!(url=%url, has_api_key=%api_key.is_some(), "Uploading to BTFS");

    let req = with_api_key(client.post(&url), api_key.as_deref()).multipart(form);
    let resp = req
        .send()
        .await
        .map_err(|e| anyhow::anyhow!("BTFS unreachable at {url}: {e}"))?;

    if !resp.status().is_success() {
        anyhow::bail!("BTFS /api/v0/add failed: {}", resp.status());
    }

    let body = resp.text().await?;
    let cid_str = body
        .lines()
        .filter_map(|l| serde_json::from_str::<serde_json::Value>(l).ok())
        .filter_map(|v| v["Hash"].as_str().map(|s| s.to_string()))
        .next_back()
        .ok_or_else(|| anyhow::anyhow!("BTFS returned no CID"))?;

    let cid = shared::parsers::recognize_btfs_cid(&cid_str)
        .map_err(|e| anyhow::anyhow!("BTFS invalid CID: {e}"))?;

    info!(isrc=%isrc, cid=%cid.0, "Uploaded to BTFS");
    Ok(cid)
}

#[allow(dead_code)]
pub async fn pin(cid: &BtfsCid) -> anyhow::Result<()> {
    // SECURITY: Pin errors propagated — a failed pin means content is not
    // guaranteed to persist on the BTFS network. Do not silently ignore.
    let api = std::env::var("BTFS_API_URL").unwrap_or_else(|_| "http://127.0.0.1:5001".into());
    let url = format!("{}/api/v0/pin/add?arg={}", api, cid.0);

    let (client, api_key) = btfs_client()?;
    let req = with_api_key(client.post(&url), api_key.as_deref());

    let resp = req
        .send()
        .await
        .map_err(|e| anyhow::anyhow!("BTFS pin request failed for CID {}: {}", cid.0, e))?;

    if !resp.status().is_success() {
        anyhow::bail!("BTFS pin failed for CID {} — HTTP {}", cid.0, resp.status());
    }

    info!(cid=%cid.0, "BTFS content pinned successfully");
    Ok(())
}