Friends system for app (#2958)

* Friends system for app

* Fix impl issues

* move friends to in-memory store
This commit is contained in:
Geometrically 2024-11-26 18:23:29 -07:00 committed by GitHub
parent 7184c5f5c7
commit 47b0ccdf78
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
46 changed files with 1078 additions and 539 deletions

5
Cargo.lock generated
View File

@ -280,13 +280,14 @@ dependencies = [
[[package]]
name = "actix-ws"
version = "0.2.5"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "535aec173810be3ca6f25dd5b4d431ae7125d62000aa3cbae1ec739921b02cf3"
checksum = "a3a1fb4f9f2794b0aadaf2ba5f14a6f034c7e86957b458c506a8cb75953f2d99"
dependencies = [
"actix-codec",
"actix-http",
"actix-web",
"bytestring",
"futures-core",
"tokio 1.40.0",
]

View File

@ -9,7 +9,7 @@ import {
SteamIcon,
GitLabIcon,
} from '@/assets/external'
import { login, login_2fa, create_account, login_pass } from '@/helpers/mr_auth.js'
import { login } from '@/helpers/mr_auth.js'
import { handleError, useNotifications } from '@/store/state.js'
import { ref } from 'vue'
import { handleSevereError } from '@/store/error.js'
@ -72,7 +72,7 @@ const confirmPassword = ref('')
const subscribe = ref(true)
async function signInOauth(provider) {
const creds = await login(provider).catch(handleSevereError)
const creds = await login().catch(handleSevereError)
if (creds && creds.type === 'two_factor_required') {
twoFactorFlow.value = creds.flow

View File

@ -5,26 +5,8 @@
*/
import { invoke } from '@tauri-apps/api/core'
export async function login(provider) {
return await invoke('modrinth_auth_login', { provider })
}
export async function login_pass(username, password, challenge) {
return await invoke('plugin:mr-auth|login_pass', { username, password, challenge })
}
export async function login_2fa(code, flow) {
return await invoke('plugin:mr-auth|login_2fa', { code, flow })
}
export async function create_account(username, email, password, challenge, signUpNewsletter) {
return await invoke('plugin:mr-auth|create_account', {
username,
email,
password,
challenge,
signUpNewsletter,
})
export async function login() {
return await invoke('plugin:mr-auth|modrinth_login')
}
export async function logout() {

View File

@ -102,13 +102,7 @@ fn main() {
.plugin(
"mr-auth",
InlinedPlugin::new()
.commands(&[
"login_pass",
"login_2fa",
"create_account",
"logout",
"get",
])
.commands(&["modrinth_login", "logout", "get"])
.default_permission(
DefaultPermissionRule::AllowAllCommands,
),

File diff suppressed because one or more lines are too long

View File

@ -2410,6 +2410,11 @@
"type": "string",
"const": "mr-auth:allow-logout"
},
{
"description": "Enables the modrinth_login command without any pre-configured scope.",
"type": "string",
"const": "mr-auth:allow-modrinth-login"
},
{
"description": "Denies the create_account command without any pre-configured scope.",
"type": "string",
@ -2435,6 +2440,11 @@
"type": "string",
"const": "mr-auth:deny-logout"
},
{
"description": "Denies the modrinth_login command without any pre-configured scope.",
"type": "string",
"const": "mr-auth:deny-modrinth-login"
},
{
"description": "This permission set configures which\noperating system information are available\nto gather from the frontend.\n\n#### Granted Permissions\n\nAll information except the host name are available.\n\n",
"type": "string",

View File

@ -2410,6 +2410,11 @@
"type": "string",
"const": "mr-auth:allow-logout"
},
{
"description": "Enables the modrinth_login command without any pre-configured scope.",
"type": "string",
"const": "mr-auth:allow-modrinth-login"
},
{
"description": "Denies the create_account command without any pre-configured scope.",
"type": "string",
@ -2435,6 +2440,11 @@
"type": "string",
"const": "mr-auth:deny-logout"
},
{
"description": "Denies the modrinth_login command without any pre-configured scope.",
"type": "string",
"const": "mr-auth:deny-modrinth-login"
},
{
"description": "This permission set configures which\noperating system information are available\nto gather from the frontend.\n\n#### Granted Permissions\n\nAll information except the host name are available.\n\n",
"type": "string",

View File

@ -1,27 +1,20 @@
use crate::api::Result;
use chrono::{Duration, Utc};
use tauri::plugin::TauriPlugin;
use tauri::{Manager, UserAttentionType};
use tauri::{Manager, Runtime, UserAttentionType};
use theseus::prelude::*;
pub fn init<R: tauri::Runtime>() -> TauriPlugin<R> {
tauri::plugin::Builder::new("mr-auth")
.invoke_handler(tauri::generate_handler![
login_pass,
login_2fa,
create_account,
logout,
get,
])
.invoke_handler(tauri::generate_handler![modrinth_login, logout, get,])
.build()
}
#[tauri::command]
pub async fn modrinth_auth_login(
app: tauri::AppHandle,
provider: &str,
) -> Result<Option<ModrinthCredentialsResult>> {
let redirect_uri = mr_auth::authenticate_begin_flow(provider);
pub async fn modrinth_login<R: Runtime>(
app: tauri::AppHandle<R>,
) -> Result<Option<ModrinthCredentials>> {
let redirect_uri = mr_auth::authenticate_begin_flow();
let start = Utc::now();
@ -39,6 +32,10 @@ pub async fn modrinth_auth_login(
.as_error()
})?),
)
.min_inner_size(420.0, 632.0)
.inner_size(420.0, 632.0)
.max_inner_size(420.0, 632.0)
.zoom_hotkeys_enabled(false)
.title("Sign into Modrinth")
.always_on_top(true)
.center()
@ -55,23 +52,21 @@ pub async fn modrinth_auth_login(
if window
.url()?
.as_str()
.starts_with("https://launcher-files.modrinth.com/detect.txt")
.starts_with("https://launcher-files.modrinth.com")
{
let query = window
.url()?
.query_pairs()
.map(|(key, val)| {
(
key.to_string(),
serde_json::Value::String(val.to_string()),
)
})
.collect();
let url = window.url()?;
let code = url.query_pairs().find(|(key, _)| key == "code");
window.close()?;
let val = mr_auth::authenticate_finish_flow(query).await?;
return if let Some((_, code)) = code {
let val = mr_auth::authenticate_finish_flow(&code).await?;
return Ok(Some(val));
Ok(Some(val))
} else {
Ok(None)
};
}
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
@ -81,38 +76,6 @@ pub async fn modrinth_auth_login(
Ok(None)
}
#[tauri::command]
pub async fn login_pass(
username: &str,
password: &str,
challenge: &str,
) -> Result<ModrinthCredentialsResult> {
Ok(theseus::mr_auth::login_password(username, password, challenge).await?)
}
#[tauri::command]
pub async fn login_2fa(code: &str, flow: &str) -> Result<ModrinthCredentials> {
Ok(theseus::mr_auth::login_2fa(code, flow).await?)
}
#[tauri::command]
pub async fn create_account(
username: &str,
email: &str,
password: &str,
challenge: &str,
sign_up_newsletter: bool,
) -> Result<ModrinthCredentials> {
Ok(theseus::mr_auth::create_account(
username,
email,
password,
challenge,
sign_up_newsletter,
)
.await?)
}
#[tauri::command]
pub async fn logout() -> Result<()> {
Ok(theseus::mr_auth::logout().await?)

View File

@ -264,7 +264,6 @@ fn main() {
initialize_state,
is_dev,
toggle_decorations,
api::mr_auth::modrinth_auth_login,
show_window,
restart_app,
]);

View File

@ -91,14 +91,14 @@
"capabilities": ["ads", "core", "plugins"],
"csp": {
"default-src": "'self' customprotocol: asset:",
"connect-src": "ipc: http://ipc.localhost https://modrinth.com https://*.modrinth.com https://*.posthog.com https://*.sentry.io https://*.cloudflare.com https://api.mclo.gs https://cmp.inmobi.com",
"connect-src": "ipc: http://ipc.localhost https://modrinth.com https://*.modrinth.com https://*.posthog.com https://*.sentry.io https://api.mclo.gs",
"font-src": [
"https://cdn-raw.modrinth.com/fonts/inter/"
],
"img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost blob: data:",
"style-src": "'unsafe-inline' 'self'",
"script-src": "https://cmp.inmobi.com https://*.cloudflare.com https://*.posthog.com 'self'",
"frame-src": "https://*.cloudflare.com https://www.youtube.com https://www.youtube-nocookie.com https://discord.com 'self'"
"script-src": "https://*.posthog.com 'self'",
"frame-src": "https://www.youtube.com https://www.youtube-nocookie.com https://discord.com 'self'"
}
}
}

View File

@ -111,7 +111,13 @@ export const getAuthUrl = (provider, redirect = "") => {
if (redirect === "") {
redirect = route.path;
}
const fullURL = `${config.public.siteUrl}${redirect}`;
let fullURL;
if (route.query.launcher) {
fullURL = `https://launcher-files.modrinth.com`;
} else {
fullURL = `${config.public.siteUrl}${redirect}`;
}
return `${config.public.apiBaseUrl}auth/init?provider=${provider}&url=${fullURL}`;
};

View File

@ -0,0 +1 @@
<template><slot id="main" /></template>

View File

@ -0,0 +1,5 @@
export default defineNuxtRouteMiddleware((to) => {
if (to.query.launcher) {
setPageLayout("empty");
}
});

View File

@ -1,3 +1,8 @@
<script setup lang="ts">
definePageMeta({
middleware: ["launcher-auth"],
});
</script>
<template>
<NuxtPage class="auth-container universal-card" />
</template>

View File

@ -94,12 +94,24 @@
<div class="auth-form__additional-options">
<IntlFormatted :message-id="messages.additionalOptionsLabel">
<template #forgot-password-link="{ children }">
<NuxtLink class="text-link" to="/auth/reset-password">
<NuxtLink
class="text-link"
:to="{
path: '/auth/reset-password',
query: route.query,
}"
>
<component :is="() => children" />
</NuxtLink>
</template>
<template #create-account-link="{ children }">
<NuxtLink class="text-link" :to="signUpLink">
<NuxtLink
class="text-link"
:to="{
path: '/auth/sign-up',
query: route.query,
}"
>
<component :is="() => children" />
</NuxtLink>
</template>
@ -193,10 +205,6 @@ const token = ref("");
const flow = ref(route.query.flow);
const signUpLink = computed(
() => `/auth/sign-up${route.query.redirect ? `?redirect=${route.query.redirect}` : ""}`,
);
async function beginPasswordSignIn() {
startLoading();
try {
@ -252,6 +260,11 @@ async function begin2FASignIn() {
}
async function finishSignIn(token) {
if (route.query.launcher) {
await navigateTo(`https://launcher-files.modrinth.com/?code=${token}`, { external: true });
return;
}
if (token) {
await useAuth(token);
await useUser();

View File

@ -91,7 +91,7 @@
:description="formatMessage(messages.subscribeLabel)"
/>
<p>
<p v-if="!route.query.launcher">
<IntlFormatted :message-id="messages.legalDisclaimer">
<template #terms-link="{ children }">
<NuxtLink to="/legal/terms" class="text-link">
@ -118,7 +118,13 @@
<div class="auth-form__additional-options">
{{ formatMessage(messages.alreadyHaveAccountLabel) }}
<NuxtLink class="text-link" :to="signInLink">
<NuxtLink
class="text-link"
:to="{
path: '/auth/sign-in',
query: route.query,
}"
>
{{ formatMessage(commonMessages.signInButton) }}
</NuxtLink>
</div>
@ -214,10 +220,6 @@ const confirmPassword = ref("");
const token = ref("");
const subscribe = ref(true);
const signInLink = computed(
() => `/auth/sign-in${route.query.redirect ? `?redirect=${route.query.redirect}` : ""}`,
);
async function createAccount() {
startLoading();
try {
@ -245,6 +247,13 @@ async function createAccount() {
},
});
if (route.query.launcher) {
await navigateTo(`https://launcher-files.modrinth.com/?code=${res.session}`, {
external: true,
});
return;
}
await useAuth(res.session);
await useUser();

View File

@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO users (\n id, username, email,\n avatar_url, raw_avatar_url, bio, created,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password, paypal_id, paypal_country, paypal_email,\n venmo_handle, stripe_customer_id\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7,\n $8, $9, $10, $11, $12, $13,\n $14, $15, $16, $17, $18, $19, $20\n )\n ",
"query": "\n INSERT INTO users (\n id, username, email,\n avatar_url, raw_avatar_url, bio, created,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password, paypal_id, paypal_country, paypal_email,\n venmo_handle, stripe_customer_id, allow_friend_requests\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7,\n $8, $9, $10, $11, $12, $13,\n $14, $15, $16, $17, $18, $19, $20, $21\n )\n ",
"describe": {
"columns": [],
"parameters": {
@ -24,10 +24,11 @@
"Text",
"Text",
"Text",
"Text"
"Text",
"Bool"
]
},
"nullable": []
},
"hash": "1d09169d25a30f4495778b0695ac00e48d682db15963a01195bbd5f981178ce9"
"hash": "32fa1030a3a69f6bc36c6ec916ba8d0724dfd683576629ab05f5df321d5f9a55"
}

View File

@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "\n DELETE FROM friends\n WHERE user_id = $1 OR friend_id = $1\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": []
},
"hash": "61742fef80cb016f7c88985fe8170b27ff356dce5933490630491d385c72b365"
}

View File

@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n DELETE FROM friends\n WHERE (user_id = $1 AND friend_id = $2) OR (user_id = $2 AND friend_id = $1)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Int8"
]
},
"nullable": []
},
"hash": "66bdfd161a289694ab3245bbf079b29e7bef5723f28bb523b7b88a9b6a5feb4c"
}

