Skip to content

Commit

Permalink
refactor uploader
Browse files Browse the repository at this point in the history
- use `tokio` to asynchronous upload and delete
- use `clap` and `anyhow` to build the modern cli
- implement sm.ms other apis
  • Loading branch information
c3b2a7 committed Sep 13, 2024
1 parent a75c4c2 commit e6289f4
Show file tree
Hide file tree
Showing 9 changed files with 1,335 additions and 422 deletions.
1,199 changes: 886 additions & 313 deletions Cargo.lock

Large diffs are not rendered by default.

22 changes: 19 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,10 +1,26 @@
[package]
name = "smms-uploader"
name = "smmstools"
authors = ["萌面喵喵侠<[email protected]"]
homepage = "https://lolico.me"
version = "0.1.0"
edition = "2021"

[[bin]]
name = "smmstools"
path = "src/bin/main.rs"

[dependencies]
reqwest = { version = "0.11.7", features = ["blocking", "json", "multipart"] }
serde = { version = "1.0.72", features = ["derive"] }
reqwest = { version = "0.12", features = ["json", "multipart", "stream"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serde_repr = "0.1"
mime_guess = { version = "2" }
tokio = { version = "1.40", features = ["full"] }
thiserror = "1.0"
clap = { version = "4.5", features = ["derive", "env"] }
anyhow = "1.0"

[dev-dependencies]
assert_matches = "1.5.0"
# Enable test-utilities in dev mode only. This is mostly for tests.
tokio = { version = "1.40", features = ["test-util"] }
37 changes: 28 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,23 +1,42 @@
## SM.MS Uploader
# SM.MS Tools

A lightweight [SM.MS](https://sm.ms/) upload tool with no external dependencies, no interface, support for multiple file uploads and works with typora.
A lightweight [SM.MS](https://sm.ms/) tools, no external dependencies, no interface, supports multiple image uploads and can work with typora.

### Install
## Features

- Get your profile (disk usage, disk limit, etc...)
- Asynchronous multi-file upload or delete
- Get the list of upload history

## Install

```shell
sudo mkdir -p /usr/local/bin && \
sudo wget "https://github.com/c3b2a7/smms-uploader/releases/latest/download/smms-uploader-$(uname -s)-$(uname -m)" -O /usr/local/bin/smms-uploader && \
sudo chmod +x /usr/local/bin/smms-uploader
sudo wget "https://github.com/c3b2a7/smmstools/releases/latest/download/smmstools-$(uname -s)-$(uname -m)" -O /usr/local/bin/smmstools && \
sudo chmod +x /usr/local/bin/smmstools
```

### Usage
## Usage

```shell
# visit https://sm.ms/home/apitoken to get apitoken
smms-uploader apitoken image1-path image2-path ...
Uploader for sm.ms

Usage: smmstools.exe --token <SMMS_TOKEN> <COMMAND>

Commands:
profile Get user profile
upload Upload image(s) to sm.ms
delete Delete image(s)
history Get upload history
help Print this message or the help of the given subcommand(s)

Options:
-t, --token <SMMS_TOKEN> API token of sm.ms, visit https://sm.ms/home/apitoken to get apitoken [env: SMMS_TOKEN=]
-h, --help Print help
-V, --version Print version
```

### License
## License

MIT

113 changes: 113 additions & 0 deletions src/bin/main.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
use clap::{Parser, Subcommand, ValueHint};
use smmstools::client::Client;
use smmstools::Result;
use std::sync::Arc;
use tokio::sync::mpsc;

#[derive(Parser, Debug)]
#[command(name = env!("CARGO_PKG_NAME"), version, author, about = "Uploader for sm.ms")]
struct Cli {
#[command(subcommand)]
cmd: Command,

/// API token of sm.ms, visit https://sm.ms/home/apitoken to get your token
#[arg(short = 't', long, value_name = "SMMS_TOKEN", env = "SMMS_TOKEN")]
token: String,
}

#[derive(Subcommand, Debug)]
enum Command {
/// Get user profile
Profile,

/// Upload image(s) to sm.ms
#[command(arg_required_else_help = true)]
Upload {
/// Path of image(s) to upload
#[arg(value_name = "PATH", required = true, value_hint = ValueHint::FilePath)]
image_paths: Vec<String>,
},

/// Delete image(s)
#[command(arg_required_else_help = true)]
Delete {
/// Hash of image(s) to delete
#[arg(value_name = "HASH", required = true)]
hashes: Vec<String>,
},

/// Get upload history
History {
#[arg(long, default_value_t = 1)]
page: usize,
},
}

#[tokio::main]
pub async fn main() -> Result<()> {
let cli = Cli::parse();

let uploader = Arc::new(Client::new(cli.token));
match cli.cmd {
Command::Profile => println!("{}", uploader.get_profile().await?),
Command::Upload { image_paths } => upload_and_print_url(uploader, image_paths).await,
Command::Delete { hashes } => delete_and_print_error(&uploader, hashes).await,
Command::History { page } => println!(
"{}",
serde_json::to_string_pretty(&uploader.get_upload_history(page).await?)?
),
}

Ok(())
}

async fn upload_and_print_url(uploader: Arc<Client>, image_paths: Vec<String>) {
let capacity = image_paths.len();
let (tx, mut rx) = mpsc::channel(capacity);
let mut ret = Vec::with_capacity(capacity);
for _ in 0..capacity {
ret.push(None);
}

for (index, path) in image_paths.into_iter().enumerate() {
let tx = tx.clone();
let uploader = uploader.clone();
tokio::spawn(async move {
let upload_result = uploader.upload(path).await;
tx.send((index, upload_result)).await
});
}

drop(tx);
let mut next_print_index = 0;
while let Some((index, url)) = rx.recv().await {
ret[index] = Some(url);
while let Some(Some(upload_result)) = ret.get(next_print_index) {
match upload_result {
Ok(data) => {
println!("{}", data.url);
}
Err(err) => {
eprintln!("Error: {err}");
}
}
next_print_index += 1;
}
}
}

async fn delete_and_print_error(uploader: &Arc<Client>, hashes: Vec<String>) {
let (tx, mut rx) = mpsc::channel(hashes.len());
for (index, hash) in hashes.into_iter().enumerate() {
let tx = tx.clone();
let uploader = uploader.clone();
tokio::spawn(async move {
let ret = uploader.delete(hash).await;
tx.send((index, ret)).await
});
}
drop(tx);
while let Some((_, Err(err))) = rx.recv().await {
eprintln!("Error: {err}")
}
}
163 changes: 163 additions & 0 deletions src/client/client.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
use crate::client::api;
use crate::client::response::{Base, HistoryRecord, Profile, UploadResult};
use crate::{Error, Result};
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION};
use reqwest::multipart::{Form, Part};
use std::collections::HashMap;
use std::fmt::Display;
use std::path::Path;
use tokio::fs::File;

pub struct Client {
client: reqwest::Client,
}

impl Client {
pub fn new<T: AsRef<str>>(token: T) -> Client {
Self {
client: build_client_with_auth_header(token),
}
}

pub async fn get_profile(&self) -> Result<Profile> {
let response = self
.client
.post(api::PROFILE_API)
.send()
.await?
.json::<Base<Profile>>()
.await?;

Self::success_or_else(response, |response| Error::NotSuccess {
code: response.code,
message: response.message,
})
}

pub async fn upload<T: AsRef<Path>>(&self, path: T) -> Result<UploadResult> {
let form = Form::new()
.text("format", "json")
.part("smfile", create_part_from_file(path).await?);
let response = self
.client
.post(api::UPLOAD_API)
.multipart(form)
.send()
.await?
.json::<Base<UploadResult>>()
.await?;

Self::success_or_else(response, |base| match base.exists_image {
Some(exists_at) => Error::ImageRepeated {
request_id: base.request_id,
exists_image_url: exists_at,
},
None => Error::NotSuccess {
code: base.code,
message: base.message,
},
})
}

pub async fn delete<T>(&self, hash: T) -> Result<()>
where
T: AsRef<str> + Display,
{
let response = self
.client
.get(format!("{}/{hash}", api::DELETE_API))
.send()
.await?
.json::<Base<Vec<()>>>()
.await?;

Self::success_or_else(response, |base| Error::NotSuccess {
code: base.code,
message: base.message,
})
.map(|_| ())
}

pub async fn get_upload_history(&self, page: usize) -> Result<Vec<HistoryRecord>> {
let mut params = HashMap::new();
params.insert("page", page);

let response = self
.client
.get(api::UPLOAD_HISTORY_API)
.query(&params)
.send()
.await?
.json::<Base<Vec<HistoryRecord>>>()
.await?;

Self::success_or_else(response, |base| Error::NotSuccess {
code: base.code,
message: base.message,
})
}

fn success_or_else<T, E, F>(response: Base<T>, f: F) -> Result<T>
where
F: FnOnce(Base<T>) -> E,
E: Into<anyhow::Error>,
{
if response.success {
response.data.ok_or(Error::SuccessWithNoData.into())
} else {
Err(f(response).into())
}
}
}

fn build_client_with_auth_header<T: AsRef<str>>(token: T) -> reqwest::Client {
let token = token.as_ref();

let mut headers = HeaderMap::with_capacity(2);
let mut auth_value = HeaderValue::from_str(token).expect("add auth header");
auth_value.set_sensitive(true);
headers.insert(AUTHORIZATION, auth_value);

reqwest::Client::builder()
.default_headers(headers)
.build()
.expect("build client")
}

async fn create_part_from_file<T: AsRef<Path>>(path: T) -> Result<Part> {
let path = path.as_ref();
let file_name = path
.file_name()
.map(|filename| filename.to_string_lossy().into_owned());
let ext = path.extension().and_then(|ext| ext.to_str()).unwrap_or("");
let mime = mime_guess::from_ext(ext).first_or_octet_stream();

let file = File::open(path).await?;
let part = Part::stream(file).mime_str(mime.as_ref())?;

Ok(if let Some(file_name) = file_name {
part.file_name(file_name)
} else {
part
})
}

#[cfg(test)]
mod tests {
use super::Client;
use std::io;

#[tokio::test]
async fn io_error() {
let uploader = Client::new("token");
let result = uploader.upload("not-exists.png").await;
assert!(result.is_err());

let error = result.err().expect("error");
if let Some(error) = error.downcast_ref::<io::Error>() {
assert_eq!(error.kind(), io::ErrorKind::NotFound);
} else {
panic!("Expected io::Error, Got: {:#?}", error);
}
}
}
18 changes: 18 additions & 0 deletions src/client/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
mod client;
pub use client::Client;

pub mod response;

/// see [SM.MS API](https://doc.sm.ms)
#[allow(unused)]
mod api {
pub(crate) const BASE_URL: &'static str = "https://sm.ms/api/v2/";

pub(crate) const PROFILE_API: &'static str = "https://sm.ms/api/v2/profile";

pub(crate) const UPLOAD_API: &'static str = "https://sm.ms/api/v2/upload";

pub(crate) const DELETE_API: &'static str = "https://sm.ms/api/v2/delete";

pub(crate) const UPLOAD_HISTORY_API: &'static str = "https://sm.ms/api/v2/upload_history";
}
Loading

0 comments on commit e6289f4

Please sign in to comment.