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
+}