Skip to main content

steam_user/services/
file_upload.rs

1use std::path::Path;
2
3use image::GenericImageView;
4
5use crate::{
6    client::SteamUser,
7    endpoint::steam_endpoint,
8    error::SteamUserError,
9    types::{
10        BeginFileUploadResult, CommitFileDetails, CommitFileUploadParams, CommitFileUploadResponse, CommitFileUploadResult,
11        file_upload::{BeginFileUploadRaw, CommitFileUploadRaw},
12    },
13};
14
15impl SteamUser {
16    /// Initiates a file upload to Steam's servers.
17    ///
18    /// This is the first step in a multi-part upload process. It notifies Steam
19    /// about the file being uploaded and retrieves the destination host and
20    /// headers required for the actual transfer.
21    ///
22    /// # Arguments
23    ///
24    /// * `file_path` - The path to the local image file to be uploaded.
25    ///
26    /// # Returns
27    ///
28    /// Returns a [`BeginFileUploadResult`] containing the upload destination
29    /// and required credentials.
30    ///
31    /// # Example
32    ///
33    /// ```rust,no_run
34    /// # use steam_user::client::SteamUser;
35    /// # async fn example(user: SteamUser) -> Result<(), Box<dyn std::error::Error>> {
36    /// let upload_init = user.begin_file_upload("path/to/image.png").await?;
37    /// println!("Upload initiated for: {}", upload_init.ugcid);
38    /// # Ok(())
39    /// # }
40    /// ```
41    #[steam_endpoint(POST, host = Community, path = "/chat/beginfileupload", kind = Upload)]
42    pub async fn begin_file_upload(&self, file_path: impl AsRef<Path>) -> Result<BeginFileUploadResult, SteamUserError> {
43        let file_path = file_path.as_ref();
44
45        // Read file stats and dimensions
46        let file_size = tokio::fs::metadata(file_path).await?.len();
47
48        let file_name = file_path.file_name().and_then(|n| n.to_str()).ok_or_else(|| SteamUserError::InvalidInput("Invalid file name".to_string()))?.to_string();
49
50        let img = image::open(file_path).map_err(|e| SteamUserError::InvalidImageFormat(format!("Failed to open image: {e}")))?;
51        let (width, height) = img.dimensions();
52        let format = image::ImageFormat::from_path(file_path).ok();
53
54        let type_str = match format {
55            Some(image::ImageFormat::WebP) => "png",
56            Some(image::ImageFormat::Png) => "png",
57            Some(image::ImageFormat::Jpeg) => "jpg",
58            Some(image::ImageFormat::Gif) => "gif",
59            Some(image::ImageFormat::Bmp) => "bmp",
60            Some(image::ImageFormat::Tiff) => "tiff",
61            _ => "png",
62        };
63
64        let file_type = format!("image/{}", type_str);
65
66        // Generate random SHA1 (JS does random 40 hex chars)
67        let sha1: String = (0..40).map(|_| format!("{:x}", rand::random::<u8>() % 16)).collect();
68
69        // 1. Begin File Upload
70        let raw: BeginFileUploadRaw = self
71            .post_path("/chat/beginfileupload")
72            .header("Referer", "https://steamcommunity.com/chat/")
73            .form(&[("l", "english"), ("file_size", &file_size.to_string()), ("file_name", &file_name), ("file_sha", &sha1), ("file_image_width", &width.to_string()), ("file_image_height", &height.to_string()), ("file_type", &file_type)])
74            .send()
75            .await?
76            .json()
77            .await?;
78
79        // Narrow i64 → i32 explicitly; `as` would silently wrap on out-of-range values.
80        let success = i32::try_from(raw.success).unwrap_or(0);
81        if success != 1 {
82            return Err(SteamUserError::SteamError(format!("Begin upload failed with code {}", success)));
83        }
84
85        let result = raw.result.ok_or_else(|| SteamUserError::MalformedResponse("Missing result object".to_string()))?;
86        let use_https = i32::try_from(result.use_https).unwrap_or(0);
87
88        Ok(BeginFileUploadResult {
89            success,
90            url_host: result.url_host,
91            url_path: result.url_path,
92            use_https,
93            request_headers: result.request_headers,
94            timestamp: result.timestamp,
95            ugcid: result.ugcid,
96            hmac: result.hmac,
97            file_name,
98            file_sha: sha1,
99            file_image_width: width,
100            file_image_height: height,
101            file_type,
102        })
103    }
104
105    /// Performs the actual file data transfer using the details from a previous
106    /// [`Self::begin_file_upload`] call.
107    ///
108    /// This method sends the raw file bytes via a PUT request to the host
109    /// specified in `begin_result`.
110    ///
111    /// # Arguments
112    ///
113    /// * `file_path` - The path to the local file to be uploaded.
114    /// * `begin_result` - The result from a successful call to
115    ///   [`Self::begin_file_upload`].
116    ///
117    /// # Example
118    ///
119    /// ```rust,no_run
120    /// # use steam_user::client::SteamUser;
121    /// # async fn example(user: SteamUser) -> Result<(), Box<dyn std::error::Error>> {
122    /// let init = user.begin_file_upload("image.png").await?;
123    /// user.do_file_upload("image.png", &init).await?;
124    /// # Ok(())
125    /// # }
126    /// ```
127    // dynamic per-upload host (from begin_result) — no #[steam_endpoint]
128    #[tracing::instrument(skip(self, file_path, begin_result), fields(url_host = %begin_result.url_host))]
129    pub async fn do_file_upload(&self, file_path: impl AsRef<Path>, begin_result: &BeginFileUploadResult) -> Result<(), SteamUserError> {
130        let file_path = file_path.as_ref();
131        let file_bytes = tokio::fs::read(file_path).await?;
132
133        let protocol = if begin_result.use_https == 1 { "https" } else { "http" };
134        let url = format!("{}://{}{}", protocol, begin_result.url_host, begin_result.url_path);
135
136        let mut req = self.request(reqwest::Method::PUT, &url);
137
138        for header in &begin_result.request_headers {
139            if header.name.eq_ignore_ascii_case("Content-Length") || header.name.eq_ignore_ascii_case("Host") {
140                continue;
141            }
142            req = req.header(&header.name, &header.value);
143        }
144
145        let response = req.body(file_bytes).send().await?;
146
147        if !response.status().is_success() {
148            return Err(SteamUserError::HttpStatus { status: response.status().as_u16(), url: response.url().to_string() });
149        }
150
151        Ok(())
152    }
153
154    /// Finalizes and commits an uploaded file on the Steam servers.
155    ///
156    /// This is the final step in the upload process. Once committed, the file
157    /// becomes available on Steam's content delivery network.
158    ///
159    /// # Arguments
160    ///
161    /// * `params` - A [`CommitFileUploadParams`] struct containing necessary
162    ///   identifiers and metadata.
163    ///
164    /// # Returns
165    ///
166    /// Returns a [`CommitFileUploadResponse`] containing the final URL of the
167    /// uploaded image.
168    ///
169    /// # Example
170    ///
171    /// ```rust,no_run
172    /// # use steam_user::client::SteamUser;
173    /// # async fn example(user: SteamUser) -> Result<(), Box<dyn std::error::Error>> {
174    /// # let init = user.begin_file_upload("img.png").await?;
175    /// let commit_params = steam_user::types::CommitFileUploadParams {
176    ///     file_name: init.file_name,
177    ///     file_sha: init.file_sha,
178    ///     file_image_width: init.file_image_width,
179    ///     file_image_height: init.file_image_height,
180    ///     file_type: init.file_type,
181    ///     ugcid: init.ugcid,
182    ///     timestamp: init.timestamp,
183    ///     hmac: init.hmac,
184    ///     friend_steamid: None,
185    /// };
186    /// let response = user.commit_file_upload(commit_params).await?;
187    /// if let Some(res) = response.result {
188    ///     if let Some(details) = res.details {
189    ///         println!("Uploaded image URL: {}", details.url);
190    ///     }
191    /// }
192    /// # Ok(())
193    /// # }
194    /// ```
195    #[steam_endpoint(POST, host = Community, path = "/chat/commitfileupload/", kind = Upload)]
196    pub async fn commit_file_upload(&self, params: CommitFileUploadParams) -> Result<CommitFileUploadResponse, SteamUserError> {
197        let mut form_fields = vec![
198            ("l", "english".to_string()),
199            ("file_name", params.file_name),
200            ("success", "1".to_string()),
201            ("file_sha", params.file_sha),
202            ("file_image_width", params.file_image_width.to_string()),
203            ("file_image_height", params.file_image_height.to_string()),
204            ("file_type", params.file_type),
205            ("ugcid", params.ugcid),
206            ("timestamp", params.timestamp),
207            ("hmac", params.hmac),
208            ("spoiler", "0".to_string()),
209        ];
210
211        if let Some(friend_id) = params.friend_steamid {
212            form_fields.push(("friend_steamid", friend_id));
213        }
214
215        let raw: CommitFileUploadRaw = self.post_path("/chat/commitfileupload/?l=english").form(&form_fields).send().await?.json().await?;
216
217        // Narrow i64 → i32 explicitly; `as` would silently wrap on out-of-range values.
218        let success = i32::try_from(raw.success).unwrap_or(0);
219
220        let details = raw.result.and_then(|r| r.details).map(|det| CommitFileDetails {
221            url: det.url.replace("https://steamusercontent-a.akamaihd.net", "https://steamuserimages-a.akamaihd.net"),
222        });
223
224        Ok(CommitFileUploadResponse { success, result: Some(CommitFileUploadResult { details }), error: raw.error })
225    }
226}