diff --git a/CHANGELOG.md b/CHANGELOG.md index 18901f85..fc6bca3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,18 @@ ### Added -- Introduce environment variable `MAIL_PORT` +- Introduce environment variable `MAIL_PORT` (#980) +- Introduce email sign-up feature (#636) + +### Changed + +- Some styling improvements (#976, #977, #978) ### Fiexed -- Fix a bug that notification page doesn't show +- Fix comment link (#974) +- Fix a bug that prevents posting articles (#975) +- Fix a bug that notification page doesn't show (#981) ## [[0.7.0]] - 2022-01-02 diff --git a/migrations/postgres/2022-01-04-122156_create_email_signups_table/down.sql b/migrations/postgres/2022-01-04-122156_create_email_signups_table/down.sql new file mode 100644 index 00000000..40af0a6c --- /dev/null +++ b/migrations/postgres/2022-01-04-122156_create_email_signups_table/down.sql @@ -0,0 +1 @@ +DROP TABLE email_signups; diff --git a/migrations/postgres/2022-01-04-122156_create_email_signups_table/up.sql b/migrations/postgres/2022-01-04-122156_create_email_signups_table/up.sql new file mode 100644 index 00000000..720509f6 --- /dev/null +++ b/migrations/postgres/2022-01-04-122156_create_email_signups_table/up.sql @@ -0,0 +1,9 @@ +CREATE TABLE email_signups ( + id SERIAL PRIMARY KEY, + email VARCHAR NOT NULL, + token VARCHAR NOT NULL, + expiration_date TIMESTAMP NOT NULL +); + +CREATE INDEX email_signups_token ON email_signups (token); +CREATE UNIQUE INDEX email_signups_token_requests_email ON email_signups (email); diff --git a/migrations/sqlite/2022-01-04-122156_create_email_signups_table/down.sql b/migrations/sqlite/2022-01-04-122156_create_email_signups_table/down.sql new file mode 100644 index 00000000..40af0a6c --- /dev/null +++ b/migrations/sqlite/2022-01-04-122156_create_email_signups_table/down.sql @@ -0,0 +1 @@ +DROP TABLE email_signups; diff --git a/migrations/sqlite/2022-01-04-122156_create_email_signups_table/up.sql b/migrations/sqlite/2022-01-04-122156_create_email_signups_table/up.sql new file mode 100644 index 00000000..fa8ce118 --- /dev/null +++ b/migrations/sqlite/2022-01-04-122156_create_email_signups_table/up.sql @@ -0,0 +1,9 @@ +CREATE TABLE email_signups ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + email VARCHAR NOT NULL, + token VARCHAR NOT NULL, + expiration_date TIMESTAMP NOT NULL +); + +CREATE INDEX email_signups_token ON email_signups (token); +CREATE UNIQUE INDEX email_signups_token_requests_email ON email_signups (email); diff --git a/plume-models/src/config.rs b/plume-models/src/config.rs index cb316d5b..ba705d42 100644 --- a/plume-models/src/config.rs +++ b/plume-models/src/config.rs @@ -1,4 +1,5 @@ use crate::search::TokenizerKind as SearchTokenizer; +use crate::signups::Strategy as SignupStrategy; use crate::smtp::{SMTP_PORT, SUBMISSIONS_PORT, SUBMISSION_PORT}; use rocket::config::Limits; use rocket::Config as RocketConfig; @@ -16,6 +17,7 @@ pub struct Config { pub db_name: &'static str, pub db_max_size: Option, pub db_min_idle: Option, + pub signup: SignupStrategy, pub search_index: String, pub search_tokenizers: SearchTokenizerConfig, pub rocket: Result, @@ -362,6 +364,7 @@ lazy_static! { s.parse::() .expect("Couldn't parse DB_MIN_IDLE into u32") )), + signup: var("SIGNUP").map_or(SignupStrategy::default(), |s| s.parse().unwrap()), #[cfg(feature = "postgres")] database_url: var("DATABASE_URL") .unwrap_or_else(|_| format!("postgres://plume:plume@localhost/{}", DB_NAME)), diff --git a/plume-models/src/email_signups.rs b/plume-models/src/email_signups.rs new file mode 100644 index 00000000..6867e7c7 --- /dev/null +++ b/plume-models/src/email_signups.rs @@ -0,0 +1,143 @@ +use crate::{ + db_conn::DbConn, + schema::email_signups, + users::{NewUser, Role, User}, + Error, Result, +}; +use chrono::{offset::Utc, Duration, NaiveDateTime}; +use diesel::{ + Connection as _, ExpressionMethods, Identifiable, Insertable, QueryDsl, Queryable, RunQueryDsl, +}; +use plume_common::utils::random_hex; +use std::ops::Deref; + +const TOKEN_VALIDITY_HOURS: i64 = 2; + +#[repr(transparent)] +pub struct Token(String); + +impl From for Token { + fn from(string: String) -> Self { + Token(string) + } +} + +impl From for String { + fn from(token: Token) -> Self { + token.0 + } +} + +impl Deref for Token { + type Target = String; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl Token { + fn generate() -> Self { + Self(random_hex()) + } +} + +#[derive(Identifiable, Queryable)] +pub struct EmailSignup { + pub id: i32, + pub email: String, + pub token: String, + pub expiration_date: NaiveDateTime, +} + +#[derive(Insertable)] +#[table_name = "email_signups"] +pub struct NewEmailSignup<'a> { + pub email: &'a str, + pub token: &'a str, + pub expiration_date: NaiveDateTime, +} + +impl EmailSignup { + pub fn start(conn: &DbConn, email: &str) -> Result { + conn.transaction(|| { + Self::ensure_user_not_exist_by_email(conn, email)?; + let _rows = Self::delete_existings_by_email(conn, email)?; + let token = Token::generate(); + let expiration_date = Utc::now() + .naive_utc() + .checked_add_signed(Duration::hours(TOKEN_VALIDITY_HOURS)) + .expect("could not calculate expiration date"); + let new_signup = NewEmailSignup { + email, + token: &token, + expiration_date, + }; + let _rows = diesel::insert_into(email_signups::table) + .values(new_signup) + .execute(&**conn)?; + + Ok(token) + }) + } + + pub fn find_by_token(conn: &DbConn, token: Token) -> Result { + let signup = email_signups::table + .filter(email_signups::token.eq(token.as_str())) + .first::(&**conn) + .map_err(Error::from)?; + Ok(signup) + } + + pub fn confirm(&self, conn: &DbConn) -> Result<()> { + conn.transaction(|| { + Self::ensure_user_not_exist_by_email(conn, &self.email)?; + if self.expired() { + Self::delete_existings_by_email(conn, &self.email)?; + return Err(Error::Expired); + } + Ok(()) + }) + } + + pub fn complete(&self, conn: &DbConn, username: String, password: String) -> Result { + conn.transaction(|| { + Self::ensure_user_not_exist_by_email(conn, &self.email)?; + let user = NewUser::new_local( + conn, + username, + "".to_string(), + Role::Normal, + "", + self.email.clone(), + Some(User::hash_pass(&password)?), + )?; + self.delete(conn)?; + Ok(user) + }) + } + + fn delete(&self, conn: &DbConn) -> Result<()> { + let _rows = diesel::delete(self).execute(&**conn).map_err(Error::from)?; + Ok(()) + } + + fn ensure_user_not_exist_by_email(conn: &DbConn, email: &str) -> Result<()> { + if User::email_used(conn, email)? { + let _rows = Self::delete_existings_by_email(conn, email)?; + return Err(Error::UserAlreadyExists); + } + Ok(()) + } + + fn delete_existings_by_email(conn: &DbConn, email: &str) -> Result { + let existing_signups = email_signups::table.filter(email_signups::email.eq(email)); + diesel::delete(existing_signups) + .execute(&**conn) + .map_err(Error::from) + } + + fn expired(&self) -> bool { + self.expiration_date < Utc::now().naive_utc() + } +} diff --git a/plume-models/src/lib.rs b/plume-models/src/lib.rs index 1115f83b..fd93a601 100644 --- a/plume-models/src/lib.rs +++ b/plume-models/src/lib.rs @@ -67,6 +67,7 @@ pub enum Error { Url, Webfinger, Expired, + UserAlreadyExists, } impl From for Error { @@ -376,6 +377,7 @@ pub mod blogs; pub mod comment_seers; pub mod comments; pub mod db_conn; +pub mod email_signups; pub mod follows; pub mod headers; pub mod inbox; @@ -396,6 +398,7 @@ pub mod safe_string; #[allow(unused_imports)] pub mod schema; pub mod search; +pub mod signups; pub mod tags; pub mod timeline; pub mod users; diff --git a/plume-models/src/schema.rs b/plume-models/src/schema.rs index dc6d77fd..db492a08 100644 --- a/plume-models/src/schema.rs +++ b/plume-models/src/schema.rs @@ -73,6 +73,7 @@ table! { user_id -> Int4, } } + table! { email_blocklist(id){ id -> Int4, @@ -83,6 +84,15 @@ table! { } } +table! { + email_signups (id) { + id -> Int4, + email -> Varchar, + token -> Varchar, + expiration_date -> Timestamp, + } +} + table! { follows (id) { id -> Int4, @@ -306,6 +316,7 @@ allow_tables_to_appear_in_same_query!( blogs, comments, comment_seers, + email_signups, follows, instances, likes, diff --git a/plume-models/src/signups.rs b/plume-models/src/signups.rs new file mode 100644 index 00000000..07bdff15 --- /dev/null +++ b/plume-models/src/signups.rs @@ -0,0 +1,45 @@ +use std::fmt; +use std::str::FromStr; + +pub enum Strategy { + Password, + Email, +} + +impl Default for Strategy { + fn default() -> Self { + Self::Password + } +} + +impl FromStr for Strategy { + type Err = StrategyError; + + fn from_str(s: &str) -> Result { + use self::Strategy::*; + + match s { + "password" => Ok(Password), + "email" => Ok(Email), + s => Err(StrategyError::Unsupported(s.to_string())), + } + } +} + +#[derive(Debug)] +pub enum StrategyError { + Unsupported(String), +} + +impl fmt::Display for StrategyError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + use self::StrategyError::*; + + match self { + // FIXME: Calc option strings from enum + Unsupported(s) => write!(f, "Unsupported strategy: {}. Choose password or email", s), + } + } +} + +impl std::error::Error for StrategyError {} diff --git a/plume-models/src/users.rs b/plume-models/src/users.rs index 8c42701b..8244075e 100644 --- a/plume-models/src/users.rs +++ b/plume-models/src/users.rs @@ -202,6 +202,22 @@ impl User { } } + /** + * TODO: Should create user record with normalized(lowercased) email + */ + pub fn email_used(conn: &DbConn, email: &str) -> Result { + use diesel::dsl::{exists, select}; + + select(exists( + users::table + .filter(users::instance_id.eq(Instance::get_local()?.id)) + .filter(users::email.eq(email)) + .or_filter(users::email.eq(email.to_ascii_lowercase())), + )) + .get_result(&**conn) + .map_err(Error::from) + } + fn fetch_from_webfinger(conn: &DbConn, acct: &str) -> Result { let link = resolve(acct.to_owned(), true)? .links diff --git a/src/main.rs b/src/main.rs index 58cb3abe..854ba825 100755 --- a/src/main.rs +++ b/src/main.rs @@ -149,6 +149,10 @@ Then try to restart Plume. routes::comments::create, routes::comments::delete, routes::comments::activity_pub, + routes::email_signups::create, + routes::email_signups::created, + routes::email_signups::show, + routes::email_signups::signup, routes::instance::index, routes::instance::admin, routes::instance::admin_mod, diff --git a/src/routes/email_signups.rs b/src/routes/email_signups.rs new file mode 100644 index 00000000..15a30db3 --- /dev/null +++ b/src/routes/email_signups.rs @@ -0,0 +1,235 @@ +use crate::{ + mail::{build_mail, Mailer}, + routes::{errors::ErrorPage, RespondOrRedirect}, + template_utils::{IntoContext, Ructe}, +}; +use plume_models::{ + db_conn::DbConn, email_signups::EmailSignup, instance::Instance, lettre::Transport, + signups::Strategy as SignupStrategy, Error, PlumeRocket, CONFIG, +}; +use rocket::{ + http::Status, + request::LenientForm, + response::{Flash, Redirect}, + State, +}; +use std::sync::{Arc, Mutex}; +use tracing::warn; +use validator::{Validate, ValidationError, ValidationErrors}; + +#[derive(Default, FromForm, Validate)] +#[validate(schema( + function = "emails_match", + skip_on_field_errors = "false", + message = "Emails are not matching" +))] +pub struct EmailSignupForm { + #[validate(email(message = "Invalid email"))] + pub email: String, + #[validate(email(message = "Invalid email"))] + pub email_confirmation: String, +} + +fn emails_match(form: &EmailSignupForm) -> Result<(), ValidationError> { + if form.email_confirmation == form.email { + Ok(()) + } else { + Err(ValidationError::new("emails_match")) + } +} + +#[derive(Default, FromForm, Validate)] +#[validate(schema( + function = "passwords_match", + skip_on_field_errors = "false", + message = "Passwords are not matching" +))] +pub struct NewUserForm { + #[validate(length(min = "1", message = "Username should be at least 1 characters long"))] + pub username: String, + #[validate(length(min = "8", message = "Password should be at least 8 characters long"))] + pub password: String, + #[validate(length(min = "8", message = "Password should be at least 8 characters long"))] + pub password_confirmation: String, + pub email: String, + pub token: String, +} + +pub fn passwords_match(form: &NewUserForm) -> Result<(), ValidationError> { + if form.password != form.password_confirmation { + Err(ValidationError::new("password_match")) + } else { + Ok(()) + } +} + +#[post("/email_signups/new", data = "
")] +pub fn create( + mail: State<'_, Arc>>, + form: LenientForm, + conn: DbConn, + rockets: PlumeRocket, +) -> Result { + use RespondOrRedirect::{FlashRedirect, Response}; + + if !matches!(CONFIG.signup, SignupStrategy::Email) { + return Ok(FlashRedirect(Flash::error( + Redirect::to(uri!(super::user::new)), + i18n!( + rockets.intl.catalog, + "Email registrations are not enabled. Please restart." + ), + ))); + } + + let registration_open = !Instance::get_local() + .map(|i| i.open_registrations) + .unwrap_or(true); + + if registration_open { + return Ok(FlashRedirect(Flash::error( + Redirect::to(uri!(super::user::new)), + i18n!( + rockets.intl.catalog, + "Registrations are closed on this instance." + ), + ))); // Actually, it is an error + } + let mut form = form.into_inner(); + form.email = form.email.trim().to_owned(); + form.validate().map_err(|err| { + render!(email_signups::new( + &(&conn, &rockets).to_context(), + registration_open, + &form, + err + )) + })?; + let res = EmailSignup::start(&conn, &form.email); + if let Some(err) = res.as_ref().err() { + return Ok(match err { + Error::UserAlreadyExists => { + // TODO: Notify to admin (and the user?) + warn!("Registration attempted for existing user: {}. Registraion halted and email sending skipped.", &form.email); + Response(render!(email_signups::create( + &(&conn, &rockets).to_context() + ))) + } + Error::NotFound => { + Response(render!(errors::not_found(&(&conn, &rockets).to_context()))) + } + _ => Response(render!(errors::not_found(&(&conn, &rockets).to_context()))), // FIXME + }); + } + let token = res.unwrap(); + let url = format!( + "https://{}{}", + CONFIG.base_url, + uri!(show: token = token.to_string()) + ); + let message = build_mail( + form.email, + i18n!(rockets.intl.catalog, "User registration"), + i18n!(rockets.intl.catalog, "Here is the link for registration: {0}"; url), + ) + .expect("Mail configuration has already been done at ignition process"); + // TODO: Render error page + if let Some(ref mut mailer) = *mail.lock().unwrap() { + mailer.send(message.into()).ok(); // TODO: Render error page + } + + Ok(Response(render!(email_signups::create( + &(&conn, &rockets).to_context() + )))) +} + +#[get("/email_signups/new")] +pub fn created(conn: DbConn, rockets: PlumeRocket) -> Ructe { + render!(email_signups::create(&(&conn, &rockets).to_context())) +} + +#[get("/email_signups/")] +pub fn show(token: String, conn: DbConn, rockets: PlumeRocket) -> Result { + let signup = EmailSignup::find_by_token(&conn, token.into())?; + let confirmation = signup.confirm(&conn); + if let Some(err) = confirmation.err() { + match err { + Error::Expired => { + return Ok(render!(email_signups::new( + &(&conn, &rockets).to_context(), + Instance::get_local()?.open_registrations, + &EmailSignupForm::default(), + ValidationErrors::default() + ))) + } // TODO: Flash and redirect + Error::NotFound => return Err(Error::NotFound.into()), + _ => return Err(Error::NotFound.into()), // FIXME + } + } + + let form = NewUserForm { + email: signup.email, + token: signup.token, + ..NewUserForm::default() + }; + Ok(render!(email_signups::edit( + &(&conn, &rockets).to_context(), + Instance::get_local()?.open_registrations, + &form, + ValidationErrors::default() + ))) +} + +#[post("/email_signups/signup", data = "")] +pub fn signup( + form: LenientForm, + conn: DbConn, + rockets: PlumeRocket, +) -> Result { + use RespondOrRedirect::{FlashRedirect, Response}; + + let instance = Instance::get_local().map_err(|e| { + warn!("{:?}", e); + Status::InternalServerError + })?; + if let Some(err) = form.validate().err() { + return Ok(Response(render!(email_signups::edit( + &(&conn, &rockets).to_context(), + instance.open_registrations, + &form, + err + )))); + } + let signup = EmailSignup::find_by_token(&conn, form.token.clone().into()) + .map_err(|_| Status::NotFound)?; + if form.email != signup.email { + let mut err = ValidationErrors::default(); + err.add("email", ValidationError::new("Email couldn't changed")); + let form = NewUserForm { + username: form.username.clone(), + password: form.password.clone(), + password_confirmation: form.password_confirmation.clone(), + email: signup.email, + token: form.token.clone(), + }; + return Ok(Response(render!(email_signups::edit( + &(&conn, &rockets).to_context(), + instance.open_registrations, + &form, + err + )))); + } + let _user = signup + .complete(&conn, form.username.clone(), form.password.clone()) + .map_err(|e| { + warn!("{:?}", e); + Status::UnprocessableEntity + })?; + Ok(FlashRedirect(Flash::success( + Redirect::to(uri!(super::session::new: m = _)), + i18n!( + rockets.intl.catalog, + "Your account has been created. Now you just need to log in, before you can use it." + ), + ))) +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs index fd47e1cd..56e4d90c 100755 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -190,6 +190,7 @@ fn post_to_atom(post: Post, conn: &Connection) -> Entry { pub mod blogs; pub mod comments; +pub mod email_signups; pub mod errors; pub mod instance; pub mod likes; diff --git a/src/routes/user.rs b/src/routes/user.rs index 30b7ecb7..487d28d3 100644 --- a/src/routes/user.rs +++ b/src/routes/user.rs @@ -10,14 +10,16 @@ use std::{borrow::Cow, collections::HashMap}; use validator::{Validate, ValidationError, ValidationErrors}; use crate::inbox; -use crate::routes::{errors::ErrorPage, Page, RemoteForm, RespondOrRedirect}; +use crate::routes::{ + email_signups::EmailSignupForm, errors::ErrorPage, Page, RemoteForm, RespondOrRedirect, +}; use crate::template_utils::{IntoContext, Ructe}; use plume_common::activity_pub::{broadcast, ActivityStream, ApRequest, Id}; use plume_common::utils; use plume_models::{ blogs::Blog, db_conn::DbConn, follows, headers::Headers, inbox::inbox as local_inbox, instance::Instance, medias::Media, posts::Post, reshares::Reshare, safe_string::SafeString, - users::*, Error, PlumeRocket, CONFIG, + signups::Strategy as SignupStrategy, users::*, Error, PlumeRocket, CONFIG, }; #[get("/me")] @@ -260,12 +262,23 @@ pub fn activity_details( #[get("/users/new")] pub fn new(conn: DbConn, rockets: PlumeRocket) -> Result { - Ok(render!(users::new( - &(&conn, &rockets).to_context(), - Instance::get_local()?.open_registrations, - &NewUserForm::default(), - ValidationErrors::default() - ))) + use SignupStrategy::*; + + let rendered = match CONFIG.signup { + Password => render!(users::new( + &(&conn, &rockets).to_context(), + Instance::get_local()?.open_registrations, + &NewUserForm::default(), + ValidationErrors::default() + )), + Email => render!(email_signups::new( + &(&conn, &rockets).to_context(), + Instance::get_local()?.open_registrations, + &EmailSignupForm::default(), + ValidationErrors::default() + )), + }; + Ok(rendered) } #[get("/@//edit")] diff --git a/templates/email_signups/create.rs.html b/templates/email_signups/create.rs.html new file mode 100644 index 00000000..41e7483f --- /dev/null +++ b/templates/email_signups/create.rs.html @@ -0,0 +1,9 @@ +@use crate::template_utils::*; +@use crate::templates::base; + +@(ctx: BaseContext) + +@:base(ctx, i18n!(ctx.1, "Registration"), {}, {}, { +

