-
-
Notifications
You must be signed in to change notification settings - Fork 948
feat: add RequireInvitation registration mode for invite-link-style signups #6450
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 9 commits
0f257da
9a95334
5f6aa9b
3355031
b6280af
2f9b358
746b940
465f05a
b0dfeaf
f8ddd02
c719489
0e4c988
8904b68
c83e93e
9ba0ca6
f0567fb
5efce10
e560db9
7c514d5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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, | ||
| 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()) | ||
|
dessalines marked this conversation as resolved.
Outdated
|
||
| } | ||
| 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>>> { | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm returning |
||
| 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)) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| pub mod create; | ||
| pub mod list; | ||
| pub mod revoke; |
| 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())) | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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, | ||||||
|
|
@@ -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)), | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Can use the same route with different http method. |
||||||
| ) | ||||||
| .route("/invites", get().to(list_invitations)), | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Make this |
||||||
| ) | ||||||
| // Person / User actions | ||||||
| .service( | ||||||
|
|
||||||
| 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) | ||
| } | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
| } | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
|
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) | ||
| } | ||
| } | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Also unnecessary.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This method is used when revoking a user invite. If removed and
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 { | ||
|
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) | ||
| } | ||
|
dessalines marked this conversation as resolved.
|
||
| pub fn is_active(&self) -> bool { | ||
| !self.is_exhausted() && !self.is_expired() | ||
| } | ||
|
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 | ||
| ))?) | ||
| } | ||
| } | ||
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes thats fine.