View File

@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO friends (user_id, friend_id, created, accepted)\n VALUES ($1, $2, $3, $4)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Int8",
"Timestamptz",
"Bool"
]
},
"nullable": []
},
"hash": "a8733e3dc014df728f785ef25e7b20d6d7d96bacc9e9824fe95f1abc8340d463"
}

View File

@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE users\n SET allow_friend_requests = $1\n WHERE (id = $2)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Bool",
"Int8"
]
},
"nullable": []
},
"hash": "aee305585877a5733c06fc67147df275135a1b2f265bb46216854526e77863c2"
}

View File

@ -0,0 +1,40 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT f.user_id, f.friend_id, f.created, f.accepted\n FROM friends f\n WHERE f.user_id = $1 OR f.friend_id = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "user_id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "friend_id",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "created",
"type_info": "Timestamptz"
},
{
"ordinal": 3,
"name": "accepted",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
false,
false,
false,
false
]
},
"hash": "b2a0e2820b4f464b16918613efc043ea52381e27f651f6eae38bc64768f27d5b"
}

View File

@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id, email,\n avatar_url, raw_avatar_url, username, bio,\n created, role, badges,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password, totp_secret, paypal_id, paypal_country, paypal_email,\n venmo_handle, stripe_customer_id\n FROM users\n WHERE id = ANY($1) OR LOWER(username) = ANY($2)\n ",
"query": "\n SELECT id, email,\n avatar_url, raw_avatar_url, username, bio,\n created, role, badges,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password, totp_secret, paypal_id, paypal_country, paypal_email,\n venmo_handle, stripe_customer_id, allow_friend_requests\n FROM users\n WHERE id = ANY($1) OR LOWER(username) = ANY($2)\n ",
"describe": {
"columns": [
{
@ -117,6 +117,11 @@
"ordinal": 22,
"name": "stripe_customer_id",
"type_info": "Text"
},
{
"ordinal": 23,
"name": "allow_friend_requests",
"type_info": "Bool"
}
],
"parameters": {
@ -148,8 +153,9 @@
true,
true,
true,
true
true,
false
]
},
"hash": "5cce25ecda748f570de563bd3b312075dd09094b44d2aea2910011eb56778ee0"
"hash": "b5857aafa522ca62294bc296fb6cddd862eca2889203e43b4962df07ba1221a0"
}

View File

@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE friends\n SET accepted = $3\n WHERE (user_id = $1 AND friend_id = $2) OR (user_id = $2 AND friend_id = $1)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Int8",
"Bool"
]
},
"nullable": []
},
"hash": "c6214ac083ee2ff73e437a55112f5669b086b7a66959033847dac6d30a4cf445"
}

View File

@ -0,0 +1,41 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT f.user_id, f.friend_id, f.created, f.accepted\n FROM friends f\n WHERE (f.user_id = $1 AND f.friend_id = $2) OR (f.user_id = $2 AND f.friend_id = $1)\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "user_id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "friend_id",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "created",
"type_info": "Timestamptz"
},
{
"ordinal": 3,
"name": "accepted",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Int8",
"Int8"
]
},
"nullable": [
false,
false,
false,
false
]
},
"hash": "deef2cd808aab305336fbc8e556da37ca07f64462085382f2fd0eabaefceec50"
}

View File

@ -15,7 +15,7 @@ actix-web = "4.4.1"
actix-rt = "2.9.0"
actix-multipart = "0.6.1"
actix-cors = "0.7.0"
actix-ws = "0.2.5"
actix-ws = "0.3.0"
actix-files = "0.6.5"
actix-web-prom = { version = "0.8.0", features = ["process"] }
governor = "0.6.3"

View File

@ -0,0 +1,11 @@
CREATE TABLE friends (
user_id BIGINT NOT NULL REFERENCES users(id),
friend_id BIGINT NOT NULL REFERENCES users(id),
created TIMESTAMPTZ NOT NULL DEFAULT NOW(),
accepted BOOLEAN NOT NULL DEFAULT FALSE,
PRIMARY KEY (user_id, friend_id)
);
ALTER TABLE users
ADD COLUMN allow_friend_requests BOOLEAN NOT NULL DEFAULT TRUE;

View File

@ -18,7 +18,7 @@ const FLOWS_NAMESPACE: &str = "flows";
pub enum Flow {
OAuth {
user_id: Option<UserId>,
url: Option<String>,
url: String,
provider: AuthProvider,
},
Login2FA {

View File

@ -0,0 +1,132 @@
use crate::database::models::UserId;
use chrono::{DateTime, Utc};
pub struct FriendItem {
pub user_id: UserId,
pub friend_id: UserId,
pub created: DateTime<Utc>,
pub accepted: bool,
}
impl FriendItem {
pub async fn insert(
&self,
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<(), sqlx::Error> {
sqlx::query!(
"
INSERT INTO friends (user_id, friend_id, created, accepted)
VALUES ($1, $2, $3, $4)
",
self.user_id.0,
self.friend_id.0,
self.created,
self.accepted,
)
.execute(&mut **transaction)
.await?;
Ok(())
}
pub async fn get_friend<'a, E>(
user_id: UserId,
friend_id: UserId,
exec: E,
) -> Result<Option<FriendItem>, sqlx::Error>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
let friend = sqlx::query!(
"
SELECT f.user_id, f.friend_id, f.created, f.accepted
FROM friends f
WHERE (f.user_id = $1 AND f.friend_id = $2) OR (f.user_id = $2 AND f.friend_id = $1)
",
user_id.0,
friend_id.0,
)
.fetch_optional(exec)
.await?
.map(|row| FriendItem {
user_id: UserId(row.user_id),
friend_id: UserId(row.friend_id),
created: row.created,
accepted: row.accepted,
});
Ok(friend)
}
pub async fn update_friend(
user_id: UserId,
friend_id: UserId,
accepted: bool,
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<(), sqlx::Error> {
sqlx::query!(
"
UPDATE friends
SET accepted = $3
WHERE (user_id = $1 AND friend_id = $2) OR (user_id = $2 AND friend_id = $1)
",
user_id.0,
friend_id.0,
accepted,
)
.execute(&mut **transaction)
.await?;
Ok(())
}
pub async fn get_user_friends<'a, E>(
user_id: UserId,
accepted: Option<bool>,
exec: E,
) -> Result<Vec<FriendItem>, sqlx::Error>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
let friends = sqlx::query!(
"
SELECT f.user_id, f.friend_id, f.created, f.accepted
FROM friends f
WHERE f.user_id = $1 OR f.friend_id = $1
",
user_id.0,
)
.fetch_all(exec)
.await?
.into_iter()
.map(|row| FriendItem {
user_id: UserId(row.user_id),
friend_id: UserId(row.friend_id),
created: row.created,
accepted: row.accepted,
})
.filter(|x| accepted.map(|y| y == x.accepted).unwrap_or(true))
.collect::<Vec<_>>();
Ok(friends)
}
pub async fn remove(
user_id: UserId,
friend_id: UserId,
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<(), sqlx::Error> {
sqlx::query!(
"
DELETE FROM friends
WHERE (user_id = $1 AND friend_id = $2) OR (user_id = $2 AND friend_id = $1)
",
user_id.0 as i64,
friend_id.0 as i64,
)
.execute(&mut **transaction)
.await?;
Ok(())
}
}

View File

@ -4,6 +4,7 @@ pub mod categories;
pub mod charge_item;
pub mod collection_item;
pub mod flow_item;
pub mod friend_item;
pub mod ids;
pub mod image_item;
pub mod legacy_loader_fields;

View File

@ -44,6 +44,8 @@ pub struct User {
pub created: DateTime<Utc>,
pub role: String,
pub badges: Badges,
pub allow_friend_requests: bool,
}
impl User {
@ -58,13 +60,13 @@ impl User {
avatar_url, raw_avatar_url, bio, created,
github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,
email_verified, password, paypal_id, paypal_country, paypal_email,
venmo_handle, stripe_customer_id
venmo_handle, stripe_customer_id, allow_friend_requests
)
VALUES (
$1, $2, $3, $4, $5,
$6, $7,
$8, $9, $10, $11, $12, $13,
$14, $15, $16, $17, $18, $19, $20
$14, $15, $16, $17, $18, $19, $20, $21
)
",
self.id as UserId,
@ -86,7 +88,8 @@ impl User {
self.paypal_country,
self.paypal_email,
self.venmo_handle,
self.stripe_customer_id
self.stripe_customer_id,
self.allow_friend_requests,
)
.execute(&mut **transaction)
.await?;
@ -172,7 +175,7 @@ impl User {
created, role, badges,
github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,
email_verified, password, totp_secret, paypal_id, paypal_country, paypal_email,
venmo_handle, stripe_customer_id
venmo_handle, stripe_customer_id, allow_friend_requests
FROM users
WHERE id = ANY($1) OR LOWER(username) = ANY($2)
",
@ -205,6 +208,7 @@ impl User {
venmo_handle: u.venmo_handle,
stripe_customer_id: u.stripe_customer_id,
totp_secret: u.totp_secret,
allow_friend_requests: u.allow_friend_requests,
};
acc.insert(u.id, (Some(u.username), user));
@ -643,6 +647,16 @@ impl User {
.execute(&mut **transaction)
.await?;
sqlx::query!(
"
DELETE FROM friends
WHERE user_id = $1 OR friend_id = $1
",
id as UserId,
)
.execute(&mut **transaction)
.await?;
sqlx::query!(
"
DELETE FROM user_backup_codes

View File

@ -547,6 +547,23 @@ impl RedisConnection {
Ok(res)
}
pub async fn get_many(
&mut self,
namespace: &str,
ids: &[String],
) -> Result<Vec<Option<String>>, DatabaseError> {
let mut cmd = cmd("MGET");
redis_args(
&mut cmd,
ids.iter()
.map(|x| format!("{}_{}:{}", self.meta_namespace, namespace, x))
.collect::<Vec<_>>()
.as_slice(),
);
let res = redis_execute(&mut cmd, &mut self.connection).await?;
Ok(res)
}
pub async fn get_deserialized_from_json<R>(
&mut self,
namespace: &str,
@ -561,6 +578,22 @@ impl RedisConnection {
.and_then(|x| serde_json::from_str(&x).ok()))
}
pub async fn get_many_deserialized_from_json<R>(
&mut self,
namespace: &str,
ids: &[String],
) -> Result<Vec<Option<R>>, DatabaseError>
where
R: for<'a> serde::Deserialize<'a>,
{
Ok(self
.get_many(namespace, ids)
.await?
.into_iter()
.map(|x| x.and_then(|val| serde_json::from_str::<R>(&val).ok()))
.collect::<Vec<_>>())
}
pub async fn delete<T1>(
&mut self,
namespace: &str,

View File

@ -10,7 +10,6 @@ use queue::{
socket::ActiveSockets,
};
use sqlx::Postgres;
use tokio::sync::RwLock;
extern crate clickhouse as clickhouse_crate;
use clickhouse_crate::Client;
@ -56,7 +55,7 @@ pub struct LabrinthConfig {
pub session_queue: web::Data<AuthQueue>,
pub payouts_queue: web::Data<PayoutsQueue>,
pub analytics_queue: Arc<AnalyticsQueue>,
pub active_sockets: web::Data<RwLock<ActiveSockets>>,
pub active_sockets: web::Data<ActiveSockets>,
pub automated_moderation_queue: web::Data<AutomatedModerationQueue>,
pub rate_limiter: KeyedRateLimiter,
pub stripe_client: stripe::Client,
@ -303,7 +302,7 @@ pub fn app_setup(
};
let payouts_queue = web::Data::new(PayoutsQueue::new());
let active_sockets = web::Data::new(RwLock::new(ActiveSockets::default()));
let active_sockets = web::Data::new(ActiveSockets::default());
LabrinthConfig {
pool,

View File

@ -52,6 +52,7 @@ pub struct User {
pub has_totp: Option<bool>,
pub payout_data: Option<UserPayoutData>,
pub stripe_customer_id: Option<String>,
pub allow_friend_requests: Option<bool>,
// DEPRECATED. Always returns None
pub github_id: Option<u64>,
@ -85,6 +86,7 @@ impl From<DBUser> for User {
has_totp: None,
github_id: None,
stripe_customer_id: None,
allow_friend_requests: None,
}
}
}
@ -136,6 +138,7 @@ impl User {
balance: Decimal::ZERO,
}),
stripe_customer_id: db_user.stripe_customer_id,
allow_friend_requests: Some(db_user.allow_friend_requests),
}
}
}
@ -185,3 +188,29 @@ impl Role {
}
}
}
#[derive(Serialize, Deserialize)]
pub struct UserFriend {
pub id: UserId,
pub pending: bool,
pub created: DateTime<Utc>,
}
impl UserFriend {
pub fn from(
data: crate::database::models::friend_item::FriendItem,
) -> Self {
Self {
id: data.friend_id.into(),
pending: data.accepted,
created: data.created,
}
}
}
#[derive(Serialize, Deserialize, Clone)]
pub struct UserStatus {
pub user_id: UserId,
pub profile_name: Option<String>,
pub last_update: DateTime<Utc>,
}

View File

@ -1,9 +1,10 @@
//! "Database" for Hydra
use crate::models::users::{UserId, UserStatus};
use actix_ws::Session;
use dashmap::DashMap;
pub struct ActiveSockets {
pub auth_sockets: DashMap<String, Session>,
pub auth_sockets: DashMap<UserId, (UserStatus, Session)>,
}
impl Default for ActiveSockets {

View File

@ -9,7 +9,6 @@ use crate::models::ids::random_base62_rng;
use crate::models::pats::Scopes;
use crate::models::users::{Badges, Role};
use crate::queue::session::AuthQueue;
use crate::queue::socket::ActiveSockets;
use crate::routes::internal::session::issue_session;
use crate::routes::ApiError;
use crate::util::captcha::check_hcaptcha;
@ -17,9 +16,8 @@ use crate::util::env::parse_strings_from_var;
use crate::util::ext::get_image_ext;
use crate::util::img::upload_image_optimized;
use crate::util::validate::{validation_errors_to_string, RE_URL_SAFE};
use actix_web::web::{scope, Data, Payload, Query, ServiceConfig};
use actix_web::web::{scope, Data, Query, ServiceConfig};
use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse};
use actix_ws::Closed;
use argon2::password_hash::SaltString;
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
use base64::Engine;
@ -32,13 +30,11 @@ use sqlx::postgres::PgPool;
use std::collections::HashMap;
use std::str::FromStr;
use std::sync::Arc;
use tokio::sync::RwLock;
use validator::Validate;
pub fn config(cfg: &mut ServiceConfig) {
cfg.service(
scope("auth")
.service(ws_init)
.service(init)
.service(auth_callback)
.service(delete_auth_provider)
@ -233,6 +229,7 @@ impl TempUser {
created: Utc::now(),
role: Role::Developer.to_string(),
badges: Badges::default(),
allow_friend_requests: true,
}
.insert(transaction)
.await?;
@ -1090,7 +1087,7 @@ pub async fn init(
let state = Flow::OAuth {
user_id,
url: Some(info.url),
url: info.url,
provider: info.provider,
}
.insert(Duration::minutes(30), &redis)
@ -1102,59 +1099,10 @@ pub async fn init(
.json(serde_json::json!({ "url": url })))
}
#[derive(Serialize, Deserialize)]
pub struct WsInit {
pub provider: AuthProvider,
}
#[get("ws")]
pub async fn ws_init(
req: HttpRequest,
Query(info): Query<WsInit>,
body: Payload,
db: Data<RwLock<ActiveSockets>>,
redis: Data<RedisPool>,
) -> Result<HttpResponse, actix_web::Error> {
let (res, session, _msg_stream) = actix_ws::handle(&req, body)?;
async fn sock(
mut ws_stream: actix_ws::Session,
info: WsInit,
db: Data<RwLock<ActiveSockets>>,
redis: Data<RedisPool>,
) -> Result<(), Closed> {
let flow = Flow::OAuth {
user_id: None,
url: None,
provider: info.provider,
}
.insert(Duration::minutes(30), &redis)
.await;
if let Ok(state) = flow {
if let Ok(url) = info.provider.get_redirect_url(state.clone()) {
ws_stream
.text(serde_json::json!({ "url": url }).to_string())
.await?;
let db = db.write().await;
db.auth_sockets.insert(state, ws_stream);
}
}
Ok(())
}
let _ = sock(session, info, db, redis).await;
Ok(res)
}
#[get("callback")]
pub async fn auth_callback(
req: HttpRequest,
Query(query): Query<HashMap<String, String>>,
active_sockets: Data<RwLock<ActiveSockets>>,
client: Data<PgPool>,
file_host: Data<Arc<dyn FileHost + Send + Sync>>,
redis: Data<RedisPool>,
@ -1164,10 +1112,8 @@ pub async fn auth_callback(
.ok_or_else(|| AuthenticationError::InvalidCredentials)?
.clone();
let sockets = active_sockets.clone();
let state = state_string.clone();
let res: Result<HttpResponse, AuthenticationError> = async move {
let flow = Flow::get(&state, &redis).await?;
// Extract cookie header from request
@ -1223,13 +1169,9 @@ pub async fn auth_callback(
transaction.commit().await?;
crate::database::models::User::clear_caches(&[(id, None)], &redis).await?;
if let Some(url) = url {
Ok(HttpResponse::TemporaryRedirect()
.append_header(("Location", &*url))
.json(serde_json::json!({ "url": url })))
} else {
Err(AuthenticationError::InvalidCredentials)
}
Ok(HttpResponse::TemporaryRedirect()
.append_header(("Location", &*url))
.json(serde_json::json!({ "url": url })))
} else {
let user_id = if let Some(user_id) = user_id_opt {
let user = crate::database::models::User::get_id(user_id, &**client, &redis)
@ -1241,45 +1183,16 @@ pub async fn auth_callback(
.insert(Duration::minutes(30), &redis)
.await?;
if let Some(url) = url {
let redirect_url = format!(
"{}{}error=2fa_required&flow={}",
url,
if url.contains('?') { "&" } else { "?" },
flow
);
let redirect_url = format!(
"{}{}error=2fa_required&flow={}",
url,
if url.contains('?') { "&" } else { "?" },
flow
);
return Ok(HttpResponse::TemporaryRedirect()
.append_header(("Location", &*redirect_url))
.json(serde_json::json!({ "url": redirect_url })));
} else {
let mut ws_conn = {
let db = sockets.read().await;
let mut x = db
.auth_sockets
.get_mut(&state)
.ok_or_else(|| AuthenticationError::SocketError)?;
x.value_mut().clone()
};
ws_conn
.text(
serde_json::json!({
"error": "2fa_required",
"flow": flow,
}).to_string()
)
.await.map_err(|_| AuthenticationError::SocketError)?;
let _ = ws_conn.close(None).await;
return Ok(crate::auth::templates::Success {
icon: user.avatar_url.as_deref().unwrap_or("https://cdn-raw.modrinth.com/placeholder.svg"),
name: &user.username,
}.render());
}
return Ok(HttpResponse::TemporaryRedirect()
.append_header(("Location", &*redirect_url))
.json(serde_json::json!({ "url": redirect_url })));
}
user_id
@ -1290,83 +1203,27 @@ pub async fn auth_callback(
let session = issue_session(req, user_id, &mut transaction, &redis).await?;
transaction.commit().await?;
if let Some(url) = url {
let redirect_url = format!(
"{}{}code={}{}",
url,
if url.contains('?') { '&' } else { '?' },
session.session,
if user_id_opt.is_none() {
"&new_account=true"
} else {
""
}
);
let redirect_url = format!(
"{}{}code={}{}",
url,
if url.contains('?') { '&' } else { '?' },
session.session,
if user_id_opt.is_none() {
"&new_account=true"
} else {
""
}
);
Ok(HttpResponse::TemporaryRedirect()
.append_header(("Location", &*redirect_url))
.json(serde_json::json!({ "url": redirect_url })))
} else {
let user = crate::database::models::user_item::User::get_id(
user_id,
&**client,
&redis,
)
.await?.ok_or_else(|| AuthenticationError::InvalidCredentials)?;
let mut ws_conn = {
let db = sockets.read().await;
let mut x = db
.auth_sockets
.get_mut(&state)
.ok_or_else(|| AuthenticationError::SocketError)?;
x.value_mut().clone()
};
ws_conn
.text(
serde_json::json!({
"code": session.session,
}).to_string()
)
.await.map_err(|_| AuthenticationError::SocketError)?;
let _ = ws_conn.close(None).await;
return Ok(crate::auth::templates::Success {
icon: user.avatar_url.as_deref().unwrap_or("https://cdn-raw.modrinth.com/placeholder.svg"),
name: &user.username,
}.render());
}
Ok(HttpResponse::TemporaryRedirect()
.append_header(("Location", &*redirect_url))
.json(serde_json::json!({ "url": redirect_url })))
}
} else {
Err::<HttpResponse, AuthenticationError>(AuthenticationError::InvalidCredentials)
}
}.await;
// Because this is callback route, if we have an error, we need to ensure we close the original socket if it exists
if let Err(ref e) = res {
let db = active_sockets.read().await;
let mut x = db.auth_sockets.get_mut(&state_string);
if let Some(x) = x.as_mut() {
let mut ws_conn = x.value_mut().clone();
ws_conn
.text(
serde_json::json!({
"error": &e.error_name(),
"description": &e.to_string(),
} )
.to_string(),
)
.await
.map_err(|_| AuthenticationError::SocketError)?;
let _ = ws_conn.close(None).await;
}
}
Ok(res?)
}
@ -1557,6 +1414,7 @@ pub async fn create_account_with_password(
created: Utc::now(),
role: Role::Developer.to_string(),
badges: Badges::default(),
allow_friend_requests: true,
}
.insert(&mut transaction)
.await?;

View File

@ -6,6 +6,8 @@ pub mod moderation;
pub mod pats;
pub mod session;
pub mod statuses;
use super::v3::oauth_clients;
pub use super::ApiError;
use crate::util::cors::default_cors;
@ -21,6 +23,7 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
.configure(pats::config)
.configure(moderation::config)
.configure(billing::config)
.configure(gdpr::config),
.configure(gdpr::config)
.configure(statuses::config),
);
}

View File

@ -0,0 +1,255 @@
use crate::auth::validate::get_user_record_from_bearer_token;
use crate::auth::AuthenticationError;
use crate::database::models::friend_item::FriendItem;
use crate::database::redis::RedisPool;
use crate::models::ids::UserId;
use crate::models::pats::Scopes;
use crate::models::users::{User, UserStatus};
use crate::queue::session::AuthQueue;
use crate::queue::socket::ActiveSockets;
use crate::routes::ApiError;
use actix_web::web::{Data, Payload};
use actix_web::{get, web, HttpRequest, HttpResponse};
use actix_ws::AggregatedMessage;
use chrono::Utc;
use futures_util::StreamExt;
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(ws_init);
}
#[derive(Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ClientToServerMessage {
StatusUpdate { profile_name: Option<String> },
}
#[derive(Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ServerToClientMessage {
StatusUpdate { status: UserStatus },
UserOffline { id: UserId },
FriendStatuses { statuses: Vec<UserStatus> },
FriendRequest { from: UserId },
}
#[derive(Deserialize)]
struct LauncherHeartbeatInit {
code: String,
}
#[get("launcher_heartbeat")]
pub async fn ws_init(
req: HttpRequest,
pool: Data<PgPool>,
web::Query(auth): web::Query<LauncherHeartbeatInit>,
body: Payload,
db: Data<ActiveSockets>,
redis: Data<RedisPool>,
session_queue: Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let (scopes, db_user) = get_user_record_from_bearer_token(
&req,
Some(&auth.code),
&**pool,
&redis,
&session_queue,
)
.await?
.ok_or_else(|| {
ApiError::Authentication(AuthenticationError::InvalidCredentials)
})?;
if !scopes.contains(Scopes::SESSION_ACCESS) {
return Err(ApiError::Authentication(
AuthenticationError::InvalidCredentials,
));
}
let user = User::from_full(db_user);
if let Some((_, (_, session))) = db.auth_sockets.remove(&user.id) {
let _ = session.close(None).await;
}
let (res, mut session, msg_stream) = match actix_ws::handle(&req, body) {
Ok(x) => x,
Err(e) => return Ok(e.error_response()),
};
let status = UserStatus {
user_id: user.id,
profile_name: None,
last_update: Utc::now(),
};
let friends =
FriendItem::get_user_friends(user.id.into(), Some(true), &**pool)
.await?;
let friend_statuses = if !friends.is_empty() {
friends
.iter()
.filter_map(|x| {
db.auth_sockets.get(
&if x.user_id == user.id.into() {
x.friend_id
} else {
x.user_id
}
.into(),
)
})
.map(|x| x.value().0.clone())
.collect::<Vec<_>>()
} else {
Vec::new()
};
let _ = session
.text(serde_json::to_string(
&ServerToClientMessage::FriendStatuses {
statuses: friend_statuses,
},
)?)
.await;
db.auth_sockets.insert(user.id, (status.clone(), session));
broadcast_friends(
user.id,
ServerToClientMessage::StatusUpdate { status },
&pool,
&redis,
&db,
Some(friends),
)
.await?;
let mut stream = msg_stream
.aggregate_continuations()
// aggregate continuation frames up to 1MiB
.max_continuation_size(2_usize.pow(20));
actix_web::rt::spawn(async move {
// receive messages from websocket
while let Some(msg) = stream.next().await {
match msg {
Ok(AggregatedMessage::Text(text)) => {
if let Ok(message) =
serde_json::from_str::<ClientToServerMessage>(&text)
{
match message {
ClientToServerMessage::StatusUpdate {
profile_name,
} => {
if let Some(mut pair) =
db.auth_sockets.get_mut(&user.id)
{
let (status, _) = pair.value_mut();
if status.profile_name.as_ref().map(|x| x.len() > 64).unwrap_or(false) {
continue;
}
status.profile_name = profile_name;
status.last_update = Utc::now();
let _ = broadcast_friends(
user.id,
ServerToClientMessage::StatusUpdate {
status: status.clone(),
},
&pool,
&redis,
&db,
None,
)
.await;
}
}
}
}
}
Ok(AggregatedMessage::Close(_)) => {
let _ = close_socket(user.id, &pool, &redis, &db).await;
}
_ => {}
}
}
});
Ok(res)
}
pub async fn broadcast_friends(
user_id: UserId,
message: ServerToClientMessage,
pool: &PgPool,
redis: &RedisPool,
sockets: &ActiveSockets,
friends: Option<Vec<FriendItem>>,
) -> Result<(), crate::database::models::DatabaseError> {
let friends = if let Some(friends) = friends {
friends
} else {
FriendItem::get_user_friends(user_id.into(), Some(true), pool).await?
};
for friend in friends {
let friend_id = if friend.user_id == user_id.into() {
friend.friend_id
} else {
friend.user_id
};
if friend.accepted {
if let Some(mut socket) =
sockets.auth_sockets.get_mut(&friend_id.into())
{
let (_, socket) = socket.value_mut();
// TODO: bulk close sockets for better perf
if socket.text(serde_json::to_string(&message)?).await.is_err()
{
Box::pin(close_socket(
friend_id.into(),
pool,
redis,
sockets,
))
.await?;
}
}
}
}
Ok(())
}
pub async fn close_socket(
id: UserId,
pool: &PgPool,
redis: &RedisPool,
sockets: &ActiveSockets,
) -> Result<(), crate::database::models::DatabaseError> {
if let Some((_, (_, socket))) = sockets.auth_sockets.remove(&id) {
let _ = socket.close(None).await;
}
broadcast_friends(
id,
ServerToClientMessage::UserOffline { id },
pool,
redis,
sockets,
None,
)
.await?;
Ok(())
}

View File

@ -156,6 +156,7 @@ pub struct EditUser {
pub bio: Option<Option<String>>,
pub role: Option<Role>,
pub badges: Option<Badges>,
pub allow_friend_requests: Option<bool>,
}
#[patch("{id}")]
@ -178,6 +179,7 @@ pub async fn user_edit(
role: new_user.role,
badges: new_user.badges,
venmo_handle: None,
allow_friend_requests: new_user.allow_friend_requests,
}),
pool,
redis,

View File

@ -0,0 +1,242 @@
use crate::auth::get_user_from_headers;
use crate::database::models::UserId;
use crate::database::redis::RedisPool;
use crate::models::pats::Scopes;
use crate::models::users::UserFriend;
use crate::queue::session::AuthQueue;
use crate::queue::socket::ActiveSockets;
use crate::routes::internal::statuses::{close_socket, ServerToClientMessage};
use crate::routes::ApiError;
use actix_web::{delete, get, post, web, HttpRequest, HttpResponse};
use chrono::Utc;
use sqlx::PgPool;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(add_friend);
cfg.service(remove_friend);
cfg.service(friends);
}
#[post("friend/{id}")]
pub async fn add_friend(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
db: web::Data<ActiveSockets>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::USER_WRITE]),
)
.await?
.1;
let string = info.into_inner().0;
let friend =
crate::database::models::User::get(&string, &**pool, &redis).await?;
if let Some(friend) = friend {
let mut transaction = pool.begin().await?;
if let Some(friend) =
crate::database::models::friend_item::FriendItem::get_friend(
user.id.into(),
friend.id,
&**pool,
)
.await?
{
if friend.accepted {
return Err(ApiError::InvalidInput(
"You are already friends with this user!".to_string(),
));
}
if !friend.accepted && user.id != friend.friend_id.into() {
return Err(ApiError::InvalidInput(
"You cannot accept your own friend request!".to_string(),
));
}
crate::database::models::friend_item::FriendItem::update_friend(
friend.user_id,
friend.friend_id,
true,
&mut transaction,
)
.await?;
async fn send_friend_status(
user_id: UserId,
friend_id: UserId,
pool: &PgPool,
redis: &RedisPool,
sockets: &ActiveSockets,
) -> Result<(), ApiError> {
if let Some(pair) = sockets.auth_sockets.get(&user_id.into()) {
let (friend_status, _) = pair.value();
if let Some(mut socket) =
sockets.auth_sockets.get_mut(&friend_id.into())
{
let (_, socket) = socket.value_mut();
if socket
.text(serde_json::to_string(
&ServerToClientMessage::StatusUpdate {
status: friend_status.clone(),
},
)?)
.await
.is_err()
{
close_socket(
friend_id.into(),
pool,
redis,
sockets,
)
.await?;
}
}
}
Ok(())
}
send_friend_status(
friend.user_id,
friend.friend_id,
&pool,
&redis,
&db,
)
.await?;
send_friend_status(
friend.friend_id,
friend.user_id,
&pool,
&redis,
&db,
)
.await?;
} else {
if friend.id == user.id.into() {
return Err(ApiError::InvalidInput(
"You cannot add yourself as a friend!".to_string(),
));
}
if !friend.allow_friend_requests {
return Err(ApiError::InvalidInput(
"Friend requests are disabled for this user!".to_string(),
));
}
crate::database::models::friend_item::FriendItem {
user_id: user.id.into(),
friend_id: friend.id,
created: Utc::now(),
accepted: false,
}
.insert(&mut transaction)
.await?;
if let Some(mut socket) = db.auth_sockets.get_mut(&friend.id.into())
{
let (_, socket) = socket.value_mut();
if socket
.text(serde_json::to_string(
&ServerToClientMessage::FriendRequest { from: user.id },
)?)
.await
.is_err()
{
close_socket(user.id, &pool, &redis, &db).await?;
}
}
}
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
} else {
Err(ApiError::NotFound)
}
}
#[delete("friend/{id}")]
pub async fn remove_friend(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::USER_WRITE]),
)
.await?
.1;
let string = info.into_inner().0;
let friend =
crate::database::models::User::get(&string, &**pool, &redis).await?;
if let Some(friend) = friend {
let mut transaction = pool.begin().await?;
crate::database::models::friend_item::FriendItem::remove(
user.id.into(),
friend.id,
&mut transaction,
)
.await?;
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
} else {
Err(ApiError::NotFound)
}
}
#[get("friends")]
pub async fn friends(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::USER_READ]),
)
.await?
.1;
let friends =
crate::database::models::friend_item::FriendItem::get_user_friends(
user.id.into(),
None,
&**pool,
)
.await?
.into_iter()
.map(UserFriend::from)
.collect::<Vec<_>>();
Ok(HttpResponse::Ok().json(friends))
}

