You've already forked AstralRinth
forked from didirus/AstralRinth
move to monorepo dir
This commit is contained in:
347
apps/labrinth/src/auth/checks.rs
Normal file
347
apps/labrinth/src/auth/checks.rs
Normal file
@@ -0,0 +1,347 @@
|
||||
use crate::database;
|
||||
use crate::database::models::project_item::QueryProject;
|
||||
use crate::database::models::version_item::QueryVersion;
|
||||
use crate::database::models::Collection;
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::database::{models, Project, Version};
|
||||
use crate::models::users::User;
|
||||
use crate::routes::ApiError;
|
||||
use itertools::Itertools;
|
||||
use sqlx::PgPool;
|
||||
|
||||
pub trait ValidateAuthorized {
|
||||
fn validate_authorized(&self, user_option: Option<&User>) -> Result<(), ApiError>;
|
||||
}
|
||||
|
||||
pub trait ValidateAllAuthorized {
|
||||
fn validate_all_authorized(self, user_option: Option<&User>) -> Result<(), ApiError>;
|
||||
}
|
||||
|
||||
impl<'a, T, A> ValidateAllAuthorized for T
|
||||
where
|
||||
T: IntoIterator<Item = &'a A>,
|
||||
A: ValidateAuthorized + 'a,
|
||||
{
|
||||
fn validate_all_authorized(self, user_option: Option<&User>) -> Result<(), ApiError> {
|
||||
self.into_iter()
|
||||
.try_for_each(|c| c.validate_authorized(user_option))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn is_visible_project(
|
||||
project_data: &Project,
|
||||
user_option: &Option<User>,
|
||||
pool: &PgPool,
|
||||
hide_unlisted: bool,
|
||||
) -> Result<bool, ApiError> {
|
||||
filter_visible_project_ids(vec![project_data], user_option, pool, hide_unlisted)
|
||||
.await
|
||||
.map(|x| !x.is_empty())
|
||||
}
|
||||
|
||||
pub async fn is_team_member_project(
|
||||
project_data: &Project,
|
||||
user_option: &Option<User>,
|
||||
pool: &PgPool,
|
||||
) -> Result<bool, ApiError> {
|
||||
filter_enlisted_projects_ids(vec![project_data], user_option, pool)
|
||||
.await
|
||||
.map(|x| !x.is_empty())
|
||||
}
|
||||
|
||||
pub async fn filter_visible_projects(
|
||||
mut projects: Vec<QueryProject>,
|
||||
user_option: &Option<User>,
|
||||
pool: &PgPool,
|
||||
hide_unlisted: bool,
|
||||
) -> Result<Vec<crate::models::projects::Project>, ApiError> {
|
||||
let filtered_project_ids = filter_visible_project_ids(
|
||||
projects.iter().map(|x| &x.inner).collect_vec(),
|
||||
user_option,
|
||||
pool,
|
||||
hide_unlisted,
|
||||
)
|
||||
.await?;
|
||||
projects.retain(|x| filtered_project_ids.contains(&x.inner.id));
|
||||
Ok(projects.into_iter().map(|x| x.into()).collect())
|
||||
}
|
||||
|
||||
// Filters projects for which we can see, meaning one of the following is true:
|
||||
// - it's not hidden
|
||||
// - the user is enlisted on the project's team (filter_enlisted_projects)
|
||||
// - the user is a mod
|
||||
// This is essentially whether you can know of the project's existence
|
||||
pub async fn filter_visible_project_ids(
|
||||
projects: Vec<&Project>,
|
||||
user_option: &Option<User>,
|
||||
pool: &PgPool,
|
||||
hide_unlisted: bool,
|
||||
) -> Result<Vec<crate::database::models::ProjectId>, ApiError> {
|
||||
let mut return_projects = Vec::new();
|
||||
let mut check_projects = Vec::new();
|
||||
|
||||
// Return projects that are not hidden or we are a mod of
|
||||
for project in projects {
|
||||
if (if hide_unlisted {
|
||||
project.status.is_searchable()
|
||||
} else {
|
||||
!project.status.is_hidden()
|
||||
}) || user_option
|
||||
.as_ref()
|
||||
.map(|x| x.role.is_mod())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return_projects.push(project.id);
|
||||
} else if user_option.is_some() {
|
||||
check_projects.push(project);
|
||||
}
|
||||
}
|
||||
|
||||
// For hidden projects, return a filtered list of projects for which we are enlisted on the team
|
||||
if !check_projects.is_empty() {
|
||||
return_projects
|
||||
.extend(filter_enlisted_projects_ids(check_projects, user_option, pool).await?);
|
||||
}
|
||||
|
||||
Ok(return_projects)
|
||||
}
|
||||
|
||||
// Filters out projects for which we are a member of the team (or a mod)
|
||||
// These are projects we have internal access to and can potentially see even if they are hidden
|
||||
// This is useful for getting visibility of versions, or seeing analytics or sensitive team-restricted data of a project
|
||||
pub async fn filter_enlisted_projects_ids(
|
||||
projects: Vec<&Project>,
|
||||
user_option: &Option<User>,
|
||||
pool: &PgPool,
|
||||
) -> Result<Vec<crate::database::models::ProjectId>, ApiError> {
|
||||
let mut return_projects = vec![];
|
||||
|
||||
if let Some(user) = user_option {
|
||||
let user_id: models::ids::UserId = user.id.into();
|
||||
|
||||
use futures::TryStreamExt;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
SELECT m.id id, m.team_id team_id FROM team_members tm
|
||||
INNER JOIN mods m ON m.team_id = tm.team_id
|
||||
LEFT JOIN organizations o ON o.team_id = tm.team_id
|
||||
WHERE tm.team_id = ANY($1) AND tm.user_id = $3
|
||||
UNION
|
||||
SELECT m.id id, m.team_id team_id FROM team_members tm
|
||||
INNER JOIN organizations o ON o.team_id = tm.team_id
|
||||
INNER JOIN mods m ON m.organization_id = o.id
|
||||
WHERE o.id = ANY($2) AND tm.user_id = $3
|
||||
",
|
||||
&projects.iter().map(|x| x.team_id.0).collect::<Vec<_>>(),
|
||||
&projects
|
||||
.iter()
|
||||
.filter_map(|x| x.organization_id.map(|x| x.0))
|
||||
.collect::<Vec<_>>(),
|
||||
user_id as database::models::ids::UserId,
|
||||
)
|
||||
.fetch(pool)
|
||||
.map_ok(|row| {
|
||||
for x in projects.iter() {
|
||||
let bool = Some(x.id.0) == row.id && Some(x.team_id.0) == row.team_id;
|
||||
if bool {
|
||||
return_projects.push(x.id);
|
||||
}
|
||||
}
|
||||
})
|
||||
.try_collect::<Vec<()>>()
|
||||
.await?;
|
||||
}
|
||||
Ok(return_projects)
|
||||
}
|
||||
|
||||
pub async fn is_visible_version(
|
||||
version_data: &Version,
|
||||
user_option: &Option<User>,
|
||||
pool: &PgPool,
|
||||
redis: &RedisPool,
|
||||
) -> Result<bool, ApiError> {
|
||||
filter_visible_version_ids(vec![version_data], user_option, pool, redis)
|
||||
.await
|
||||
.map(|x| !x.is_empty())
|
||||
}
|
||||
|
||||
pub async fn is_team_member_version(
|
||||
version_data: &Version,
|
||||
user_option: &Option<User>,
|
||||
pool: &PgPool,
|
||||
redis: &RedisPool,
|
||||
) -> Result<bool, ApiError> {
|
||||
filter_enlisted_version_ids(vec![version_data], user_option, pool, redis)
|
||||
.await
|
||||
.map(|x| !x.is_empty())
|
||||
}
|
||||
|
||||
pub async fn filter_visible_versions(
|
||||
mut versions: Vec<QueryVersion>,
|
||||
user_option: &Option<User>,
|
||||
pool: &PgPool,
|
||||
redis: &RedisPool,
|
||||
) -> Result<Vec<crate::models::projects::Version>, ApiError> {
|
||||
let filtered_version_ids = filter_visible_version_ids(
|
||||
versions.iter().map(|x| &x.inner).collect_vec(),
|
||||
user_option,
|
||||
pool,
|
||||
redis,
|
||||
)
|
||||
.await?;
|
||||
versions.retain(|x| filtered_version_ids.contains(&x.inner.id));
|
||||
Ok(versions.into_iter().map(|x| x.into()).collect())
|
||||
}
|
||||
|
||||
impl ValidateAuthorized for models::OAuthClient {
|
||||
fn validate_authorized(&self, user_option: Option<&User>) -> Result<(), ApiError> {
|
||||
if let Some(user) = user_option {
|
||||
return if user.role.is_mod() || user.id == self.created_by.into() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ApiError::CustomAuthentication(
|
||||
"You don't have sufficient permissions to interact with this OAuth application"
|
||||
.to_string(),
|
||||
))
|
||||
};
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn filter_visible_version_ids(
|
||||
versions: Vec<&Version>,
|
||||
user_option: &Option<User>,
|
||||
pool: &PgPool,
|
||||
redis: &RedisPool,
|
||||
) -> Result<Vec<crate::database::models::VersionId>, ApiError> {
|
||||
let mut return_versions = Vec::new();
|
||||
let mut check_versions = Vec::new();
|
||||
|
||||
// First, filter out versions belonging to projects we can't see
|
||||
// (ie: a hidden project, but public version, should still be hidden)
|
||||
// Gets project ids of versions
|
||||
let project_ids = versions.iter().map(|x| x.project_id).collect::<Vec<_>>();
|
||||
|
||||
// Get visible projects- ones we are allowed to see public versions for.
|
||||
let visible_project_ids = filter_visible_project_ids(
|
||||
Project::get_many_ids(&project_ids, pool, redis)
|
||||
.await?
|
||||
.iter()
|
||||
.map(|x| &x.inner)
|
||||
.collect(),
|
||||
user_option,
|
||||
pool,
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Then, get enlisted versions (Versions that are a part of a project we are a member of)
|
||||
let enlisted_version_ids =
|
||||
filter_enlisted_version_ids(versions.clone(), user_option, pool, redis).await?;
|
||||
|
||||
// Return versions that are not hidden, we are a mod of, or we are enlisted on the team of
|
||||
for version in versions {
|
||||
// We can see the version if:
|
||||
// - it's not hidden and we can see the project
|
||||
// - we are a mod
|
||||
// - we are enlisted on the team of the mod
|
||||
if (!version.status.is_hidden() && visible_project_ids.contains(&version.project_id))
|
||||
|| user_option
|
||||
.as_ref()
|
||||
.map(|x| x.role.is_mod())
|
||||
.unwrap_or(false)
|
||||
|| enlisted_version_ids.contains(&version.id)
|
||||
{
|
||||
return_versions.push(version.id);
|
||||
} else if user_option.is_some() {
|
||||
check_versions.push(version);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(return_versions)
|
||||
}
|
||||
|
||||
pub async fn filter_enlisted_version_ids(
|
||||
versions: Vec<&Version>,
|
||||
user_option: &Option<User>,
|
||||
pool: &PgPool,
|
||||
redis: &RedisPool,
|
||||
) -> Result<Vec<crate::database::models::VersionId>, ApiError> {
|
||||
let mut return_versions = Vec::new();
|
||||
|
||||
// Get project ids of versions
|
||||
let project_ids = versions.iter().map(|x| x.project_id).collect::<Vec<_>>();
|
||||
|
||||
// Get enlisted projects- ones we are allowed to see hidden versions for.
|
||||
let authorized_project_ids = filter_enlisted_projects_ids(
|
||||
Project::get_many_ids(&project_ids, pool, redis)
|
||||
.await?
|
||||
.iter()
|
||||
.map(|x| &x.inner)
|
||||
.collect(),
|
||||
user_option,
|
||||
pool,
|
||||
)
|
||||
.await?;
|
||||
|
||||
for version in versions {
|
||||
if user_option
|
||||
.as_ref()
|
||||
.map(|x| x.role.is_mod())
|
||||
.unwrap_or(false)
|
||||
|| (user_option.is_some() && authorized_project_ids.contains(&version.project_id))
|
||||
{
|
||||
return_versions.push(version.id);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(return_versions)
|
||||
}
|
||||
|
||||
pub async fn is_visible_collection(
|
||||
collection_data: &Collection,
|
||||
user_option: &Option<User>,
|
||||
) -> Result<bool, ApiError> {
|
||||
let mut authorized = !collection_data.status.is_hidden();
|
||||
if let Some(user) = &user_option {
|
||||
if !authorized && (user.role.is_mod() || user.id == collection_data.user_id.into()) {
|
||||
authorized = true;
|
||||
}
|
||||
}
|
||||
Ok(authorized)
|
||||
}
|
||||
|
||||
pub async fn filter_visible_collections(
|
||||
collections: Vec<Collection>,
|
||||
user_option: &Option<User>,
|
||||
) -> Result<Vec<crate::models::collections::Collection>, ApiError> {
|
||||
let mut return_collections = Vec::new();
|
||||
let mut check_collections = Vec::new();
|
||||
|
||||
for collection in collections {
|
||||
if !collection.status.is_hidden()
|
||||
|| user_option
|
||||
.as_ref()
|
||||
.map(|x| x.role.is_mod())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return_collections.push(collection.into());
|
||||
} else if user_option.is_some() {
|
||||
check_collections.push(collection);
|
||||
}
|
||||
}
|
||||
|
||||
for collection in check_collections {
|
||||
// Collections are simple- if we are the owner or a mod, we can see it
|
||||
if let Some(user) = user_option {
|
||||
if user.role.is_mod() || user.id == collection.user_id.into() {
|
||||
return_collections.push(collection.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(return_collections)
|
||||
}
|
||||
197
apps/labrinth/src/auth/email/auth_notif.html
Normal file
197
apps/labrinth/src/auth/email/auth_notif.html
Normal file
@@ -0,0 +1,197 @@
|
||||
<!doctype html>
|
||||
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
<head>
|
||||
<title>{{ email_title }}</title>
|
||||
<!--[if !mso]><!-->
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<!--<![endif]-->
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style type="text/css">
|
||||
#outlook a{padding:0;}body{margin:0;padding:0;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;}table,td{border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt;}img{border:0;height:auto;line-height:100%;outline:none;text-decoration:none;-ms-interpolation-mode:bicubic;}p{display:block;margin:0;}
|
||||
</style>
|
||||
<!--[if mso]> <noscript><xml><o:OfficeDocumentSettings><o:AllowPNG/><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript>
|
||||
<![endif]-->
|
||||
<!--[if lte mso 11]>
|
||||
<style type="text/css">
|
||||
.ogf{width:100% !important;}
|
||||
</style>
|
||||
<![endif]-->
|
||||
<!--[if !mso]><!-->
|
||||
<link href="https://fonts.googleapis.com/css?family=Inter:700,400" rel="stylesheet" type="text/css">
|
||||
<style type="text/css">
|
||||
|
||||
</style>
|
||||
<!--<![endif]-->
|
||||
<style type="text/css">
|
||||
@media only screen and (min-width:599px){.xc568{width:568px!important;max-width:568px;}.xc536{width:536px!important;max-width:536px;}.pc100{width:100%!important;max-width:100%;}.pc48-5915{width:48.5915%!important;max-width:48.5915%;}.pc2-8169{width:2.8169%!important;max-width:2.8169%;}}
|
||||
</style>
|
||||
<style media="screen and (min-width:599px)">.moz-text-html .xc568{width:568px!important;max-width:568px;}.moz-text-html .xc536{width:536px!important;max-width:536px;}.moz-text-html .pc100{width:100%!important;max-width:100%;}.moz-text-html .pc48-5915{width:48.5915%!important;max-width:48.5915%;}.moz-text-html .pc2-8169{width:2.8169%!important;max-width:2.8169%;}
|
||||
</style>
|
||||
<style type="text/css">
|
||||
@media only screen and (max-width:598px){table.fwm{width:100%!important;}td.fwm{width:auto!important;}}
|
||||
</style>
|
||||
<style type="text/css">
|
||||
u+.modrinth-email .gs{background:#000;mix-blend-mode:screen;display:inline-block;padding:0;margin:0;}u+.modrinth-email .gd{background:#000;mix-blend-mode:difference;display:inline-block;padding:0;margin:0;}p{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;}u+.modrinth-email a,#MessageViewBody a,a[x-apple-data-detectors]{color:inherit!important;text-decoration:none!important;font-size:inherit!important;font-family:inherit!important;font-weight:inherit!important;line-height:inherit!important;}td.b .klaviyo-image-block{display:inline;vertical-align:middle;}
|
||||
@media only screen and (max-width:599px){.modrinth-email{height:100%!important;margin:0!important;padding:0!important;width:100%!important;}u+.modrinth-email .glist{margin-left:1em!important;}td.ico.v>div.il>a.l.m,td.ico.v .mn-label{padding-right:0!important;padding-bottom:16px!important;}td.x{padding-left:0!important;padding-right:0!important;}.fwm img{max-width:100%!important;height:auto!important;}.aw img{width:auto!important;margin-left:auto!important;margin-right:auto!important;}.ah img{height:auto!important;}td.b.nw>table,td.b.nw a{width:auto!important;}td.stk{border:0!important;}td.u{height:auto!important;}br.sb{display:none!important;}.thd-1 .i-thumbnail{display:inline-block!important;height:auto!important;overflow:hidden!important;}.hd-1{display:block!important;height:auto!important;overflow:visible!important;}.ht-1{display:table!important;height:auto!important;overflow:visible!important;}.hr-1{display:table-row!important;height:auto!important;overflow:visible!important;}.hc-1{display:table-cell!important;height:auto!important;overflow:visible!important;}div.r.pr-16>table>tbody>tr>td,div.r.pr-16>div>table>tbody>tr>td{padding-right:16px!important}div.r.pl-16>table>tbody>tr>td,div.r.pl-16>div>table>tbody>tr>td{padding-left:16px!important}td.b.fw-1>table{width:100%!important}td.fw-1>table>tbody>tr>td>a{display:block!important;width:100%!important;padding-left:0!important;padding-right:0!important;}td.b.fw-1>table{width:100%!important}td.fw-1>table>tbody>tr>td{width:100%!important;padding-left:0!important;padding-right:0!important;}td.b.hvt-D9D9D9>table>tbody>tr>td:hover *{color:#D9D9D9!important}td.b.hvb-00954E>table>tbody>tr>td:hover,td.b.hvb-00954E>table>tbody>tr>td:hover>a{background-color:#00954E!important;}.hvr-fade *{-webkit-transition-duration:0.2s;transition-duration:0.2s;-webkit-transition-property:background-color,color;transition-property:background-color,color;}}
|
||||
@media (prefers-color-scheme:light) and (max-width:599px){.ds-1.hd-1{display:none!important;height:0!important;overflow:hidden!important;}}
|
||||
@media (prefers-color-scheme:dark) and (max-width:599px){.ds-1.hd-1{display:block!important;height:auto!important;overflow:visible!important;}}
|
||||
@media (prefers-color-scheme:dark){div.r.db-000000,div.r.db-000000>table{background-color:#000000!important;}div.r.dt-FFFFFE *{color:#FFFFFE!important}.dh-1{display:none!important;max-width:0!important;max-height:0!important;overflow:hidden!important;mso-hide:all!important;}.ds-1{display:block!important;max-width:none!important;max-height:none!important;height:auto!important;overflow:visible!important;mso-hide:all!important;}td.b.dt-000000 *{color:#000000!important}td.b.db-1BD96A>table>tbody>tr>td,td.b.db-1BD96A>table>tbody>tr>td>a{background-color:#1BD96A!important;}td.x.dt-B0BAC5 *{color:#B0BAC5!important}}.hvr-fade *{-webkit-transition-duration:0.2s;transition-duration:0.2s;-webkit-transition-property:background-color,color;transition-property:background-color,color;}td.b.hvt-D9D9D9>table>tbody>tr>td:hover *{color:#000000!important}td.b.hvb-00954E>table>tbody>tr>td:hover,td.b.hvb-00954E>table>tbody>tr>td:hover>a{background-color:#12b95a!important;}
|
||||
</style>
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="supported-color-schemes" content="light dark">
|
||||
<!--[if gte mso 9]>
|
||||
<style>a:link,span.MsoHyperlink{mso-style-priority:99;color:inherit;text-decoration:none;}a:visited,span.MsoHyperlinkFollowed{mso-style-priority:99;color:inherit;text-decoration:none;}li{text-indent:-1em;}table,td,p,div,span,ul,ol,li,a{mso-hyphenate:none;}
|
||||
</style>
|
||||
<![endif]-->
|
||||
</head>
|
||||
<body lang="en" link="#DD0000" vlink="#DD0000" class="modrinth-email" style="mso-line-height-rule:exactly;mso-hyphenate:none;word-spacing:normal;background-color:#1e1e1e;"><div style="display:none;font-size:1px;color:#ffffff;line-height:1px;max-height:0;max-width:0;opacity:0;overflow:hidden;">{{ email_description }}͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ </div><div class="bg" style="background-color:#1e1e1e;" lang="en">
|
||||
<!--[if mso | IE]>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="r-outlook -outlook pr-16-outlook pl-16-outlook db-000000-outlook dt-FFFFFE-outlook -outlook" role="presentation" style="width:600px;" width="600"><tr><td style="line-height:0;font-size:0;mso-line-height-rule:exactly;">
|
||||
<![endif]--><div class="r pr-16 pl-16 db-000000 dt-FFFFFE" style="background:#eeeeee;background-color:#eeeeee;margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#eeeeee;background-color:#eeeeee;width:100%;"><tbody><tr><td style="border:none;direction:ltr;font-size:0;padding:16px 16px 16px 16px;text-align:left;">
|
||||
<!--[if mso | IE]>
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="c-outlook -outlook -outlook" style="vertical-align:middle;width:568px;">
|
||||
<![endif]--><div class="xc568 ogf c" style="font-size:0;text-align:left;direction:ltr;display:inline-block;vertical-align:middle;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border:none;vertical-align:middle;" width="100%"><tbody><tr><td align="left" class="i dh-1 m" style="font-size:0;padding:0;padding-bottom:8px;word-break:break-word;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0;"><tbody><tr><td style="width:29px;"> <img alt src="https://cdn.modrinth.com/email/f740e2decee8764a4629bff677a284f9.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;" title width="29" height="auto">
|
||||
</td></tr></tbody></table>
|
||||
</td></tr><tr><td align="left" class="i ds-1" style="mso-hide:all;display:none;height:0;overflow:hidden;font-size:0;padding:0;padding-bottom:0;word-break:break-word;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-hide:all;border-collapse:collapse;border-spacing:0;"><tbody style="mso-hide:all;"><tr style="mso-hide:all;"><td style="mso-hide:all;width:29px;" width="29"> <img alt src="https://cdn.modrinth.com/email/d3cbe6edea372a9884c705303f4147f1.png" style="mso-hide:all;border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;" title width="29" height="auto">
|
||||
</td></tr></tbody></table>
|
||||
</td></tr></tbody></table></div>
|
||||
<!--[if mso | IE]>
|
||||
</td></tr></table>
|
||||
<![endif]-->
|
||||
</td></tr></tbody></table></div>
|
||||
<!--[if mso | IE]>
|
||||
</td></tr></table>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="r-outlook -outlook pr-16-outlook pl-16-outlook db-000000-outlook dt-FFFFFE-outlook -outlook" role="presentation" style="width:600px;" width="600"><tr><td style="line-height:0;font-size:0;mso-line-height-rule:exactly;">
|
||||
<![endif]--><div class="r pr-16 pl-16 db-000000 dt-FFFFFE" style="background:#fffffe;background-color:#fffffe;margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#fffffe;background-color:#fffffe;width:100%;"><tbody><tr><td style="border:none;direction:ltr;font-size:0;padding:32px 32px 32px 32px;text-align:left;">
|
||||
<!--[if mso | IE]>
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="c-outlook -outlook -outlook" style="vertical-align:middle;width:536px;">
|
||||
<![endif]--><div class="xc536 ogf c" style="font-size:0;text-align:left;direction:ltr;display:inline-block;vertical-align:middle;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border:none;vertical-align:middle;" width="100%"><tbody><tr><td align="left" class="x m" style="font-size:0;padding-bottom:8px;word-break:break-word;"><div style="text-align:left;"><p style="Margin:0;text-align:left;mso-line-height-alt:32px;mso-ansi-font-size:28px;"><span style="font-size:28px;font-family:Inter,Arial,sans-serif;font-weight:700;color:#000000;line-height:114%;mso-line-height-alt:32px;mso-ansi-font-size:28px;">{{ email_title }}</span></p></div>
|
||||
</td></tr><tr><td align="left" class="x m" style="font-size:0;padding-bottom:8px;word-break:break-word;"><div style="text-align:left;"><p style="Margin:0;text-align:left;mso-line-height-alt:24px;mso-ansi-font-size:16px;"><span style="font-size:16px;font-family:Inter,Arial,sans-serif;font-weight:400;color:#777777;line-height:150%;mso-line-height-alt:24px;mso-ansi-font-size:16px;">{{ line_one }}</span></p><p style="Margin:0;mso-line-height-alt:24px;mso-ansi-font-size:16px;"><span style="font-size:16px;font-family:Inter,Arial,sans-serif;font-weight:400;color:#777777;line-height:150%;mso-line-height-alt:24px;mso-ansi-font-size:16px;"> </span></p><p style="Margin:0;mso-line-height-alt:24px;mso-ansi-font-size:16px;"><span style="font-size:16px;font-family:Inter,Arial,sans-serif;font-weight:400;color:#777777;line-height:150%;mso-line-height-alt:24px;mso-ansi-font-size:16px;">{{ line_two }}</span></p></div>
|
||||
</td></tr></tbody></table></div>
|
||||
<!--[if mso | IE]>
|
||||
</td></tr></table>
|
||||
<![endif]-->
|
||||
</td></tr></tbody></table></div>
|
||||
<!--[if mso | IE]>
|
||||
</td></tr></table>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="r-outlook -outlook pr-16-outlook pl-16-outlook db-000000-outlook dt-FFFFFE-outlook -outlook" role="presentation" style="width:600px;" width="600"><tr><td style="line-height:0;font-size:0;mso-line-height-rule:exactly;">
|
||||
<![endif]--><div class="r pr-16 pl-16 db-000000 dt-FFFFFE" style="background:#eeeeee;background-color:#eeeeee;margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#eeeeee;background-color:#eeeeee;width:100%;"><tbody><tr><td style="border:none;direction:ltr;font-size:0;padding:16px 16px 16px 16px;text-align:left;">
|
||||
<!--[if mso | IE]>
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="width:568px;">
|
||||
<![endif]--><div class="pc100 ogf" style="font-size:0;line-height:0;text-align:left;display:inline-block;width:100%;direction:ltr;">
|
||||
<!--[if mso | IE]>
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation"><tr><td style="vertical-align:middle;width:275px;">
|
||||
<![endif]--><div class="pc48-5915 ogf m c" style="font-size:0;text-align:left;direction:ltr;display:inline-block;vertical-align:middle;width:48.5915%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border:none;vertical-align:middle;" width="100%"><tbody><tr><td align="left" class="i dh-1 m" style="font-size:0;padding:0;padding-bottom:8px;word-break:break-word;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0;"><tbody><tr><td style="width:102px;"> <a href="https://modrinth.com" target="_blank" title> <img alt="modrinth logo" src="https://cdn.modrinth.com/email/bd3357dfae4b1d266250372db3a0988f.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;" title width="102" height="auto"></a>
|
||||
</td></tr></tbody></table>
|
||||
</td></tr><tr><td align="left" class="i ds-1 m" style="mso-hide:all;display:none;height:0;overflow:hidden;font-size:0;padding:0;padding-bottom:8px;word-break:break-word;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-hide:all;border-collapse:collapse;border-spacing:0;"><tbody style="mso-hide:all;"><tr style="mso-hide:all;"><td style="mso-hide:all;width:102px;" width="102"> <a href="https://modrinth.com" target="_blank" title style="mso-hide:all;"> <img alt="modrinth logo" src="https://cdn.modrinth.com/email/4783699dd14602e3d326335cc56ff7ec.png" style="mso-hide:all;border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;" title width="102" height="auto"></a>
|
||||
</td></tr></tbody></table>
|
||||
</td></tr><tr><td align="left" class="x m" style="font-size:0;padding-bottom:8px;word-break:break-word;"><div style="text-align:left;"><p style="Margin:0;text-align:left;mso-line-height-alt:16px;mso-ansi-font-size:14px;"><span style="font-size:13px;font-family:Inter,Arial,sans-serif;font-weight:400;color:#4d4d4d;line-height:123%;mso-line-height-alt:16px;mso-ansi-font-size:14px;">Rinth, Inc.</span></p></div>
|
||||
</td></tr><tr><td align="left" class="x dt-B0BAC5" style="font-size:0;padding-bottom:0;word-break:break-word;"><div style="text-align:left;"><p style="Margin:0;text-align:left;mso-line-height-alt:16px;mso-ansi-font-size:14px;"><span style="font-size:13px;font-family:Inter,Arial,sans-serif;font-weight:400;color:#aaaaaa;line-height:123%;mso-line-height-alt:16px;mso-ansi-font-size:14px;">410 N Scottsdale Road</span></p><p style="Margin:0;mso-line-height-alt:16px;mso-ansi-font-size:14px;"><span style="font-size:13px;font-family:Inter,Arial,sans-serif;font-weight:400;color:#aaaaaa;line-height:123%;mso-line-height-alt:16px;mso-ansi-font-size:14px;">Suite 1000</span></p><p style="Margin:0;mso-line-height-alt:16px;mso-ansi-font-size:14px;"><span style="font-size:13px;font-family:Inter,Arial,sans-serif;font-weight:400;color:#aaaaaa;line-height:123%;mso-line-height-alt:16px;mso-ansi-font-size:14px;">Tempe, AZ 85281</span></p></div>
|
||||
</td></tr></tbody></table></div>
|
||||
<!--[if mso | IE]>
|
||||
</td><td style="width:15px;">
|
||||
<![endif]--><div class="pc2-8169 ogf g" style="font-size:0;text-align:left;direction:ltr;display:inline-block;width:2.8169%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%"><tbody><tr><td style="padding:0;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style width="100%"><tbody></tbody></table>
|
||||
</td></tr></tbody></table></div>
|
||||
<!--[if mso | IE]>
|
||||
</td><td style="vertical-align:middle;width:275px;">
|
||||
<![endif]--><div class="pc48-5915 ogf c" style="font-size:0;text-align:left;direction:ltr;display:inline-block;vertical-align:middle;width:48.5915%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border:none;vertical-align:middle;" width="100%"><tbody><tr><td align="right" class="o" style="font-size:0;padding:0;word-break:break-word;">
|
||||
<!--[if mso | IE]>
|
||||
<table align="right" border="0" cellpadding="0" cellspacing="0" role="presentation"><tr><td>
|
||||
<![endif]-->
|
||||
<table align="right" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;"><tbody><tr class="e dh-1 m"><td style="padding:0 16px 0 0;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:20px;"><tbody><tr><td style="font-size:0;height:20px;vertical-align:middle;width:20px;"> <a href="https://discord.modrinth.com" target="_blank"> <img alt="Discord" title height="20" src="https://cdn.modrinth.com/email/e089a3a07be91c2940beff1fb191b247.png" style="display:block;" width="20"></a>
|
||||
</td></tr></tbody></table>
|
||||
</td></tr></tbody></table>
|
||||
<!--[if mso | IE]>
|
||||
</td><td>
|
||||
<![endif]-->
|
||||
<table align="right" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;"><tbody><tr class="e dh-1 m"><td style="padding:0 16px 0 0;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:20px;"><tbody><tr><td style="font-size:0;height:20px;vertical-align:middle;width:20px;"> <a href="https://twitter.com/modrinth" target="_blank"> <img alt="Twitter" title height="20" src="https://cdn.modrinth.com/email/363985aad91cab53854276e12f267b0b.png" style="display:block;" width="20"></a>
|
||||
</td></tr></tbody></table>
|
||||
</td></tr></tbody></table>
|
||||
<!--[if mso | IE]>
|
||||
</td><td>
|
||||
<![endif]-->
|
||||
<table align="right" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;"><tbody><tr class="e dh-1 m"><td style="padding:0 16px 0 0;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:20px;"><tbody><tr><td style="font-size:0;height:20px;vertical-align:middle;width:20px;"> <a href="https://floss.social/@modrinth" target="_blank"> <img alt="Mastodon" title height="20" src="https://cdn.modrinth.com/email/e25c30d5707744f31dcf18651a67d3d5.png" style="display:block;" width="20"></a>
|
||||
</td></tr></tbody></table>
|
||||
</td></tr></tbody></table>
|
||||
<!--[if mso | IE]>
|
||||
</td><td>
|
||||
<![endif]-->
|
||||
<table align="right" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;"><tbody><tr class="e dh-1 m"><td style="padding:0 16px 0 0;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:20px;"><tbody><tr><td style="font-size:0;height:20px;vertical-align:middle;width:20px;"> <a href="https://github.com/modrinth/" target="_blank"> <img alt="GitHub" title height="20" src="https://cdn.modrinth.com/email/45993023966d64e6138fd65a530a5d03.png" style="display:block;" width="20"></a>
|
||||
</td></tr></tbody></table>
|
||||
</td></tr></tbody></table>
|
||||
<!--[if mso | IE]>
|
||||
</td><td>
|
||||
<![endif]-->
|
||||
<table align="right" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;"><tbody><tr class="e dh-1 m"><td style="padding:0 16px 0 0;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:20px;"><tbody><tr><td style="font-size:0;height:20px;vertical-align:middle;width:20px;"> <a href="https://www.youtube.com/@modrinth" target="_blank"> <img alt="YouTube" title height="20" src="https://cdn.modrinth.com/email/a15afae9fc94e105caeb1bb4d33a0a13.png" style="display:block;" width="20"></a>
|
||||
</td></tr></tbody></table>
|
||||
</td></tr></tbody></table>
|
||||
<!--[if mso | IE]>
|
||||
</td><td>
|
||||
<![endif]-->
|
||||
<table align="right" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;"><tbody><tr class="e ds-1 m" style="mso-hide:all;display:none;height:0;overflow:hidden;"><td style="mso-hide:all;padding:0 16px 0 0;vertical-align:middle;" valign="middle">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-hide:all;width:20px;" width="20"><tbody style="mso-hide:all;"><tr style="mso-hide:all;"><td style="mso-hide:all;font-size:0;height:20px;vertical-align:middle;width:20px;" width="20" height="20" valign="middle"> <a href="https://discord.modrinth.com" target="_blank" style="mso-hide:all;"> <img alt="Discord" title height="20" src="https://cdn.modrinth.com/email/8a44a64dd39ba64e3a480ef9872351c9.png" style="mso-hide:all;display:block;" width="20"></a>
|
||||
</td></tr></tbody></table>
|
||||
</td></tr></tbody></table>
|
||||
<!--[if mso | IE]>
|
||||
</td><td>
|
||||
<![endif]-->
|
||||
<table align="right" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;"><tbody><tr class="e ds-1 m" style="mso-hide:all;display:none;height:0;overflow:hidden;"><td style="mso-hide:all;padding:0 16px 0 0;vertical-align:middle;" valign="middle">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-hide:all;width:20px;" width="20"><tbody style="mso-hide:all;"><tr style="mso-hide:all;"><td style="mso-hide:all;font-size:0;height:20px;vertical-align:middle;width:20px;" width="20" height="20" valign="middle"> <a href="https://twitter.com/modrinth" target="_blank" style="mso-hide:all;"> <img alt="Twitter" title height="20" src="https://cdn.modrinth.com/email/b42fea63b0f099e851cb0ee60649c7aa.png" style="mso-hide:all;display:block;" width="20"></a>
|
||||
</td></tr></tbody></table>
|
||||
</td></tr></tbody></table>
|
||||
<!--[if mso | IE]>
|
||||
</td><td>
|
||||
<![endif]-->
|
||||
<table align="right" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;"><tbody><tr class="e ds-1 m" style="mso-hide:all;display:none;height:0;overflow:hidden;"><td style="mso-hide:all;padding:0 16px 0 0;vertical-align:middle;" valign="middle">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-hide:all;width:20px;" width="20"><tbody style="mso-hide:all;"><tr style="mso-hide:all;"><td style="mso-hide:all;font-size:0;height:20px;vertical-align:middle;width:20px;" width="20" height="20" valign="middle"> <a href="https://floss.social/@modrinth" target="_blank" style="mso-hide:all;"> <img alt="Mastodon" title height="20" src="https://cdn.modrinth.com/email/7469febd9dfb6dd61ce7fdac12b4a644.png" style="mso-hide:all;display:block;" width="20"></a>
|
||||
</td></tr></tbody></table>
|
||||
</td></tr></tbody></table>
|
||||
<!--[if mso | IE]>
|
||||
</td><td>
|
||||
<![endif]-->
|
||||
<table align="right" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;"><tbody><tr class="e ds-1 m" style="mso-hide:all;display:none;height:0;overflow:hidden;"><td style="mso-hide:all;padding:0 16px 0 0;vertical-align:middle;" valign="middle">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-hide:all;width:20px;" width="20"><tbody style="mso-hide:all;"><tr style="mso-hide:all;"><td style="mso-hide:all;font-size:0;height:20px;vertical-align:middle;width:20px;" width="20" height="20" valign="middle"> <a href="https://github.com/modrinth/" target="_blank" style="mso-hide:all;"> <img alt="GitHub" title height="20" src="https://cdn.modrinth.com/email/431cdb26e1afcff6b6d74c452c581987.png" style="mso-hide:all;display:block;" width="20"></a>
|
||||
</td></tr></tbody></table>
|
||||
</td></tr></tbody></table>
|
||||
<!--[if mso | IE]>
|
||||
</td><td>
|
||||
<![endif]-->
|
||||
<table align="right" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;"><tbody><tr class="e ds-1" style="mso-hide:all;display:none;height:0;overflow:hidden;"><td style="mso-hide:all;padding:0;padding-right:0;vertical-align:middle;" valign="middle">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-hide:all;width:20px;" width="20"><tbody style="mso-hide:all;"><tr style="mso-hide:all;"><td style="mso-hide:all;font-size:0;height:20px;vertical-align:middle;width:20px;" width="20" height="20" valign="middle"> <a href="https://www.youtube.com/@modrinth" target="_blank" style="mso-hide:all;"> <img alt="YouTube" title height="20" src="https://cdn.modrinth.com/email/44f82a157ad4c4b122e1927a20b62660.png" style="mso-hide:all;display:block;" width="20"></a>
|
||||
</td></tr></tbody></table>
|
||||
</td></tr></tbody></table>
|
||||
<!--[if mso | IE]>
|
||||
</td></tr></table>
|
||||
<![endif]-->
|
||||
</td></tr></tbody></table></div>
|
||||
<!--[if mso | IE]>
|
||||
</td></tr></table>
|
||||
<![endif]--></div>
|
||||
<!--[if mso | IE]>
|
||||
</td></tr></table>
|
||||
<![endif]-->
|
||||
</td></tr></tbody></table></div>
|
||||
<!--[if mso | IE]>
|
||||
</td></tr></table>
|
||||
<![endif]--></div>
|
||||
</body>
|
||||
</html>
|
||||
202
apps/labrinth/src/auth/email/button_notif.html
Normal file
202
apps/labrinth/src/auth/email/button_notif.html
Normal file
@@ -0,0 +1,202 @@
|
||||
<!doctype html>
|
||||
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
<head>
|
||||
<title>{{ email_title }}</title>
|
||||
<!--[if !mso]><!-->
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<!--<![endif]-->
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style type="text/css">
|
||||
#outlook a{padding:0;}body{margin:0;padding:0;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;}table,td{border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt;}img{border:0;height:auto;line-height:100%;outline:none;text-decoration:none;-ms-interpolation-mode:bicubic;}p{display:block;margin:0;}
|
||||
</style>
|
||||
<!--[if mso]> <noscript><xml><o:OfficeDocumentSettings><o:AllowPNG/><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript>
|
||||
<![endif]-->
|
||||
<!--[if lte mso 11]>
|
||||
<style type="text/css">
|
||||
.ogf{width:100% !important;}
|
||||
</style>
|
||||
<![endif]-->
|
||||
<!--[if !mso]><!-->
|
||||
<link href="https://fonts.googleapis.com/css?family=Inter:700,400" rel="stylesheet" type="text/css">
|
||||
<style type="text/css">
|
||||
|
||||
</style>
|
||||
<!--<![endif]-->
|
||||
<style type="text/css">
|
||||
@media only screen and (min-width:599px){.xc568{width:568px!important;max-width:568px;}.xc536{width:536px!important;max-width:536px;}.pc100{width:100%!important;max-width:100%;}.pc48-5915{width:48.5915%!important;max-width:48.5915%;}.pc2-8169{width:2.8169%!important;max-width:2.8169%;}}
|
||||
</style>
|
||||
<style media="screen and (min-width:599px)">.moz-text-html .xc568{width:568px!important;max-width:568px;}.moz-text-html .xc536{width:536px!important;max-width:536px;}.moz-text-html .pc100{width:100%!important;max-width:100%;}.moz-text-html .pc48-5915{width:48.5915%!important;max-width:48.5915%;}.moz-text-html .pc2-8169{width:2.8169%!important;max-width:2.8169%;}
|
||||
</style>
|
||||
<style type="text/css">
|
||||
@media only screen and (max-width:598px){table.fwm{width:100%!important;}td.fwm{width:auto!important;}}
|
||||
</style>
|
||||
<style type="text/css">
|
||||
u+.modrinth-email .gs{background:#000;mix-blend-mode:screen;display:inline-block;padding:0;margin:0;}u+.modrinth-email .gd{background:#000;mix-blend-mode:difference;display:inline-block;padding:0;margin:0;}p{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;}u+.modrinth-email a,#MessageViewBody a,a[x-apple-data-detectors]{color:inherit!important;text-decoration:none!important;font-size:inherit!important;font-family:inherit!important;font-weight:inherit!important;line-height:inherit!important;}td.b .klaviyo-image-block{display:inline;vertical-align:middle;}
|
||||
@media only screen and (max-width:599px){.modrinth-email{height:100%!important;margin:0!important;padding:0!important;width:100%!important;}u+.modrinth-email .glist{margin-left:1em!important;}td.ico.v>div.il>a.l.m,td.ico.v .mn-label{padding-right:0!important;padding-bottom:16px!important;}td.x{padding-left:0!important;padding-right:0!important;}.fwm img{max-width:100%!important;height:auto!important;}.aw img{width:auto!important;margin-left:auto!important;margin-right:auto!important;}.ah img{height:auto!important;}td.b.nw>table,td.b.nw a{width:auto!important;}td.stk{border:0!important;}td.u{height:auto!important;}br.sb{display:none!important;}.thd-1 .i-thumbnail{display:inline-block!important;height:auto!important;overflow:hidden!important;}.hd-1{display:block!important;height:auto!important;overflow:visible!important;}.ht-1{display:table!important;height:auto!important;overflow:visible!important;}.hr-1{display:table-row!important;height:auto!important;overflow:visible!important;}.hc-1{display:table-cell!important;height:auto!important;overflow:visible!important;}div.r.pr-16>table>tbody>tr>td,div.r.pr-16>div>table>tbody>tr>td{padding-right:16px!important}div.r.pl-16>table>tbody>tr>td,div.r.pl-16>div>table>tbody>tr>td{padding-left:16px!important}td.b.fw-1>table{width:100%!important}td.fw-1>table>tbody>tr>td>a{display:block!important;width:100%!important;padding-left:0!important;padding-right:0!important;}td.b.fw-1>table{width:100%!important}td.fw-1>table>tbody>tr>td{width:100%!important;padding-left:0!important;padding-right:0!important;}}
|
||||
@media (prefers-color-scheme:light) and (max-width:599px){.ds-1.hd-1{display:none!important;height:0!important;overflow:hidden!important;}}
|
||||
@media (prefers-color-scheme:dark) and (max-width:599px){.ds-1.hd-1{display:block!important;height:auto!important;overflow:visible!important;}}
|
||||
@media (prefers-color-scheme:dark){div.r.db-000000,div.r.db-000000>table{background-color:#000000!important;}div.r.dt-FFFFFE *{color:#FFFFFE!important}.dh-1{display:none!important;max-width:0!important;max-height:0!important;overflow:hidden!important;mso-hide:all!important;}.ds-1{display:block!important;max-width:none!important;max-height:none!important;height:auto!important;overflow:visible!important;mso-hide:all!important;}td.b.dt-000000 *{color:#000000!important}td.b.db-1BD96A>table>tbody>tr>td,td.b.db-1BD96A>table>tbody>tr>td>a{background-color:#1BD96A!important;}td.x.dt-B0BAC5 *{color:#B0BAC5!important}}.hvr-fade *{-webkit-transition-duration:0.2s;transition-duration:0.2s;-webkit-transition-property:background-color,color;transition-property:background-color,color;}
|
||||
</style>
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="supported-color-schemes" content="light dark">
|
||||
<!--[if gte mso 9]>
|
||||
<style>a:link,span.MsoHyperlink{mso-style-priority:99;color:inherit;text-decoration:none;}a:visited,span.MsoHyperlinkFollowed{mso-style-priority:99;color:inherit;text-decoration:none;}li{text-indent:-1em;}table,td,p,div,span,ul,ol,li,a{mso-hyphenate:none;}
|
||||
</style>
|
||||
<![endif]-->
|
||||
</head>
|
||||
<body lang="en" link="#DD0000" vlink="#DD0000" class="modrinth-email" style="mso-line-height-rule:exactly;mso-hyphenate:none;word-spacing:normal;background-color:#1e1e1e;"><div style="display:none;font-size:1px;color:#ffffff;line-height:1px;max-height:0;max-width:0;opacity:0;overflow:hidden;">{{ email_description }}͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ </div><div class="bg" style="background-color:#1e1e1e;" lang="en">
|
||||
<!--[if mso | IE]>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="r-outlook -outlook pr-16-outlook pl-16-outlook db-000000-outlook dt-FFFFFE-outlook -outlook" role="presentation" style="width:600px;" width="600"><tr><td style="line-height:0;font-size:0;mso-line-height-rule:exactly;">
|
||||
<![endif]--><div class="r pr-16 pl-16 db-000000 dt-FFFFFE" style="background:#eeeeee;background-color:#eeeeee;margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#eeeeee;background-color:#eeeeee;width:100%;"><tbody><tr><td style="border:none;direction:ltr;font-size:0;padding:16px 16px 16px 16px;text-align:left;">
|
||||
<!--[if mso | IE]>
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="c-outlook -outlook -outlook" style="vertical-align:middle;width:568px;">
|
||||
<![endif]--><div class="xc568 ogf c" style="font-size:0;text-align:left;direction:ltr;display:inline-block;vertical-align:middle;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border:none;vertical-align:middle;" width="100%"><tbody><tr><td align="left" class="i dh-1 m" style="font-size:0;padding:0;padding-bottom:8px;word-break:break-word;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0;"><tbody><tr><td style="width:29px;"> <img alt src="https://cdn.modrinth.com/email/f740e2decee8764a4629bff677a284f9.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;" title width="29" height="auto">
|
||||
</td></tr></tbody></table>
|
||||
</td></tr><tr><td align="left" class="i ds-1" style="mso-hide:all;display:none;height:0;overflow:hidden;font-size:0;padding:0;padding-bottom:0;word-break:break-word;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-hide:all;border-collapse:collapse;border-spacing:0;"><tbody style="mso-hide:all;"><tr style="mso-hide:all;"><td style="mso-hide:all;width:29px;" width="29"> <img alt src="https://cdn.modrinth.com/email/d3cbe6edea372a9884c705303f4147f1.png" style="mso-hide:all;border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;" title width="29" height="auto">
|
||||
</td></tr></tbody></table>
|
||||
</td></tr></tbody></table></div>
|
||||
<!--[if mso | IE]>
|
||||
</td></tr></table>
|
||||
<![endif]-->
|
||||
</td></tr></tbody></table></div>
|
||||
<!--[if mso | IE]>
|
||||
</td></tr></table>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="r-outlook -outlook pr-16-outlook pl-16-outlook db-000000-outlook dt-FFFFFE-outlook -outlook" role="presentation" style="width:600px;" width="600"><tr><td style="line-height:0;font-size:0;mso-line-height-rule:exactly;">
|
||||
<![endif]--><div class="r pr-16 pl-16 db-000000 dt-FFFFFE" style="background:#fffffe;background-color:#fffffe;margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#fffffe;background-color:#fffffe;width:100%;"><tbody><tr><td style="border:none;direction:ltr;font-size:0;padding:32px 32px 32px 32px;text-align:left;">
|
||||
<!--[if mso | IE]>
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="c-outlook -outlook -outlook" style="vertical-align:middle;width:536px;">
|
||||
<![endif]--><div class="xc536 ogf c" style="font-size:0;text-align:left;direction:ltr;display:inline-block;vertical-align:middle;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border:none;vertical-align:middle;" width="100%"><tbody><tr><td align="left" class="x m" style="font-size:0;padding-bottom:8px;word-break:break-word;"><div style="text-align:left;"><p style="Margin:0;text-align:left;mso-line-height-alt:32px;mso-ansi-font-size:28px;"><span style="font-size:28px;font-family:Inter,Arial,sans-serif;font-weight:700;color:#000000;line-height:114%;mso-line-height-alt:32px;mso-ansi-font-size:28px;">{{ email_title }}</span></p></div>
|
||||
</td></tr><tr><td align="left" class="x m" style="font-size:0;padding-bottom:8px;word-break:break-word;"><div style="text-align:left;"><p style="Margin:0;text-align:left;mso-line-height-alt:24px;mso-ansi-font-size:16px;"><span style="font-size:16px;font-family:Inter,Arial,sans-serif;font-weight:400;color:#777777;line-height:150%;mso-line-height-alt:24px;mso-ansi-font-size:16px;">{{ line_one }}</span></p><p style="Margin:0;mso-line-height-alt:24px;mso-ansi-font-size:16px;"><span style="font-size:16px;font-family:Inter,Arial,sans-serif;font-weight:400;color:#777777;line-height:150%;mso-line-height-alt:24px;mso-ansi-font-size:16px;"> </span></p><p style="Margin:0;mso-line-height-alt:24px;mso-ansi-font-size:16px;"><span style="font-size:16px;font-family:Inter,Arial,sans-serif;font-weight:400;color:#777777;line-height:150%;mso-line-height-alt:24px;mso-ansi-font-size:16px;">{{ line_two }}</span></p></div>
|
||||
</td></tr><tr><td class="s m" style="font-size:0;padding:0;padding-bottom:8px;word-break:break-word;" aria-hidden="true"><div style="height:4px;line-height:4px;"> </div>
|
||||
</td></tr><tr><td align="left" vertical-align="middle" class="b fw-1 dt-000000 db-1BD96A hvt-D9D9D9 hvb-00954E hvr-fade m" style="font-size:0;padding:0;padding-bottom:8px;word-break:break-word;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;width:146px;line-height:100%;"><tbody><tr><td align="center" bgcolor="#00af5c" role="presentation" style="border:none;border-radius:12px 12px 12px 12px;cursor:auto;mso-padding-alt:12px 0px 12px 0px;background:#00af5c;" valign="middle"> <a href="{{ button_link }}" style="display:inline-block;width:146px;background:#00af5c;color:#ffffff;font-family:Inter,Arial,sans-serif;font-size:13px;font-weight:normal;line-height:100%;margin:0;text-decoration:none;text-transform:none;padding:12px 0px 12px 0px;mso-padding-alt:0;border-radius:12px 12px 12px 12px;" target="_blank"> <span style="font-size:14px;font-family:Inter,Arial,sans-serif;font-weight:700;color:#ffffff;line-height:121%;mso-line-height-alt:18px;mso-ansi-font-size:14px;">{{ button_title }}</span></a>
|
||||
</td></tr></tbody></table>
|
||||
</td></tr><tr><td align="left" class="x" style="font-size:0;padding-bottom:0;word-break:break-word;"><div style="text-align:left;"><p style="Margin:0;text-align:left;mso-line-height-alt:22px;mso-ansi-font-size:14px;"><span style="font-size:14px;font-family:Inter,Arial,sans-serif;font-weight:700;color:#777777;line-height:150%;mso-line-height-alt:22px;mso-ansi-font-size:14px;">{{ button_link }}</span></p></div>
|
||||
</td></tr></tbody></table></div>
|
||||
<!--[if mso | IE]>
|
||||
</td></tr></table>
|
||||
<![endif]-->
|
||||
</td></tr></tbody></table></div>
|
||||
<!--[if mso | IE]>
|
||||
</td></tr></table>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="r-outlook -outlook pr-16-outlook pl-16-outlook db-000000-outlook dt-FFFFFE-outlook -outlook" role="presentation" style="width:600px;" width="600"><tr><td style="line-height:0;font-size:0;mso-line-height-rule:exactly;">
|
||||
<![endif]--><div class="r pr-16 pl-16 db-000000 dt-FFFFFE" style="background:#eeeeee;background-color:#eeeeee;margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#eeeeee;background-color:#eeeeee;width:100%;"><tbody><tr><td style="border:none;direction:ltr;font-size:0;padding:16px 16px 16px 16px;text-align:left;">
|
||||
<!--[if mso | IE]>
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="width:568px;">
|
||||
<![endif]--><div class="pc100 ogf" style="font-size:0;line-height:0;text-align:left;display:inline-block;width:100%;direction:ltr;">
|
||||
<!--[if mso | IE]>
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation"><tr><td style="vertical-align:middle;width:275px;">
|
||||
<![endif]--><div class="pc48-5915 ogf m c" style="font-size:0;text-align:left;direction:ltr;display:inline-block;vertical-align:middle;width:48.5915%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border:none;vertical-align:middle;" width="100%"><tbody><tr><td align="left" class="i dh-1 m" style="font-size:0;padding:0;padding-bottom:8px;word-break:break-word;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0;"><tbody><tr><td style="width:102px;"> <a href="https://modrinth.com" target="_blank" title> <img alt="modrinth logo" src="https://cdn.modrinth.com/email/bd3357dfae4b1d266250372db3a0988f.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;" title width="102" height="auto"></a>
|
||||
</td></tr></tbody></table>
|
||||
</td></tr><tr><td align="left" class="i ds-1 m" style="mso-hide:all;display:none;height:0;overflow:hidden;font-size:0;padding:0;padding-bottom:8px;word-break:break-word;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-hide:all;border-collapse:collapse;border-spacing:0;"><tbody style="mso-hide:all;"><tr style="mso-hide:all;"><td style="mso-hide:all;width:102px;" width="102"> <a href="https://modrinth.com" target="_blank" title style="mso-hide:all;"> <img alt="modrinth logo" src="https://cdn.modrinth.com/email/4783699dd14602e3d326335cc56ff7ec.png" style="mso-hide:all;border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;" title width="102" height="auto"></a>
|
||||
</td></tr></tbody></table>
|
||||
</td></tr><tr><td align="left" class="x m" style="font-size:0;padding-bottom:8px;word-break:break-word;"><div style="text-align:left;"><p style="Margin:0;text-align:left;mso-line-height-alt:16px;mso-ansi-font-size:14px;"><span style="font-size:13px;font-family:Inter,Arial,sans-serif;font-weight:400;color:#4d4d4d;line-height:123%;mso-line-height-alt:16px;mso-ansi-font-size:14px;">Rinth, Inc.</span></p></div>
|
||||
</td></tr><tr><td align="left" class="x dt-B0BAC5" style="font-size:0;padding-bottom:0;word-break:break-word;"><div style="text-align:left;"><p style="Margin:0;text-align:left;mso-line-height-alt:16px;mso-ansi-font-size:14px;"><span style="font-size:13px;font-family:Inter,Arial,sans-serif;font-weight:400;color:#aaaaaa;line-height:123%;mso-line-height-alt:16px;mso-ansi-font-size:14px;">410 N Scottsdale Road</span></p><p style="Margin:0;mso-line-height-alt:16px;mso-ansi-font-size:14px;"><span style="font-size:13px;font-family:Inter,Arial,sans-serif;font-weight:400;color:#aaaaaa;line-height:123%;mso-line-height-alt:16px;mso-ansi-font-size:14px;">Suite 1000</span></p><p style="Margin:0;mso-line-height-alt:16px;mso-ansi-font-size:14px;"><span style="font-size:13px;font-family:Inter,Arial,sans-serif;font-weight:400;color:#aaaaaa;line-height:123%;mso-line-height-alt:16px;mso-ansi-font-size:14px;">Tempe, AZ 85281</span></p></div>
|
||||
</td></tr></tbody></table></div>
|
||||
<!--[if mso | IE]>
|
||||
</td><td style="width:15px;">
|
||||
<![endif]--><div class="pc2-8169 ogf g" style="font-size:0;text-align:left;direction:ltr;display:inline-block;width:2.8169%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%"><tbody><tr><td style="padding:0;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style width="100%"><tbody></tbody></table>
|
||||
</td></tr></tbody></table></div>
|
||||
<!--[if mso | IE]>
|
||||
</td><td style="vertical-align:middle;width:275px;">
|
||||
<![endif]--><div class="pc48-5915 ogf c" style="font-size:0;text-align:left;direction:ltr;display:inline-block;vertical-align:middle;width:48.5915%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border:none;vertical-align:middle;" width="100%"><tbody><tr><td align="right" class="o" style="font-size:0;padding:0;word-break:break-word;">
|
||||
<!--[if mso | IE]>
|
||||
<table align="right" border="0" cellpadding="0" cellspacing="0" role="presentation"><tr><td>
|
||||
<![endif]-->
|
||||
<table align="right" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;"><tbody><tr class="e dh-1 m"><td style="padding:0 16px 0 0;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:20px;"><tbody><tr><td style="font-size:0;height:20px;vertical-align:middle;width:20px;"> <a href="https://discord.modrinth.com" target="_blank"> <img alt="Discord" title height="20" src="https://cdn.modrinth.com/email/e089a3a07be91c2940beff1fb191b247.png" style="display:block;" width="20"></a>
|
||||
</td></tr></tbody></table>
|
||||
</td></tr></tbody></table>
|
||||
<!--[if mso | IE]>
|
||||
</td><td>
|
||||
<![endif]-->
|
||||
<table align="right" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;"><tbody><tr class="e dh-1 m"><td style="padding:0 16px 0 0;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:20px;"><tbody><tr><td style="font-size:0;height:20px;vertical-align:middle;width:20px;"> <a href="https://twitter.com/modrinth" target="_blank"> <img alt="Twitter" title height="20" src="https://cdn.modrinth.com/email/363985aad91cab53854276e12f267b0b.png" style="display:block;" width="20"></a>
|
||||
</td></tr></tbody></table>
|
||||
</td></tr></tbody></table>
|
||||
<!--[if mso | IE]>
|
||||
</td><td>
|
||||
<![endif]-->
|
||||
<table align="right" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;"><tbody><tr class="e dh-1 m"><td style="padding:0 16px 0 0;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:20px;"><tbody><tr><td style="font-size:0;height:20px;vertical-align:middle;width:20px;"> <a href="https://floss.social/@modrinth" target="_blank"> <img alt="Mastodon" title height="20" src="https://cdn.modrinth.com/email/e25c30d5707744f31dcf18651a67d3d5.png" style="display:block;" width="20"></a>
|
||||
</td></tr></tbody></table>
|
||||
</td></tr></tbody></table>
|
||||
<!--[if mso | IE]>
|
||||
</td><td>
|
||||
<![endif]-->
|
||||
<table align="right" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;"><tbody><tr class="e dh-1 m"><td style="padding:0 16px 0 0;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:20px;"><tbody><tr><td style="font-size:0;height:20px;vertical-align:middle;width:20px;"> <a href="https://github.com/modrinth/" target="_blank"> <img alt="GitHub" title height="20" src="https://cdn.modrinth.com/email/45993023966d64e6138fd65a530a5d03.png" style="display:block;" width="20"></a>
|
||||
</td></tr></tbody></table>
|
||||
</td></tr></tbody></table>
|
||||
<!--[if mso | IE]>
|
||||
</td><td>
|
||||
<![endif]-->
|
||||
<table align="right" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;"><tbody><tr class="e dh-1 m"><td style="padding:0 16px 0 0;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:20px;"><tbody><tr><td style="font-size:0;height:20px;vertical-align:middle;width:20px;"> <a href="https://www.youtube.com/@modrinth" target="_blank"> <img alt="YouTube" title height="20" src="https://cdn.modrinth.com/email/a15afae9fc94e105caeb1bb4d33a0a13.png" style="display:block;" width="20"></a>
|
||||
</td></tr></tbody></table>
|
||||
</td></tr></tbody></table>
|
||||
<!--[if mso | IE]>
|
||||
</td><td>
|
||||
<![endif]-->
|
||||
<table align="right" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;"><tbody><tr class="e ds-1 m" style="mso-hide:all;display:none;height:0;overflow:hidden;"><td style="mso-hide:all;padding:0 16px 0 0;vertical-align:middle;" valign="middle">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-hide:all;width:20px;" width="20"><tbody style="mso-hide:all;"><tr style="mso-hide:all;"><td style="mso-hide:all;font-size:0;height:20px;vertical-align:middle;width:20px;" width="20" height="20" valign="middle"> <a href="https://discord.modrinth.com" target="_blank" style="mso-hide:all;"> <img alt="Discord" title height="20" src="https://cdn.modrinth.com/email/8a44a64dd39ba64e3a480ef9872351c9.png" style="mso-hide:all;display:block;" width="20"></a>
|
||||
</td></tr></tbody></table>
|
||||
</td></tr></tbody></table>
|
||||
<!--[if mso | IE]>
|
||||
</td><td>
|
||||
<![endif]-->
|
||||
<table align="right" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;"><tbody><tr class="e ds-1 m" style="mso-hide:all;display:none;height:0;overflow:hidden;"><td style="mso-hide:all;padding:0 16px 0 0;vertical-align:middle;" valign="middle">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-hide:all;width:20px;" width="20"><tbody style="mso-hide:all;"><tr style="mso-hide:all;"><td style="mso-hide:all;font-size:0;height:20px;vertical-align:middle;width:20px;" width="20" height="20" valign="middle"> <a href="https://twitter.com/modrinth" target="_blank" style="mso-hide:all;"> <img alt="Twitter" title height="20" src="https://cdn.modrinth.com/email/b42fea63b0f099e851cb0ee60649c7aa.png" style="mso-hide:all;display:block;" width="20"></a>
|
||||
</td></tr></tbody></table>
|
||||
</td></tr></tbody></table>
|
||||
<!--[if mso | IE]>
|
||||
</td><td>
|
||||
<![endif]-->
|
||||
<table align="right" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;"><tbody><tr class="e ds-1 m" style="mso-hide:all;display:none;height:0;overflow:hidden;"><td style="mso-hide:all;padding:0 16px 0 0;vertical-align:middle;" valign="middle">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-hide:all;width:20px;" width="20"><tbody style="mso-hide:all;"><tr style="mso-hide:all;"><td style="mso-hide:all;font-size:0;height:20px;vertical-align:middle;width:20px;" width="20" height="20" valign="middle"> <a href="https://floss.social/@modrinth" target="_blank" style="mso-hide:all;"> <img alt="Mastodon" title height="20" src="https://cdn.modrinth.com/email/7469febd9dfb6dd61ce7fdac12b4a644.png" style="mso-hide:all;display:block;" width="20"></a>
|
||||
</td></tr></tbody></table>
|
||||
</td></tr></tbody></table>
|
||||
<!--[if mso | IE]>
|
||||
</td><td>
|
||||
<![endif]-->
|
||||
<table align="right" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;"><tbody><tr class="e ds-1 m" style="mso-hide:all;display:none;height:0;overflow:hidden;"><td style="mso-hide:all;padding:0 16px 0 0;vertical-align:middle;" valign="middle">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-hide:all;width:20px;" width="20"><tbody style="mso-hide:all;"><tr style="mso-hide:all;"><td style="mso-hide:all;font-size:0;height:20px;vertical-align:middle;width:20px;" width="20" height="20" valign="middle"> <a href="https://github.com/modrinth/" target="_blank" style="mso-hide:all;"> <img alt="GitHub" title height="20" src="https://cdn.modrinth.com/email/431cdb26e1afcff6b6d74c452c581987.png" style="mso-hide:all;display:block;" width="20"></a>
|
||||
</td></tr></tbody></table>
|
||||
</td></tr></tbody></table>
|
||||
<!--[if mso | IE]>
|
||||
</td><td>
|
||||
<![endif]-->
|
||||
<table align="right" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;"><tbody><tr class="e ds-1" style="mso-hide:all;display:none;height:0;overflow:hidden;"><td style="mso-hide:all;padding:0;padding-right:0;vertical-align:middle;" valign="middle">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-hide:all;width:20px;" width="20"><tbody style="mso-hide:all;"><tr style="mso-hide:all;"><td style="mso-hide:all;font-size:0;height:20px;vertical-align:middle;width:20px;" width="20" height="20" valign="middle"> <a href="https://www.youtube.com/@modrinth" target="_blank" style="mso-hide:all;"> <img alt="YouTube" title height="20" src="https://cdn.modrinth.com/email/44f82a157ad4c4b122e1927a20b62660.png" style="mso-hide:all;display:block;" width="20"></a>
|
||||
</td></tr></tbody></table>
|
||||
</td></tr></tbody></table>
|
||||
<!--[if mso | IE]>
|
||||
</td></tr></table>
|
||||
<![endif]-->
|
||||
</td></tr></tbody></table></div>
|
||||
<!--[if mso | IE]>
|
||||
</td></tr></table>
|
||||
<![endif]--></div>
|
||||
<!--[if mso | IE]>
|
||||
</td></tr></table>
|
||||
<![endif]-->
|
||||
</td></tr></tbody></table></div>
|
||||
<!--[if mso | IE]>
|
||||
</td></tr></table>
|
||||
<![endif]--></div>
|
||||
</body>
|
||||
</html>
|
||||
68
apps/labrinth/src/auth/email/mod.rs
Normal file
68
apps/labrinth/src/auth/email/mod.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
use lettre::message::header::ContentType;
|
||||
use lettre::message::Mailbox;
|
||||
use lettre::transport::smtp::authentication::Credentials;
|
||||
use lettre::{Address, Message, SmtpTransport, Transport};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum MailError {
|
||||
#[error("Environment Error")]
|
||||
Env(#[from] dotenvy::Error),
|
||||
#[error("Mail Error: {0}")]
|
||||
Mail(#[from] lettre::error::Error),
|
||||
#[error("Address Parse Error: {0}")]
|
||||
Address(#[from] lettre::address::AddressError),
|
||||
#[error("SMTP Error: {0}")]
|
||||
Smtp(#[from] lettre::transport::smtp::Error),
|
||||
}
|
||||
|
||||
pub fn send_email_raw(to: String, subject: String, body: String) -> Result<(), MailError> {
|
||||
let email = Message::builder()
|
||||
.from(Mailbox::new(
|
||||
Some("Modrinth".to_string()),
|
||||
Address::new("no-reply", "mail.modrinth.com")?,
|
||||
))
|
||||
.to(to.parse()?)
|
||||
.subject(subject)
|
||||
.header(ContentType::TEXT_HTML)
|
||||
.body(body)?;
|
||||
|
||||
let username = dotenvy::var("SMTP_USERNAME")?;
|
||||
let password = dotenvy::var("SMTP_PASSWORD")?;
|
||||
let host = dotenvy::var("SMTP_HOST")?;
|
||||
let creds = Credentials::new(username, password);
|
||||
|
||||
let mailer = SmtpTransport::relay(&host)?.credentials(creds).build();
|
||||
|
||||
mailer.send(&email)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn send_email(
|
||||
to: String,
|
||||
email_title: &str,
|
||||
email_description: &str,
|
||||
line_two: &str,
|
||||
button_info: Option<(&str, &str)>,
|
||||
) -> Result<(), MailError> {
|
||||
let mut email = if button_info.is_some() {
|
||||
include_str!("button_notif.html")
|
||||
} else {
|
||||
include_str!("auth_notif.html")
|
||||
}
|
||||
.replace("{{ email_title }}", email_title)
|
||||
.replace("{{ email_description }}", email_description)
|
||||
.replace("{{ line_one }}", email_description)
|
||||
.replace("{{ line_two }}", line_two);
|
||||
|
||||
if let Some((button_title, button_link)) = button_info {
|
||||
email = email
|
||||
.replace("{{ button_title }}", button_title)
|
||||
.replace("{{ button_link }}", button_link);
|
||||
}
|
||||
|
||||
send_email_raw(to, email_title.to_string(), email)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
113
apps/labrinth/src/auth/mod.rs
Normal file
113
apps/labrinth/src/auth/mod.rs
Normal file
@@ -0,0 +1,113 @@
|
||||
pub mod checks;
|
||||
pub mod email;
|
||||
pub mod oauth;
|
||||
pub mod templates;
|
||||
pub mod validate;
|
||||
pub use crate::auth::email::send_email;
|
||||
pub use checks::{
|
||||
filter_enlisted_projects_ids, filter_enlisted_version_ids, filter_visible_collections,
|
||||
filter_visible_project_ids, filter_visible_projects,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
// pub use pat::{generate_pat, PersonalAccessToken};
|
||||
pub use validate::{check_is_moderator_from_headers, get_user_from_headers};
|
||||
|
||||
use crate::file_hosting::FileHostingError;
|
||||
use crate::models::error::ApiError;
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::HttpResponse;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum AuthenticationError {
|
||||
#[error("Environment Error")]
|
||||
Env(#[from] dotenvy::Error),
|
||||
#[error("An unknown database error occurred: {0}")]
|
||||
Sqlx(#[from] sqlx::Error),
|
||||
#[error("Database Error: {0}")]
|
||||
Database(#[from] crate::database::models::DatabaseError),
|
||||
#[error("Error while parsing JSON: {0}")]
|
||||
SerDe(#[from] serde_json::Error),
|
||||
#[error("Error while communicating to external provider")]
|
||||
Reqwest(#[from] reqwest::Error),
|
||||
#[error("Error uploading user profile picture")]
|
||||
FileHosting(#[from] FileHostingError),
|
||||
#[error("Error while decoding PAT: {0}")]
|
||||
Decoding(#[from] crate::models::ids::DecodingError),
|
||||
#[error("{0}")]
|
||||
Mail(#[from] email::MailError),
|
||||
#[error("Invalid Authentication Credentials")]
|
||||
InvalidCredentials,
|
||||
#[error("Authentication method was not valid")]
|
||||
InvalidAuthMethod,
|
||||
#[error("GitHub Token from incorrect Client ID")]
|
||||
InvalidClientId,
|
||||
#[error("User email/account is already registered on Modrinth")]
|
||||
DuplicateUser,
|
||||
#[error("Invalid state sent, you probably need to get a new websocket")]
|
||||
SocketError,
|
||||
#[error("Invalid callback URL specified")]
|
||||
Url,
|
||||
}
|
||||
|
||||
impl actix_web::ResponseError for AuthenticationError {
|
||||
fn status_code(&self) -> StatusCode {
|
||||
match self {
|
||||
AuthenticationError::Env(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
AuthenticationError::Sqlx(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
AuthenticationError::Database(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
AuthenticationError::SerDe(..) => StatusCode::BAD_REQUEST,
|
||||
AuthenticationError::Reqwest(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
AuthenticationError::InvalidCredentials => StatusCode::UNAUTHORIZED,
|
||||
AuthenticationError::Decoding(..) => StatusCode::BAD_REQUEST,
|
||||
AuthenticationError::Mail(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
AuthenticationError::InvalidAuthMethod => StatusCode::UNAUTHORIZED,
|
||||
AuthenticationError::InvalidClientId => StatusCode::UNAUTHORIZED,
|
||||
AuthenticationError::Url => StatusCode::BAD_REQUEST,
|
||||
AuthenticationError::FileHosting(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
AuthenticationError::DuplicateUser => StatusCode::BAD_REQUEST,
|
||||
AuthenticationError::SocketError => StatusCode::BAD_REQUEST,
|
||||
}
|
||||
}
|
||||
|
||||
fn error_response(&self) -> HttpResponse {
|
||||
HttpResponse::build(self.status_code()).json(ApiError {
|
||||
error: self.error_name(),
|
||||
description: self.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthenticationError {
|
||||
pub fn error_name(&self) -> &'static str {
|
||||
match self {
|
||||
AuthenticationError::Env(..) => "environment_error",
|
||||
AuthenticationError::Sqlx(..) => "database_error",
|
||||
AuthenticationError::Database(..) => "database_error",
|
||||
AuthenticationError::SerDe(..) => "invalid_input",
|
||||
AuthenticationError::Reqwest(..) => "network_error",
|
||||
AuthenticationError::InvalidCredentials => "invalid_credentials",
|
||||
AuthenticationError::Decoding(..) => "decoding_error",
|
||||
AuthenticationError::Mail(..) => "mail_error",
|
||||
AuthenticationError::InvalidAuthMethod => "invalid_auth_method",
|
||||
AuthenticationError::InvalidClientId => "invalid_client_id",
|
||||
AuthenticationError::Url => "url_error",
|
||||
AuthenticationError::FileHosting(..) => "file_hosting",
|
||||
AuthenticationError::DuplicateUser => "duplicate_user",
|
||||
AuthenticationError::SocketError => "socket",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Default, Eq, PartialEq, Clone, Copy, Debug)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AuthProvider {
|
||||
#[default]
|
||||
GitHub,
|
||||
Discord,
|
||||
Microsoft,
|
||||
GitLab,
|
||||
Google,
|
||||
Steam,
|
||||
PayPal,
|
||||
}
|
||||
175
apps/labrinth/src/auth/oauth/errors.rs
Normal file
175
apps/labrinth/src/auth/oauth/errors.rs
Normal file
@@ -0,0 +1,175 @@
|
||||
use super::ValidatedRedirectUri;
|
||||
use crate::auth::AuthenticationError;
|
||||
use crate::models::error::ApiError;
|
||||
use crate::models::ids::DecodingError;
|
||||
use actix_web::http::{header::LOCATION, StatusCode};
|
||||
use actix_web::HttpResponse;
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
#[error("{}", .error_type)]
|
||||
pub struct OAuthError {
|
||||
#[source]
|
||||
pub error_type: OAuthErrorType,
|
||||
|
||||
pub state: Option<String>,
|
||||
pub valid_redirect_uri: Option<ValidatedRedirectUri>,
|
||||
}
|
||||
|
||||
impl<T> From<T> for OAuthError
|
||||
where
|
||||
T: Into<OAuthErrorType>,
|
||||
{
|
||||
fn from(value: T) -> Self {
|
||||
OAuthError::error(value.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl OAuthError {
|
||||
/// The OAuth request failed either because of an invalid redirection URI
|
||||
/// or before we could validate the one we were given, so return an error
|
||||
/// directly to the caller
|
||||
///
|
||||
/// See: IETF RFC 6749 4.1.2.1 (https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1)
|
||||
pub fn error(error_type: impl Into<OAuthErrorType>) -> Self {
|
||||
Self {
|
||||
error_type: error_type.into(),
|
||||
valid_redirect_uri: None,
|
||||
state: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The OAuth request failed for a reason other than an invalid redirection URI
|
||||
/// So send the error in url-encoded form to the redirect URI
|
||||
///
|
||||
/// See: IETF RFC 6749 4.1.2.1 (https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1)
|
||||
pub fn redirect(
|
||||
err: impl Into<OAuthErrorType>,
|
||||
state: &Option<String>,
|
||||
valid_redirect_uri: &ValidatedRedirectUri,
|
||||
) -> Self {
|
||||
Self {
|
||||
error_type: err.into(),
|
||||
state: state.clone(),
|
||||
valid_redirect_uri: Some(valid_redirect_uri.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl actix_web::ResponseError for OAuthError {
|
||||
fn status_code(&self) -> StatusCode {
|
||||
match self.error_type {
|
||||
OAuthErrorType::AuthenticationError(_)
|
||||
| OAuthErrorType::FailedScopeParse(_)
|
||||
| OAuthErrorType::ScopesTooBroad
|
||||
| OAuthErrorType::AccessDenied => {
|
||||
if self.valid_redirect_uri.is_some() {
|
||||
StatusCode::OK
|
||||
} else {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
}
|
||||
}
|
||||
OAuthErrorType::RedirectUriNotConfigured(_)
|
||||
| OAuthErrorType::ClientMissingRedirectURI { client_id: _ }
|
||||
| OAuthErrorType::InvalidAcceptFlowId
|
||||
| OAuthErrorType::MalformedId(_)
|
||||
| OAuthErrorType::InvalidClientId(_)
|
||||
| OAuthErrorType::InvalidAuthCode
|
||||
| OAuthErrorType::OnlySupportsAuthorizationCodeGrant(_)
|
||||
| OAuthErrorType::RedirectUriChanged(_)
|
||||
| OAuthErrorType::UnauthorizedClient => StatusCode::BAD_REQUEST,
|
||||
OAuthErrorType::ClientAuthenticationFailed => StatusCode::UNAUTHORIZED,
|
||||
}
|
||||
}
|
||||
|
||||
fn error_response(&self) -> HttpResponse {
|
||||
if let Some(ValidatedRedirectUri(mut redirect_uri)) = self.valid_redirect_uri.clone() {
|
||||
redirect_uri = format!(
|
||||
"{}?error={}&error_description={}",
|
||||
redirect_uri,
|
||||
self.error_type.error_name(),
|
||||
self.error_type,
|
||||
);
|
||||
|
||||
if let Some(state) = self.state.as_ref() {
|
||||
redirect_uri = format!("{}&state={}", redirect_uri, state);
|
||||
}
|
||||
|
||||
HttpResponse::Ok()
|
||||
.append_header((LOCATION, redirect_uri.clone()))
|
||||
.body(redirect_uri)
|
||||
} else {
|
||||
HttpResponse::build(self.status_code()).json(ApiError {
|
||||
error: &self.error_type.error_name(),
|
||||
description: self.error_type.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum OAuthErrorType {
|
||||
#[error(transparent)]
|
||||
AuthenticationError(#[from] AuthenticationError),
|
||||
#[error("Client {} has no redirect URIs specified", .client_id.0)]
|
||||
ClientMissingRedirectURI {
|
||||
client_id: crate::database::models::OAuthClientId,
|
||||
},
|
||||
#[error("The provided redirect URI did not match any configured in the client")]
|
||||
RedirectUriNotConfigured(String),
|
||||
#[error("The provided scope was malformed or did not correspond to known scopes ({0})")]
|
||||
FailedScopeParse(bitflags::parser::ParseError),
|
||||
#[error(
|
||||
"The provided scope requested scopes broader than the developer app is configured with"
|
||||
)]
|
||||
ScopesTooBroad,
|
||||
#[error("The provided flow id was invalid")]
|
||||
InvalidAcceptFlowId,
|
||||
#[error("The provided client id was invalid")]
|
||||
InvalidClientId(crate::database::models::OAuthClientId),
|
||||
#[error("The provided ID could not be decoded: {0}")]
|
||||
MalformedId(#[from] DecodingError),
|
||||
#[error("Failed to authenticate client")]
|
||||
ClientAuthenticationFailed,
|
||||
#[error("The provided authorization grant code was invalid")]
|
||||
InvalidAuthCode,
|
||||
#[error("The provided client id did not match the id this authorization code was granted to")]
|
||||
UnauthorizedClient,
|
||||
#[error("The provided redirect URI did not exactly match the uri originally provided when this flow began")]
|
||||
RedirectUriChanged(Option<String>),
|
||||
#[error("The provided grant type ({0}) must be \"authorization_code\"")]
|
||||
OnlySupportsAuthorizationCodeGrant(String),
|
||||
#[error("The resource owner denied the request")]
|
||||
AccessDenied,
|
||||
}
|
||||
|
||||
impl From<crate::database::models::DatabaseError> for OAuthErrorType {
|
||||
fn from(value: crate::database::models::DatabaseError) -> Self {
|
||||
OAuthErrorType::AuthenticationError(value.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<sqlx::Error> for OAuthErrorType {
|
||||
fn from(value: sqlx::Error) -> Self {
|
||||
OAuthErrorType::AuthenticationError(value.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl OAuthErrorType {
|
||||
pub fn error_name(&self) -> String {
|
||||
// IETF RFC 6749 4.1.2.1 (https://datatracker.ietf.org/doc/html/rfc6749#autoid-38)
|
||||
// And 5.2 (https://datatracker.ietf.org/doc/html/rfc6749#section-5.2)
|
||||
match self {
|
||||
Self::RedirectUriNotConfigured(_) | Self::ClientMissingRedirectURI { client_id: _ } => {
|
||||
"invalid_uri"
|
||||
}
|
||||
Self::AuthenticationError(_) | Self::InvalidAcceptFlowId => "server_error",
|
||||
Self::RedirectUriChanged(_) | Self::MalformedId(_) => "invalid_request",
|
||||
Self::FailedScopeParse(_) | Self::ScopesTooBroad => "invalid_scope",
|
||||
Self::InvalidClientId(_) | Self::ClientAuthenticationFailed => "invalid_client",
|
||||
Self::InvalidAuthCode | Self::OnlySupportsAuthorizationCodeGrant(_) => "invalid_grant",
|
||||
Self::UnauthorizedClient => "unauthorized_client",
|
||||
Self::AccessDenied => "access_denied",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
429
apps/labrinth/src/auth/oauth/mod.rs
Normal file
429
apps/labrinth/src/auth/oauth/mod.rs
Normal file
@@ -0,0 +1,429 @@
|
||||
use crate::auth::get_user_from_headers;
|
||||
use crate::auth::oauth::uris::{OAuthRedirectUris, ValidatedRedirectUri};
|
||||
use crate::auth::validate::extract_authorization_header;
|
||||
use crate::database::models::flow_item::Flow;
|
||||
use crate::database::models::oauth_client_authorization_item::OAuthClientAuthorization;
|
||||
use crate::database::models::oauth_client_item::OAuthClient as DBOAuthClient;
|
||||
use crate::database::models::oauth_token_item::OAuthAccessToken;
|
||||
use crate::database::models::{
|
||||
generate_oauth_access_token_id, generate_oauth_client_authorization_id,
|
||||
OAuthClientAuthorizationId,
|
||||
};
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::models;
|
||||
use crate::models::ids::OAuthClientId;
|
||||
use crate::models::pats::Scopes;
|
||||
use crate::queue::session::AuthQueue;
|
||||
use actix_web::http::header::LOCATION;
|
||||
use actix_web::web::{Data, Query, ServiceConfig};
|
||||
use actix_web::{get, post, web, HttpRequest, HttpResponse};
|
||||
use chrono::Duration;
|
||||
use rand::distributions::Alphanumeric;
|
||||
use rand::{Rng, SeedableRng};
|
||||
use rand_chacha::ChaCha20Rng;
|
||||
use reqwest::header::{CACHE_CONTROL, PRAGMA};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::postgres::PgPool;
|
||||
|
||||
use self::errors::{OAuthError, OAuthErrorType};
|
||||
|
||||
use super::AuthenticationError;
|
||||
|
||||
pub mod errors;
|
||||
pub mod uris;
|
||||
|
||||
pub fn config(cfg: &mut ServiceConfig) {
|
||||
cfg.service(init_oauth)
|
||||
.service(accept_client_scopes)
|
||||
.service(reject_client_scopes)
|
||||
.service(request_token);
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct OAuthInit {
|
||||
pub client_id: OAuthClientId,
|
||||
pub redirect_uri: Option<String>,
|
||||
pub scope: Option<String>,
|
||||
pub state: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct OAuthClientAccessRequest {
|
||||
pub flow_id: String,
|
||||
pub client_id: OAuthClientId,
|
||||
pub client_name: String,
|
||||
pub client_icon: Option<String>,
|
||||
pub requested_scopes: Scopes,
|
||||
}
|
||||
|
||||
#[get("authorize")]
|
||||
pub async fn init_oauth(
|
||||
req: HttpRequest,
|
||||
Query(oauth_info): Query<OAuthInit>,
|
||||
pool: Data<PgPool>,
|
||||
redis: Data<RedisPool>,
|
||||
session_queue: Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, OAuthError> {
|
||||
let user = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Some(&[Scopes::USER_AUTH_WRITE]),
|
||||
)
|
||||
.await?
|
||||
.1;
|
||||
|
||||
let client_id = oauth_info.client_id.into();
|
||||
let client = DBOAuthClient::get(client_id, &**pool).await?;
|
||||
|
||||
if let Some(client) = client {
|
||||
let redirect_uri = ValidatedRedirectUri::validate(
|
||||
&oauth_info.redirect_uri,
|
||||
client.redirect_uris.iter().map(|r| r.uri.as_ref()),
|
||||
client.id,
|
||||
)?;
|
||||
|
||||
let requested_scopes = oauth_info
|
||||
.scope
|
||||
.as_ref()
|
||||
.map_or(Ok(client.max_scopes), |s| {
|
||||
Scopes::parse_from_oauth_scopes(s).map_err(|e| {
|
||||
OAuthError::redirect(
|
||||
OAuthErrorType::FailedScopeParse(e),
|
||||
&oauth_info.state,
|
||||
&redirect_uri,
|
||||
)
|
||||
})
|
||||
})?;
|
||||
|
||||
if !client.max_scopes.contains(requested_scopes) {
|
||||
return Err(OAuthError::redirect(
|
||||
OAuthErrorType::ScopesTooBroad,
|
||||
&oauth_info.state,
|
||||
&redirect_uri,
|
||||
));
|
||||
}
|
||||
|
||||
let existing_authorization =
|
||||
OAuthClientAuthorization::get(client.id, user.id.into(), &**pool)
|
||||
.await
|
||||
.map_err(|e| OAuthError::redirect(e, &oauth_info.state, &redirect_uri))?;
|
||||
let redirect_uris =
|
||||
OAuthRedirectUris::new(oauth_info.redirect_uri.clone(), redirect_uri.clone());
|
||||
match existing_authorization {
|
||||
Some(existing_authorization)
|
||||
if existing_authorization.scopes.contains(requested_scopes) =>
|
||||
{
|
||||
init_oauth_code_flow(
|
||||
user.id.into(),
|
||||
client.id.into(),
|
||||
existing_authorization.id,
|
||||
requested_scopes,
|
||||
redirect_uris,
|
||||
oauth_info.state,
|
||||
&redis,
|
||||
)
|
||||
.await
|
||||
}
|
||||
_ => {
|
||||
let flow_id = Flow::InitOAuthAppApproval {
|
||||
user_id: user.id.into(),
|
||||
client_id: client.id,
|
||||
existing_authorization_id: existing_authorization.map(|a| a.id),
|
||||
scopes: requested_scopes,
|
||||
redirect_uris,
|
||||
state: oauth_info.state.clone(),
|
||||
}
|
||||
.insert(Duration::minutes(30), &redis)
|
||||
.await
|
||||
.map_err(|e| OAuthError::redirect(e, &oauth_info.state, &redirect_uri))?;
|
||||
|
||||
let access_request = OAuthClientAccessRequest {
|
||||
client_id: client.id.into(),
|
||||
client_name: client.name,
|
||||
client_icon: client.icon_url,
|
||||
flow_id,
|
||||
requested_scopes,
|
||||
};
|
||||
Ok(HttpResponse::Ok().json(access_request))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Err(OAuthError::error(OAuthErrorType::InvalidClientId(
|
||||
client_id,
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct RespondToOAuthClientScopes {
|
||||
pub flow: String,
|
||||
}
|
||||
|
||||
#[post("accept")]
|
||||
pub async fn accept_client_scopes(
|
||||
req: HttpRequest,
|
||||
accept_body: web::Json<RespondToOAuthClientScopes>,
|
||||
pool: Data<PgPool>,
|
||||
redis: Data<RedisPool>,
|
||||
session_queue: Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, OAuthError> {
|
||||
accept_or_reject_client_scopes(true, req, accept_body, pool, redis, session_queue).await
|
||||
}
|
||||
|
||||
#[post("reject")]
|
||||
pub async fn reject_client_scopes(
|
||||
req: HttpRequest,
|
||||
body: web::Json<RespondToOAuthClientScopes>,
|
||||
pool: Data<PgPool>,
|
||||
redis: Data<RedisPool>,
|
||||
session_queue: Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, OAuthError> {
|
||||
accept_or_reject_client_scopes(false, req, body, pool, redis, session_queue).await
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct TokenRequest {
|
||||
pub grant_type: String,
|
||||
pub code: String,
|
||||
pub redirect_uri: Option<String>,
|
||||
pub client_id: models::ids::OAuthClientId,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct TokenResponse {
|
||||
pub access_token: String,
|
||||
pub token_type: String,
|
||||
pub expires_in: i64,
|
||||
}
|
||||
|
||||
#[post("token")]
|
||||
/// Params should be in the urlencoded request body
|
||||
/// And client secret should be in the HTTP basic authorization header
|
||||
/// Per IETF RFC6749 Section 4.1.3 (https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3)
|
||||
pub async fn request_token(
|
||||
req: HttpRequest,
|
||||
req_params: web::Form<TokenRequest>,
|
||||
pool: Data<PgPool>,
|
||||
redis: Data<RedisPool>,
|
||||
) -> Result<HttpResponse, OAuthError> {
|
||||
let req_client_id = req_params.client_id;
|
||||
let client = DBOAuthClient::get(req_client_id.into(), &**pool).await?;
|
||||
if let Some(client) = client {
|
||||
authenticate_client_token_request(&req, &client)?;
|
||||
|
||||
// Ensure auth code is single use
|
||||
// per IETF RFC6749 Section 10.5 (https://datatracker.ietf.org/doc/html/rfc6749#section-10.5)
|
||||
let flow = Flow::take_if(
|
||||
&req_params.code,
|
||||
|f| matches!(f, Flow::OAuthAuthorizationCodeSupplied { .. }),
|
||||
&redis,
|
||||
)
|
||||
.await?;
|
||||
if let Some(Flow::OAuthAuthorizationCodeSupplied {
|
||||
user_id,
|
||||
client_id,
|
||||
authorization_id,
|
||||
scopes,
|
||||
original_redirect_uri,
|
||||
}) = flow
|
||||
{
|
||||
// https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3
|
||||
if req_client_id != client_id.into() {
|
||||
return Err(OAuthError::error(OAuthErrorType::UnauthorizedClient));
|
||||
}
|
||||
|
||||
if original_redirect_uri != req_params.redirect_uri {
|
||||
return Err(OAuthError::error(OAuthErrorType::RedirectUriChanged(
|
||||
req_params.redirect_uri.clone(),
|
||||
)));
|
||||
}
|
||||
|
||||
if req_params.grant_type != "authorization_code" {
|
||||
return Err(OAuthError::error(
|
||||
OAuthErrorType::OnlySupportsAuthorizationCodeGrant(
|
||||
req_params.grant_type.clone(),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
let scopes = scopes - Scopes::restricted();
|
||||
|
||||
let mut transaction = pool.begin().await?;
|
||||
let token_id = generate_oauth_access_token_id(&mut transaction).await?;
|
||||
let token = generate_access_token();
|
||||
let token_hash = OAuthAccessToken::hash_token(&token);
|
||||
let time_until_expiration = OAuthAccessToken {
|
||||
id: token_id,
|
||||
authorization_id,
|
||||
token_hash,
|
||||
scopes,
|
||||
created: Default::default(),
|
||||
expires: Default::default(),
|
||||
last_used: None,
|
||||
client_id,
|
||||
user_id,
|
||||
}
|
||||
.insert(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
transaction.commit().await?;
|
||||
|
||||
// IETF RFC6749 Section 5.1 (https://datatracker.ietf.org/doc/html/rfc6749#section-5.1)
|
||||
Ok(HttpResponse::Ok()
|
||||
.append_header((CACHE_CONTROL, "no-store"))
|
||||
.append_header((PRAGMA, "no-cache"))
|
||||
.json(TokenResponse {
|
||||
access_token: token,
|
||||
token_type: "Bearer".to_string(),
|
||||
expires_in: time_until_expiration.num_seconds(),
|
||||
}))
|
||||
} else {
|
||||
Err(OAuthError::error(OAuthErrorType::InvalidAuthCode))
|
||||
}
|
||||
} else {
|
||||
Err(OAuthError::error(OAuthErrorType::InvalidClientId(
|
||||
req_client_id.into(),
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn accept_or_reject_client_scopes(
|
||||
accept: bool,
|
||||
req: HttpRequest,
|
||||
body: web::Json<RespondToOAuthClientScopes>,
|
||||
pool: Data<PgPool>,
|
||||
redis: Data<RedisPool>,
|
||||
session_queue: Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, OAuthError> {
|
||||
let current_user = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Some(&[Scopes::SESSION_ACCESS]),
|
||||
)
|
||||
.await?
|
||||
.1;
|
||||
|
||||
let flow = Flow::take_if(
|
||||
&body.flow,
|
||||
|f| matches!(f, Flow::InitOAuthAppApproval { .. }),
|
||||
&redis,
|
||||
)
|
||||
.await?;
|
||||
if let Some(Flow::InitOAuthAppApproval {
|
||||
user_id,
|
||||
client_id,
|
||||
existing_authorization_id,
|
||||
scopes,
|
||||
redirect_uris,
|
||||
state,
|
||||
}) = flow
|
||||
{
|
||||
if current_user.id != user_id.into() {
|
||||
return Err(OAuthError::error(AuthenticationError::InvalidCredentials));
|
||||
}
|
||||
|
||||
if accept {
|
||||
let mut transaction = pool.begin().await?;
|
||||
|
||||
let auth_id = match existing_authorization_id {
|
||||
Some(id) => id,
|
||||
None => generate_oauth_client_authorization_id(&mut transaction).await?,
|
||||
};
|
||||
OAuthClientAuthorization::upsert(auth_id, client_id, user_id, scopes, &mut transaction)
|
||||
.await?;
|
||||
|
||||
transaction.commit().await?;
|
||||
|
||||
init_oauth_code_flow(
|
||||
user_id,
|
||||
client_id.into(),
|
||||
auth_id,
|
||||
scopes,
|
||||
redirect_uris,
|
||||
state,
|
||||
&redis,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
Err(OAuthError::redirect(
|
||||
OAuthErrorType::AccessDenied,
|
||||
&state,
|
||||
&redirect_uris.validated,
|
||||
))
|
||||
}
|
||||
} else {
|
||||
Err(OAuthError::error(OAuthErrorType::InvalidAcceptFlowId))
|
||||
}
|
||||
}
|
||||
|
||||
fn authenticate_client_token_request(
|
||||
req: &HttpRequest,
|
||||
client: &DBOAuthClient,
|
||||
) -> Result<(), OAuthError> {
|
||||
let client_secret = extract_authorization_header(req)?;
|
||||
let hashed_client_secret = DBOAuthClient::hash_secret(client_secret);
|
||||
if client.secret_hash != hashed_client_secret {
|
||||
Err(OAuthError::error(
|
||||
OAuthErrorType::ClientAuthenticationFailed,
|
||||
))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_access_token() -> String {
|
||||
let random = ChaCha20Rng::from_entropy()
|
||||
.sample_iter(&Alphanumeric)
|
||||
.take(60)
|
||||
.map(char::from)
|
||||
.collect::<String>();
|
||||
format!("mro_{}", random)
|
||||
}
|
||||
|
||||
async fn init_oauth_code_flow(
|
||||
user_id: crate::database::models::UserId,
|
||||
client_id: OAuthClientId,
|
||||
authorization_id: OAuthClientAuthorizationId,
|
||||
scopes: Scopes,
|
||||
redirect_uris: OAuthRedirectUris,
|
||||
state: Option<String>,
|
||||
redis: &RedisPool,
|
||||
) -> Result<HttpResponse, OAuthError> {
|
||||
let code = Flow::OAuthAuthorizationCodeSupplied {
|
||||
user_id,
|
||||
client_id: client_id.into(),
|
||||
authorization_id,
|
||||
scopes,
|
||||
original_redirect_uri: redirect_uris.original.clone(),
|
||||
}
|
||||
.insert(Duration::minutes(10), redis)
|
||||
.await
|
||||
.map_err(|e| OAuthError::redirect(e, &state, &redirect_uris.validated.clone()))?;
|
||||
|
||||
let mut redirect_params = vec![format!("code={code}")];
|
||||
if let Some(state) = state {
|
||||
redirect_params.push(format!("state={state}"));
|
||||
}
|
||||
|
||||
let redirect_uri = append_params_to_uri(&redirect_uris.validated.0, &redirect_params);
|
||||
|
||||
// IETF RFC 6749 Section 4.1.2 (https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2)
|
||||
Ok(HttpResponse::Ok()
|
||||
.append_header((LOCATION, redirect_uri.clone()))
|
||||
.body(redirect_uri))
|
||||
}
|
||||
|
||||
fn append_params_to_uri(uri: &str, params: &[impl AsRef<str>]) -> String {
|
||||
let mut uri = uri.to_string();
|
||||
let mut connector = if uri.contains('?') { "&" } else { "?" };
|
||||
for param in params {
|
||||
uri.push_str(&format!("{}{}", connector, param.as_ref()));
|
||||
connector = "&";
|
||||
}
|
||||
|
||||
uri
|
||||
}
|
||||
94
apps/labrinth/src/auth/oauth/uris.rs
Normal file
94
apps/labrinth/src/auth/oauth/uris.rs
Normal file
@@ -0,0 +1,94 @@
|
||||
use super::errors::OAuthError;
|
||||
use crate::auth::oauth::OAuthErrorType;
|
||||
use crate::database::models::OAuthClientId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(derive_new::new, Serialize, Deserialize)]
|
||||
pub struct OAuthRedirectUris {
|
||||
pub original: Option<String>,
|
||||
pub validated: ValidatedRedirectUri,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ValidatedRedirectUri(pub String);
|
||||
|
||||
impl ValidatedRedirectUri {
|
||||
pub fn validate<'a>(
|
||||
to_validate: &Option<String>,
|
||||
validate_against: impl IntoIterator<Item = &'a str> + Clone,
|
||||
client_id: OAuthClientId,
|
||||
) -> Result<Self, OAuthError> {
|
||||
if let Some(first_client_redirect_uri) = validate_against.clone().into_iter().next() {
|
||||
if let Some(to_validate) = to_validate {
|
||||
if validate_against
|
||||
.into_iter()
|
||||
.any(|uri| same_uri_except_query_components(uri, to_validate))
|
||||
{
|
||||
Ok(ValidatedRedirectUri(to_validate.clone()))
|
||||
} else {
|
||||
Err(OAuthError::error(OAuthErrorType::RedirectUriNotConfigured(
|
||||
to_validate.clone(),
|
||||
)))
|
||||
}
|
||||
} else {
|
||||
Ok(ValidatedRedirectUri(first_client_redirect_uri.to_string()))
|
||||
}
|
||||
} else {
|
||||
Err(OAuthError::error(
|
||||
OAuthErrorType::ClientMissingRedirectURI { client_id },
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn same_uri_except_query_components(a: &str, b: &str) -> bool {
|
||||
let mut a_components = a.split('?');
|
||||
let mut b_components = b.split('?');
|
||||
a_components.next() == b_components.next()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn validate_for_none_returns_first_valid_uri() {
|
||||
let validate_against = vec!["https://modrinth.com/a"];
|
||||
|
||||
let validated =
|
||||
ValidatedRedirectUri::validate(&None, validate_against.clone(), OAuthClientId(0))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(validate_against[0], validated.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_for_valid_uri_returns_first_matching_uri_ignoring_query_params() {
|
||||
let validate_against = vec![
|
||||
"https://modrinth.com/a?q3=p3&q4=p4",
|
||||
"https://modrinth.com/a/b/c?q1=p1&q2=p2",
|
||||
];
|
||||
let to_validate = "https://modrinth.com/a/b/c?query0=param0&query1=param1".to_string();
|
||||
|
||||
let validated = ValidatedRedirectUri::validate(
|
||||
&Some(to_validate.clone()),
|
||||
validate_against,
|
||||
OAuthClientId(0),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(to_validate, validated.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_for_invalid_uri_returns_err() {
|
||||
let validate_against = vec!["https://modrinth.com/a"];
|
||||
let to_validate = "https://modrinth.com/a/b".to_string();
|
||||
|
||||
let validated =
|
||||
ValidatedRedirectUri::validate(&Some(to_validate), validate_against, OAuthClientId(0));
|
||||
|
||||
assert!(validated
|
||||
.is_err_and(|e| matches!(e.error_type, OAuthErrorType::RedirectUriNotConfigured(_))));
|
||||
}
|
||||
}
|
||||
21
apps/labrinth/src/auth/templates/error.html
Normal file
21
apps/labrinth/src/auth/templates/error.html
Normal file
@@ -0,0 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<link rel="stylesheet" href="/auth/style.css"/>
|
||||
<link rel="icon" type="image/png" href="/favicon.ico"/>
|
||||
<title>Error - Modrinth</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="content">
|
||||
<img src="/logo.svg" alt="Modrinth Logo" class="logo"/>
|
||||
<h2>{{ code }}</h2>
|
||||
<p>An error has occurred during the authentication process.</p>
|
||||
<p>
|
||||
Try closing this window and signing in again.
|
||||
Join <a href="https://discord.modrinth.com">our Discord server</a> to get help if this error persists after three attempts.
|
||||
</p>
|
||||
<p><b>Debug information:</b> {{ message }}</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
66
apps/labrinth/src/auth/templates/mod.rs
Normal file
66
apps/labrinth/src/auth/templates/mod.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
use crate::auth::AuthenticationError;
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::{HttpResponse, ResponseError};
|
||||
use std::fmt::{Debug, Display, Formatter};
|
||||
|
||||
pub struct Success<'a> {
|
||||
pub icon: &'a str,
|
||||
pub name: &'a str,
|
||||
}
|
||||
|
||||
impl<'a> Success<'a> {
|
||||
pub fn render(self) -> HttpResponse {
|
||||
let html = include_str!("success.html");
|
||||
|
||||
HttpResponse::Ok()
|
||||
.append_header(("Content-Type", "text/html; charset=utf-8"))
|
||||
.body(
|
||||
html.replace("{{ icon }}", self.icon)
|
||||
.replace("{{ name }}", self.name),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ErrorPage {
|
||||
pub code: StatusCode,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl Display for ErrorPage {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
let html = include_str!("error.html")
|
||||
.replace("{{ code }}", &self.code.to_string())
|
||||
.replace("{{ message }}", &self.message);
|
||||
write!(f, "{}", html)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl ErrorPage {
|
||||
pub fn render(&self) -> HttpResponse {
|
||||
HttpResponse::Ok()
|
||||
.append_header(("Content-Type", "text/html; charset=utf-8"))
|
||||
.body(self.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl actix_web::ResponseError for ErrorPage {
|
||||
fn status_code(&self) -> StatusCode {
|
||||
self.code
|
||||
}
|
||||
|
||||
fn error_response(&self) -> HttpResponse {
|
||||
self.render()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AuthenticationError> for ErrorPage {
|
||||
fn from(item: AuthenticationError) -> Self {
|
||||
ErrorPage {
|
||||
code: item.status_code(),
|
||||
message: item.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
16
apps/labrinth/src/auth/templates/success.html
Normal file
16
apps/labrinth/src/auth/templates/success.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<link rel="stylesheet" href="/auth/style.css"/>
|
||||
<link rel="icon" type="image/png" href="/favicon.ico"/>
|
||||
<title>Login - Modrinth</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="content">
|
||||
<img src="{{ icon }}" alt="{{ name }}" class="logo"/>
|
||||
<h2>Login Successful</h2>
|
||||
<p>Hey, {{ name }}! You can now safely close this tab.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
166
apps/labrinth/src/auth/validate.rs
Normal file
166
apps/labrinth/src/auth/validate.rs
Normal file
@@ -0,0 +1,166 @@
|
||||
use super::AuthProvider;
|
||||
use crate::auth::AuthenticationError;
|
||||
use crate::database::models::user_item;
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::models::pats::Scopes;
|
||||
use crate::models::users::User;
|
||||
use crate::queue::session::AuthQueue;
|
||||
use crate::routes::internal::session::get_session_metadata;
|
||||
use actix_web::http::header::{HeaderValue, AUTHORIZATION};
|
||||
use actix_web::HttpRequest;
|
||||
use chrono::Utc;
|
||||
|
||||
pub async fn get_user_from_headers<'a, E>(
|
||||
req: &HttpRequest,
|
||||
executor: E,
|
||||
redis: &RedisPool,
|
||||
session_queue: &AuthQueue,
|
||||
required_scopes: Option<&[Scopes]>,
|
||||
) -> Result<(Scopes, User), AuthenticationError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
|
||||
{
|
||||
// Fetch DB user record and minos user from headers
|
||||
let (scopes, db_user) =
|
||||
get_user_record_from_bearer_token(req, None, executor, redis, session_queue)
|
||||
.await?
|
||||
.ok_or_else(|| AuthenticationError::InvalidCredentials)?;
|
||||
|
||||
let user = User::from_full(db_user);
|
||||
|
||||
if let Some(required_scopes) = required_scopes {
|
||||
for scope in required_scopes {
|
||||
if !scopes.contains(*scope) {
|
||||
return Err(AuthenticationError::InvalidCredentials);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok((scopes, user))
|
||||
}
|
||||
|
||||
pub async fn get_user_record_from_bearer_token<'a, 'b, E>(
|
||||
req: &HttpRequest,
|
||||
token: Option<&str>,
|
||||
executor: E,
|
||||
redis: &RedisPool,
|
||||
session_queue: &AuthQueue,
|
||||
) -> Result<Option<(Scopes, user_item::User)>, AuthenticationError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
|
||||
{
|
||||
let token = if let Some(token) = token {
|
||||
token
|
||||
} else {
|
||||
extract_authorization_header(req)?
|
||||
};
|
||||
|
||||
let possible_user = match token.split_once('_') {
|
||||
Some(("mrp", _)) => {
|
||||
let pat =
|
||||
crate::database::models::pat_item::PersonalAccessToken::get(token, executor, redis)
|
||||
.await?
|
||||
.ok_or_else(|| AuthenticationError::InvalidCredentials)?;
|
||||
|
||||
if pat.expires < Utc::now() {
|
||||
return Err(AuthenticationError::InvalidCredentials);
|
||||
}
|
||||
|
||||
let user = user_item::User::get_id(pat.user_id, executor, redis).await?;
|
||||
|
||||
session_queue.add_pat(pat.id).await;
|
||||
|
||||
user.map(|x| (pat.scopes, x))
|
||||
}
|
||||
Some(("mra", _)) => {
|
||||
let session =
|
||||
crate::database::models::session_item::Session::get(token, executor, redis)
|
||||
.await?
|
||||
.ok_or_else(|| AuthenticationError::InvalidCredentials)?;
|
||||
|
||||
if session.expires < Utc::now() {
|
||||
return Err(AuthenticationError::InvalidCredentials);
|
||||
}
|
||||
|
||||
let user = user_item::User::get_id(session.user_id, executor, redis).await?;
|
||||
|
||||
let rate_limit_ignore = dotenvy::var("RATE_LIMIT_IGNORE_KEY")?;
|
||||
if !req
|
||||
.headers()
|
||||
.get("x-ratelimit-key")
|
||||
.and_then(|x| x.to_str().ok())
|
||||
.map(|x| x == rate_limit_ignore)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
let metadata = get_session_metadata(req).await?;
|
||||
session_queue.add_session(session.id, metadata).await;
|
||||
}
|
||||
|
||||
user.map(|x| (Scopes::all(), x))
|
||||
}
|
||||
Some(("mro", _)) => {
|
||||
use crate::database::models::oauth_token_item::OAuthAccessToken;
|
||||
|
||||
let hash = OAuthAccessToken::hash_token(token);
|
||||
let access_token =
|
||||
crate::database::models::oauth_token_item::OAuthAccessToken::get(hash, executor)
|
||||
.await?
|
||||
.ok_or(AuthenticationError::InvalidCredentials)?;
|
||||
|
||||
if access_token.expires < Utc::now() {
|
||||
return Err(AuthenticationError::InvalidCredentials);
|
||||
}
|
||||
|
||||
let user = user_item::User::get_id(access_token.user_id, executor, redis).await?;
|
||||
|
||||
session_queue.add_oauth_access_token(access_token.id).await;
|
||||
|
||||
user.map(|u| (access_token.scopes, u))
|
||||
}
|
||||
Some(("github", _)) | Some(("gho", _)) | Some(("ghp", _)) => {
|
||||
let user = AuthProvider::GitHub.get_user(token).await?;
|
||||
let id = AuthProvider::GitHub.get_user_id(&user.id, executor).await?;
|
||||
|
||||
let user = user_item::User::get_id(
|
||||
id.ok_or_else(|| AuthenticationError::InvalidCredentials)?,
|
||||
executor,
|
||||
redis,
|
||||
)
|
||||
.await?;
|
||||
|
||||
user.map(|x| ((Scopes::all() ^ Scopes::restricted()), x))
|
||||
}
|
||||
_ => return Err(AuthenticationError::InvalidAuthMethod),
|
||||
};
|
||||
Ok(possible_user)
|
||||
}
|
||||
|
||||
pub fn extract_authorization_header(req: &HttpRequest) -> Result<&str, AuthenticationError> {
|
||||
let headers = req.headers();
|
||||
let token_val: Option<&HeaderValue> = headers.get(AUTHORIZATION);
|
||||
token_val
|
||||
.ok_or_else(|| AuthenticationError::InvalidAuthMethod)?
|
||||
.to_str()
|
||||
.map_err(|_| AuthenticationError::InvalidCredentials)
|
||||
}
|
||||
|
||||
pub async fn check_is_moderator_from_headers<'a, 'b, E>(
|
||||
req: &HttpRequest,
|
||||
executor: E,
|
||||
redis: &RedisPool,
|
||||
session_queue: &AuthQueue,
|
||||
required_scopes: Option<&[Scopes]>,
|
||||
) -> Result<User, AuthenticationError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
|
||||
{
|
||||
let user = get_user_from_headers(req, executor, redis, session_queue, required_scopes)
|
||||
.await?
|
||||
.1;
|
||||
|
||||
if user.role.is_mod() {
|
||||
Ok(user)
|
||||
} else {
|
||||
Err(AuthenticationError::InvalidCredentials)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user