diff --git a/.gitignore b/.gitignore index 5c8db42..753e9e3 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ szurubooru-integration-test/szurubooru/data/* szurubooru-integration-test/szurubooru/pgsql/* szurubooru-integration-test/szurubooru/server/* +**/.DS_Store diff --git a/Cargo.lock b/Cargo.lock index 2d66a6c..7ff6dc5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -687,6 +687,15 @@ version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" +[[package]] +name = "matchers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +dependencies = [ + "regex-automata 0.1.10", +] + [[package]] name = "memchr" version = "2.7.4" @@ -771,6 +780,16 @@ dependencies = [ "tempfile", ] +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -839,6 +858,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + [[package]] name = "parking_lot" version = "0.12.3" @@ -924,6 +949,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "progress" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17b820305721858696053a7fd0215cfeeee16ecaaf96b7a209945428e02f1c44" +dependencies = [ + "terminal_size", +] + [[package]] name = "quote" version = "1.0.36" @@ -980,8 +1014,17 @@ checksum = "4219d74c6b67a3654a9fbebc4b419e22126d13d2f3c4a07ee0cb61ff79a79619" dependencies = [ "aho-corasick", "memchr", - "regex-automata", - "regex-syntax", + "regex-automata 0.4.7", + "regex-syntax 0.8.4", +] + +[[package]] +name = "regex-automata" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" +dependencies = [ + "regex-syntax 0.6.29", ] [[package]] @@ -992,9 +1035,15 @@ checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df" dependencies = [ "aho-corasick", "memchr", - "regex-syntax", + "regex-syntax 0.8.4", ] +[[package]] +name = "regex-syntax" +version = "0.6.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" + [[package]] name = "regex-syntax" version = "0.8.4" @@ -1226,6 +1275,15 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "signal-hook-registry" version = "1.4.2" @@ -1346,7 +1404,7 @@ dependencies = [ [[package]] name = "szurubooru-client" -version = "0.1.0" +version = "0.1.1" dependencies = [ "base64", "bytes", @@ -1372,7 +1430,11 @@ dependencies = [ name = "szurubooru-integration-test" version = "0.1.0" dependencies = [ + "progress", "szurubooru-client", + "tokio", + "tracing", + "tracing-subscriber", ] [[package]] @@ -1388,6 +1450,16 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "terminal_size" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633c1a546cee861a1a6d0dc69ebeca693bf4296661ba7852b9d21d159e0506df" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "thiserror" version = "1.0.63" @@ -1408,6 +1480,16 @@ dependencies = [ "syn", ] +[[package]] +name = "thread_local" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" +dependencies = [ + "cfg-if", + "once_cell", +] + [[package]] name = "tinyvec" version = "1.8.0" @@ -1438,6 +1520,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", + "tracing", "windows-sys 0.52.0", ] @@ -1542,6 +1625,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -1609,6 +1722,12 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +[[package]] +name = "valuable" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" + [[package]] name = "vcpkg" version = "0.2.15" @@ -1725,6 +1844,28 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.52.0" diff --git a/szurubooru-client/Cargo.toml b/szurubooru-client/Cargo.toml index 01f847a..130ebbb 100644 --- a/szurubooru-client/Cargo.toml +++ b/szurubooru-client/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "szurubooru-client" -version = "0.1.0" +version = "0.1.1" edition = "2021" authors = ["Scott Lyons "] description = "A wrapper around the Szurubooru API, including type-safe Query and Sort tokens" diff --git a/szurubooru-client/src/client.rs b/szurubooru-client/src/client.rs index 6fb49da..f3a8490 100644 --- a/szurubooru-client/src/client.rs +++ b/szurubooru-client/src/client.rs @@ -2,12 +2,14 @@ use crate::{errors::*, models::*, tokens::*}; use base64::{engine::general_purpose::STANDARD, Engine as _}; +use reqwest::header::CONTENT_TYPE; use reqwest::{ header::{HeaderMap, ACCEPT, AUTHORIZATION}, multipart::{Form, Part}, Client, ClientBuilder, Method, RequestBuilder, Response, }; use serde::{de::DeserializeOwned, Serialize}; +use serde_json::Value; use sha1::{Digest, Sha1}; use std::fmt::{Display, Formatter}; use std::path::Path; @@ -88,6 +90,12 @@ impl SzurubooruClient { SzurubooruClient::new(host, auth, allow_insecure) } + /// Create a new client with anonymous credentials + pub fn new_anonymous(host: &str, allow_insecure: bool) -> SzurubooruResult { + let auth = SzurubooruAuth::None; + SzurubooruClient::new(host, auth, allow_insecure) + } + fn new(host: &str, auth: SzurubooruAuth, allow_insecure: bool) -> SzurubooruResult { let host = if host.ends_with("/") { &host[0..host.len() - 1] @@ -103,6 +111,7 @@ impl SzurubooruClient { let mut header_map = HeaderMap::new(); //header_map.append(AUTHORIZATION, token_header_value.parse().unwrap()); header_map.append(ACCEPT, "application/json".parse().unwrap()); + header_map.append(CONTENT_TYPE, "application/json".parse().unwrap()); let client = ClientBuilder::new() .danger_accept_invalid_certs(allow_insecure) @@ -288,7 +297,7 @@ impl<'a> SzurubooruRequest<'a> { T: AsRef + Display, { let mut req_url = self.client.base_url.clone(); - req_url.set_path(path.as_ref()); + req_url.set_path(&format!("/api{}", path.as_ref())); if let Some(query_vec) = query { let mut qpm = req_url.query_pairs_mut(); @@ -315,7 +324,7 @@ impl<'a> SzurubooruRequest<'a> { // This doesn't detect the required `mut` for some reason #[allow(unused_mut)] let mut req = self.client.client.request(method, req_url); - match &self.client.auth { + let req = match &self.client.auth { SzurubooruAuth::TokenAuth(t) => { let mut header_map = HeaderMap::new(); header_map.append(AUTHORIZATION, t.parse().unwrap()); @@ -324,7 +333,8 @@ impl<'a> SzurubooruRequest<'a> { } SzurubooruAuth::BasicAuth(u, p) => req.basic_auth(u, Some(p)), SzurubooruAuth::None => req, - } + }; + req } #[tracing::instrument(skip(self), fields(base_url=self.client.base_url.to_string()))] @@ -351,6 +361,20 @@ impl<'a> SzurubooruRequest<'a> { self.handle_request(request).await } + async fn handle_response(&self, response: Response) -> SzurubooruResult { + if response.status().is_client_error() || response.status().is_server_error() { + let resp_json = response + .text() + .await + .map_err(SzurubooruClientError::RequestError)?; + let server_error = serde_json::from_str::(&resp_json) + .map_err(|e| SzurubooruClientError::ResponseParsingError(e, resp_json))?; + Err(SzurubooruClientError::SzurubooruServerError(server_error)) + } else { + Ok(response) + } + } + async fn handle_request( &self, request: RequestBuilder, @@ -361,10 +385,11 @@ impl<'a> SzurubooruRequest<'a> { let response = self.client.client.execute(request).await; - let response = response - .map_err(SzurubooruClientError::RequestError)? - .error_for_status() - .map_err(SzurubooruClientError::RequestError)?; + let response = self + .handle_response(response.map_err(SzurubooruClientError::RequestError)?) + .await?; + //.error_for_status() + //.map_err(SzurubooruClientError::RequestError)?; let response_text = response .text() @@ -400,13 +425,13 @@ impl<'a> SzurubooruRequest<'a> { pub async fn update_tag_category( &self, name: T, - resource: &TagCategoryResource, + update_tag_cat: &CreateUpdateTagCategory, ) -> SzurubooruResult where T: AsRef + Display, { let path = format!("/tag-category/{name}"); - self.do_request(Method::PUT, &path, None, Some(resource)) + self.do_request(Method::PUT, &path, None, Some(update_tag_cat)) .await } @@ -427,8 +452,9 @@ impl<'a> SzurubooruRequest<'a> { { let path = format!("/tag-category/{name}"); let version_obj = ResourceVersion { version }; - self.do_request(Method::DELETE, &path, None, Some(&version_obj)) + self.do_request::(Method::DELETE, &path, None, Some(&version_obj)) .await + .map(|_| ()) } /// Sets given tag category as default. All new tags created manually or automatically will @@ -447,9 +473,9 @@ impl<'a> SzurubooruRequest<'a> { /// all possible query tokens, or use (QueryToken)[tokens::QueryToken] for a custom token pub async fn list_tags( &self, - query: &Vec, + query: Option<&Vec>, ) -> SzurubooruResult> { - self.do_request(Method::GET, "/tags", Some(query), None::<&String>) + self.do_request(Method::GET, "/tags", query, None::<&String>) .await } @@ -502,8 +528,9 @@ impl<'a> SzurubooruRequest<'a> { { let path = format!("/tag/{name}"); let version_obj = ResourceVersion { version }; - self.do_request(Method::DELETE, &path, None, Some(&version_obj)) + self.do_request::(Method::DELETE, &path, None, Some(&version_obj)) .await + .map(|_| ()) } /// Removes source tag and merges all of its usages, suggestions and implications to the @@ -697,11 +724,13 @@ impl<'a> SzurubooruRequest<'a> { .build() .map_err(SzurubooruClientError::RequestBuilderError)?; - self.client + let resp_res = self + .client .client .execute(request) .await - .map_err(SzurubooruClientError::RequestError) + .map_err(|e| SzurubooruClientError::RequestError(e))?; + self.handle_response(resp_res).await } ///Downloads the given post ID's image as a stream of bytes @@ -718,10 +747,11 @@ impl<'a> SzurubooruRequest<'a> { ///Downloads the given post ID's image as a (Bytes)[bytes::Bytes] struct pub async fn get_post_content_bytes(&self, post_id: u32) -> SzurubooruResult { let content_response = self.get_post_content(post_id).await?; + content_response .bytes() .await - .map_err(SzurubooruClientError::RequestError) + .map_err(|e| SzurubooruClientError::RequestError(e)) } /// Retrieves posts that look like the input image diff --git a/szurubooru-client/src/models.rs b/szurubooru-client/src/models.rs index 7cd6967..c1a0b3b 100644 --- a/szurubooru-client/src/models.rs +++ b/szurubooru-client/src/models.rs @@ -5,9 +5,10 @@ //! See [here](https://github.com/rr-/szurubooru/blob/master/doc/API.md#field-selecting) for //! more information. -use chrono::NaiveDateTime; +use chrono::{DateTime, Utc}; use derive_builder::Builder; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use strum_macros::AsRefStr; #[derive(Serialize, Deserialize, Debug, Clone)] @@ -98,9 +99,9 @@ pub struct TagResource { /// the user by the web client on usage pub suggestions: Option>, /// time the tag was created - pub creation_time: Option, + pub creation_time: Option>, /// time the tag was edited - pub last_edit_time: Option, + pub last_edit_time: Option>, /// the number of posts the tag was used in pub usages: Option, /// the tag description (instructions how to use, history etc.) The client should render @@ -128,21 +129,27 @@ pub struct TagResource { #[builder(setter(strip_option))] pub struct CreateUpdateTag { #[serde(skip_serializing_if = "Option::is_none")] + #[builder(default)] /// resource version. See [versioning](ResourceVersion) pub version: Option, #[serde(skip_serializing_if = "Option::is_none")] + #[builder(default)] /// Tag names and aliases, must match `tag_name_regex` from the server's configuration pub names: Option>, #[serde(skip_serializing_if = "Option::is_none")] + #[builder(default)] /// Category that this tag belongs to. Must already exist pub category: Option, #[serde(skip_serializing_if = "Option::is_none")] + #[builder(default)] /// The tag description in Markdown format pub description: Option, #[serde(skip_serializing_if = "Option::is_none")] + #[builder(default)] /// Tags that should be implied when this tag is used pub implications: Option>, #[serde(skip_serializing_if = "Option::is_none")] + #[builder(default)] /// Tags that should be suggested when this tag is used pub suggestions: Option>, } @@ -160,21 +167,31 @@ pub struct TagCategoryResource { /// How many tags is the given category used with pub usages: Option, /// The order in which tags with this category are displayed, ascending - pub order: Option, + pub order: Option, /// Whether the tag category is the default one pub default: Option, } -#[derive(Debug, Clone, Serialize, Deserialize, Builder)] -#[builder(setter(into))] +#[derive(Debug, Clone, Serialize, Deserialize, Default, Builder)] +#[builder(setter(strip_option))] /// Used for creating or updating a Tag Category pub struct CreateUpdateTagCategory { + /// Resource version. See [versioning](ResourceVersion) + #[serde(skip_serializing_if = "Option::is_none")] + #[builder(default)] + pub version: Option, /// The name of the category to create - pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + #[builder(default)] + pub name: Option, /// The display color to use for the category - pub color: String, + #[serde(skip_serializing_if = "Option::is_none")] + #[builder(default)] + pub color: Option, /// The order in which tags with this category are displayed, ascending - pub order: String, + #[serde(skip_serializing_if = "Option::is_none")] + #[builder(default)] + pub order: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Builder)] @@ -261,9 +278,9 @@ pub struct PostResource { /// The post identifier pub id: Option, /// Time the post was created - pub creation_time: Option, + pub creation_time: Option>, /// Time the post was edited - pub last_edit_time: Option, + pub last_edit_time: Option>, /// Whether the post is safe for work pub safety: Option, #[serde(rename = "type")] @@ -311,7 +328,7 @@ pub struct PostResource { /// How many posts are related to this post pub relation_count: Option, /// The last time the post was featured - pub last_feature_time: Option, + pub last_feature_time: Option>, /// List of users who have favorited this post pub favorited_by: Option>, /// Whether the post uses custom thumbnail @@ -326,7 +343,7 @@ pub struct PostResource { } #[derive(Debug, Clone, Serialize, Deserialize, Builder)] -#[builder(setter(into, strip_option))] +#[builder(setter(strip_option))] #[serde(rename_all = "camelCase")] /// A `struct` used to create or update a post. For updating purposes /// the [version](CreateUpdatePost::version) field is required @@ -447,10 +464,10 @@ pub struct UserResource { pub rank: Option, #[serde(rename = "last-login-time")] /// The last login time - pub last_login_time: Option, + pub last_login_time: Option>, #[serde(rename = "creation-time")] /// The user registration time - pub creation_time: Option, + pub creation_time: Option>, /// How to render the user avatar pub avatar_style: Option, /// The URL to the avatar @@ -474,7 +491,7 @@ pub struct UserResource { pub favorite_post_count: Option>, } -#[derive(Debug, Clone, Serialize, Deserialize, Builder)] +#[derive(Debug, Clone, Serialize, Deserialize, Default, Builder)] #[builder(setter(into, strip_option))] #[serde(rename_all = "camelCase")] /// `struct` used to create or update a user resource. The version field is only used when @@ -482,15 +499,20 @@ pub struct UserResource { pub struct CreateUpdateUser { /// Resource version. See [versioning](ResourceVersion) #[serde(skip_serializing_if = "Option::is_none")] + #[builder(default)] pub version: Option, /// The username + #[builder(default)] pub name: Option, /// The user's password + #[builder(default)] pub password: Option, /// The user's desired rank, if not given will default to `default_rank` in the server's /// configuration + #[builder(default)] pub rank: Option, /// The user avatar style, Gravatar or Manual + #[builder(default)] pub avatar_style: Option, } @@ -517,15 +539,15 @@ pub struct UserAuthTokenResource { /// Whether the token is still valid for authentication pub enabled: Option, /// Time when the token expires - pub expiration_time: Option, + pub expiration_time: Option>, /// Resource version. See [versioning](ResourceVersion) pub version: Option, /// time the user token was created - pub creation_time: Option, + pub creation_time: Option>, /// time the user token was edited - pub last_edit_time: Option, + pub last_edit_time: Option>, /// the last time this token was used - pub last_usage_time: Option, + pub last_usage_time: Option>, } #[derive(Debug, Clone, Serialize, Deserialize, Builder, Default)] @@ -545,7 +567,7 @@ pub struct CreateUpdateUserAuthToken { pub note: Option, /// Time when the token expires #[serde(skip_serializing_if = "Option::is_none")] - pub expiration_time: Option, + pub expiration_time: Option>, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -578,8 +600,14 @@ pub struct GlobalInfoConfig { pub tag_category_name_regex: String, /// Default user rank upon signup pub default_user_rank: String, + /// Whether safety is enabled + pub enable_safety: bool, + /// Contact email for this server + pub contact_email: Option, + /// Is sending email enabled for this server + pub can_send_mails: bool, /// Available privileges enabled for this server - pub privileges: Vec, + pub privileges: HashMap, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -593,11 +621,11 @@ pub struct GlobalInfo { /// The current featured post pub featured_post: Option, /// The time the current featured post was featured - pub featuring_time: Option, + pub featuring_time: Option>, /// The user who uploaded the featured post pub featuring_user: Option, /// The current server time - pub server_time: NaiveDateTime, + pub server_time: DateTime, /// The configuration for this server pub config: GlobalInfoConfig, } @@ -660,9 +688,9 @@ pub struct PoolResource { /// An ordered list of posts. Posts are ordered by insertion by default pub posts: Option>, /// Time the pool was created - pub creation_time: Option, + pub creation_time: Option>, /// Time the pool was edited - pub last_edit_time: Option, + pub last_edit_time: Option>, /// The total number of posts the pool has pub post_count: Option, /// The pool description (instructions how to use, history etc). The client should render @@ -758,9 +786,9 @@ pub struct CommentResource { /// The text of the comment pub text: Option, /// When was the comment posted - pub creation_time: Option, + pub creation_time: Option>, /// When was the last time this comment was edited - pub last_edit_time: Option, + pub last_edit_time: Option>, /// The sum of the -1/0/+1 scores by other users pub score: Option, /// The user's own score for this comment @@ -890,7 +918,7 @@ pub struct SnapshotResource { /// The data associated with this resource change pub data: Option, /// When this resource change occurred - pub time: Option, + pub time: Option>, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -923,3 +951,78 @@ pub struct AroundPostResult { /// The next post, if it exists next: Option, } + +#[cfg(test)] +mod tests { + use crate::models::{GlobalInfo, GlobalInfoConfig, TagCategoryResource}; + use chrono::Datelike; + + #[test] + fn test_parse_global_info() { + let cfg_str = r#"{ + "name": "integrationland", + "userNameRegex": "^[a-zA-Z0-9_-]{1,32}$", + "passwordRegex": "^.{5,}$", + "tagNameRegex": "^\\S+$", + "tagCategoryNameRegex": "^[^\\s%+#/]+$", + "defaultUserRank": "regular", + "enableSafety": true, + "contactEmail": null, + "canSendMails": false, + "privileges": { + "users:create:self": "anonymous", + "users:create:any": "administrator", + "comments:edit:own": "regular", + "comments:list": "regular", + "comments:view": "regular", + "comments:score": "regular", + "snapshots:list": "power", + "uploads:create": "regular", + "uploads:useDownloader": "power" + } + }"#; + + let global_config = + serde_json::from_str::(cfg_str).expect("Unable to parse cfg_str"); + assert_eq!(global_config.can_send_mails, false); + let info_str = r#"{"postCount": 0, + "diskUsage": 0, + "serverTime": "2024-08-09T21:41:24.123623Z", + "config": { + "name": "integrationland", + "userNameRegex": "^[a-zA-Z0-9_-]{1,32}$", + "passwordRegex": "^.{5,}$", + "tagNameRegex": "^\\S+$", + "tagCategoryNameRegex": "^[^\\s%+#/]+$", + "defaultUserRank": "regular", + "enableSafety": true, + "contactEmail": null, + "canSendMails": false, + "privileges": { + "users:create:self": "anonymous" + } + }, + "featuredPost": null, + "featuringUser": null, + "featuringTime": null + }"#; + let global_info = + serde_json::from_str::(info_str).expect("Unable to parse info_str"); + assert_eq!(global_info.server_time.year(), 2024); + } + + #[test] + fn test_parse_tag_category_resource() { + let input_str = r#" { + "name": "default", + "version": 1, + "color": "default", + "usages": 0, + "default": true, + "order": 1 + }"#; + let tag_cat = serde_json::from_str::(input_str) + .expect("Unable to parse tag category string"); + assert_eq!(tag_cat.name, Some("default".to_string())); + } +} diff --git a/szurubooru-integration-test/Cargo.toml b/szurubooru-integration-test/Cargo.toml index bfbfdca..dbf1457 100644 --- a/szurubooru-integration-test/Cargo.toml +++ b/szurubooru-integration-test/Cargo.toml @@ -4,4 +4,8 @@ version = "0.1.0" edition = "2021" [dependencies] +progress = "0.2.0" szurubooru-client = { version = "0.1.0", path = "../szurubooru-client" } +tokio = { version = "1.39.2", features = ["full", "test-util", "tracing"] } +tracing = "0.1.40" +tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } diff --git a/szurubooru-integration-test/docker-compose.yml b/szurubooru-integration-test/docker-compose.yml index c9e9509..a51c8a7 100644 --- a/szurubooru-integration-test/docker-compose.yml +++ b/szurubooru-integration-test/docker-compose.yml @@ -15,15 +15,15 @@ services: ## or FQDNs/IP addresses if these services are running outside of Docker POSTGRES_HOST: sql ## Credentials for database: - POSTGRES_USER: - POSTGRES_PASSWORD: + POSTGRES_USER: pguser + POSTGRES_PASSWORD: pgpassword ## Commented Values are Default: #POSTGRES_DB: defaults to same as POSTGRES_USER #POSTGRES_PORT: 5432 #LOG_SQL: 0 (1 for verbose SQL logs) THREADS: volumes: - - "${MOUNT_DATA}:/data" + - "sz-data:/data" - "${SERVER_MOUNT}/config.yaml:/opt/app/config.yaml" client: @@ -34,7 +34,7 @@ services: BACKEND_HOST: server BASE_URL: ${BASE_URL} volumes: - - "${MOUNT_DATA}:/data:ro" + - "sz-data:/data:ro" ports: - "${PORT}:80" @@ -42,7 +42,10 @@ services: image: postgres:11-alpine restart: unless-stopped environment: - POSTGRES_USER: - POSTGRES_PASSWORD: - volumes: - - "${MOUNT_SQL}:/var/lib/postgresql/data" + POSTGRES_USER: pguser + POSTGRES_PASSWORD: pgpassword + #volumes: + # - "${MOUNT_SQL}/sqldata:/var/lib/postgresql/data" + +volumes: + sz-data: diff --git a/szurubooru-integration-test/folly.jpg b/szurubooru-integration-test/folly.jpg new file mode 100644 index 0000000..39ea1c7 Binary files /dev/null and b/szurubooru-integration-test/folly.jpg differ diff --git a/szurubooru-integration-test/src/main.rs b/szurubooru-integration-test/src/main.rs new file mode 100644 index 0000000..ce26051 --- /dev/null +++ b/szurubooru-integration-test/src/main.rs @@ -0,0 +1,286 @@ +use std::error::Error; +use std::process::Stdio; +use std::time::Duration; +use szurubooru_client::models::*; +use szurubooru_client::*; +use tokio::process::Command; +use tracing::level_filters::LevelFilter; +use tracing::{error, info, instrument}; +use tracing_subscriber::filter::EnvFilter; +use tracing_subscriber::fmt::format::FmtSpan; + +#[tokio::main] +#[tracing::instrument] +async fn main() -> Result<(), Box> { + let ev = EnvFilter::builder() + .with_default_directive(LevelFilter::INFO.into()) + .from_env_lossy(); + tracing_subscriber::fmt() + .compact() + //.with_span_events(FmtSpan::ACTIVE) + .with_target(false) + .with_level(false) + .with_env_filter(ev) + .init(); + + //info!("Starting Szurubooru instance..."); + let anon_client = start_instance().await; + + let create_user = CreateUpdateUserBuilder::default() + .name("integration_user") + .password("integration_password") + .rank(UserRank::Administrator) + .avatar_style(UserAvatarStyle::Gravatar) + .build() + .expect("Unable to create CreateUser object"); + let _user = anon_client + .request() + .create_user(&create_user) + .await + .expect("Error creating user"); + + let auth_client = SzurubooruClient::new_with_basic_auth( + "http://localhost:9801", + "integration_user", + "integration_password", + true, + )?; + + test_tag_categories(&auth_client).await; + test_tags(&auth_client).await; + + Command::new("sh") + .current_dir(env!("CARGO_MANIFEST_DIR")) + .arg("-c") + .arg("./stop_szurubooru.sh") + .stderr(Stdio::null()) + .stdout(Stdio::null()) + .stdin(Stdio::null()) + .status() + .await + .expect("Failed to stop szurubooru"); + Ok(()) +} + +#[tracing::instrument] +async fn start_instance() -> SzurubooruClient { + info!("Starting Szurubooru instance..."); + let anon_client = SzurubooruClient::new_anonymous("http://localhost:9801", true) + .expect("Can't create anonymous client"); + + Command::new("sh") + .current_dir(env!("CARGO_MANIFEST_DIR")) + .arg("-c") + .arg("./start_szurubooru.sh") + .stderr(Stdio::null()) + .stdout(Stdio::null()) + .stdin(Stdio::null()) + .status() + .await + .expect("Failed to start szurubooru"); + + let mut connected = false; + let mut error = None; + + for _ in 0..5 { + let info = anon_client.request().get_global_info().await; + if info.is_ok() { + connected = true; + break; + } else if let Err(e) = info { + error = Some(e); + } + tokio::time::sleep(Duration::from_secs(5)).await; + } + if !connected { + panic!( + "{}", + format!("Unable to connect to instance. Last error: {error:?}") + ); + } + + anon_client +} + +#[tracing::instrument(skip(client), level = "INFO")] +async fn test_tag_categories(client: &SzurubooruClient) { + info!("Testing tag categories"); + let tag_cats = client + .request() + .list_tag_categories() + .await + .expect("Could not list tag categories"); + assert_eq!(tag_cats.results.len(), 1); + + info!("Creating tag category"); + let create_tag_cat = CreateUpdateTagCategoryBuilder::default() + .name("my_tag_cat".to_string()) + .color("purple".to_string()) + .order(1) + .build() + .expect("Unable to build create_tag_cat object"); + let result_tag_cat = client + .request() + .create_tag_category(&create_tag_cat) + .await + .expect("Unable to create tag category"); + assert_eq!(result_tag_cat.name, Some("my_tag_cat".to_string())); + let tag_cats = client + .request() + .list_tag_categories() + .await + .expect("Could not list tag categories again"); + assert_eq!(tag_cats.results.len(), 2); + + info!("GETting tag category"); + let tag_res = client + .request() + .get_tag_category("my_tag_cat") + .await + .expect("Could not fetch tag category"); + assert_eq!(result_tag_cat.color, tag_res.color); + + info!("Updating tag category"); + let update_tag_cat = CreateUpdateTagCategoryBuilder::default() + .version(tag_res.version) + .color("red".to_string()) + .build() + .expect("Could not create update for tag category"); + let update_res = client + .request() + .update_tag_category("my_tag_cat", &update_tag_cat) + .await + .expect("Unable to update tag category"); + assert_eq!(update_res.color, Some("red".to_string())); + + info!("Deleting tag category"); + client + .request() + .delete_tag_category("my_tag_cat", update_res.version) + .await + .expect("Could not delete tag category"); + let tag_cats = client + .request() + .list_tag_categories() + .await + .expect("Could not list tag categories again"); + assert_eq!(tag_cats.results.len(), 1); +} + +#[instrument(skip(client))] +async fn test_tags(client: &SzurubooruClient) { + info!("Testing tag functions"); + + info!("Listing tags"); + let tag_list = client + .request() + .list_tags(None) + .await + .expect("Could not list tags"); + assert_eq!(tag_list.total, 0); + + info!("Creating tag"); + let cutag = CreateUpdateTagBuilder::default() + .names(vec!["foo".to_string(), "foo2".to_string()]) + .category("default".to_string()) + .description("The foo tag".to_string()) + .build() + .expect("Could not build the CreateUpdateTag"); + let tag_res = client + .request() + .create_tag(&cutag) + .await + .expect("Could not create tag"); + + assert_eq!( + tag_res.names, + Some(vec!["foo".to_string(), "foo2".to_string()]) + ); + assert!(tag_res.description.is_some()); + + let tag_list = client + .request() + .list_tags(None) + .await + .expect("Could not list tags"); + assert_eq!(tag_list.total, 1); + + info!("Testing field selection"); + let tag_list = client + .with_fields(vec!["version", "names", "category"]) + .list_tags(None) + .await + .expect("Could not list tags"); + assert_eq!(tag_list.total, 1); + assert!(tag_list.results.first().unwrap().description.is_none()); + + info!("Updating tag"); + let utag = CreateUpdateTagBuilder::default() + .version(tag_res.version) + .description("The foo2 tag".to_string()) + .build() + .expect("Could not build the CreateUpdateTag"); + let tag_res2 = client + .request() + .update_tag("foo", &utag) + .await + .expect("Could not update tag"); + assert_ne!(tag_res.description, tag_res2.description); + + info!("Getting tag"); + let tag_res3 = client + .request() + .get_tag("foo") + .await + .expect("Could not fetch tag"); + assert_eq!(tag_res2.description, tag_res3.description); + + info!("Creating a second tag"); + let cutag = CreateUpdateTagBuilder::default() + .names(vec!["bar".to_string()]) + .category("default".to_string()) + .description("The foo tag".to_string()) + .build() + .expect("Could not build the CreateUpdateTag"); + let bar_tag = client + .request() + .create_tag(&cutag) + .await + .expect("Unable to create second tag"); + let tag_list = client + .request() + .list_tags(None) + .await + .expect("Could not list tags"); + assert_eq!(tag_list.total, 2); + + info!("Merging tags"); + let merge_tag = MergeTagsBuilder::default() + .remove_version(bar_tag.version) + .remove(bar_tag.names.as_ref().unwrap().first().unwrap()) + .merge_to_version(tag_res3.version) + .merge_to(tag_res3.names.as_ref().unwrap().first().unwrap()) + .build() + .expect("Could not create merge tags object"); + let merged_tag = client + .request() + .merge_tag(&merge_tag) + .await + .expect("Could not merge tags"); + assert_eq!(tag_res3.names, merged_tag.names); + let tag_list = client + .request() + .list_tags(None) + .await + .expect("Could not list tags"); + assert_eq!(tag_list.total, 1); + + info!("Deleting tag"); + client + .request() + .delete_tag("foo", merged_tag.version) + .await + .expect("Could not delete tag"); +} + +//TODO: test Tag siblings once we've tested a post diff --git a/szurubooru-integration-test/start_szurubooru.sh b/szurubooru-integration-test/start_szurubooru.sh old mode 100644 new mode 100755 index 9a710d6..43284ad --- a/szurubooru-integration-test/start_szurubooru.sh +++ b/szurubooru-integration-test/start_szurubooru.sh @@ -1,9 +1,11 @@ #!/bin/bash -export MOUNT_DATA=szurubooru/data -export SERVER_MOUNT=szurubooru/server -export MOUNT_SQL=szurubooru/pgsql -export BASE_URL=http://localhost:5000 -export PORT=5000 +#export MOUNT_DATA=szurubooru/data +export SERVER_MOUNT=./szurubooru/server +#export MOUNT_SQL=szurubooru/pgsql +export BASE_URL=http://localhost:9801 +export PORT=9801 + +docker compose down || true docker compose up -d diff --git a/szurubooru-integration-test/stop_szurubooru.sh b/szurubooru-integration-test/stop_szurubooru.sh new file mode 100755 index 0000000..5e50d10 --- /dev/null +++ b/szurubooru-integration-test/stop_szurubooru.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +docker compose down diff --git a/szurubooru-integration-test/szurubooru/server/config.yaml b/szurubooru-integration-test/szurubooru/server/config.yaml new file mode 100644 index 0000000..aab75cf --- /dev/null +++ b/szurubooru-integration-test/szurubooru/server/config.yaml @@ -0,0 +1,179 @@ +# rather than editing this file, it is strongly suggested to create config.yaml +# and override only what you need. + +# shown in the website title and on the front page +name: integrationland +# full url to the homepage of this szurubooru site, with no trailing slash +domain: https://localhost:9801 +# used to salt the users' password hashes and generate filenames for static content +secret: awdopk1mdqsapdoawd21!!!#asw + +# Delete thumbnails and source files on post delete +# Original functionality is no, to mitigate the impacts of admins going +# on unchecked post purges. +delete_source_files: no + +thumbnails: + avatar_width: 300 + avatar_height: 300 + post_width: 300 + post_height: 300 + +# settings used to download files from the web on behalf of the api users +user_agent: +max_dl_filesize: 25.0E+6 # maximum filesize limit in bytes + +# automatically convert animated GIF uploads to video formats +convert: + gif: + to_webm: false + to_mp4: false + +# allow posts to be uploaded even if some image processing errors occur +allow_broken_uploads: false + +# used to send password reset e-mails +smtp: + host: # example: localhost + port: # example: 25 + user: # example: bot + pass: # example: groovy123 + from: # example: noreply@example.com + # if host is left empty the password reset feature will be disabled, + # in which case it is recommended to fill contactEmail so that users + # know who to contact when they want to reset their password + +contact_email: # example: bob@example.com. Meant for manual password reset procedures + +enable_safety: yes + +tag_name_regex: ^\S+$ +tag_category_name_regex: ^[^\s%+#/]+$ + +pool_name_regex: ^\S+$ +pool_category_name_regex: ^[^\s%+#/]+$ + +# don't make these more restrictive unless you want to annoy people; if you do +# customize them, make sure to update the instructions in the registration form +# template as well. +password_regex: '^.{5,}$' +user_name_regex: '^[a-zA-Z0-9_-]{1,32}$' + +# webhooks to call when events occur (such as post/tag/user/etc. changes) +# the listed urls will be called with a HTTP POST request with a payload +# containing a snapshot resource as JSON. See doc/API.md for details +webhooks: +# - https://api.example.com/webhooks/ + +default_rank: regular + +privileges: + 'users:create:self': anonymous # Registration permission + 'users:create:any': administrator + 'users:list': regular + 'users:view': regular + 'users:edit:any:name': moderator + 'users:edit:any:pass': moderator + 'users:edit:any:email': moderator + 'users:edit:any:avatar': moderator + 'users:edit:any:rank': moderator + 'users:edit:self:name': regular + 'users:edit:self:pass': regular + 'users:edit:self:email': regular + 'users:edit:self:avatar': regular + 'users:edit:self:rank': moderator # one can't promote themselves or anyone to upper rank than their own. + 'users:delete:any': administrator + 'users:delete:self': regular + + 'user_tokens:list:any': administrator + 'user_tokens:list:self': regular + 'user_tokens:create:any': administrator + 'user_tokens:create:self': regular + 'user_tokens:edit:any': administrator + 'user_tokens:edit:self': regular + 'user_tokens:delete:any': administrator + 'user_tokens:delete:self': regular + + 'posts:create:anonymous': regular + 'posts:create:identified': regular + 'posts:list': anonymous + 'posts:reverse_search': regular + 'posts:view': anonymous + 'posts:view:featured': anonymous + 'posts:edit:content': power + 'posts:edit:flags': regular + 'posts:edit:notes': regular + 'posts:edit:relations': regular + 'posts:edit:safety': power + 'posts:edit:source': regular + 'posts:edit:tags': regular + 'posts:edit:thumbnail': power + 'posts:feature': moderator + 'posts:delete': moderator + 'posts:score': regular + 'posts:merge': moderator + 'posts:favorite': regular + 'posts:bulk-edit:tags': power + 'posts:bulk-edit:safety': power + 'posts:bulk-edit:delete': power + + 'tags:create': regular + 'tags:edit:names': power + 'tags:edit:category': power + 'tags:edit:description': power + 'tags:edit:implications': power + 'tags:edit:suggestions': power + 'tags:list': regular + 'tags:view': anonymous + 'tags:merge': moderator + 'tags:delete': moderator + + 'tag_categories:create': moderator + 'tag_categories:edit:name': moderator + 'tag_categories:edit:color': moderator + 'tag_categories:edit:order': moderator + 'tag_categories:list': anonymous + 'tag_categories:view': anonymous + 'tag_categories:delete': moderator + 'tag_categories:set_default': moderator + + 'pools:create': regular + 'pools:edit:names': power + 'pools:edit:category': power + 'pools:edit:description': power + 'pools:edit:posts': power + 'pools:list': regular + 'pools:view': anonymous + 'pools:merge': moderator + 'pools:delete': moderator + + 'pool_categories:create': moderator + 'pool_categories:edit:name': moderator + 'pool_categories:edit:color': moderator + 'pool_categories:list': anonymous + 'pool_categories:view': anonymous + 'pool_categories:delete': moderator + 'pool_categories:set_default': moderator + + 'comments:create': regular + 'comments:delete:any': moderator + 'comments:delete:own': regular + 'comments:edit:any': moderator + 'comments:edit:own': regular + 'comments:list': regular + 'comments:view': regular + 'comments:score': regular + + 'snapshots:list': power + + 'uploads:create': regular + 'uploads:use_downloader': power + +## ONLY SET THESE IF DEPLOYING OUTSIDE OF DOCKER +#debug: 0 # generate server logs? +#show_sql: 0 # show sql in server logs? +#data_url: /data/ +#data_dir: /var/www/data +## usage: schema://user:password@host:port/database_name +## example: postgres://szuru:dog@localhost:5432/szuru_test +#database: