diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index 5bd2fc8..27c4b8f 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -168,10 +168,12 @@ version = "1.0.0" "settings:label.export" = "Export" "settings:label.manage_blocks" = "Manage blocks" "settings:label.users" = "Users" +"settings:label.generate_invite" = "Generate invite" "settings:tab.security" = "Security" "settings:tab.blocks" = "Blocks" "settings:tab.billing" = "Billing" "settings:tab.uploads" = "Uploads" +"settings:tab.invites" = "Invites" "mod_panel:label.open_reported_content" = "Open reported content" "mod_panel:label.manage_profile" = "Manage profile" diff --git a/crates/app/src/public/html/auth/register.lisp b/crates/app/src/public/html/auth/register.lisp index 116cdcf..739e538 100644 --- a/crates/app/src/public/html/auth/register.lisp +++ b/crates/app/src/public/html/auth/register.lisp @@ -25,7 +25,7 @@ (div ("class" "flex flex-col gap-1") (label - ("for" "username") + ("for" "password") (b (text "Password"))) (input @@ -34,6 +34,20 @@ ("required" "") ("name" "password") ("id" "password"))) + (text "{% if config.security.enable_invite_codes -%}") + (div + ("class" "flex flex-col gap-1") + (label + ("for" "invite_code") + (b + (text "Invite code"))) + (input + ("type" "text") + ("placeholder" "invite code") + ("required" "") + ("name" "invite_code") + ("id" "invite_code"))) + (text "{%- endif %}") (hr) (div ("class" "card-nest w-full") @@ -89,6 +103,7 @@ captcha_response: e.target.querySelector( \"[name=cf-turnstile-response]\", ).value, + invite_code: (e.target.invite_code || { value: \"\" }).value, }), }) .then((res) => res.json()) diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index 50c0c60..3c76c1d 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -61,21 +61,35 @@ ("href" "#/account/blocks") (text "{{ icon \"shield\" }}") (span - (text "{{ text \"settings:tab.blocks\" }}"))) + (text "{{ text \"settings:tab.blocks\" }}")))) + + (text "{% if config.stripe -%}") + ; stripe menu + (div + ("class" "pillmenu") + ("ui_ident" "account_settings_tabs") (a ("data-tab-button" "account/uploads") ("href" "?page=0#/account/uploads") (text "{{ icon \"image-up\" }}") (span (text "{{ text \"settings:tab.uploads\" }}"))) - (text "{% if config.stripe -%}") + (text "{% if config.security.enable_invite_codes -%}") + (a + ("data-tab-button" "account/invites") + ("href" "#/account/invites") + (text "{{ icon \"ticket\" }}") + (span + (text "{{ text \"settings:tab.invites\" }}"))) + (text "{%- endif %}") (a ("data-tab-button" "account/billing") ("href" "#/account/billing") (text "{{ icon \"credit-card\" }}") (span - (text "{{ text \"settings:tab.billing\" }}"))) - (text "{%- endif %}")) + (text "{{ text \"settings:tab.billing\" }}")))) + (text "{%- endif %}") + (div ("class" "card-nest") ("ui_ident" "home_timeline") @@ -495,6 +509,72 @@ ]); }); };")))))) + + (text "{% if config.security.enable_invite_codes -%}") + (div + ("class" "w-full flex flex-col gap-2 hidden") + ("data-tab" "account/invites") + (div + ("class" "card lowered flex flex-col gap-2") + (a + ("href" "#/account") + ("class" "button secondary") + (text "{{ icon \"arrow-left\" }}") + (span + (text "{{ text \"general:action.back\" }}"))) + (div + ("class" "card-nest") + (div + ("class" "card flex items-center gap-2 small") + (text "{{ icon \"ticket\" }}") + (span + (text "{{ text \"settings:tab.invites\" }}"))) + (div + ("class" "card flex flex-col gap-2 secondary") + (button + ("onclick" "generate_invite_code()") + (icon (text "plus")) + (str (text "settings:label.generate_invite"))) + + (text "{{ components::supporter_ad(body=\"Become a supporter to generate invite codes!\") }} {% for code in invites %}") + (div + ("class" "card flex flex-col gap-2") + (text "{% if code[1].is_used -%}") + ; used + (b ("class" "{% if code[1].is_used -%} fade {%- endif %}") (s (text "{{ code[1].code }}"))) + (text "{{ components::full_username(user=code[0]) }}") + (text "{% else %}") + ; unused + (b (text "{{ code[1].code }}")) + (text "{%- endif %}")) + (text "{% endfor %}") + (script + (text "globalThis.generate_invite_code = async () => { + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this? This action is permanent.\", + ])) + ) { + return; + } + + fetch(`/api/v1/invite`, { + method: \"POST\", + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + alert(res.payload); + } + }); + };")))))) + (text "{%- endif %}") + (div ("class" "w-full flex flex-col gap-2 hidden") ("data-tab" "account/billing") diff --git a/crates/app/src/routes/api/v1/auth/mod.rs b/crates/app/src/routes/api/v1/auth/mod.rs index f8e5b83..f5d17f1 100644 --- a/crates/app/src/routes/api/v1/auth/mod.rs +++ b/crates/app/src/routes/api/v1/auth/mod.rs @@ -18,7 +18,7 @@ use axum::{ }; use axum_extra::extract::CookieJar; use serde::Deserialize; -use tetratto_core::model::addr::RemoteAddr; +use tetratto_core::model::{addr::RemoteAddr, permissions::FinePermission}; use tetratto_shared::hash::hash; use cf_turnstile::{SiteVerifyRequest, TurnstileClient}; @@ -86,6 +86,50 @@ pub async fn register_request( let mut user = User::new(props.username.to_lowercase(), props.password); user.settings.policy_consent = true; + // check invite code + if data.0.0.security.enable_invite_codes { + if props.invite_code.is_empty() { + return ( + None, + Json(Error::MiscError("Missing invite code".to_string()).into()), + ); + } + + let invite_code = match data.get_invite_code_by_code(&props.invite_code).await { + Ok(c) => c, + Err(e) => return (None, Json(e.into())), + }; + + if invite_code.is_used { + return ( + None, + Json(Error::MiscError("This code has already been used".to_string()).into()), + ); + } + + let owner = match data.get_user_by_id(invite_code.owner).await { + Ok(u) => u, + Err(e) => return (None, Json(e.into())), + }; + + if !owner.permissions.check(FinePermission::SUPPORTER) { + return ( + None, + Json( + Error::MiscError("Invite code owner must be an active supporter".to_string()) + .into(), + ), + ); + } + + user.invite_code = invite_code.id; + + if let Err(e) = data.update_invite_code_is_used(invite_code.id, true).await { + return (None, Json(e.into())); + } + } + + // push initial token let (initial_token, t) = User::create_token(&real_ip); user.tokens.push(t); diff --git a/crates/app/src/routes/api/v1/auth/profile.rs b/crates/app/src/routes/api/v1/auth/profile.rs index 05eefb4..834f317 100644 --- a/crates/app/src/routes/api/v1/auth/profile.rs +++ b/crates/app/src/routes/api/v1/auth/profile.rs @@ -21,7 +21,7 @@ use futures_util::{sink::SinkExt, stream::StreamExt}; use tetratto_core::{ cache::Cache, model::{ - auth::{Token, UserSettings}, + auth::{InviteCode, Token, UserSettings}, oauth, permissions::FinePermission, socket::{PacketType, SocketMessage, SocketMethod}, @@ -817,3 +817,33 @@ pub async fn refresh_grant_request( Err(e) => Json(e.into()), } } + +/// Generate an invite code. +/// +/// Does not support third-party grants. +pub async fn generate_invite_code_request( + jar: CookieJar, + Extension(data): Extension, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + if !data.0.0.security.enable_invite_codes { + return Json(Error::NotAllowed.into()); + } + + match data + .create_invite_code(InviteCode::new(user.id), &user) + .await + { + Ok(x) => Json(ApiReturn { + ok: true, + message: "Code generated".to_string(), + payload: Some(x.code), + }), + Err(e) => Json(e.into()), + } +} diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 44467ba..74571af 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -37,6 +37,7 @@ pub fn routes() -> Router { .route("/util/proxy", get(util::proxy_request)) .route("/util/lang", get(util::set_langfile_request)) .route("/util/ip", get(util::ip_test_request)) + .route("/invite", post(auth::profile::generate_invite_code_request)) // reactions .route("/reactions", post(reactions::create_request)) .route("/reactions/{id}", get(reactions::get_request)) @@ -605,6 +606,8 @@ pub struct RegisterProps { pub password: String, pub policy_consent: bool, pub captcha_response: String, + #[serde(default)] + pub invite_code: String, } #[derive(Deserialize)] diff --git a/crates/app/src/routes/pages/profile.rs b/crates/app/src/routes/pages/profile.rs index 89a4026..78b2d22 100644 --- a/crates/app/src/routes/pages/profile.rs +++ b/crates/app/src/routes/pages/profile.rs @@ -101,6 +101,18 @@ pub async fn settings_request( } }; + let invites = match data.0.get_invite_codes_by_owner(profile.id).await { + Ok(l) => match data.0.fill_invite_codes(l).await { + Ok(l) => l, + Err(e) => { + return Err(Html(render_error(e, &jar, &data, &None).await)); + } + }, + Err(e) => { + return Err(Html(render_error(e, &jar, &data, &None).await)); + } + }; + let tokens = profile.tokens.clone(); let lang = get_lang!(jar, data.0); @@ -113,6 +125,7 @@ pub async fn settings_request( context.insert("following", &following); context.insert("blocks", &blocks); context.insert("stackblocks", &stackblocks); + context.insert("invites", &invites); context.insert( "user_tokens_serde", &serde_json::to_string(&tokens) diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 7e2713b..810f989 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -13,6 +13,9 @@ pub struct SecurityConfig { /// The name of the header which will contain the real IP of the connecting user. #[serde(default = "default_real_ip_header")] pub real_ip_header: String, + /// If users require an invite code to register. Invite codes can be generated by supporters. + #[serde(default = "default_enable_invite_codes")] + pub enable_invite_codes: bool, } fn default_security_registration_enabled() -> bool { @@ -23,11 +26,16 @@ fn default_real_ip_header() -> String { "CF-Connecting-IP".to_string() } +fn default_enable_invite_codes() -> bool { + false +} + impl Default for SecurityConfig { fn default() -> Self { Self { registration_enabled: default_security_registration_enabled(), real_ip_header: default_real_ip_header(), + enable_invite_codes: default_enable_invite_codes(), } } } @@ -341,6 +349,7 @@ fn default_banned_usernames() -> Vec { "stacks".to_string(), "stack".to_string(), "search".to_string(), + "journals".to_string(), ] } diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs index 3c22a3e..927b7fb 100644 --- a/crates/core/src/database/auth.rs +++ b/crates/core/src/database/auth.rs @@ -45,6 +45,7 @@ impl DataManager { stripe_id: get!(x->18(String)), grants: serde_json::from_str(&get!(x->19(String)).to_string()).unwrap(), associated: serde_json::from_str(&get!(x->20(String)).to_string()).unwrap(), + invite_code: get!(x->21(i64)) as usize, } } @@ -200,7 +201,7 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO users VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21)", + "INSERT INTO users VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)", params![ &(data.id as i64), &(data.created as i64), @@ -223,6 +224,7 @@ impl DataManager { &"", &serde_json::to_string(&data.grants).unwrap(), &serde_json::to_string(&data.associated).unwrap(), + &(data.invite_code as i64) ] ); @@ -842,4 +844,6 @@ impl DataManager { auto_method!(update_user_request_count(i32)@get_user_by_id -> "UPDATE users SET request_count = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user); auto_method!(incr_user_request_count()@get_user_by_id -> "UPDATE users SET request_count = request_count + 1 WHERE id = $1" --cache-key-tmpl=cache_clear_user --incr); auto_method!(decr_user_request_count()@get_user_by_id -> "UPDATE users SET request_count = request_count - 1 WHERE id = $1" --cache-key-tmpl=cache_clear_user --decr=request_count); + + auto_method!(get_user_by_invite_code(i64)@get_user_from_row -> "SELECT * FROM users WHERE invite_code = $1" --name="user" --returns=User); } diff --git a/crates/core/src/database/common.rs b/crates/core/src/database/common.rs index 0841e15..a82e389 100644 --- a/crates/core/src/database/common.rs +++ b/crates/core/src/database/common.rs @@ -39,6 +39,7 @@ impl DataManager { execute!(&conn, common::CREATE_TABLE_JOURNALS).unwrap(); execute!(&conn, common::CREATE_TABLE_NOTES).unwrap(); execute!(&conn, common::CREATE_TABLE_MESSAGE_REACTIONS).unwrap(); + execute!(&conn, common::CREATE_TABLE_INVITE_CODES).unwrap(); self.0 .1 @@ -115,7 +116,8 @@ macro_rules! auto_method { Err(e) => return Err(Error::DatabaseConnection(e.to_string())), }; - let res = query_row!(&conn, $query, &[&selector], |x| { Ok(Self::$select_fn(x)) }); + let res = + oiseau::query_row!(&conn, $query, &[&selector], |x| { Ok(Self::$select_fn(x)) }); if res.is_err() { return Err(Error::GeneralNotFound($name_.to_string())); diff --git a/crates/core/src/database/drivers/common.rs b/crates/core/src/database/drivers/common.rs index 6b7902e..e1cfad7 100644 --- a/crates/core/src/database/drivers/common.rs +++ b/crates/core/src/database/drivers/common.rs @@ -26,3 +26,4 @@ pub const CREATE_TABLE_STACKBLOCKS: &str = include_str!("./sql/create_stackblock pub const CREATE_TABLE_JOURNALS: &str = include_str!("./sql/create_journals.sql"); pub const CREATE_TABLE_NOTES: &str = include_str!("./sql/create_notes.sql"); pub const CREATE_TABLE_MESSAGE_REACTIONS: &str = include_str!("./sql/create_message_reactions.sql"); +pub const CREATE_TABLE_INVITE_CODES: &str = include_str!("./sql/create_invite_codes.sql"); diff --git a/crates/core/src/database/drivers/sql/create_invite_codes.sql b/crates/core/src/database/drivers/sql/create_invite_codes.sql new file mode 100644 index 0000000..5272745 --- /dev/null +++ b/crates/core/src/database/drivers/sql/create_invite_codes.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS invite_codes ( + id BIGINT NOT NULL PRIMARY KEY, + created BIGINT NOT NULL, + owner BIGINT NOT NULL, + code TEXT NOT NULL, + is_used INT NOT NULL +) diff --git a/crates/core/src/database/invite_codes.rs b/crates/core/src/database/invite_codes.rs new file mode 100644 index 0000000..672e5a9 --- /dev/null +++ b/crates/core/src/database/invite_codes.rs @@ -0,0 +1,159 @@ +use oiseau::{cache::Cache, query_rows}; +use crate::model::{ + Error, Result, + auth::{User, InviteCode}, + permissions::FinePermission, +}; +use crate::{auto_method, DataManager}; +use oiseau::{PostgresRow, execute, get, params}; + +impl DataManager { + /// Get a [`InviteCode`] from an SQL row. + pub(crate) fn get_invite_code_from_row(x: &PostgresRow) -> InviteCode { + InviteCode { + id: get!(x->0(i64)) as usize, + created: get!(x->1(i64)) as usize, + owner: get!(x->2(i64)) as usize, + code: get!(x->3(String)), + is_used: get!(x->4(i32)) as i8 == 1, + } + } + + auto_method!(get_invite_code_by_id()@get_invite_code_from_row -> "SELECT * FROM invite_codes WHERE id = $1" --name="invite_code" --returns=InviteCode --cache-key-tmpl="atto.invite_code:{}"); + auto_method!(get_invite_code_by_code(&str)@get_invite_code_from_row -> "SELECT * FROM invite_codes WHERE code = $1" --name="invite_code" --returns=InviteCode); + + /// Get invite_codes by `owner`. + pub async fn get_invite_codes_by_owner(&self, owner: usize) -> Result> { + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_rows!( + &conn, + "SELECT * FROM invite_codes WHERE owner = $1", + &[&(owner as i64)], + |x| { Self::get_invite_code_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("invite_code".to_string())); + } + + Ok(res.unwrap()) + } + + /// Fill a vector of invite codes with the user that used them. + pub async fn fill_invite_codes( + &self, + codes: Vec, + ) -> Result, InviteCode)>> { + let mut out = Vec::new(); + + for code in codes { + if code.is_used { + out.push(( + match self.get_user_by_invite_code(code.id as i64).await { + Ok(u) => Some(u), + Err(_) => None, + }, + code, + )) + } else { + out.push((None, code)) + } + } + + Ok(out) + } + + const MAXIMUM_SUPPORTER_INVITE_CODES: usize = 15; + + /// Create a new invite_code in the database. + /// + /// # Arguments + /// * `data` - a mock [`InviteCode`] object to insert + pub async fn create_invite_code(&self, data: InviteCode, user: &User) -> Result { + if !user.permissions.check(FinePermission::SUPPORTER) { + return Err(Error::RequiresSupporter); + } else { + // check count + if self.get_invite_codes_by_owner(user.id).await?.len() + >= Self::MAXIMUM_SUPPORTER_INVITE_CODES + { + return Err(Error::MiscError( + "You already have the maximum number of invite codes you can create" + .to_string(), + )); + } + } + + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!( + &conn, + "INSERT INTO invite_codes VALUES ($1, $2, $3, $4, $5)", + params![ + &(data.id as i64), + &(data.created as i64), + &(data.owner as i64), + &data.code, + &{ if data.is_used { 1 } else { 0 } } + ] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + Ok(data) + } + + pub async fn delete_invite_code(&self, id: usize, user: &User) -> Result<()> { + if !user.permissions.check(FinePermission::MANAGE_INVITES) { + return Err(Error::NotAllowed); + } + + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!( + &conn, + "DELETE FROM invite_codes WHERE id = $1", + &[&(id as i64)] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + self.0.1.remove(format!("atto.invite_code:{}", id)).await; + + Ok(()) + } + + pub async fn update_invite_code_is_used(&self, id: usize, new_is_used: bool) -> Result<()> { + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!( + &conn, + "UPDATE invite_codes SET is_used = $1 WHERE id = $2", + params![&{ if new_is_used { 1 } else { 0 } }, &(id as i64)] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + self.0.1.remove(format!("atto.invite_code:{}", id)).await; + Ok(()) + } +} diff --git a/crates/core/src/database/mod.rs b/crates/core/src/database/mod.rs index 774b345..5f81259 100644 --- a/crates/core/src/database/mod.rs +++ b/crates/core/src/database/mod.rs @@ -8,6 +8,7 @@ pub mod connections; mod drafts; mod drivers; mod emojis; +mod invite_codes; mod ipbans; mod ipblocks; mod journals; diff --git a/crates/core/src/model/addr.rs b/crates/core/src/model/addr.rs index 61174fb..ffca6aa 100644 --- a/crates/core/src/model/addr.rs +++ b/crates/core/src/model/addr.rs @@ -1,7 +1,7 @@ use std::net::SocketAddr; /// How many bytes should be taken as the prefix (from the begining of the address). -pub(crate) const IPV6_PREFIX_BYTES: usize = 16; +pub(crate) const IPV6_PREFIX_BYTES: usize = 11; /// The protocol of a [`RemoteAddr`]. #[derive(Clone, Debug, PartialEq, Eq)] diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index ff910f5..f1ed465 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -49,6 +49,9 @@ pub struct User { /// A list of the IDs of all accounts the user has signed into through the UI. #[serde(default)] pub associated: Vec, + /// The ID of the [`InviteCode`] this user provided during registration. + #[serde(default)] + pub invite_code: usize, } pub type UserConnections = @@ -283,6 +286,7 @@ impl User { stripe_id: String::new(), grants: Vec::new(), associated: Vec::new(), + invite_code: 0, } } @@ -591,3 +595,25 @@ impl UserWarning { } } } + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct InviteCode { + pub id: usize, + pub created: usize, + pub owner: usize, + pub code: String, + pub is_used: bool, +} + +impl InviteCode { + /// Create a new [`InviteCode`]. + pub fn new(owner: usize) -> Self { + Self { + id: Snowflake::new().to_string().parse::().unwrap(), + created: unix_epoch_timestamp(), + owner, + code: salt(), + is_used: false, + } + } +} diff --git a/crates/core/src/model/permissions.rs b/crates/core/src/model/permissions.rs index 9cd6dcb..97c0c3f 100644 --- a/crates/core/src/model/permissions.rs +++ b/crates/core/src/model/permissions.rs @@ -39,6 +39,7 @@ bitflags! { const MANAGE_APPS = 1 << 28; const MANAGE_JOURNALS = 1 << 29; const MANAGE_NOTES = 1 << 30; + const MANAGE_INVITES = 1 << 31; const _ = !0; } diff --git a/example/tetratto.toml b/example/tetratto.toml index 37119a4..488bc89 100644 --- a/example/tetratto.toml +++ b/example/tetratto.toml @@ -23,6 +23,7 @@ html_footer_path = "public/footer.html" [security] registration_enabled = true real_ip_header = "CF-Connecting-IP" +enable_invite_codes = false [dirs] templates = "html" diff --git a/sql_changes/users_invite_code.sql b/sql_changes/users_invite_code.sql new file mode 100644 index 0000000..2e97a8d --- /dev/null +++ b/sql_changes/users_invite_code.sql @@ -0,0 +1,2 @@ +ALTER TABLE users +ADD COLUMN invite_code BIGINT NOT NULL DEFAULT 0;