1use crate::response::{parse_response, Info, ListEntry};
22use crate::{Auth, Error, Result};
23use derive_builder::Builder;
24use form_data_builder::FormData;
25use std::{ffi::OsStr, io::Cursor};
26use tap::prelude::*;
27use typed_path::Utf8UnixPath;
28use ureq::{Agent, OrAnyStatus, Request};
29
30const DEFAULT_BASE_URL: &str = "https://neocities.org/api";
32
33const DEFAULT_USER_AGENT: &str = concat!("neocities_client/", env!("CARGO_PKG_VERSION"));
35
36const ALLOWED_EXTS_FOR_FREE_ACCOUNTS: &[&str] = &[
38 "apng",
39 "asc",
40 "atom",
41 "avif",
42 "bin",
43 "css",
44 "csv",
45 "dae",
46 "eot",
47 "epub",
48 "geojson",
49 "gif",
50 "gltf",
51 "gpg",
52 "htm",
53 "html",
54 "ico",
55 "jpeg",
56 "jpg",
57 "js",
58 "json",
59 "key",
60 "kml",
61 "knowl",
62 "less",
63 "manifest",
64 "map",
65 "markdown",
66 "md",
67 "mf",
68 "mid",
69 "midi",
70 "mtl",
71 "obj",
72 "opml",
73 "osdx",
74 "otf",
75 "pdf",
76 "pgp",
77 "pls",
78 "png",
79 "rdf",
80 "resolveHandle",
81 "rss",
82 "sass",
83 "scss",
84 "svg",
85 "text",
86 "toml",
87 "tsv",
88 "ttf",
89 "txt",
90 "webapp",
91 "webmanifest",
92 "webp",
93 "woff",
94 "woff2",
95 "xcf",
96 "xml",
97 "yaml",
98 "yml",
99];
100
101#[derive(Debug, Builder)]
115pub struct Client {
116 #[builder(default = "ureq::builder().build()")]
121 ureq_agent: Agent,
122 #[builder(default = "DEFAULT_BASE_URL.to_owned()")]
128 base_url: String,
129 #[builder(default = "DEFAULT_USER_AGENT.to_owned()")]
133 user_agent: String,
134 auth: Auth,
136}
137
138#[allow(clippy::result_large_err)]
140impl Client {
141 pub fn builder() -> ClientBuilder {
143 ClientBuilder::default()
144 }
145
146 pub fn delete(&self, paths: &[&str]) -> Result<()> {
148 #[cfg(debug_assertions)]
149 log::trace!("Deleting files {:?}", paths);
150 let form = paths
151 .iter()
152 .map(|path| ("filenames[]", *path))
153 .collect::<Vec<_>>();
154 self.make_request("POST", "delete")
155 .send_form(&form)
156 .or_any_status()
157 .map_err(Error::from)
158 .and_then(|res| parse_response::<String>("message", res))
159 .tap_ok_dbg(|msg| log::trace!("{}", msg))
160 .tap_err(|e| log::debug!("{}", e))
161 .and(Ok(()))
162 }
163
164 pub fn info(&self) -> Result<Info> {
166 #[cfg(debug_assertions)]
167 log::trace!("Getting website info");
168 self.make_request("GET", "info")
169 .call()
170 .or_any_status()
171 .map_err(Error::from)
172 .and_then(|res| parse_response::<Info>("info", res))
173 .tap_ok_dbg(|info| log::trace!("{:?}", info))
174 .tap_err(|e| log::debug!("{}", e))
175 }
176
177 pub fn key(&self) -> Result<String> {
179 #[cfg(debug_assertions)]
180 log::trace!("Getting API key");
181 self.make_request("GET", "key")
182 .call()
183 .or_any_status()
184 .map_err(Error::from)
185 .and_then(|res| parse_response::<String>("api_key", res))
186 .tap_ok_dbg(|_| log::trace!("Got an API key: <redacted>"))
187 .tap_err(|e| log::debug!("{}", e))
188 }
189
190 pub fn list(&self) -> Result<Vec<ListEntry>> {
192 #[cfg(debug_assertions)]
193 log::trace!("Listing files");
194 self.make_request("GET", "list")
195 .call()
196 .or_any_status()
197 .map_err(Error::from)
198 .and_then(|res| parse_response::<Vec<ListEntry>>("files", res))
199 .tap_ok_dbg(|list| log::trace!("{:?}", list))
200 .tap_err(|e| log::debug!("{}", e))
201 }
202
203 pub fn upload(&self, files: &[(&str, &[u8])]) -> Result<()> {
220 #[cfg(debug_assertions)]
221 log::trace!(
222 "Uploading files {:?}",
223 files.iter().map(|(name, _)| name).collect::<Vec<_>>()
224 );
225 let mut form = FormData::new(Vec::new());
226 for (name, content) in files {
227 form.write_file(
228 name,
229 Cursor::new(content),
230 Some(OsStr::new("file")),
231 "application/octet-stream",
232 )
233 .tap_err(|e| log::debug!("{}", e))
234 .expect("Failed to write file contents to form data");
238 }
239 let post_body = form
240 .finish()
241 .tap_err(|e| log::debug!("{}", e))
242 .expect("Failed to finish form data"); let content_type = form.content_type_header();
244 self.make_request("POST", "upload")
245 .set("Content-Type", &content_type)
246 .send_bytes(&post_body)
247 .or_any_status()
248 .map_err(Error::from)
249 .and_then(|res| parse_response::<String>("message", res))
250 .tap_ok_dbg(|list| log::trace!("{:?}", list))
251 .tap_err(|e| log::debug!("{}", e))
252 .and(Ok(()))
253 }
254
255 pub fn has_allowed_extension(free_account: bool, path: &str) -> bool {
269 if !free_account {
270 true
271 } else {
272 let unix_path = Utf8UnixPath::new(path);
273 let ext = unix_path
274 .extension()
275 .unwrap_or_default()
276 .to_ascii_lowercase();
277 ALLOWED_EXTS_FOR_FREE_ACCOUNTS.contains(&ext.as_str())
278 }
279 }
280
281 fn make_request(&self, method: &str, path: &str) -> Request {
288 let path = format!("{}/{}", self.base_url, path);
289 self.ureq_agent
290 .request(method, &path)
291 .set("User-Agent", &self.user_agent)
292 .set("Accept", "application/json")
293 .set("Accept-Charset", "utf-8")
294 .set("Authorization", &self.auth.header())
295 }
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301 use crate::ErrorKind;
302 use indoc::indoc;
303 use mockito::{Matcher, Server};
304
305 #[test]
306 fn delete_ok() {
307 let mut server = Server::new();
308 let mock = server
309 .mock("POST", "/delete")
310 .match_header("Accept", "application/json")
311 .match_header("Accept-Charset", "utf-8")
312 .match_header("Authorization", "Basic dXNlcm5hbWU6cGFzc3dvcmQ=")
313 .match_body(Matcher::UrlEncoded(
314 "filenames[]".to_owned(),
315 "hello.txt".to_owned(),
316 ))
317 .with_status(200)
318 .with_header("Content-Type", "application/json")
319 .with_body(r#"{ "result": "success", "message": "file(s) have been deleted" }"#)
320 .create();
321 let client = Client::builder()
322 .base_url(server.url())
323 .auth(Auth::from("username:password"))
324 .build()
325 .unwrap();
326 client.delete(&["hello.txt"]).unwrap();
327 mock.assert();
328 }
329
330 #[test]
331 fn delete_err() {
332 let mut server = Server::new();
333 let mock = server
334 .mock("POST", "/delete")
335 .match_header("Accept", "application/json")
336 .match_header("Accept-Charset", "utf-8")
337 .match_header("Authorization", "Basic dXNlcm5hbWU6cGFzc3dvcmQ=")
338 .match_body(Matcher::UrlEncoded(
339 "filenames[]".to_owned(),
340 "hello.txt".to_owned(),
341 ))
342 .with_status(200)
343 .with_header("Content-Type", "application/json")
344 .with_body(
345 r#"{
346 "result": "error",
347 "error_type": "missing_files",
348 "message": "img1.jpg was not found on your site, canceled deleting"
349 }"#,
350 )
351 .create();
352 let client = Client::builder()
353 .base_url(server.url())
354 .auth(Auth::from("username:password"))
355 .build()
356 .unwrap();
357 let err = client.delete(&["hello.txt"]).unwrap_err();
358 mock.assert();
359 assert!(matches!(
360 err,
361 Error::Api {
362 kind: ErrorKind::MissingFiles,
363 ..
364 }
365 ));
366 }
367
368 #[test]
369 fn info() {
370 let mut server = Server::new();
371 let mock = server
372 .mock("GET", "/info")
373 .match_header("Accept", "application/json")
374 .match_header("Accept-Charset", "utf-8")
375 .match_header("Authorization", "Basic dXNlcm5hbWU6cGFzc3dvcmQ=")
376 .with_status(200)
377 .with_header("Content-Type", "application/json")
378 .with_body(
379 r#"{
380 "result": "success",
381 "info": {
382 "sitename": "youpi",
383 "views": 235684,
384 "hits": 1487423,
385 "created_at": "Sat, 29 Jun 2013 10:11:38 -0000",
386 "last_updated": "Fri, 01 Dec 2017 18:47:51 -0000",
387 "domain": null,
388 "tags": ["anime", "music", "videogames", "personal", "art"],
389 "latest_ipfs_hash": null
390 }
391 }"#,
392 )
393 .create();
394 let client = Client::builder()
395 .base_url(server.url())
396 .auth(Auth::from("username:password"))
397 .build()
398 .unwrap();
399 let info = client.info().unwrap();
400 mock.assert();
401 assert_eq!(info.sitename, "youpi");
402 assert_eq!(info.views, 235684);
403 assert_eq!(info.hits, 1487423);
404 assert_eq!(info.created_at, "Sat, 29 Jun 2013 10:11:38 -0000");
405 assert_eq!(
406 info.last_updated.unwrap(),
407 "Fri, 01 Dec 2017 18:47:51 -0000"
408 );
409 assert_eq!(info.domain, None);
410 assert_eq!(
411 info.tags,
412 vec!["anime", "music", "videogames", "personal", "art"]
413 );
414 assert_eq!(info.latest_ipfs_hash, None);
415 }
416
417 #[test]
418 fn key_ok() {
419 let mut server = Server::new();
420 let mock = server
421 .mock("GET", "/key")
422 .match_header("Accept", "application/json")
423 .match_header("Accept-Charset", "utf-8")
424 .match_header("Authorization", "Basic dXNlcm5hbWU6cGFzc3dvcmQ=")
425 .with_status(200)
426 .with_header("Content-Type", "application/json")
427 .with_body(r#"{ "result": "success", "api_key": "c6275ca833ac06c83926ccb00dff4c82" }"#)
428 .create();
429 let client = Client::builder()
430 .base_url(server.url())
431 .auth(Auth::from("username:password"))
432 .build()
433 .unwrap();
434 let key = client.key().unwrap();
435 mock.assert();
436 assert_eq!(key, "c6275ca833ac06c83926ccb00dff4c82");
437 }
438
439 #[test]
440 fn key_err() {
441 let mut server = Server::new();
442 let mock = server
443 .mock("GET", "/key")
444 .match_header("Accept", "application/json")
445 .match_header("Accept-Charset", "utf-8")
446 .match_header("Authorization", "Basic dXNlcm5hbWU6cGFzc3dvcmQ=")
447 .with_status(200)
448 .with_header("Content-Type", "application/json")
449 .with_body(r#"{
450 "result": "error",
451 "error_type": "invalid_auth",
452 "message": "invalid credentials - please check your username and password (or your api key)"
453 }"#)
454 .create();
455 let client = Client::builder()
456 .base_url(server.url())
457 .auth(Auth::from("username:password"))
458 .build()
459 .unwrap();
460 let key = client.key().unwrap_err();
461 mock.assert();
462 assert!(matches!(
463 key,
464 Error::Api {
465 kind: ErrorKind::InvalidAuth,
466 ..
467 }
468 ));
469 }
470
471 #[test]
472 fn list() {
473 let mut server = Server::new();
474 let mock = server
475 .mock("GET", "/list")
476 .match_header("Accept", "application/json")
477 .match_header("Accept-Charset", "utf-8")
478 .match_header("Authorization", "Basic dXNlcm5hbWU6cGFzc3dvcmQ=")
479 .with_status(200)
480 .with_header("Content-Type", "application/json")
481 .with_body(
482 r#"{
483 "result": "success",
484 "files": [{
485 "path": "index.html",
486 "is_directory": false,
487 "size": 1023,
488 "updated_at": "Sat, 13 Feb 2016 03:04:00 -0000",
489 "sha1_hash": "c8aac06f343c962a24a7eb111aad739ff48b7fb1"
490 }, {
491 "path": "not_found.html",
492 "is_directory": false,
493 "size": 271,
494 "updated_at": "Sat, 13 Feb 2016 03:04:00 -0000",
495 "sha1_hash": "cfdf0bda2557c322be78302da23c32fec72ffc0b"
496 }, {
497 "path": "images",
498 "is_directory": true,
499 "updated_at": "Sat, 13 Feb 2016 03:04:00 -0000"
500 }, {
501 "path": "images/cat.png",
502 "is_directory": false,
503 "size": 16793,
504 "updated_at": "Sat, 13 Feb 2016 03:04:00 -0000",
505 "sha1_hash": "41fe08fc0dd44e79f799d03ece903e62be25dc7d"
506 }]
507 }"#,
508 )
509 .create();
510 let client = Client::builder()
511 .base_url(server.url())
512 .auth(Auth::from("username:password"))
513 .build()
514 .unwrap();
515 let list = client.list().unwrap();
516 mock.assert();
517 assert_eq!(list.len(), 4);
518 assert_eq!(list[0].path, "index.html");
519 assert!(!list[0].is_directory);
520 assert_eq!(list[0].size, Some(1023));
521 assert_eq!(list[0].updated_at, "Sat, 13 Feb 2016 03:04:00 -0000");
522 assert_eq!(
523 list[0].sha1_hash.clone().unwrap(),
524 "c8aac06f343c962a24a7eb111aad739ff48b7fb1"
525 );
526 assert_eq!(list[1].path, "not_found.html");
527 assert!(!list[1].is_directory);
528 assert_eq!(list[1].size, Some(271));
529 assert_eq!(list[1].updated_at, "Sat, 13 Feb 2016 03:04:00 -0000");
530 assert_eq!(
531 list[1].sha1_hash.clone().unwrap(),
532 "cfdf0bda2557c322be78302da23c32fec72ffc0b"
533 );
534 assert_eq!(list[2].path, "images");
535 assert!(list[2].is_directory);
536 assert_eq!(list[2].size, None);
537 assert_eq!(list[2].updated_at, "Sat, 13 Feb 2016 03:04:00 -0000");
538 assert_eq!(list[2].sha1_hash, None);
539 assert_eq!(list[3].path, "images/cat.png");
540 assert!(!list[3].is_directory);
541 assert_eq!(list[3].size, Some(16793));
542 assert_eq!(list[3].updated_at, "Sat, 13 Feb 2016 03:04:00 -0000");
543 assert_eq!(
544 list[3].sha1_hash.clone().unwrap(),
545 "41fe08fc0dd44e79f799d03ece903e62be25dc7d"
546 );
547 }
548
549 #[test]
550 fn upload_ok() {
551 let content_type =
552 Matcher::Regex("multipart/form-data; boundary=--------+[-A-Za-z0-9_]{32}".to_owned());
553 let body = Matcher::Regex(
554 indoc! {"
555 --------+[-A-Za-z0-9_]{32}\r\n\
556 Content-Disposition: form-data; name=\"hello.txt\"; filename=\"file\"\r\n\
557 Content-Type: application/octet-stream\r\n\
558 \r\n\
559 Hello, world!\n\r\n\
560 --------+[-A-Za-z0-9_]{32}\r\n\
561 Content-Disposition: form-data; name=\"hello1.txt\"; filename=\"file\"\r\n\
562 Content-Type: application/octet-stream\r\n\
563 \r\n\
564 Hello, world!\n\r\n\
565 --------+[-A-Za-z0-9_]{32}--\r\n\
566 "}
567 .to_owned(),
568 );
569 let mut server = Server::new();
570 let mock = server
571 .mock("POST", "/upload")
572 .match_header("Accept", "application/json")
573 .match_header("Accept-Charset", "utf-8")
574 .match_header("Authorization", "Basic dXNlcm5hbWU6cGFzc3dvcmQ=")
575 .match_header("Content-Type", content_type)
576 .match_body(body)
577 .with_status(200)
578 .with_header("Content-Type", "application/json")
579 .with_body(
580 r#"{
581 "result": "success",
582 "message": "your file(s) have been successfully uploaded"
583 }"#,
584 )
585 .create();
586 let content = b"Hello, world!\n";
587 let client = Client::builder()
588 .base_url(server.url())
589 .auth(Auth::from("username:password"))
590 .build()
591 .unwrap();
592 client
593 .upload(&[("hello.txt", content), ("hello1.txt", content)])
594 .unwrap();
595 mock.assert();
596 }
597}