@i18n!(ctx.1, "Check your inbox!")

+

@i18n!(ctx.1, "We sent a mail to the address you gave us, with a link for registration.")

+}) diff --git a/templates/email_signups/edit.rs.html b/templates/email_signups/edit.rs.html new file mode 100644 index 00000000..f6beebb5 --- /dev/null +++ b/templates/email_signups/edit.rs.html @@ -0,0 +1,43 @@ +@use std::borrow::Cow; +@use validator::{ValidationErrors, ValidationErrorsKind}; +@use crate::templates::base; +@use crate::template_utils::*; +@use crate::routes::email_signups::NewUserForm; +@use crate::routes::*; + +@(ctx: BaseContext, enabled: bool, form: &NewUserForm, errors: ValidationErrors) + +@:base(ctx, i18n!(ctx.1, "Create your account"), {}, {}, { + @if enabled { +

@i18n!(ctx.1, "Create an account")

+ + @if let Some(ValidationErrorsKind::Field(errs)) = errors.clone().errors().get("__all__") { +

@errs[0].message.as_ref().unwrap_or(&Cow::from("Unknown error"))

+ } + + @(Input::new("username", i18n!(ctx.1, "Username")) + .default(&form.username) + .error(&errors) + .set_prop("required", "") + .html(ctx.1)) + @(Input::new("password", i18n!(ctx.1, "Password")) + .default(&form.password) + .error(&errors) + .set_prop("minlength", 8) + .input_type("password") + .html(ctx.1)) + @(Input::new("password_confirmation", i18n!(ctx.1, "Password confirmation")) + .default(&form.password_confirmation) + .error(&errors) + .set_prop("minlength", 8) + .input_type("password") + .html(ctx.1)) + + + + + + } else { +

@i18n!(ctx.1, "Apologies, but registrations are closed on this particular instance. You can, however, find a different one.")

+ } +}) diff --git a/templates/email_signups/new.rs.html b/templates/email_signups/new.rs.html new file mode 100644 index 00000000..0d0d19da --- /dev/null +++ b/templates/email_signups/new.rs.html @@ -0,0 +1,37 @@ +@use std::borrow::Cow; +@use validator::{ValidationErrors, ValidationErrorsKind}; +@use crate::templates::base; +@use crate::template_utils::*; +@use crate::routes::email_signups::EmailSignupForm; +@use crate::routes::*; + +@(ctx: BaseContext, enabled: bool, form: &EmailSignupForm, errors: ValidationErrors) + +@:base(ctx, i18n!(ctx.1, "Create your account"), {}, {}, { + @if enabled { +

@i18n!(ctx.1, "Create an account")

+
+ @if let Some(ValidationErrorsKind::Field(errs)) = errors.clone().errors().get("__all__") { +

@errs[0].message.as_ref().unwrap_or(&Cow::from("Unknown error"))

+ } + + @(Input::new("email", i18n!(ctx.1, "Email")) + .input_type("email") + .default(&form.email) + .error(&errors) + .set_prop("required", "") + .html(ctx.1)) + + @(Input::new("email_confirmation", i18n!(ctx.1, "Email confirmation")) + .input_type("email") + .default(&form.email_confirmation) + .error(&errors) + .set_prop("required", "") + .html(ctx.1)) + + +
+ } else { +

@i18n!(ctx.1, "Apologies, but registrations are closed on this particular instance. You can, however, find a different one.")

+ } +})