1use base64::prelude::*;
2use serde_json as json;
3use thiserror::Error;
4
5use std::io::{prelude::*, Cursor};
6
7#[derive(Error, Debug)]
8pub enum Error {
9 #[error("B2 I/O failure: {0}")]
10 Io(#[from] std::io::Error),
11 #[error("B2 HTTP transport error: {0}")]
12 Http(Box<ureq::Transport>),
13 #[error("B2 HTTP {code}: {reason}")]
14 BadReply { code: u16, reason: String },
15 #[error("B2 {why}: {response}")]
16 UnexpectedResponse { why: String, response: String },
17 #[error("B2: Couldn't find {what}")]
18 NotFound { what: String },
19}
20
21impl From<ureq::Error> for Error {
22 fn from(e: ureq::Error) -> Self {
23 match e {
24 ureq::Error::Status(code, resp) => {
25 let reason = resp.into_string().unwrap_or_else(|e| e.to_string());
26 Self::BadReply { code, reason }
27 }
28 ureq::Error::Transport(t) => Self::Http(Box::new(t)),
29 }
30 }
31}
32
33fn unexpected(w: &str, r: &json::Value) -> Error {
34 Error::UnexpectedResponse {
35 why: w.to_string(),
36 response: r.to_string(),
37 }
38}
39
40pub type Result<T> = std::result::Result<T, Error>;
41
42#[derive(Debug)]
43pub struct Session {
44 token: String,
45 url: String,
46 upload_url: String,
47 upload_token: String,
48 bucket_name: String,
49 bucket_id: String,
50}
51
52fn noredir() -> ureq::Agent {
55 ureq::builder().redirects(0).build()
56}
57
58impl Session {
59 pub fn new<S: Into<String>>(key_id: &str, application_key: &str, bucket: S) -> Result<Self> {
60 let bucket = bucket.into();
61
62 let creds = String::from(key_id) + ":" + application_key;
63 let auth = String::from("Basic") + &BASE64_STANDARD.encode(creds);
64 let v: json::Value = ureq::get("https://api.backblazeb2.com/b2api/v3/b2_authorize_account")
65 .set("Authorization", &auth)
66 .call()?
67 .into_json()?;
68
69 let bad = |s| unexpected(s, &v);
70
71 let id: String = v["accountId"]
72 .as_str()
73 .ok_or_else(|| bad("login response missing authorization token"))?
74 .to_owned();
75
76 let token: String = v["authorizationToken"]
77 .as_str()
78 .ok_or_else(|| bad("login response missing authorization token"))?
79 .to_owned();
80
81 let url = v["apiInfo"]["storageApi"]["apiUrl"]
82 .as_str()
83 .ok_or_else(|| bad("login response missing API URL"))?
84 .to_owned();
85
86 let capes = v["apiInfo"]["storageApi"]["capabilities"]
87 .as_array()
88 .ok_or_else(|| bad("login response missing capabilities"))?;
89 let capes = capes
90 .iter()
91 .map(|v| {
92 v.as_str()
93 .ok_or_else(|| bad("login response had malformed capabilities"))
94 })
95 .collect::<Result<Vec<&str>>>()?;
96
97 if !capes.iter().any(|c| *c == "listKeys") {
98 return Err(bad("credentials can not list files"));
99 }
100 if !capes.iter().any(|c| *c == "readFiles") {
101 return Err(bad("credentials can not read files"));
102 }
103 if !capes.iter().any(|c| *c == "writeFiles") {
104 return Err(bad("credentials can not write files"));
105 }
106 if !capes.iter().any(|c| *c == "deleteFiles") {
107 return Err(bad("credentials can not delete files"));
108 }
109
110 let br: json::Value = ureq::get(&(url.clone() + "/b2api/v2/b2_list_buckets"))
111 .set("Authorization", &token)
112 .query("accountId", &id)
113 .query("bucketName", &bucket)
114 .call()?
115 .into_json()?;
116
117 let bucket_id = match br["buckets"].as_array() {
118 Some(bs) => {
119 match bs
120 .iter()
121 .find(|b| b["bucketName"].as_str() == Some(&bucket))
122 {
123 Some(mah_bukkit) => mah_bukkit["bucketId"]
124 .as_str()
125 .ok_or_else(|| unexpected("bucket was missing ID", &br))?
126 .to_owned(),
127 None => return Err(Error::NotFound { what: bucket }),
128 }
129 }
130 None => return Err(Error::NotFound { what: bucket }),
131 };
132
133 let ur: json::Value = ureq::get(&(url.clone() + "/b2api/v2/b2_get_upload_url"))
134 .set("Authorization", &token)
135 .query("bucketId", &bucket_id)
136 .call()?
137 .into_json()?;
138
139 let upload_url = ur["uploadUrl"]
140 .as_str()
141 .ok_or_else(|| unexpected("couldn't get bucket upload URL", &ur))?
142 .to_owned();
143
144 let upload_token = ur["authorizationToken"]
145 .as_str()
146 .ok_or_else(|| unexpected("couldn't get bucket upload token", &ur))?
147 .to_owned();
148
149 Ok(Session {
150 token,
151 url,
152 upload_url,
153 upload_token,
154 bucket_name: bucket,
155 bucket_id,
156 })
157 }
158
159 pub fn list(&self, prefix: Option<&str>) -> Result<Vec<(String, u64)>> {
160 let mut fs = vec![];
161 let mut start_name: Option<String> = None;
162 loop {
163 let mut req = noredir()
164 .get(&(self.url.clone() + "/b2api/v2/b2_list_file_names"))
165 .set("Authorization", &self.token)
166 .query("bucketId", &self.bucket_id)
167 .query("maxFileCount", "10000");
168 if let Some(p) = prefix {
169 req = req.query("prefix", p);
170 }
171 if let Some(sn) = &start_name {
172 req = req.query("startFileName", sn);
173 }
174
175 let lfn = req.call()?.into_json()?;
176
177 let bad = |s| unexpected(s, &lfn);
178
179 let files = lfn["files"]
180 .as_array()
181 .ok_or_else(|| bad("didn't list file names"))?;
182 for fj in files
183 .iter()
184 .filter(|f| f["action"].as_str() == Some("upload"))
185 .filter_map(
186 |f| match (f["fileName"].as_str(), f["contentLength"].as_u64()) {
187 (Some(n), Some(l)) => Some((n.to_owned(), l)),
188 _ => None,
189 },
190 )
191 {
192 fs.push(fj);
193 }
194
195 start_name = lfn["nextFileName"].as_str().map(|s| s.to_owned());
196 if start_name.is_none() {
197 break;
198 }
199 }
200 fs.shrink_to_fit(); Ok(fs)
202 }
203
204 pub fn get(&self, name: &str) -> Result<impl Read> {
205 let r = noredir()
206 .get(&(self.url.clone() + "/file/" + &self.bucket_name + "/" + name))
207 .set("Authorization", &self.token)
208 .call()?;
209
210 Ok(r.into_reader())
211 }
212
213 pub fn put(&self, name: &str, len: u64, contents: &mut dyn Read) -> Result<()> {
214 use data_encoding::HEXLOWER;
215 use sha1::{Digest, Sha1};
216
217 enum HashAppendingReader<R> {
220 Contents { inner: R, hasher: Option<Sha1> },
221 HashSuffix(Cursor<Vec<u8>>),
222 }
223
224 impl<R> HashAppendingReader<R> {
225 fn new(inner: R) -> Self {
226 Self::Contents {
227 inner,
228 hasher: Some(Sha1::new()),
229 }
230 }
231 }
232
233 impl<R: Read> Read for HashAppendingReader<R> {
234 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
235 match self {
236 Self::Contents { inner, hasher } => {
237 let bytes_read = inner.read(buf)?;
239 if bytes_read > 0 {
240 hasher.as_mut().unwrap().update(&buf[..bytes_read]);
242 Ok(bytes_read)
243 } else {
244 let sha = hasher.take().unwrap().finalize();
248 let sha_hex = HEXLOWER.encode(&sha).to_string().into_bytes();
249 *self = Self::HashSuffix(Cursor::new(sha_hex));
250 self.read(buf)
252 }
253 }
254 Self::HashSuffix(c) => c.read(buf),
255 }
256 }
257 }
258
259 let hr = HashAppendingReader::new(contents);
260
261 noredir()
262 .post(&self.upload_url)
263 .set("Authorization", &self.upload_token)
264 .set("Content-Length", &(len + 40).to_string()) .set("X-Bz-File-Name", name) .set("Content-Type", "b2/x-auto") .set("X-Bz-Content-Sha1", "hex_digits_at_end")
268 .send(hr)?;
269
270 Ok(())
271 }
272
273 pub fn delete(&self, name: &str) -> Result<()> {
274 let req = noredir()
275 .get(&(self.url.clone() + "/b2api/v2/b2_list_file_versions"))
276 .set("Authorization", &self.token)
277 .query("bucketId", &self.bucket_id)
278 .query("prefix", name);
279
280 let lfv = req.call()?.into_json()?;
281 let where_name = || unexpected(&format!("couldn't find {name}"), &lfv);
282
283 let versions = lfv["files"].as_array().ok_or_else(where_name)?;
284
285 if versions.is_empty() {
286 return Err(where_name());
287 }
288 if versions.len() != 1 {
289 return Err(unexpected(
290 &format!("found multiple versions of {name}"),
291 &lfv,
292 ));
293 }
294 let id = versions[0]["fileId"]
295 .as_str()
296 .ok_or_else(|| unexpected(&format!("couldn't find ID for {name}"), &lfv))?;
297
298 ureq::post(&(self.url.clone() + "/b2api/v2/b2_delete_file_version"))
299 .set("Authorization", &self.token)
300 .send_json(json::json!({
301 "fileName": name,
302 "fileId": id
303 }))?;
304
305 Ok(())
306 }
307}