neocities_client/
client.rs

1////////       This file is part of the source code for neocities-client, a Rust           ////////
2////////       library for interacting with the https://neocities.org/ API.                ////////
3////////                                                                                   ////////
4////////                           Copyright © 2024  André Kugland                         ////////
5////////                                                                                   ////////
6////////       This program is free software: you can redistribute it and/or modify        ////////
7////////       it under the terms of the GNU General Public License as published by        ////////
8////////       the Free Software Foundation, either version 3 of the License, or           ////////
9////////       (at your option) any later version.                                         ////////
10////////                                                                                   ////////
11////////       This program is distributed in the hope that it will be useful,             ////////
12////////       but WITHOUT ANY WARRANTY; without even the implied warranty of              ////////
13////////       MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the                ////////
14////////       GNU General Public License for more details.                                ////////
15////////                                                                                   ////////
16////////       You should have received a copy of the GNU General Public License           ////////
17////////       along with this program. If not, see https://www.gnu.org/licenses/.         ////////
18
19//! Client for the Neocities API.
20
21use 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
30/// Default base URL for the Neocities API.
31const DEFAULT_BASE_URL: &str = "https://neocities.org/api";
32
33/// Default user agent to use for the requests.
34const DEFAULT_USER_AGENT: &str = concat!("neocities_client/", env!("CARGO_PKG_VERSION"));
35
36/// List of file extensions allowed for free accounts.
37const 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/// Client for the Neocities API.
102///
103/// This struct is used to make requests to the Neocities API. It can be built using the
104/// [`Client::builder()`](#method.builder) method, which returns a
105/// [`ClientBuilder`](struct.ClientBuilder.html) struct.
106///
107/// ```
108/// # use neocities_client::{Auth, Client};
109/// let client = Client::builder()
110///    .auth(Auth::from("username:password"))
111///    .build()
112///    .unwrap();
113/// ```
114#[derive(Debug, Builder)]
115pub struct Client {
116    /// Instance of [`ureq::Agent`] to use for the requests.
117    ///
118    /// Override this if you want to customize the [`Agent`](ureq::Agent), for example, to use a
119    /// proxy, to set a timeout, to add middlewares, *&c*.
120    #[builder(default = "ureq::builder().build()")]
121    ureq_agent: Agent,
122    /// Base URL for the Neocities API.
123    ///
124    /// Defaults to `https://neocities.org/api`.
125    ///
126    /// This is overridable for testing purposes.
127    #[builder(default = "DEFAULT_BASE_URL.to_owned()")]
128    base_url: String,
129    /// User agent to use for the requests
130    ///
131    /// Defaults to `neocities_client/x.y.z`
132    #[builder(default = "DEFAULT_USER_AGENT.to_owned()")]
133    user_agent: String,
134    /// Authorization that will be used for the requests.
135    auth: Auth,
136}
137
138/// Client for the Neocities API.
139#[allow(clippy::result_large_err)]
140impl Client {
141    /// Return a new [`ClientBuilder`] struct.
142    pub fn builder() -> ClientBuilder {
143        ClientBuilder::default()
144    }
145
146    /// Delete one or more files from the website.
147    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    /// Get the website info.
165    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    /// Get an API key for the website.
178    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    /// List the files on the website.
191    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    /// Upload one or more files to the website.
204    ///
205    /// This method receives a list of tuples, each containing the path of the file and the
206    /// contents of the file.
207    ///
208    /// ```no_run
209    /// # use neocities_client::{Auth, Client, Result};
210    /// # fn main() -> Result<()> {
211    /// # let client = Client::builder().auth(Auth::from("faketoken")).build().unwrap();
212    /// client.upload(&[
213    ///     ("/1st_file.txt", b"Contents of the first file"),
214    ///     ("/2nd_file.txt", b"Contents of the second file"),
215    /// ])?;
216    /// # Ok(())
217    /// # }
218    /// ```
219    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            // The only occasion where in-memory fake I/O can fail is when we run out of memory,
235            // and in that case, we're screwed anyway. Having this possible panic here allows us to
236            // avoid having a variant for [`std::io::Error`] in our [`Error`] enum.
237            .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"); // Same as above.
243        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    /// Check whether the given path has an allowed extension for this account.
256    ///
257    /// If the [`free_account`](ClientBuilder::free_account) field is set to `true`, this method
258    /// will check that the file extension of the given path is in the list of allowed extensions.
259    /// If the field is set to `false`, this method will always return `true`.
260    ///
261    /// For more information, see <https://neocities.org/site_files/allowed_types>.
262    ///
263    /// ```
264    /// # use neocities_client::{Auth, Client};
265    /// assert!(Client::has_allowed_extension(true, "hello.txt"));
266    /// assert!(!Client::has_allowed_extension(true, "hello.exe"));
267    /// ```
268    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    // ------------------------------------ Private methods ------------------------------------ //
282
283    /// Build a new request with the given method and path.
284    ///
285    /// This method will set the appropriate headers, including the `Authorization` header if
286    /// the the `auth` field is set.
287    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}