Skip to main content

outlayer_cli/commands/
upload.rs

1use anyhow::{Context, Result};
2use borsh::BorshSerialize;
3use sha2::{Digest, Sha256};
4
5use crate::api::ApiClient;
6use crate::config::{self, NetworkConfig};
7use crate::near::NearSigner;
8
9const CHUNK_SIZE: usize = 1 << 20; // 1 MB
10
11// ── Borsh FastFS Schemas ─────────────────────────────────────────────
12//
13// Matches the TypeScript reference (fastnear/fastdata-drag-and-drop):
14//
15// enum FastfsData {
16//   Simple(SimpleFastfs),     // variant 0
17//   Partial(PartialFastfs),   // variant 1
18// }
19
20#[derive(BorshSerialize)]
21struct FastfsFileContent {
22    mime_type: String,
23    content: Vec<u8>,
24}
25
26#[derive(BorshSerialize)]
27struct SimpleFastfs {
28    relative_path: String,
29    content: Option<FastfsFileContent>,
30}
31
32#[derive(BorshSerialize)]
33struct PartialFastfs {
34    relative_path: String,
35    offset: u32,
36    full_size: u32,
37    mime_type: String,
38    content_chunk: Vec<u8>,
39    nonce: u32,
40}
41
42/// Borsh-serialize a SimpleFastfs payload (enum variant 0).
43fn encode_simple(relative_path: &str, mime_type: &str, content: &[u8]) -> Vec<u8> {
44    let simple = SimpleFastfs {
45        relative_path: relative_path.to_string(),
46        content: Some(FastfsFileContent {
47            mime_type: mime_type.to_string(),
48            content: content.to_vec(),
49        }),
50    };
51
52    // Enum variant 0 (Simple)
53    let mut data = vec![0u8];
54    data.extend(borsh::to_vec(&simple).expect("borsh serialization"));
55    data
56}
57
58/// Borsh-serialize a PartialFastfs payload (enum variant 1).
59fn encode_partial(
60    relative_path: &str,
61    offset: u32,
62    full_size: u32,
63    mime_type: &str,
64    chunk: &[u8],
65    nonce: u32,
66) -> Vec<u8> {
67    let partial = PartialFastfs {
68        relative_path: relative_path.to_string(),
69        offset,
70        full_size,
71        mime_type: mime_type.to_string(),
72        content_chunk: chunk.to_vec(),
73        nonce,
74    };
75
76    // Enum variant 1 (Partial)
77    let mut data = vec![1u8];
78    data.extend(borsh::to_vec(&partial).expect("borsh serialization"));
79    data
80}
81
82/// Prepare FastFS payloads for a file (simple for ≤1MB, chunked for >1MB).
83fn prepare_fastfs_payloads(relative_path: &str, mime_type: &str, content: &[u8]) -> Vec<Vec<u8>> {
84    if content.len() <= CHUNK_SIZE {
85        return vec![encode_simple(relative_path, mime_type, content)];
86    }
87
88    let nonce = (std::time::SystemTime::now()
89        .duration_since(std::time::UNIX_EPOCH)
90        .unwrap()
91        .as_secs()
92        - 1_769_376_240) as u32;
93
94    let full_size = content.len() as u32;
95    let mut payloads = Vec::new();
96
97    let mut offset = 0usize;
98    while offset < content.len() {
99        let end = (offset + CHUNK_SIZE).min(content.len());
100        let chunk = &content[offset..end];
101        payloads.push(encode_partial(
102            relative_path,
103            offset as u32,
104            full_size,
105            mime_type,
106            chunk,
107            nonce,
108        ));
109        offset = end;
110    }
111
112    payloads
113}
114
115/// `outlayer upload <file>` — upload a file to FastFS via NEAR transaction.
116pub async fn upload(
117    network: &NetworkConfig,
118    file_path: &str,
119    receiver: Option<String>,
120    mime_type: Option<String>,
121) -> Result<()> {
122    let creds = config::load_credentials(network)?;
123
124    // Read file
125    let content = std::fs::read(file_path)
126        .with_context(|| format!("Failed to read file: {file_path}"))?;
127
128    // SHA-256 hash
129    let hash = hex::encode(Sha256::digest(&content));
130
131    // Determine extension and mime type
132    let extension = std::path::Path::new(file_path)
133        .extension()
134        .and_then(|e| e.to_str())
135        .unwrap_or("bin");
136    let mime = mime_type.unwrap_or_else(|| match extension {
137        "wasm" => "application/wasm".to_string(),
138        "json" => "application/json".to_string(),
139        "html" => "text/html".to_string(),
140        "js" => "application/javascript".to_string(),
141        "css" => "text/css".to_string(),
142        "png" => "image/png".to_string(),
143        "jpg" | "jpeg" => "image/jpeg".to_string(),
144        "svg" => "image/svg+xml".to_string(),
145        _ => "application/octet-stream".to_string(),
146    });
147
148    let relative_path = format!("{hash}.{extension}");
149    let receiver_id = receiver.unwrap_or_else(|| network.contract_id.clone());
150
151    eprintln!("Uploading to FastFS...");
152    eprintln!("  File: {file_path}");
153    eprintln!("  Size: {} bytes", content.len());
154    eprintln!("  SHA256: {hash}");
155    eprintln!("  Sender: {}", creds.account_id);
156    eprintln!("  Receiver: {receiver_id}");
157
158    let payloads = prepare_fastfs_payloads(&relative_path, &mime, &content);
159    let num_chunks = payloads.len();
160
161    if num_chunks > 1 {
162        eprintln!("  Chunks: {num_chunks} x {}KB max", CHUNK_SIZE / 1024);
163    }
164    eprintln!();
165
166    if creds.is_wallet_key() {
167        upload_via_wallet(network, &creds, &receiver_id, &payloads).await?;
168    } else {
169        upload_via_near_key(network, &creds, &receiver_id, &payloads).await?;
170    }
171
172    let fastfs_url = format!(
173        "https://main.fastfs.io/{}/{}/{}",
174        creds.account_id, receiver_id, relative_path
175    );
176
177    eprintln!();
178    eprintln!("Upload complete!");
179    eprintln!();
180    eprintln!("FastFS URL: {fastfs_url}");
181    eprintln!();
182
183    if extension == "wasm" {
184        eprintln!("Run directly:");
185        eprintln!("  outlayer run --wasm {fastfs_url} '{{}}' ");
186        eprintln!();
187        eprintln!("Or deploy as project version:");
188        eprintln!("  outlayer deploy <name> --wasm-url {fastfs_url}");
189    }
190
191    Ok(())
192}
193
194/// Upload via direct NEAR transaction (private key auth).
195async fn upload_via_near_key(
196    network: &NetworkConfig,
197    creds: &config::Credentials,
198    receiver_id: &str,
199    payloads: &[Vec<u8>],
200) -> Result<()> {
201    let private_key = config::load_private_key(&network.network_id, &creds.account_id, creds)?;
202    let receiver_account_id: near_primitives::types::AccountId = receiver_id
203        .parse()
204        .context("Invalid receiver account ID")?;
205    let signer = NearSigner::new(network, &creds.account_id, &private_key)?;
206    let (current_nonce, block_hash) = signer.get_tx_context().await?;
207    let num_chunks = payloads.len();
208
209    for (i, payload) in payloads.iter().enumerate() {
210        if num_chunks > 1 {
211            eprint!("  Uploading chunk {}/{}... ", i + 1, num_chunks);
212        } else {
213            eprint!("  Uploading... ");
214        }
215
216        let tx_hash = signer
217            .send_function_call_async(
218                &receiver_account_id,
219                "__fastdata_fastfs",
220                payload.clone(),
221                1,  // gas=1: intentionally fails, but data is recorded on-chain
222                0,  // no deposit
223                current_nonce + 1 + i as u64,
224                block_hash,
225            )
226            .await
227            .with_context(|| format!("Failed to upload chunk {}/{}", i + 1, num_chunks))?;
228
229        eprintln!("tx: {tx_hash}");
230    }
231
232    Ok(())
233}
234
235/// Upload via wallet API (wallet_key auth) — sends Borsh args as base64.
236async fn upload_via_wallet(
237    network: &NetworkConfig,
238    creds: &config::Credentials,
239    receiver_id: &str,
240    payloads: &[Vec<u8>],
241) -> Result<()> {
242    let wallet_key = creds
243        .wallet_key
244        .as_ref()
245        .context("Missing wallet_key in credentials")?;
246    let api = ApiClient::new(network);
247    let num_chunks = payloads.len();
248
249    for (i, payload) in payloads.iter().enumerate() {
250        if num_chunks > 1 {
251            eprint!("  Uploading chunk {}/{}... ", i + 1, num_chunks);
252        } else {
253            eprint!("  Uploading... ");
254        }
255
256        let resp = api
257            .wallet_call_raw(
258                wallet_key,
259                receiver_id,
260                "__fastdata_fastfs",
261                payload,
262                1,  // gas=1
263                0,  // no deposit
264            )
265            .await
266            .with_context(|| format!("Failed to upload chunk {}/{}", i + 1, num_chunks))?;
267
268        if let Some(tx_hash) = &resp.tx_hash {
269            eprintln!("tx: {tx_hash}");
270        } else {
271            eprintln!("request: {}", resp.request_id);
272        }
273    }
274
275    Ok(())
276}