diff --git a/deepwell/migrations/20220906103252_deepwell.sql b/deepwell/migrations/20220906103252_deepwell.sql index bdaa3d0034..767097509e 100644 --- a/deepwell/migrations/20220906103252_deepwell.sql +++ b/deepwell/migrations/20220906103252_deepwell.sql @@ -11,6 +11,7 @@ CREATE TYPE user_type AS ENUM ( 'regular', 'system', + 'site', 'bot' ); @@ -25,7 +26,7 @@ CREATE TABLE "user" ( slug TEXT NOT NULL, name_changes_left SMALLINT NOT NULL, -- Default set in runtime configuration. last_renamed_at TIMESTAMP WITH TIME ZONE, - email TEXT NOT NULL, + email TEXT NOT NULL, -- Can be empty, for instance with system accounts. email_is_alias BOOLEAN, email_verified_at TIMESTAMP WITH TIME ZONE, password TEXT NOT NULL, diff --git a/deepwell/src/database/seeder/mod.rs b/deepwell/src/database/seeder/mod.rs index 95735b7850..3456ce9d5a 100644 --- a/deepwell/src/database/seeder/mod.rs +++ b/deepwell/src/database/seeder/mod.rs @@ -145,7 +145,7 @@ pub async fn seed(state: &ServerState) -> Result<()> { { info!("Creating seed site '{}' (slug {})", site.name, site.slug); - let CreateSiteOutput { site_id, slug: _ } = SiteService::create( + let CreateSiteOutput { site_id, .. } = SiteService::create( &ctx, CreateSite { slug: site.slug, @@ -176,7 +176,7 @@ pub async fn seed(state: &ServerState) -> Result<()> { for page in pages { info!("Creating page '{}' (slug {})", page.title, page.slug); - PageService::create( + let model = PageService::create( &ctx, CreatePage { site_id, @@ -190,6 +190,9 @@ pub async fn seed(state: &ServerState) -> Result<()> { }, ) .await?; + + // TODO add attribution with site_user as author + let _ = model; } } diff --git a/deepwell/src/models/sea_orm_active_enums.rs b/deepwell/src/models/sea_orm_active_enums.rs index 2190a9552f..fe46104aa1 100644 --- a/deepwell/src/models/sea_orm_active_enums.rs +++ b/deepwell/src/models/sea_orm_active_enums.rs @@ -92,6 +92,8 @@ pub enum UserType { Bot, #[sea_orm(string_value = "regular")] Regular, + #[sea_orm(string_value = "site")] + Site, #[sea_orm(string_value = "system")] System, } diff --git a/deepwell/src/services/error.rs b/deepwell/src/services/error.rs index 8d0cf61a30..f969d12e0e 100644 --- a/deepwell/src/services/error.rs +++ b/deepwell/src/services/error.rs @@ -164,6 +164,9 @@ pub enum Error { #[error("User slug cannot be empty")] UserSlugEmpty, + #[error("User email cannot be empty")] + UserEmailEmpty, + #[error("Message subject cannot be empty")] MessageSubjectEmpty, @@ -362,6 +365,7 @@ impl Error { Error::SiteSlugEmpty => 4013, Error::UserNameTooShort => 4014, Error::UserSlugEmpty => 4015, + Error::UserEmailEmpty => 4022, Error::MessageSubjectEmpty => 4016, Error::MessageSubjectTooLong => 4017, Error::MessageBodyEmpty => 4018, diff --git a/deepwell/src/services/interaction/mod.rs b/deepwell/src/services/interaction/mod.rs index a623468088..4d18bfbcdf 100644 --- a/deepwell/src/services/interaction/mod.rs +++ b/deepwell/src/services/interaction/mod.rs @@ -44,6 +44,7 @@ mod page_star; mod page_watch; mod site_ban; mod site_member; +mod site_user; mod structs; mod user_block; mod user_contact; @@ -53,6 +54,7 @@ pub use self::page_star::*; pub use self::page_watch::*; pub use self::site_ban::*; pub use self::site_member::*; +pub use self::site_user::*; pub use self::structs::*; pub use self::user_block::*; pub use self::user_contact::*; @@ -158,6 +160,7 @@ impl InteractionService { .filter( Condition::all() .add(reference.condition()) + .add(interaction::Column::OverwrittenAt.is_null()) .add(interaction::Column::DeletedAt.is_null()), ) .one(txn) @@ -199,6 +202,10 @@ impl InteractionService { } // TODO paginate + /// Gets the history of this `dest` / `from` interaction. + /// + /// This includes all all edits of the interaction (`overwritten_at`) + /// and deleted / remade versions of the interaction (`deleted_at`). pub async fn get_history( ctx: &ServiceContext<'_>, interaction_type: InteractionType, @@ -220,6 +227,10 @@ impl InteractionService { } // TODO paginate + /// Gets all interactions from the starting object in the given direction. + /// + /// For instance, this can be used to get all blocked users, or all users who are blocking + /// someone depending on the `InteractionDirection`. pub async fn get_entries( ctx: &ServiceContext<'_>, interaction_type: InteractionType, diff --git a/deepwell/src/services/interaction/site_user.rs b/deepwell/src/services/interaction/site_user.rs new file mode 100644 index 0000000000..143e1bbfeb --- /dev/null +++ b/deepwell/src/services/interaction/site_user.rs @@ -0,0 +1,161 @@ +/* + * services/interaction/site_user.rs + * + * DEEPWELL - Wikijump API provider and database manager + * Copyright (C) 2019-2023 Wikijump Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +//! Governs the interaction which tracks "site users". +//! +//! These are special users (of type `site`) which represent a site as a whole. +//! They can be messaged to send messages to staff, and can be utilized to send +//! messages on behalf of a site (for instance, a ban notification). +//! +//! This interaction describes which site a site-user corresponds to. +//! As such, it is an invariant that all users linked here are of the type `site`. + +use super::prelude::*; +use crate::models::sea_orm_active_enums::UserType; +use crate::services::UserService; + +impl_interaction!(SiteUser, Site, site_id, User, user_id, (), NO_CREATE_IMPL,); + +impl InteractionService { + pub async fn create_site_user( + ctx: &ServiceContext<'_>, + CreateSiteUser { + site_id, + user_id, + metadata: (), + created_by, + }: CreateSiteUser, + ) -> Result<()> { + // User to be added must of type 'site' + let user = UserService::get(ctx, Reference::Id(user_id)).await?; + if user.user_type != UserType::Site { + error!( + "Can only create site user interactions if the user is of type 'site', not {:?}", + user.user_type, + ); + return Err(Error::BadRequest); + } + + // Site <--> User must be 1:1 + // + // This means there should be no results for both + // this site_id -> anything and this user_id -> anything. + + let sites = InteractionService::get_entries( + ctx, + InteractionType::SiteUser, + InteractionObject::Site(site_id), + InteractionDirection::Dest, + ) + .await?; + + if !sites.is_empty() { + error!("Found a different interaction with this site, cannot create interaction: {sites:?}"); + return Err(Error::BadRequest); + } + + let users = InteractionService::get_entries( + ctx, + InteractionType::SiteUser, + InteractionObject::User(user_id), + InteractionDirection::From, + ) + .await?; + + if !users.is_empty() { + error!("Found a different interaction with this user, cannot create interaction: {users:?}"); + return Err(Error::BadRequest); + } + + // Checks done, create + create_operation!( + ctx, + SiteMember, + Site, + site_id, + User, + user_id, + created_by, + &() + ) + } + + pub async fn get_site_user_id_for_site( + ctx: &ServiceContext<'_>, + site_id: i64, + ) -> Result { + info!("Getting site user for site ID {site_id}"); + + let model = get_interaction( + ctx, + Condition::all() + .add(interaction::Column::DestType.eq(InteractionObjectType::Site)) + .add(interaction::Column::DestId.eq(site_id)), + ) + .await?; + + Ok(model.from_id) + } + + pub async fn get_site_id_for_site_user( + ctx: &ServiceContext<'_>, + user_id: i64, + ) -> Result { + let model = get_interaction( + ctx, + Condition::all() + .add(interaction::Column::FromType.eq(InteractionObjectType::User)) + .add(interaction::Column::FromId.eq(user_id)), + ) + .await?; + + Ok(model.dest_id) + } +} + +async fn get_interaction( + ctx: &ServiceContext<'_>, + condition: Condition, +) -> Result { + // We implement our own query since it's 1:1 and we + // don't have to worry about multiple results like + // for get_entries(). + + let txn = ctx.transaction(); + let model = Interaction::find() + .filter( + Condition::all() + .add( + interaction::Column::InteractionType + .eq(InteractionType::SiteUser.value()), + ) + .add(condition) + .add(interaction::Column::OverwrittenAt.is_null()) + .add(interaction::Column::DeletedAt.is_null()), + ) + .order_by_asc(interaction::Column::CreatedAt) + .one(txn) + .await?; + + match model { + Some(model) => Ok(model), + None => Err(Error::InteractionNotFound), + } +} diff --git a/deepwell/src/services/interaction/structs.rs b/deepwell/src/services/interaction/structs.rs index 7d9736ffd3..37124635b6 100644 --- a/deepwell/src/services/interaction/structs.rs +++ b/deepwell/src/services/interaction/structs.rs @@ -126,6 +126,7 @@ pub enum InteractionDirection { #[derive(Debug, Copy, Clone, PartialEq, Eq)] pub enum InteractionType { + SiteUser, SiteBan, SiteMember, PageStar, @@ -145,6 +146,7 @@ impl InteractionType { /// of `(dest_type, from_type, interaction_value)`. pub fn value(self) -> &'static str { match self { + InteractionType::SiteUser => "site-user", InteractionType::SiteBan => "ban", InteractionType::SiteMember => "member", InteractionType::PageStar => "star", @@ -167,6 +169,7 @@ impl InteractionType { } match self { + InteractionType::SiteUser => t!(Site, User), InteractionType::SiteBan => t!(Site, User), InteractionType::SiteMember => t!(Site, User), InteractionType::PageStar => t!(Page, User), diff --git a/deepwell/src/services/message/service.rs b/deepwell/src/services/message/service.rs index 9dc3c656dc..0f55824ca6 100644 --- a/deepwell/src/services/message/service.rs +++ b/deepwell/src/services/message/service.rs @@ -27,7 +27,7 @@ use crate::models::message_recipient::{self, Entity as MessageRecipient}; use crate::models::message_record::{ self, Entity as MessageRecord, Model as MessageRecordModel, }; -use crate::models::sea_orm_active_enums::MessageRecipientType; +use crate::models::sea_orm_active_enums::{MessageRecipientType, UserType}; use crate::services::render::{RenderOutput, RenderService}; use crate::services::{InteractionService, TextService, UserService}; use cuid2::cuid; @@ -208,7 +208,7 @@ impl MessageService { let config = ctx.config(); let draft = Self::get_draft(ctx, draft_id).await?; let wikitext = TextService::get(ctx, &draft.wikitext_hash).await?; - let recipients: DraftRecipients = serde_json::from_value(draft.recipients)?; + let mut recipients: DraftRecipients = serde_json::from_value(draft.recipients)?; // Message validation checks if draft.subject.is_empty() { @@ -253,7 +253,9 @@ impl MessageService { return Err(Error::MessageTooManyRecipients); } + let mut recipients_to_add = Vec::new(); for recipient_user_id in recipients.iter() { + // Ensure user is not blocked InteractionService::check_user_block( ctx, draft.user_id, @@ -261,7 +263,22 @@ impl MessageService { "send a direct message to", ) .await?; + + // If recipient is a site user, then forward to corresponding site staff. + let user = UserService::get(ctx, Reference::Id(recipient_user_id)).await?; + if user.user_type == UserType::Site { + // TODO what to do if user is banned from site? needs to be possible to block + // permabanned bad actors, but also allow normal banned users to message + // to appeal bans etc + // TODO get the listed site staff, add them to recipients + let _site_id = + InteractionService::get_site_id_for_site_user(ctx, user.user_id) + .await?; + + let _ = &recipients_to_add; + } } + recipients.carbon_copy.append(&mut recipients_to_add); // The message sending process: // * Insert message_draft row to message_record diff --git a/deepwell/src/services/site/service.rs b/deepwell/src/services/site/service.rs index 7796da668a..8bbd1a73d6 100644 --- a/deepwell/src/services/site/service.rs +++ b/deepwell/src/services/site/service.rs @@ -21,10 +21,13 @@ use wikidot_normalize::normalize; use super::prelude::*; -use crate::models::sea_orm_active_enums::AliasType; +use crate::constants::SYSTEM_USER_ID; +use crate::models::sea_orm_active_enums::{AliasType, UserType}; use crate::models::site::{self, Entity as Site, Model as SiteModel}; use crate::services::alias::CreateAlias; -use crate::services::AliasService; +use crate::services::interaction::CreateSiteUser; +use crate::services::user::{CreateUser, UpdateUserBody}; +use crate::services::{AliasService, InteractionService, UserService}; use crate::utils::validate_locale; #[derive(Debug)] @@ -52,18 +55,59 @@ impl SiteService { // Validate locale. validate_locale(&locale)?; + // Insert into database let model = site::ActiveModel { slug: Set(slug.clone()), name: Set(name), tagline: Set(tagline), - description: Set(description), - locale: Set(locale), + description: Set(description.clone()), + locale: Set(locale.clone()), ..Default::default() }; let site = model.insert(txn).await?; + // Create site user, and add interaction + + let user = UserService::create( + ctx, + CreateUser { + user_type: UserType::Site, + name: format!("site:{slug}"), + email: String::new(), + locale, + password: String::new(), + bypass_filter: false, + bypass_email_verification: false, + }, + ) + .await?; + + // Some fields can only be set in update after creation + UserService::update( + ctx, + Reference::Id(user.user_id), + UpdateUserBody { + biography: ProvidedValue::Set(Some(description)), + ..Default::default() + }, + ) + .await?; + + InteractionService::create_site_user( + ctx, + CreateSiteUser { + site_id: site.site_id, + user_id: user.user_id, + metadata: (), + created_by: SYSTEM_USER_ID, + }, + ) + .await?; + + // Return Ok(CreateSiteOutput { site_id: site.site_id, + site_user_id: user.user_id, slug, }) } @@ -73,7 +117,7 @@ impl SiteService { ctx: &ServiceContext<'_>, reference: Reference<'_>, input: UpdateSiteBody, - user_id: i64, + updating_user_id: i64, ) -> Result { let txn = ctx.transaction(); let site = Self::get(ctx, reference).await?; @@ -82,12 +126,18 @@ impl SiteService { ..Default::default() }; + // For updating the corresponding site user + let site_user_id = + InteractionService::get_site_user_id_for_site(ctx, site.site_id).await?; + let mut site_user_body = UpdateUserBody::default(); + if let ProvidedValue::Set(name) = input.name { model.name = Set(name); } if let ProvidedValue::Set(new_slug) = input.slug { - Self::update_slug(ctx, &site, &new_slug, user_id).await?; + Self::update_slug(ctx, &site, &new_slug, updating_user_id).await?; + site_user_body.name = ProvidedValue::Set(format!("site:{new_slug}")); model.slug = Set(new_slug); } @@ -96,18 +146,23 @@ impl SiteService { } if let ProvidedValue::Set(description) = input.description { - model.description = Set(description); + model.description = Set(description.clone()); + site_user_body.biography = ProvidedValue::Set(Some(description)) } if let ProvidedValue::Set(locale) = input.locale { validate_locale(&locale)?; - model.locale = Set(locale); + model.locale = Set(locale.clone()); + site_user_body.locale = ProvidedValue::Set(locale); } // Update site model.updated_at = Set(Some(now())); let new_site = model.update(txn).await?; + // Update site user + UserService::update(ctx, Reference::Id(site_user_id), site_user_body).await?; + // Run verification afterwards if the slug changed if site.slug != new_site.slug { try_join!( diff --git a/deepwell/src/services/site/structs.rs b/deepwell/src/services/site/structs.rs index 6d83ebbe9d..3b43700437 100644 --- a/deepwell/src/services/site/structs.rs +++ b/deepwell/src/services/site/structs.rs @@ -35,6 +35,7 @@ pub struct CreateSite { #[derive(Serialize, Debug, Clone)] pub struct CreateSiteOutput { pub site_id: i64, + pub site_user_id: i64, pub slug: String, } diff --git a/deepwell/src/services/user/service.rs b/deepwell/src/services/user/service.rs index 33c538d69c..a3956fe257 100644 --- a/deepwell/src/services/user/service.rs +++ b/deepwell/src/services/user/service.rs @@ -26,7 +26,7 @@ use crate::services::blob::{BlobService, CreateBlobOutput}; use crate::services::email::{EmailClassification, EmailService}; use crate::services::filter::{FilterClass, FilterType}; use crate::services::{AliasService, FilterService, PasswordService}; -use crate::utils::{get_regular_slug, regex_replace_in_place}; +use crate::utils::regex_replace_in_place; use once_cell::sync::Lazy; use regex::Regex; use sea_orm::ActiveValue; @@ -52,7 +52,7 @@ impl UserService { }: CreateUser, ) -> Result { let txn = ctx.transaction(); - let slug = get_regular_slug(&name); + let slug = get_user_slug(&name, user_type); debug!("Normalizing user data (name '{}', slug '{}')", name, slug,); regex_replace_in_place(&mut name, &LEADING_TRAILING_CHARS, ""); @@ -91,7 +91,6 @@ impl UserService { .add( Condition::any() .add(user::Column::Name.eq(name.as_str())) - .add(user::Column::Email.eq(email.as_str())) .add(user::Column::Slug.eq(slug.as_str())), ) .add(user::Column::DeletedAt.is_null()), @@ -100,39 +99,40 @@ impl UserService { .await?; if result.is_some() { - error!("User with conflicting name or slug already exists, cannot create",); - + error!("User with conflicting name or slug already exists, cannot create"); + error!("Checked name '{name}', slug '{slug}', found {result:#?}"); return Err(Error::UserExists); } - // Check for email conflicts - // Bot accounts are allowed to have duplicate emails + // Email must be specified for humans and bots + if matches!(user_type, UserType::Regular | UserType::Bot) && email.is_empty() { + error!("Attempting to create user with empty email"); + return Err(Error::UserEmailEmpty); + } + + // Check for email conflicts, if a regular user + // Other kinds of accounts do not need unique emails if user_type == UserType::Regular { let result = User::find() .filter( Condition::all() - .add( - Condition::any() - .add(user::Column::Name.eq(name.as_str())) - .add(user::Column::Email.eq(email.as_str())) - .add(user::Column::Slug.eq(slug.as_str())), - ) + .add(user::Column::Email.eq(email.as_str())) .add(user::Column::DeletedAt.is_null()), ) .one(txn) .await?; if result.is_some() { - error!("User with conflicting email already exists, cannot create",); - + error!("User with conflicting email already exists, cannot create"); + error!("Checked email '{email}' found {result:#?}"); return Err(Error::UserExists); } } // Check for alias conflicts if AliasService::exists(ctx, AliasType::User, &slug).await? { - error!("User alias with conflicting slug already exists, cannot create",); - + error!("User alias with conflicting slug already exists, cannot create"); + error!("Checked slug '{slug}'"); return Err(Error::UserExists); } @@ -142,11 +142,11 @@ impl UserService { info!("Creating regular user '{slug}' with password"); PasswordService::new_hash(&password)? } - UserType::System => { - info!("Creating system user '{slug}'"); + UserType::System | UserType::Site => { + info!("Creating site or system user '{slug}'"); if !password.is_empty() { - warn!("Password was specified for system user"); + warn!("Password was specified for site or system user"); return Err(Error::BadRequest); } @@ -168,7 +168,10 @@ impl UserService { // // The assigned variable is also used to check whether email validation occurred, as it // will always be `Some` if validation occurred and `None` otherwise. - let email_is_alias = if !bypass_email_verification { + // + // Also bypass email verification if it's empty (obviously invalid). + // We've already checked for empty emails above (e.g. system users can have empty emails). + let email_is_alias = if !bypass_email_verification && !email.is_empty() { let email_validation_output = EmailService::validate(&email).await?; match email_validation_output.classification { @@ -463,7 +466,7 @@ impl UserService { // unaltered, or if the slug is a prior name of theirs // (i.e. they have a user alias for it). - let new_slug = get_regular_slug(&new_name); + let new_slug = get_user_slug(&new_name, user.user_type); let old_slug = &user.slug; // Empty slug check @@ -691,3 +694,18 @@ impl UserService { Ok(()) } } + +fn get_user_slug(name: &str, user_type: UserType) -> String { + use crate::utils::{get_regular_slug, get_slug}; + + if user_type == UserType::Site { + debug_assert!( + name.starts_with("site:"), + "Site user slug does not start with 'site:'", + ); + + get_slug(name) + } else { + get_regular_slug(name) + } +} diff --git a/deepwell/src/utils/slug.rs b/deepwell/src/utils/slug.rs index e98be2d1b5..c9a90ad217 100644 --- a/deepwell/src/utils/slug.rs +++ b/deepwell/src/utils/slug.rs @@ -30,3 +30,10 @@ pub fn get_regular_slug>(name: S) -> String { normalize(&mut slug); slug } + +/// Normalize a name to a slug. +pub fn get_slug>(name: S) -> String { + let mut slug = name.into(); + normalize(&mut slug); + slug +}