Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ members = [
"crates/db_views/private_message",
"crates/db_views/local_user",
"crates/db_views/local_image",
"crates/db_views/local_user_invite",
"crates/db_views/person",
"crates/db_views/post",
"crates/db_views/vote",
Expand Down Expand Up @@ -128,6 +129,7 @@ lemmy_db_views_community_follower = { version = "=1.0.0-test-arm-qemu.0", path =
lemmy_db_views_community_follower_approval = { version = "=1.0.0-test-arm-qemu.0", path = "./crates/db_views/community_follower_approval" }
lemmy_db_views_community_moderator = { version = "=1.0.0-test-arm-qemu.0", path = "./crates/db_views/community_moderator" }
lemmy_db_views_custom_emoji = { version = "=1.0.0-test-arm-qemu.0", path = "./crates/db_views/custom_emoji" }
lemmy_db_views_local_user_invite = { version = "=1.0.0-test-arm-qemu.0", path = "./crates/db_views/local_user_invite" }
lemmy_db_views_notification = { version = "=1.0.0-test-arm-qemu.0", path = "./crates/db_views/notification" }
lemmy_db_views_notification_sql = { version = "=1.0.0-test-arm-qemu.0", path = "./crates/db_views/notification_sql" }
lemmy_db_views_local_image = { version = "=1.0.0-test-arm-qemu.0", path = "./crates/db_views/local_image" }
Expand Down
3 changes: 3 additions & 0 deletions crates/api/api_crud/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ lemmy_db_views_post = { workspace = true, features = ["full"] }
lemmy_db_views_local_user = { workspace = true, features = ["full"] }
lemmy_db_views_person = { workspace = true, features = ["full"] }
lemmy_db_views_custom_emoji = { workspace = true, features = ["full"] }
lemmy_db_views_local_user_invite = { workspace = true, features = ["full"] }
lemmy_db_views_private_message = { workspace = true, features = ["full"] }
lemmy_db_views_registration_applications = { workspace = true, features = [
"full",
Expand All @@ -45,6 +46,8 @@ futures = { workspace = true }
futures-util = { workspace = true }
anyhow.workspace = true
chrono.workspace = true
uuid = { workspace = true }
base64 = { workspace = true }
accept-language = "3.1.0"
regex = { workspace = true }
serde_json = { workspace = true }
Expand Down
54 changes: 54 additions & 0 deletions crates/api/api_crud/src/invite/create.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
use actix_web::web::{Data, Json};
use base64::{Engine, engine::general_purpose};
use lemmy_api_utils::{context::LemmyContext, utils::is_admin};
use lemmy_db_schema::source::local_user_invite::{LocalUserInvite, LocalUserInviteInsertForm};
use lemmy_db_views_local_user::LocalUserView;
use lemmy_db_views_local_user_invite::{
api::{CreateInvitation, CreateInvitationResponse},
impls::LocalUserInviteQuery,
};
use lemmy_utils::error::{LemmyErrorType, LemmyResult};
use uuid::Uuid;

pub async fn create_invitation(
Json(data): Json<CreateInvitation>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<CreateInvitationResponse>> {
let pool = &mut context.pool();

let local_user_id = local_user_view.local_user.id;

let active_invite_count = LocalUserInviteQuery {
local_user_id,
..Default::default()
}
.count(pool)
.await?;

if let Some(max) = context.settings().max_invites_per_user_allowed
&& is_admin(&local_user_view).is_err()
&& active_invite_count >= i64::from(max)
{
return Err(LemmyErrorType::TooManyInvites.into());
}

let token = generate_invite_token();

let insert = LocalUserInviteInsertForm {
token,
local_user_id,
max_uses: data.max_uses,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be validate a max_uses? Seems like having a limit here might be a good idea.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can add this in a subsequent PR along with a setting to control max expires_at, if that's okay?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes thats fine.

expires_at: data.expires_at,
};

let invite = LocalUserInvite::create(pool, &insert).await?;

Ok(Json(CreateInvitationResponse { invite }))
}

fn generate_invite_token() -> String {
let id = Uuid::new_v4();
// Convert to base64 for a more compact URL-friendly string
general_purpose::URL_SAFE_NO_PAD.encode(id.as_bytes())
Comment thread
dessalines marked this conversation as resolved.
Outdated
}
25 changes: 25 additions & 0 deletions crates/api/api_crud/src/invite/list.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
use actix_web::web::{Data, Json, Query};
use lemmy_api_utils::context::LemmyContext;
use lemmy_db_schema::source::local_user_invite::LocalUserInvite;
use lemmy_db_views_local_user::LocalUserView;
use lemmy_db_views_local_user_invite::{api::ListInvitations, impls::LocalUserInviteQuery};
use lemmy_diesel_utils::pagination::PagedResponse;
use lemmy_utils::error::LemmyResult;

pub async fn list_invitations(
data: Query<ListInvitations>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<PagedResponse<LocalUserInvite>>> {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm returning PagedResponse<LocalUserInvite>, which works and looks clean IMO. But need input from reviewers as it was mentioned about returning LocalUserInviteView instead, which I removed entirely in 465f05a. My understanding is that views are used when we need to join tables, which I don't think it's required for invites.

let pool = &mut context.pool();

let paged = LocalUserInviteQuery {
local_user_id: local_user_view.local_user.id,
page_cursor: data.page_cursor.clone(),
limit: data.limit,
}
.list(pool)
.await?;

Ok(Json(paged))
}
3 changes: 3 additions & 0 deletions crates/api/api_crud/src/invite/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
pub mod create;
pub mod list;
pub mod revoke;
21 changes: 21 additions & 0 deletions crates/api/api_crud/src/invite/revoke.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
use actix_web::web::{Data, Json};
use lemmy_api_utils::context::LemmyContext;
use lemmy_db_schema::source::local_user_invite::LocalUserInvite;
use lemmy_db_views_local_user::LocalUserView;
use lemmy_db_views_local_user_invite::api::RevokeInvitation;
use lemmy_db_views_site::api::SuccessResponse;
use lemmy_utils::error::LemmyResult;

pub async fn revoke_invitation(
Json(data): Json<RevokeInvitation>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<SuccessResponse>> {
let pool = &mut context.pool();

let local_user_id = local_user_view.local_user.id;

LocalUserInvite::delete_by_token_and_user(pool, &local_user_id, &data.token).await?;

Ok(Json(SuccessResponse::default()))
}
1 change: 1 addition & 0 deletions crates/api/api_crud/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use lemmy_db_schema::source::community::{Community, CommunityActions};
pub mod comment;
pub mod community;
pub mod custom_emoji;
pub mod invite;
pub mod multi_community;
pub mod oauth_provider;
pub mod post;
Expand Down
32 changes: 32 additions & 0 deletions crates/api/api_crud/src/user/create.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ use lemmy_db_schema::{
language::Language,
local_site::LocalSite,
local_user::{LocalUser, LocalUserInsertForm},
local_user_invite::{LocalUserInvite, LocalUserInviteUpdateForm},
oauth_account::{OAuthAccount, OAuthAccountInsertForm},
oauth_provider::AdminOAuthProvider,
person::{Person, PersonInsertForm},
Expand Down Expand Up @@ -89,6 +90,20 @@ pub async fn register(
let local_site = site_view.local_site.clone();
let require_registration_application =
local_site.registration_mode == RegistrationMode::RequireApplication;
let token = data.token.as_deref();

let local_user_invite = if local_site.registration_mode == RegistrationMode::RequireInvitation {
let token = token.ok_or(LemmyErrorType::MissingInviteToken)?;
let inv = LocalUserInvite::read_by_token(pool, token)
.await
.map_err(|_e| LemmyError::from(LemmyErrorType::InvalidInviteToken))?;
if !inv.is_active() {
return Err(LemmyErrorType::InvalidInviteToken.into());
Comment thread
dessalines marked this conversation as resolved.
Outdated
}
Some(inv)
} else {
None
};

if local_site.registration_mode == RegistrationMode::Closed {
return Err(LemmyErrorType::RegistrationClosed.into());
Expand Down Expand Up @@ -154,6 +169,7 @@ pub async fn register(
email: tx_data.email.as_deref().map(str::to_lowercase),
show_nsfw: Some(show_nsfw),
accepted_application,
invited_by_local_user_id: local_user_invite.as_ref().map(|inv| inv.local_user_id),
..LocalUserInsertForm::new(person.id, Some(tx_data.password.to_string()))
};

Expand All @@ -179,6 +195,22 @@ pub async fn register(
RegistrationApplication::create(&mut conn.into(), &form).await?;
}

if let Some(inv) = local_user_invite {
let new_uses_count = inv.uses_count + 1;
if inv.max_uses.map(|m| new_uses_count >= m).unwrap_or(false) {
LocalUserInvite::delete_by_token(&mut conn.into(), &inv.token).await?;
} else {
LocalUserInvite::update(
&mut conn.into(),
inv.id,
&LocalUserInviteUpdateForm {
uses_count: Some(new_uses_count),
},
)
.await?;
}
}

Ok(LocalUserView {
person,
local_user,
Expand Down
9 changes: 8 additions & 1 deletion crates/api/routes/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ use lemmy_api_crud::{
list::list_custom_emojis,
update::edit_custom_emoji,
},
invite::{create::create_invitation, list::list_invitations, revoke::revoke_invitation},
multi_community::{
create::create_multi_community,
create_entry::create_multi_community_entry,
Expand Down Expand Up @@ -408,7 +409,13 @@ pub fn config(cfg: &mut ServiceConfig, rate_limit: &RateLimit) {
resource("/data/export")
.wrap(rate_limit.import_user_settings())
.route(get().to(export_user_data)),
),
)
.service(
scope("/invite")
.route("", post().to(create_invitation))
.route("/revoke", post().to(revoke_invitation)),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
.route("/revoke", post().to(revoke_invitation)),
.route("", delete().to(revoke_invitation)),

Can use the same route with different http method.

)
.route("/invites", get().to(list_invitations)),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make this /invite/list, consistent with other api routes.

)
// Person / User actions
.service(
Expand Down
1 change: 1 addition & 0 deletions crates/api/routes_v3/src/convert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -637,6 +637,7 @@ pub(crate) fn convert_local_site(local_site: LocalSite) -> LocalSiteV3 {
RegistrationMode::Closed => RegistrationModeV3::Closed,
RegistrationMode::RequireApplication => RegistrationModeV3::RequireApplication,
RegistrationMode::Open => RegistrationModeV3::Open,
RegistrationMode::RequireInvitation => RegistrationModeV3::Closed,
};
LocalSiteV3 {
id: Default::default(),
Expand Down
138 changes: 138 additions & 0 deletions crates/db_schema/src/impls/local_user_invite.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
use crate::{
newtypes::{InvitationId, LocalUserId},
source::local_user_invite::{
LocalUserInvite,
LocalUserInviteInsertForm,
LocalUserInviteUpdateForm,
},
};
use chrono::Utc;
use diesel::{ExpressionMethods, QueryDsl, insert_into};
use diesel_async::RunQueryDsl;
use lemmy_db_schema_file::schema::local_user_invite;
use lemmy_diesel_utils::{
connection::{DbPool, get_conn},
pagination::{CursorData, PaginationCursorConversion},
};
use lemmy_utils::{
error::{LemmyErrorExt, LemmyErrorType, LemmyResult},
settings::structs::Settings,
};
use url::Url;

impl LocalUserInvite {
pub async fn read(pool: &mut DbPool<'_>, id: InvitationId) -> LemmyResult<Self> {
let conn = &mut get_conn(pool).await?;
local_user_invite::table
.find(id)
.first(conn)
.await
.with_lemmy_type(LemmyErrorType::NotFound)
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This methid is only used for pagination. You can remove it and use read_by_token for pagination instead.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call out, thanks.


pub async fn create(
pool: &mut DbPool<'_>,
form: &LocalUserInviteInsertForm,
) -> LemmyResult<Self> {
let conn = &mut get_conn(pool).await?;
insert_into(local_user_invite::table)
.values(form)
.get_result::<Self>(conn)
.await
.with_lemmy_type(LemmyErrorType::CouldntCreate)
}

pub async fn update(
pool: &mut DbPool<'_>,
id: InvitationId,
form: &LocalUserInviteUpdateForm,
) -> LemmyResult<Self> {
let conn = &mut get_conn(pool).await?;
diesel::update(local_user_invite::table.find(id))
.set(form)
.get_result::<Self>(conn)
.await
.with_lemmy_type(LemmyErrorType::CouldntUpdate)
}

pub async fn read_by_token(pool: &mut DbPool<'_>, token: &str) -> LemmyResult<Self> {
let conn = &mut get_conn(pool).await?;
local_user_invite::table
.filter(local_user_invite::token.eq(token))
.first(conn)
.await
.with_lemmy_type(LemmyErrorType::NotFound)
}

pub async fn read_by_token_and_user(
pool: &mut DbPool<'_>,
local_user_id: &LocalUserId,
token: &str,
) -> LemmyResult<Self> {
let conn = &mut get_conn(pool).await?;
local_user_invite::table
.filter(local_user_invite::local_user_id.eq(local_user_id))
.filter(local_user_invite::token.eq(token))
.first(conn)
.await
.with_lemmy_type(LemmyErrorType::NotFound)
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method is unnecessary as tokens are unique.


pub async fn delete_by_token(pool: &mut DbPool<'_>, token: &str) -> LemmyResult<Self> {
let conn = &mut get_conn(pool).await?;
diesel::delete(local_user_invite::table.filter(local_user_invite::token.eq(token)))
.get_result::<Self>(conn)
Comment thread
dessalines marked this conversation as resolved.
.await
.with_lemmy_type(LemmyErrorType::NotFound)
}

pub async fn delete_by_token_and_user(
pool: &mut DbPool<'_>,
local_user_id: &LocalUserId,
token: &str,
) -> LemmyResult<Self> {
let conn = &mut get_conn(pool).await?;
diesel::delete(
local_user_invite::table
.filter(local_user_invite::local_user_id.eq(local_user_id))
.filter(local_user_invite::token.eq(token)),
)
.get_result::<Self>(conn)
.await
.with_lemmy_type(LemmyErrorType::NotFound)
}
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also unnecessary.

Copy link
Copy Markdown
Contributor Author

@kryoseu kryoseu Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method is used when revoking a user invite. If removed and delete_by_token used instead, that means any user can delete any invite, provided that they know the token, right?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah youre right. What you could do is read the token first and compare the user id before deleting it with delete_by_token.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doable, but that means two queries instead of one. I can do that if you think it's better.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let invite = LocalUserInvite::read_by_token(pool, &data.token).await?;

if local_user_id != invite.local_user_id {
  return Err(LemmyErrorType::InvalidInviteToken.into());
}

LocalUserInvite::delete_by_token(pool, &data.token).await?;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem is that async and generic code significantly increases compilation time. These two methods probably dont make much difference, but still its probably better to have less. Instead you can make a simple helper method like:

fn check_valid_invite(invite: &Invite, local_user_view: &LocalUserView) -> LemmyResult<()> {
  if local_user_id != invite.local_user_id {
    return Err(LemmyErrorType::InvalidInviteToken.into());
  }
  Ok(())
}


impl PaginationCursorConversion for LocalUserInvite {
type PaginatedType = LocalUserInvite;

fn to_cursor(&self) -> CursorData {
CursorData::new_id(self.id.0)
}

async fn from_cursor(
cursor: CursorData,
pool: &mut DbPool<'_>,
) -> LemmyResult<Self::PaginatedType> {
LocalUserInvite::read(pool, InvitationId(cursor.id()?)).await
}
}

impl LocalUserInvite {
pub fn is_exhausted(&self) -> bool {
Comment thread
dessalines marked this conversation as resolved.
Outdated
self.max_uses.map(|m| self.uses_count >= m).unwrap_or(false)
}
pub fn is_expired(&self) -> bool {
self.expires_at.map(|d| d < Utc::now()).unwrap_or(false)
}
Comment thread
dessalines marked this conversation as resolved.
pub fn is_active(&self) -> bool {
!self.is_exhausted() && !self.is_expired()
}
Comment thread
dessalines marked this conversation as resolved.
Outdated
pub fn get_invite_url(&self, settings: &Settings) -> LemmyResult<Url> {
let protocol_and_hostname = settings.get_protocol_and_hostname();
Ok(Url::parse(&format!(
"{}/signup?token={}",
protocol_and_hostname, self.token
))?)
}
}
1 change: 1 addition & 0 deletions crates/db_schema/src/impls/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ pub mod local_site;
pub mod local_site_rate_limit;
pub mod local_site_url_blocklist;
pub mod local_user;
pub mod local_user_invite;
pub mod login_token;
pub mod modlog;
pub mod multi_community;
Expand Down
7 changes: 7 additions & 0 deletions crates/db_schema/src/newtypes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ impl fmt::Display for CommentId {
}
}

#[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "full", derive(DieselNewType))]
#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
#[cfg_attr(feature = "ts-rs", ts(optional_fields, export))]
/// The invitation id.
pub struct InvitationId(pub i32);

pub enum PostOrCommentId {
Post(PostId),
Comment(CommentId),
Expand Down
Loading