steam-user 0.1.0

Steam User web client for Rust - HTTP-based Steam Community interactions
Documentation
use std::path::Path;

use image::GenericImageView;

use crate::{
    client::SteamUser,
    endpoint::steam_endpoint,
    error::SteamUserError,
    types::{
        BeginFileUploadResult, CommitFileDetails, CommitFileUploadParams, CommitFileUploadResponse, CommitFileUploadResult,
        file_upload::{BeginFileUploadRaw, CommitFileUploadRaw},
    },
};

impl SteamUser {
    /// Initiates a file upload to Steam's servers.
    ///
    /// This is the first step in a multi-part upload process. It notifies Steam
    /// about the file being uploaded and retrieves the destination host and
    /// headers required for the actual transfer.
    ///
    /// # Arguments
    ///
    /// * `file_path` - The path to the local image file to be uploaded.
    ///
    /// # Returns
    ///
    /// Returns a [`BeginFileUploadResult`] containing the upload destination
    /// and required credentials.
    ///
    /// # Example
    ///
    /// ```rust,no_run
    /// # use steam_user::client::SteamUser;
    /// # async fn example(user: SteamUser) -> Result<(), Box<dyn std::error::Error>> {
    /// let upload_init = user.begin_file_upload("path/to/image.png").await?;
    /// println!("Upload initiated for: {}", upload_init.ugcid);
    /// # Ok(())
    /// # }
    /// ```
    #[steam_endpoint(POST, host = Community, path = "/chat/beginfileupload", kind = Upload)]
    pub async fn begin_file_upload(&self, file_path: impl AsRef<Path>) -> Result<BeginFileUploadResult, SteamUserError> {
        let file_path = file_path.as_ref();

        // Read file stats and dimensions
        let file_size = tokio::fs::metadata(file_path).await?.len();

        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();

        let img = image::open(file_path).map_err(|e| SteamUserError::InvalidImageFormat(format!("Failed to open image: {e}")))?;
        let (width, height) = img.dimensions();
        let format = image::ImageFormat::from_path(file_path).ok();

        let type_str = match format {
            Some(image::ImageFormat::WebP) => "png",
            Some(image::ImageFormat::Png) => "png",
            Some(image::ImageFormat::Jpeg) => "jpg",
            Some(image::ImageFormat::Gif) => "gif",
            Some(image::ImageFormat::Bmp) => "bmp",
            Some(image::ImageFormat::Tiff) => "tiff",
            _ => "png",
        };

        let file_type = format!("image/{}", type_str);

        // Generate random SHA1 (JS does random 40 hex chars)
        let sha1: String = (0..40).map(|_| format!("{:x}", rand::random::<u8>() % 16)).collect();

        // 1. Begin File Upload
        let raw: BeginFileUploadRaw = self
            .post_path("/chat/beginfileupload")
            .header("Referer", "https://steamcommunity.com/chat/")
            .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)])
            .send()
            .await?
            .json()
            .await?;

        // Narrow i64 → i32 explicitly; `as` would silently wrap on out-of-range values.
        let success = i32::try_from(raw.success).unwrap_or(0);
        if success != 1 {
            return Err(SteamUserError::SteamError(format!("Begin upload failed with code {}", success)));
        }

        let result = raw.result.ok_or_else(|| SteamUserError::MalformedResponse("Missing result object".to_string()))?;
        let use_https = i32::try_from(result.use_https).unwrap_or(0);

        Ok(BeginFileUploadResult {
            success,
            url_host: result.url_host,
            url_path: result.url_path,
            use_https,
            request_headers: result.request_headers,
            timestamp: result.timestamp,
            ugcid: result.ugcid,
            hmac: result.hmac,
            file_name,
            file_sha: sha1,
            file_image_width: width,
            file_image_height: height,
            file_type,
        })
    }

    /// Performs the actual file data transfer using the details from a previous
    /// [`Self::begin_file_upload`] call.
    ///
    /// This method sends the raw file bytes via a PUT request to the host
    /// specified in `begin_result`.
    ///
    /// # Arguments
    ///
    /// * `file_path` - The path to the local file to be uploaded.
    /// * `begin_result` - The result from a successful call to
    ///   [`Self::begin_file_upload`].
    ///
    /// # Example
    ///
    /// ```rust,no_run
    /// # use steam_user::client::SteamUser;
    /// # async fn example(user: SteamUser) -> Result<(), Box<dyn std::error::Error>> {
    /// let init = user.begin_file_upload("image.png").await?;
    /// user.do_file_upload("image.png", &init).await?;
    /// # Ok(())
    /// # }
    /// ```
    // dynamic per-upload host (from begin_result) — no #[steam_endpoint]
    #[tracing::instrument(skip(self, file_path, begin_result), fields(url_host = %begin_result.url_host))]
    pub async fn do_file_upload(&self, file_path: impl AsRef<Path>, begin_result: &BeginFileUploadResult) -> Result<(), SteamUserError> {
        let file_path = file_path.as_ref();
        let file_bytes = tokio::fs::read(file_path).await?;

        let protocol = if begin_result.use_https == 1 { "https" } else { "http" };
        let url = format!("{}://{}{}", protocol, begin_result.url_host, begin_result.url_path);

        let mut req = self.request(reqwest::Method::PUT, &url);

        for header in &begin_result.request_headers {
            if header.name.eq_ignore_ascii_case("Content-Length") || header.name.eq_ignore_ascii_case("Host") {
                continue;
            }
            req = req.header(&header.name, &header.value);
        }

        let response = req.body(file_bytes).send().await?;

        if !response.status().is_success() {
            return Err(SteamUserError::HttpStatus { status: response.status().as_u16(), url: response.url().to_string() });
        }

        Ok(())
    }

    /// Finalizes and commits an uploaded file on the Steam servers.
    ///
    /// This is the final step in the upload process. Once committed, the file
    /// becomes available on Steam's content delivery network.
    ///
    /// # Arguments
    ///
    /// * `params` - A [`CommitFileUploadParams`] struct containing necessary
    ///   identifiers and metadata.
    ///
    /// # Returns
    ///
    /// Returns a [`CommitFileUploadResponse`] containing the final URL of the
    /// uploaded image.
    ///
    /// # Example
    ///
    /// ```rust,no_run
    /// # use steam_user::client::SteamUser;
    /// # async fn example(user: SteamUser) -> Result<(), Box<dyn std::error::Error>> {
    /// # let init = user.begin_file_upload("img.png").await?;
    /// let commit_params = steam_user::types::CommitFileUploadParams {
    ///     file_name: init.file_name,
    ///     file_sha: init.file_sha,
    ///     file_image_width: init.file_image_width,
    ///     file_image_height: init.file_image_height,
    ///     file_type: init.file_type,
    ///     ugcid: init.ugcid,
    ///     timestamp: init.timestamp,
    ///     hmac: init.hmac,
    ///     friend_steamid: None,
    /// };
    /// let response = user.commit_file_upload(commit_params).await?;
    /// if let Some(res) = response.result {
    ///     if let Some(details) = res.details {
    ///         println!("Uploaded image URL: {}", details.url);
    ///     }
    /// }
    /// # Ok(())
    /// # }
    /// ```
    #[steam_endpoint(POST, host = Community, path = "/chat/commitfileupload/", kind = Upload)]
    pub async fn commit_file_upload(&self, params: CommitFileUploadParams) -> Result<CommitFileUploadResponse, SteamUserError> {
        let mut form_fields = vec![
            ("l", "english".to_string()),
            ("file_name", params.file_name),
            ("success", "1".to_string()),
            ("file_sha", params.file_sha),
            ("file_image_width", params.file_image_width.to_string()),
            ("file_image_height", params.file_image_height.to_string()),
            ("file_type", params.file_type),
            ("ugcid", params.ugcid),
            ("timestamp", params.timestamp),
            ("hmac", params.hmac),
            ("spoiler", "0".to_string()),
        ];

        if let Some(friend_id) = params.friend_steamid {
            form_fields.push(("friend_steamid", friend_id));
        }

        let raw: CommitFileUploadRaw = self.post_path("/chat/commitfileupload/?l=english").form(&form_fields).send().await?.json().await?;

        // Narrow i64 → i32 explicitly; `as` would silently wrap on out-of-range values.
        let success = i32::try_from(raw.success).unwrap_or(0);

        let details = raw.result.and_then(|r| r.details).map(|det| CommitFileDetails {
            url: det.url.replace("https://steamusercontent-a.akamaihd.net", "https://steamuserimages-a.akamaihd.net"),
        });

        Ok(CommitFileUploadResponse { success, result: Some(CommitFileUploadResult { details }), error: raw.error })
    }
}