Skip to main content

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–2026   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::typestate::{WithBody, WithoutBody};
29use ureq::{Agent, RequestBuilder};
30
31/// Default base URL for the Neocities API.
32const DEFAULT_BASE_URL: &str = "https://neocities.org/api";
33
34/// Default user agent to use for the requests.
35const DEFAULT_USER_AGENT: &str = concat!("neocities_client/", env!("CARGO_PKG_VERSION"));
36
37/// List of file extensions allowed for free accounts.
38const 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/// Client for the Neocities API.
103///
104/// This struct is used to make requests to the Neocities API. It can be built using the
105/// [`Client::builder()`](#method.builder) method, which returns a
106/// [`ClientBuilder`](struct.ClientBuilder.html) struct.
107///
108/// ```
109/// # use neocities_client::{Auth, Client};
110/// let client = Client::builder()
111///    .auth(Auth::from("username:password"))
112///    .build()
113///    .unwrap();
114/// ```
115#[derive(Debug, Builder)]
116pub struct Client {
117    /// Instance of [`ureq::Agent`] to use for the requests.
118    ///
119    /// Override this if you want to customize the [`Agent`](ureq::Agent), for example, to use a
120    /// proxy, to set a timeout, to add middlewares, *&c*.
121    #[builder(default = "Agent::config_builder().http_status_as_error(false).build().into()")]
122    ureq_agent: Agent,
123    /// Base URL for the Neocities API.
124    ///
125    /// Defaults to `https://neocities.org/api`.
126    ///
127    /// This is overridable for testing purposes.
128    #[builder(default = "DEFAULT_BASE_URL.to_owned()")]
129    base_url: String,
130    /// User agent to use for the requests
131    ///
132    /// Defaults to `neocities_client/x.y.z`
133    #[builder(default = "DEFAULT_USER_AGENT.to_owned()")]
134    user_agent: String,
135    /// Authorization that will be used for the requests.
136    auth: Auth,
137}
138
139/// Client for the Neocities API.
140#[allow(clippy::result_large_err)]
141impl Client {
142    /// Return a new [`ClientBuilder`] struct.
143    pub fn builder() -> ClientBuilder {
144        ClientBuilder::default()
145    }
146
147    /// Delete one or more files from the website.
148    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    /// Get the website info.
165    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    /// Get an API key for the website.
177    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    /// List the files on the website.
189    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    /// Upload one or more files to the website.
201    ///
202    /// This method receives a list of tuples, each containing the path of the file and the
203    /// contents of the file.
204    ///
205    /// ```no_run
206    /// # use neocities_client::{Auth, Client, Result};
207    /// # fn main() -> Result<()> {
208    /// # let client = Client::builder().auth(Auth::from("faketoken")).build().unwrap();
209    /// client.upload(&[
210    ///     ("/1st_file.txt", b"Contents of the first file"),
211    ///     ("/2nd_file.txt", b"Contents of the second file"),
212    /// ])?;
213    /// # Ok(())
214    /// # }
215    /// ```
216    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            // The only occasion where in-memory fake I/O can fail is when we run out of memory,
232            // and in that case, we're screwed anyway. Having this possible panic here allows us to
233            // avoid having a variant for [`std::io::Error`] in our [`Error`] enum.
234            .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"); // Same as above.
240        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    /// Check whether the given path has an allowed extension for this account.
252    ///
253    /// If the [`free_account`](ClientBuilder::free_account) field is set to `true`, this method
254    /// will check that the file extension of the given path is in the list of allowed extensions.
255    /// If the field is set to `false`, this method will always return `true`.
256    ///
257    /// For more information, see <https://neocities.org/site_files/allowed_types>.
258    ///
259    /// ```
260    /// # use neocities_client::{Auth, Client};
261    /// assert!(Client::has_allowed_extension(true, "hello.txt"));
262    /// assert!(!Client::has_allowed_extension(true, "hello.exe"));
263    /// ```
264    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    // ------------------------------------ Private methods ------------------------------------ //
278
279    /// Build a new GET request with the given path.
280    ///
281    /// This method will set the appropriate headers, including the `Authorization` header.
282    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    /// Build a new POST request with the given path.
293    ///
294    /// This method will set the appropriate headers, including the `Authorization` header.
295    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}