-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- use `tokio` to asynchronous upload and delete - use `clap` and `anyhow` to build the modern cli - implement sm.ms other apis
- Loading branch information
Showing
9 changed files
with
1,335 additions
and
422 deletions.
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"] } |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}") | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(¶ms) | ||
.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); | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"; | ||
} |
Oops, something went wrong.