View File

@ -5,6 +5,7 @@ use serde_json::json;
pub mod analytics_get;
pub mod collections;
pub mod friends;
pub mod images;
pub mod notifications;
pub mod organizations;
@ -42,7 +43,8 @@ pub fn config(cfg: &mut web::ServiceConfig) {
.configure(users::config)
.configure(version_file::config)
.configure(payouts::config)
.configure(versions::config),
.configure(versions::config)
.configure(friends::config),
);
}

View File

@ -300,6 +300,7 @@ pub struct EditUser {
pub badges: Option<Badges>,
#[validate(length(max = 160))]
pub venmo_handle: Option<String>,
pub allow_friend_requests: Option<bool>,
}
pub async fn user_edit(
@ -438,6 +439,20 @@ pub async fn user_edit(
.await?;
}
if let Some(allow_friend_requests) = &user.allow_friend_requests {
sqlx::query!(
"
UPDATE users
SET allow_friend_requests = $1
WHERE (id = $2)
",
allow_friend_requests,
id as crate::database::models::ids::UserId,
)
.execute(&mut *transaction)
.await?;
}
transaction.commit().await?;
User::clear_caches(&[(id, Some(actual_user.username))], &redis)
.await?;

View File

@ -16,9 +16,9 @@ pub mod data {
pub use crate::state::{
CacheBehaviour, CacheValueType, Credentials, Dependency, DirectoryInfo,
Hooks, JavaVersion, LinkedData, MemorySettings, ModLoader,
ModrinthCredentials, ModrinthCredentialsResult, Organization,
ProcessMetadata, ProfileFile, Project, ProjectType, SearchResult,
SearchResults, Settings, TeamMember, Theme, User, Version, WindowSize,
ModrinthCredentials, Organization, ProcessMetadata, ProfileFile,
Project, ProjectType, SearchResult, SearchResults, Settings,
TeamMember, Theme, User, Version, WindowSize,
};
}

View File

@ -1,84 +1,18 @@
use crate::state::{ModrinthCredentials, ModrinthCredentialsResult};
use serde_json::Value;
use std::collections::HashMap;
use crate::state::ModrinthCredentials;
#[tracing::instrument]
pub fn authenticate_begin_flow(provider: &str) -> String {
crate::state::get_login_url(provider)
pub fn authenticate_begin_flow() -> &'static str {
crate::state::get_login_url()
}
#[tracing::instrument]
pub async fn authenticate_finish_flow(
response: HashMap<String, Value>,
) -> crate::Result<ModrinthCredentialsResult> {
code: &str,
) -> crate::Result<ModrinthCredentials> {
let state = crate::State::get().await?;
let creds = crate::state::finish_login_flow(
response,
&state.api_semaphore,
&state.pool,
)
.await?;
if let ModrinthCredentialsResult::Credentials(creds) = &creds {
creds.upsert(&state.pool).await?;
}
Ok(creds)
}
pub async fn login_password(
username: &str,
password: &str,
challenge: &str,
) -> crate::Result<ModrinthCredentialsResult> {
let state = crate::State::get().await?;
let creds = crate::state::login_password(
username,
password,
challenge,
&state.api_semaphore,
&state.pool,
)
.await?;
if let ModrinthCredentialsResult::Credentials(creds) = &creds {
creds.upsert(&state.pool).await?;
}
Ok(creds)
}
#[tracing::instrument]
pub async fn login_2fa(
code: &str,
flow: &str,
) -> crate::Result<ModrinthCredentials> {
let state = crate::State::get().await?;
let creds =
crate::state::login_2fa(code, flow, &state.api_semaphore, &state.pool)
.await?;
creds.upsert(&state.pool).await?;
Ok(creds)
}
#[tracing::instrument]
pub async fn create_account(
username: &str,
email: &str,
password: &str,
challenge: &str,
sign_up_newsletter: bool,
) -> crate::Result<ModrinthCredentials> {
let state = crate::State::get().await?;
let creds = crate::state::create_account(
username,
email,
password,
challenge,
sign_up_newsletter,
code,
&state.api_semaphore,
&state.pool,
)

View File

@ -1,6 +1,6 @@
//! Configuration structs
pub const MODRINTH_API_URL: &str = "https://staging-api.modrinth.com/v2/";
pub const MODRINTH_API_URL_V3: &str = "https://staging-api.modrinth.com/v3/";
pub const MODRINTH_API_URL: &str = "https://api.modrinth.com/v2/";
pub const MODRINTH_API_URL_V3: &str = "https://api.modrinth.com/v3/";
pub const META_URL: &str = "https://launcher-meta.modrinth.com/";

View File

@ -6,8 +6,6 @@ use dashmap::DashMap;
use futures::TryStreamExt;
use reqwest::Method;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct ModrinthCredentials {
@ -192,176 +190,23 @@ impl ModrinthCredentials {
}
}
#[derive(Serialize, Debug)]
#[serde(tag = "type")]
#[serde(rename_all = "snake_case")]
pub enum ModrinthCredentialsResult {
TwoFactorRequired { flow: String },
Credentials(ModrinthCredentials),
}
async fn get_result_from_res(
code_key: &str,
response: HashMap<String, Value>,
semaphore: &FetchSemaphore,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
) -> crate::Result<ModrinthCredentialsResult> {
if let Some(flow) = response.get("flow").and_then(|x| x.as_str()) {
Ok(ModrinthCredentialsResult::TwoFactorRequired {
flow: flow.to_string(),
})
} else if let Some(code) = response.get(code_key).and_then(|x| x.as_str()) {
let info = fetch_info(code, semaphore, exec).await?;
Ok(ModrinthCredentialsResult::Credentials(
ModrinthCredentials {
session: code.to_string(),
expires: Utc::now() + Duration::weeks(2),
user_id: info.id,
active: true,
},
))
} else if let Some(error) =
response.get("description").and_then(|x| x.as_str())
{
Err(crate::ErrorKind::OtherError(format!(
"Failed to login with error {error}"
))
.as_error())
} else {
Err(crate::ErrorKind::OtherError(String::from(
"Flow/code/error not found in response!",
))
.as_error())
}
}
async fn get_creds_from_res(
response: HashMap<String, Value>,
semaphore: &FetchSemaphore,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
) -> crate::Result<ModrinthCredentials> {
if let Some(code) = response.get("session").and_then(|x| x.as_str()) {
let info = fetch_info(code, semaphore, exec).await?;
Ok(ModrinthCredentials {
session: code.to_string(),
expires: Utc::now() + Duration::weeks(2),
user_id: info.id,
active: true,
})
} else if let Some(error) =
response.get("description").and_then(|x| x.as_str())
{
Err(crate::ErrorKind::OtherError(format!(
"Failed to login with error {error}"
))
.as_error())
} else {
Err(crate::ErrorKind::OtherError(String::from(
"Flow/code/error not found in response!",
))
.as_error())
}
}
pub fn get_login_url(provider: &str) -> String {
format!(
"{MODRINTH_API_URL}auth/init?url={}&provider={provider}",
urlencoding::encode("https://launcher-files.modrinth.com/detect.txt")
)
pub fn get_login_url() -> &'static str {
"https:/modrinth.com/auth/sign-in?launcher=true"
}
pub async fn finish_login_flow(
response: HashMap<String, Value>,
code: &str,
semaphore: &FetchSemaphore,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
) -> crate::Result<ModrinthCredentialsResult> {
get_result_from_res("code", response, semaphore, exec).await
}
pub async fn login_password(
username: &str,
password: &str,
challenge: &str,
semaphore: &FetchSemaphore,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
) -> crate::Result<ModrinthCredentialsResult> {
let resp = fetch_advanced(
Method::POST,
&format!("{MODRINTH_API_URL}auth/login"),
None,
Some(serde_json::json!({
"username": username,
"password": password,
"challenge": challenge,
})),
None,
None,
semaphore,
exec,
)
.await?;
let value = serde_json::from_slice::<HashMap<String, Value>>(&resp)?;
get_result_from_res("session", value, semaphore, exec).await
}
pub async fn login_2fa(
code: &str,
flow: &str,
semaphore: &FetchSemaphore,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
) -> crate::Result<ModrinthCredentials> {
let resp = fetch_advanced(
Method::POST,
&format!("{MODRINTH_API_URL}auth/login/2fa"),
None,
Some(serde_json::json!({
"code": code,
"flow": flow,
})),
None,
None,
semaphore,
exec,
)
.await?;
let info = fetch_info(code, semaphore, exec).await?;
let response = serde_json::from_slice::<HashMap<String, Value>>(&resp)?;
get_creds_from_res(response, semaphore, exec).await
}
pub async fn create_account(
username: &str,
email: &str,
password: &str,
challenge: &str,
sign_up_newsletter: bool,
semaphore: &FetchSemaphore,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
) -> crate::Result<ModrinthCredentials> {
let resp = fetch_advanced(
Method::POST,
&format!("{MODRINTH_API_URL}auth/create"),
None,
Some(serde_json::json!({
"username": username,
"email": email,
"password": password,
"challenge": challenge,
"sign_up_newsletter": sign_up_newsletter,
})),
None,
None,
semaphore,
exec,
)
.await?;
let response = serde_json::from_slice::<HashMap<String, Value>>(&resp)?;
get_creds_from_res(response, semaphore, exec).await
Ok(ModrinthCredentials {
session: code.to_string(),
expires: Utc::now() + Duration::weeks(2),
user_id: info.id,
active: true,
})
}
async fn fetch_info(