tetratto/crates/core/src/model/auth.rs
2025-06-27 03:45:50 -04:00

719 lines
20 KiB
Rust

use std::collections::HashMap;
use super::{
oauth::AuthGrant,
permissions::{FinePermission, SecondaryPermission},
};
use serde::{Deserialize, Serialize};
use totp_rs::TOTP;
use tetratto_shared::{
hash::{hash_salted, salt},
snow::Snowflake,
unix_epoch_timestamp,
};
/// `(ip, token, creation timestamp)`
pub type Token = (String, String, usize);
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct User {
pub id: usize,
pub created: usize,
pub username: String,
pub password: String,
pub salt: String,
pub settings: UserSettings,
pub tokens: Vec<Token>,
pub permissions: FinePermission,
pub is_verified: bool,
pub notification_count: usize,
pub follower_count: usize,
pub following_count: usize,
pub last_seen: usize,
/// The TOTP secret for this profile. An empty value means the user has TOTP disabled.
#[serde(default)]
pub totp: String,
/// The TOTP recovery codes for this profile.
#[serde(default)]
pub recovery_codes: Vec<String>,
#[serde(default)]
pub post_count: usize,
#[serde(default)]
pub request_count: usize,
/// External service connection details.
#[serde(default)]
pub connections: UserConnections,
/// The user's Stripe customer ID.
#[serde(default)]
pub stripe_id: String,
/// The grants associated with the user's account.
#[serde(default)]
pub grants: Vec<AuthGrant>,
/// A list of the IDs of all accounts the user has signed into through the UI.
#[serde(default)]
pub associated: Vec<usize>,
/// The ID of the [`InviteCode`] this user provided during registration.
#[serde(default)]
pub invite_code: usize,
/// Secondary permissions because the regular permissions struct ran out of possible bits.
#[serde(default)]
pub secondary_permissions: SecondaryPermission,
/// Users collect achievements through little actions across the site.
#[serde(default)]
pub achievements: Vec<Achievement>,
}
pub type UserConnections =
HashMap<ConnectionService, (ExternalConnectionInfo, ExternalConnectionData)>;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum ThemePreference {
Auto,
Dark,
Light,
}
impl Default for ThemePreference {
fn default() -> Self {
Self::Auto
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum DefaultTimelineChoice {
MyCommunities,
MyCommunitiesQuestions,
PopularPosts,
PopularQuestions,
FollowingPosts,
FollowingQuestions,
AllPosts,
AllQuestions,
Stack(String),
}
impl Default for DefaultTimelineChoice {
fn default() -> Self {
Self::MyCommunities
}
}
impl DefaultTimelineChoice {
/// Get the relative URL that the timeline should bring you to.
pub fn relative_url(&self) -> String {
match &self {
Self::MyCommunities => "/".to_string(),
Self::MyCommunitiesQuestions => "/questions".to_string(),
Self::PopularPosts => "/popular".to_string(),
Self::PopularQuestions => "/popular/questions".to_string(),
Self::FollowingPosts => "/following".to_string(),
Self::FollowingQuestions => "/following/questions".to_string(),
Self::AllPosts => "/all".to_string(),
Self::AllQuestions => "/all/questions".to_string(),
Self::Stack(id) => format!("/stacks/{id}"),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
pub struct UserSettings {
#[serde(default)]
pub policy_consent: bool,
#[serde(default)]
pub display_name: String,
#[serde(default)]
pub biography: String,
#[serde(default)]
pub warning: String,
#[serde(default)]
pub private_profile: bool,
#[serde(default)]
pub private_communities: bool,
/// The theme shown to the user.
#[serde(default)]
pub theme_preference: ThemePreference,
/// The theme used on the user's profile. Setting this to `Auto` will use
/// the viewing user's `theme_preference` setting.
#[serde(default)]
pub profile_theme: ThemePreference,
#[serde(default)]
pub private_last_seen: bool,
#[serde(default)]
pub theme_hue: String,
#[serde(default)]
pub theme_sat: String,
#[serde(default)]
pub theme_lit: String,
/// Page background.
#[serde(default)]
pub theme_color_surface: String,
/// Text on elements with the surface backgrounds.
#[serde(default)]
pub theme_color_text: String,
/// Links on all elements.
#[serde(default)]
pub theme_color_text_link: String,
/// Some cards, buttons, or anything else with a darker background color than the surface.
#[serde(default)]
pub theme_color_lowered: String,
/// Text on elements with the lowered backgrounds.
#[serde(default)]
pub theme_color_text_lowered: String,
/// Borders.
#[serde(default)]
pub theme_color_super_lowered: String,
/// Some cards, buttons, or anything else with a lighter background color than the surface.
#[serde(default)]
pub theme_color_raised: String,
/// Text on elements with the raised backgrounds.
#[serde(default)]
pub theme_color_text_raised: String,
/// Some borders.
#[serde(default)]
pub theme_color_super_raised: String,
/// Primary color; navigation bar, some buttons, etc.
#[serde(default)]
pub theme_color_primary: String,
/// Text on elements with the primary backgrounds.
#[serde(default)]
pub theme_color_text_primary: String,
/// Hover state for primary buttons.
#[serde(default)]
pub theme_color_primary_lowered: String,
/// Secondary color.
#[serde(default)]
pub theme_color_secondary: String,
/// Text on elements with the secondary backgrounds.
#[serde(default)]
pub theme_color_text_secondary: String,
/// Hover state for secondary buttons.
#[serde(default)]
pub theme_color_secondary_lowered: String,
/// Custom CSS input.
#[serde(default)]
pub theme_custom_css: String,
#[serde(default)]
pub disable_other_themes: bool,
#[serde(default)]
pub disable_other_theme_css: bool,
#[serde(default)]
pub enable_questions: bool,
/// A header shown in the place of "Ask question" if `enable_questions` is true.
#[serde(default)]
pub motivational_header: String,
/// If questions from anonymous users are allowed. Requires `enable_questions`.
#[serde(default)]
pub allow_anonymous_questions: bool,
/// The username used for anonymous users.
#[serde(default)]
pub anonymous_username: String,
/// The URL of the avatar used for anonymous users.
#[serde(default)]
pub anonymous_avatar_url: String,
/// If dislikes are hidden for the user.
#[serde(default)]
pub hide_dislikes: bool,
/// The timeline that the "Home" button takes you to.
#[serde(default)]
pub default_timeline: DefaultTimelineChoice,
/// If other users that you aren't following can add you to chats.
#[serde(default)]
pub private_chats: bool,
/// The user's status. Shows over connection info.
#[serde(default)]
pub status: String,
/// The mime type of the user's avatar.
#[serde(default = "mime_avif")]
pub avatar_mime: String,
/// The mime type of the user's banner.
#[serde(default = "mime_avif")]
pub banner_mime: String,
/// Require an account to view the user's profile.
#[serde(default)]
pub require_account: bool,
/// If NSFW content should be shown.
#[serde(default)]
pub show_nsfw: bool,
/// If extra post tabs are hidden (replies, media).
#[serde(default)]
pub hide_extra_post_tabs: bool,
/// If the GPA experiment is disabled.
#[serde(default)]
pub disable_gpa_fun: bool,
/// A list of strings the user has muted.
#[serde(default)]
pub muted: Vec<String>,
/// If timelines are paged instead of infinitely scrolled.
#[serde(default)]
pub paged_timelines: bool,
/// If drawings are enabled for questions sent to the user.
#[serde(default)]
pub enable_drawings: bool,
/// Automatically unlist posts from timelines.
#[serde(default)]
pub auto_unlist: bool,
/// Hide posts that are answering a question on the "All" timeline.
#[serde(default)]
pub all_timeline_hide_answers: bool,
/// Automatically clear all notifications when notifications are viewed.
#[serde(default)]
pub auto_clear_notifs: bool,
}
fn mime_avif() -> String {
"image/avif".to_string()
}
impl Default for User {
fn default() -> Self {
Self::new("<unknown>".to_string(), String::new())
}
}
impl User {
/// Create a new [`User`].
pub fn new(username: String, password: String) -> Self {
let salt = salt();
let password = hash_salted(password, salt.clone());
Self {
id: Snowflake::new().to_string().parse::<usize>().unwrap(),
created: unix_epoch_timestamp(),
username,
password,
salt,
settings: UserSettings::default(),
tokens: Vec::new(),
permissions: FinePermission::DEFAULT,
is_verified: false,
notification_count: 0,
follower_count: 0,
following_count: 0,
last_seen: unix_epoch_timestamp(),
totp: String::new(),
recovery_codes: Vec::new(),
post_count: 0,
request_count: 0,
connections: HashMap::new(),
stripe_id: String::new(),
grants: Vec::new(),
associated: Vec::new(),
invite_code: 0,
secondary_permissions: SecondaryPermission::DEFAULT,
achievements: Vec::new(),
}
}
/// Deleted user profile.
pub fn deleted() -> Self {
Self {
username: "<deleted>".to_string(),
id: 0,
..Default::default()
}
}
/// Banned user profile.
pub fn banned() -> Self {
Self {
username: "<banned>".to_string(),
id: 0,
..Default::default()
}
}
/// Anonymous user profile.
pub fn anonymous() -> Self {
Self {
username: "anonymous".to_string(),
id: 0,
..Default::default()
}
}
/// Create a new token
///
/// # Returns
/// `(unhashed id, token)`
pub fn create_token(ip: &str) -> (String, Token) {
let unhashed = tetratto_shared::hash::uuid();
(
unhashed.clone(),
(
ip.to_string(),
tetratto_shared::hash::hash(unhashed),
unix_epoch_timestamp(),
),
)
}
/// Check if the given password is correct for the user.
pub fn check_password(&self, against: String) -> bool {
self.password == hash_salted(against, self.salt.clone())
}
/// Parse user mentions in a given `input`.
pub fn parse_mentions(input: &str) -> Vec<String> {
// state
let mut escape: bool = false;
let mut at: bool = false;
let mut buffer: String = String::new();
let mut out = Vec::new();
// parse
for char in input.chars() {
if ((char == '\\') | (char == '/')) && !escape {
escape = true;
continue;
}
if (char == '@') && !escape {
at = true;
continue; // don't push @
}
if at {
if char == ' ' {
// reached space, end @
at = false;
if !out.contains(&buffer) {
out.push(buffer);
}
buffer = String::new();
continue;
}
// push mention text
buffer.push(char);
}
escape = false;
}
if !buffer.is_empty() {
out.push(buffer);
}
// return
out
}
/// Get a [`TOTP`] from the profile's `totp` secret value.
pub fn totp(&self, issuer: Option<String>) -> Option<TOTP> {
if self.totp.is_empty() {
return None;
}
TOTP::new(
totp_rs::Algorithm::SHA1,
6,
1,
30,
self.totp.as_bytes().to_owned(),
Some(issuer.unwrap_or("tetratto!".to_string())),
self.username.clone(),
)
.ok()
}
/// Clean the struct for public viewing.
pub fn clean(&mut self) {
self.password = String::new();
self.salt = String::new();
self.tokens = Vec::new();
self.grants = Vec::new();
self.recovery_codes = Vec::new();
self.totp = String::new();
self.settings = UserSettings::default();
self.stripe_id = String::new();
self.connections = HashMap::new();
}
/// Get a grant from the user given the grant's `app` ID.
///
/// Should be used **before** adding another grant (to ensure the app doesn't
/// already have a grant for this user).
pub fn get_grant_by_app_id(&self, id: usize) -> Option<&AuthGrant> {
self.grants.iter().find(|x| x.app == id)
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ConnectionService {
/// A connection to a Spotify account.
Spotify,
/// A connection to a last.fm account.
LastFm,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub enum ConnectionType {
/// A connection through a token which never expires.
Token,
/// <https://www.rfc-editor.org/rfc/rfc7636>
PKCE,
/// A connection with no stored authentication.
None,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ExternalConnectionInfo {
pub con_type: ConnectionType,
pub data: HashMap<String, String>,
pub show_on_profile: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
pub struct ExternalConnectionData {
pub external_urls: HashMap<String, String>,
pub data: HashMap<String, String>,
}
/// The total number of achievements needed to 100% Tetratto!
pub const ACHIEVEMENTS: usize = 8;
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub enum AchievementName {
/// Create your first post.
CreatePost,
/// Follow somebody.
FollowUser,
/// Create your 50th post.
Create50Posts,
/// Create your 100th post.
Create100Posts,
/// Create your 1000th post.
Create1000Posts,
/// Ask your first question.
CreateQuestion,
/// Edit your settings.
EditSettings,
/// Create your first journal.
CreateJournal,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub enum AchievementRarity {
Common,
Uncommon,
Rare,
}
impl AchievementName {
pub fn title(&self) -> &str {
match self {
Self::CreatePost => "Dear friends,",
Self::FollowUser => "Virtual connections...",
Self::Create50Posts => "Hello, world!",
Self::Create100Posts => "It's my world",
Self::Create1000Posts => "Timeline domination",
Self::CreateQuestion => "Big questions...",
Self::EditSettings => "Just how I like it!",
Self::CreateJournal => "Dear diary...",
}
}
pub fn description(&self) -> &str {
match self {
Self::CreatePost => "Create your first post!",
Self::FollowUser => "Follow somebody!",
Self::Create50Posts => "Create your 50th post.",
Self::Create100Posts => "Create your 100th post.",
Self::Create1000Posts => "Create your 1000th post.",
Self::CreateQuestion => "Ask your first question!",
Self::EditSettings => "Edit your settings.",
Self::CreateJournal => "Create your first journal.",
}
}
pub fn rarity(&self) -> AchievementRarity {
match self {
Self::CreatePost => AchievementRarity::Common,
Self::FollowUser => AchievementRarity::Common,
Self::Create50Posts => AchievementRarity::Uncommon,
Self::Create100Posts => AchievementRarity::Uncommon,
Self::Create1000Posts => AchievementRarity::Rare,
Self::CreateQuestion => AchievementRarity::Common,
Self::EditSettings => AchievementRarity::Common,
Self::CreateJournal => AchievementRarity::Uncommon,
}
}
}
impl Into<Achievement> for AchievementName {
fn into(self) -> Achievement {
Achievement {
name: self,
unlocked: unix_epoch_timestamp(),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Achievement {
pub name: AchievementName,
pub unlocked: usize,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Notification {
pub id: usize,
pub created: usize,
pub title: String,
pub content: String,
pub owner: usize,
pub read: bool,
pub tag: String,
}
impl Notification {
/// Returns a new [`Notification`].
pub fn new(title: String, content: String, owner: usize) -> Self {
Self {
id: Snowflake::new().to_string().parse::<usize>().unwrap(),
created: unix_epoch_timestamp(),
title,
content,
owner,
read: false,
tag: String::new(),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct UserFollow {
pub id: usize,
pub created: usize,
pub initiator: usize,
pub receiver: usize,
}
impl UserFollow {
/// Create a new [`UserFollow`].
pub fn new(initiator: usize, receiver: usize) -> Self {
Self {
id: Snowflake::new().to_string().parse::<usize>().unwrap(),
created: unix_epoch_timestamp(),
initiator,
receiver,
}
}
}
#[derive(Serialize, Deserialize, PartialEq, Eq)]
pub enum FollowResult {
/// Request sent to follow other user.
Requested,
/// Successfully followed other user.
Followed,
}
#[derive(Serialize, Deserialize)]
pub struct UserBlock {
pub id: usize,
pub created: usize,
pub initiator: usize,
pub receiver: usize,
}
impl UserBlock {
/// Create a new [`UserBlock`].
pub fn new(initiator: usize, receiver: usize) -> Self {
Self {
id: Snowflake::new().to_string().parse::<usize>().unwrap(),
created: unix_epoch_timestamp(),
initiator,
receiver,
}
}
}
#[derive(Serialize, Deserialize)]
pub struct IpBlock {
pub id: usize,
pub created: usize,
pub initiator: usize,
pub receiver: String,
}
impl IpBlock {
/// Create a new [`IpBlock`].
pub fn new(initiator: usize, receiver: String) -> Self {
Self {
id: Snowflake::new().to_string().parse::<usize>().unwrap(),
created: unix_epoch_timestamp(),
initiator,
receiver,
}
}
}
#[derive(Serialize, Deserialize)]
pub struct IpBan {
pub ip: String,
pub created: usize,
pub reason: String,
pub moderator: usize,
}
impl IpBan {
/// Create a new [`IpBan`].
pub fn new(ip: String, moderator: usize, reason: String) -> Self {
Self {
ip,
created: unix_epoch_timestamp(),
reason,
moderator,
}
}
}
#[derive(Serialize, Deserialize)]
pub struct UserWarning {
pub id: usize,
pub created: usize,
pub receiver: usize,
pub moderator: usize,
pub content: String,
}
impl UserWarning {
/// Create a new [`UserWarning`].
pub fn new(user: usize, moderator: usize, content: String) -> Self {
Self {
id: Snowflake::new().to_string().parse::<usize>().unwrap(),
created: unix_epoch_timestamp(),
receiver: user,
moderator,
content,
}
}
}
#[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::<usize>().unwrap(),
created: unix_epoch_timestamp(),
owner,
code: salt(),
is_used: false,
}
}
}