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