1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
use std::{
fs,
path::{Path, PathBuf},
};
use clap::Parser;
use free_storage::FileId;
use reqwest::header::ACCEPT;
use serde::Deserialize;
use serde_json::json;
fn validate_path(s: &str) -> Result<PathBuf, String> {
let p = Path::new(s);
if p.exists() {
Ok(p.to_owned())
} else {
Err(format!("`{s}` doesn't exist."))
}
}
fn validate_repo(s: &str) -> Result<String, String> {
if s.split('/').count() != 2 {
Err(String::from(
"Invalid repository. Must be in `owner/repo` format.",
))
} else {
Ok(String::from(s))
}
}
#[derive(Debug, Parser)]
#[command(author, version, about, long_about = None)]
struct Args {
#[command(subcommand)]
action: Action,
}
#[derive(Debug, clap::Subcommand)]
enum Action {
/// Upload a file
Upload {
/// The path of the file to upload
#[arg(value_parser = validate_path)]
file_path: PathBuf,
/// Repository to put files in
///
/// Must be in `owner/repo` format
#[arg(value_parser = validate_repo)]
repo: String,
/// A GitHub token to use to upload/retrieve files
///
/// Must have read and write access to the repository
#[arg(short, long)]
token: Option<String>,
/// The file to output the [`FileId`] in MessagePack format
out_path: PathBuf,
},
/// Download a file
Download {
/// The filename of a [`FileId`] in MessagePack format
#[arg(value_parser = validate_path)]
fileid_path: PathBuf,
/// The token to use to read the files
#[arg(short, long)]
token: Option<String>,
/// Where we should place the downloaded file
///
/// Defaults to the original filename
#[arg(short, long)]
out_path: Option<PathBuf>,
},
/// Login to GitHub
Login {
/// If you already have a token, use it here instead of using the oauth flow
#[arg(short, long)]
token: Option<String>,
},
}
fn get_token(token: Option<String>) -> String {
token.unwrap_or_else(|| {
match fs::read_to_string(
dirs::config_dir()
.unwrap()
.join(".free_storage")
.join("token"),
) {
Ok(s) => s,
Err(_) => {
tracing::error!("Please provide `--token` or run `storage login` first.");
std::process::exit(1);
}
}
})
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
tracing_subscriber::fmt().without_time().compact().init();
match Args::parse().action {
Action::Upload {
file_path,
repo,
token,
out_path,
} => {
let token = get_token(token);
let fid = FileId::upload(
&*file_path.file_name().unwrap().to_string_lossy(),
&*fs::read(&file_path).unwrap(),
repo,
&token,
)
.await?;
fs::write(out_path, rmp_serde::to_vec(&fid)?)?;
}
Action::Download {
fileid_path,
token,
out_path,
} => {
let fid = rmp_serde::from_slice::<FileId>(&fs::read(fileid_path)?)?;
let (data, name) = fid.get(token).await?;
if let Some(path) = out_path {
fs::write(path, data)?;
} else {
fs::write(Path::new(&name).file_name().unwrap(), data)?;
}
}
Action::Login { token } => {
fn set_token(token: String) -> Result<(), std::io::Error> {
tracing::debug!("Setting token to {token}");
let conf_dir = dirs::config_dir().unwrap().join(".free_storage");
fs::create_dir_all(&conf_dir)?;
fs::write(conf_dir.join("token"), token)
}
if let Some(token) = token {
set_token(token)?;
return Ok(());
}
let client = reqwest::Client::new();
// https://docs.github.com/en/developers/apps/building-oauth-apps/authorizing-oauth-apps#response-parameters
#[derive(Deserialize)]
struct DeviceFlow {
/// The device verification code is 40 characters and used to verify the device.
device_code: String,
/// The user verification code is displayed on the device so the user can enter the code in a browser. This code is 8 characters with a hyphen in the middle.
user_code: String,
/// The verification URL where users need to enter the `user_code`: `<https://github.com/login/device>`.
verification_uri: String,
/// The number of seconds before the `device_code` and `user_code` expire. The default is 900 seconds or 15 minutes.
#[allow(dead_code)]
expires_in: u64,
/// The minimum number of seconds that must pass before you can make a new access token request (`POST <https://github.com/login/oauth/access_token>`) to complete the device authorization. For example, if the interval is 5, then you cannot make a new request until 5 seconds pass. If you make more than one request over 5 seconds, then you will hit the rate limit and receive a `slow_down` error.
interval: u64,
}
let device_flow = &client
.post("https://github.com/login/device/code")
.header(ACCEPT, "application/json")
.json(&json!({
// TODO: make this configurable
"client_id": "7b0a933c618e69b8f1b9",
"scope": "repo"
}))
.send()
.await?
.json::<DeviceFlow>()
.await?;
tracing::info!(
"Go to {} and enter the code {}",
device_flow.verification_uri,
device_flow.user_code
);
// https://docs.github.com/en/developers/apps/building-oauth-apps/authorizing-oauth-apps#response-2
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum AccessToken {
Error { error: AccessError },
Success { access_token: String },
}
// https://docs.github.com/en/developers/apps/building-oauth-apps/authorizing-oauth-apps#error-codes-for-the-device-flow
#[derive(Debug, Deserialize)]
#[serde(rename_all = "snake_case")]
enum AccessError {
/// This error occurs when the authorization request is pending and the user hasn't entered the user code yet. The app is expected to keep polling the `POST <https://github.com/login/oauth/access_token>` request without exceeding the `interval`, which requires a minimum number of seconds between each request.
AuthorizationPending,
/// When you receive the `slow_down` error, 5 extra seconds are added to the minimum `interval` or timeframe required between your requests using `POST <https://github.com/login/oauth/access_token>`. For example, if the starting interval required at least 5 seconds between requests and you get a `slow_down` error response, you must now wait a minimum of 10 seconds before making a new request for an OAuth access token. The error response includes the new `interval` that you must use.
#[allow(dead_code)]
SlowDown { interval: u8 },
/// When a user clicks cancel during the authorization process, you'll receive a `access_denied` error and the user won't be able to use the verification code again.
AccessDenied,
}
let mut interval =
tokio::time::interval(std::time::Duration::from_secs(device_flow.interval + 1));
loop {
interval.tick().await;
let res = client
.post("https://github.com/login/oauth/access_token")
.header(ACCEPT, "application/json")
.json(&json!({
"client_id": "7b0a933c618e69b8f1b9",
"device_code": device_flow.device_code,
"grant_type": "urn:ietf:params:oauth:grant-type:device_code"
}))
.send()
.await?
.json::<AccessToken>()
.await?;
match res {
AccessToken::Error {
error: AccessError::AccessDenied,
} => {
tracing::error!("You denied access to your GitHub account");
std::process::exit(1);
}
AccessToken::Error {
error: AccessError::AuthorizationPending,
} => {
tracing::trace!("Authorization pending");
}
AccessToken::Error {
error:
AccessError::SlowDown {
..
},
} => unreachable!("We should never get a slow down error. Also, this shouldn't be parsable for Serde."),
AccessToken::Success { access_token, .. } => {
set_token(access_token)?;
break;
},
};
}
}
}
Ok(())
}