Initial db migration/impl, guarded partner routes
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
-- Add migration script here
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users_redeemals (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id),
|
||||
offer VARCHAR NOT NULL,
|
||||
redeemed TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
status VARCHAR NOT NULL
|
||||
);
|
||||
@@ -25,6 +25,7 @@ pub mod team_item;
|
||||
pub mod thread_item;
|
||||
pub mod user_item;
|
||||
pub mod user_subscription_item;
|
||||
pub mod users_redeemals;
|
||||
pub mod version_item;
|
||||
|
||||
pub use collection_item::DBCollection;
|
||||
|
||||
189
apps/labrinth/src/database/models/users_redeemals.rs
Normal file
189
apps/labrinth/src/database/models/users_redeemals.rs
Normal file
@@ -0,0 +1,189 @@
|
||||
use crate::database::models::DBUserId;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{query, query_scalar};
|
||||
use std::fmt;
|
||||
|
||||
#[derive(
|
||||
Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize,
|
||||
)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Offer {
|
||||
#[default]
|
||||
Medal,
|
||||
}
|
||||
|
||||
impl Offer {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Offer::Medal => "medal",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str_or_default(s: &str) -> Self {
|
||||
match s {
|
||||
"medal" => Offer::Medal,
|
||||
_ => Offer::Medal,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Offer {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize,
|
||||
)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Status {
|
||||
#[default]
|
||||
Pending,
|
||||
Redeemed,
|
||||
Expired,
|
||||
}
|
||||
|
||||
impl Status {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Status::Pending => "pending",
|
||||
Status::Redeemed => "redeemed",
|
||||
Status::Expired => "expired",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str_or_default(s: &str) -> Self {
|
||||
match s {
|
||||
"pending" => Status::Pending,
|
||||
"redeemed" => Status::Redeemed,
|
||||
"expired" => Status::Expired,
|
||||
_ => Status::Pending,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Status {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct UserRedeemal {
|
||||
pub id: i32,
|
||||
pub user_id: DBUserId,
|
||||
pub offer: Offer,
|
||||
pub redeemed: DateTime<Utc>,
|
||||
pub status: Status,
|
||||
}
|
||||
|
||||
impl UserRedeemal {
|
||||
pub async fn exists_by_user_and_offer<'a, E>(
|
||||
exec: E,
|
||||
user_id: DBUserId,
|
||||
offer: Offer,
|
||||
) -> sqlx::Result<bool>
|
||||
where
|
||||
E: sqlx::PgExecutor<'a>,
|
||||
{
|
||||
query_scalar!(
|
||||
r#"SELECT
|
||||
EXISTS (
|
||||
SELECT
|
||||
1
|
||||
FROM
|
||||
users_redeemals
|
||||
WHERE
|
||||
user_id = $1
|
||||
AND offer = $2
|
||||
) AS "exists!"
|
||||
"#,
|
||||
user_id.0,
|
||||
offer.as_str(),
|
||||
)
|
||||
.fetch_one(exec)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn insert<'a, E>(&mut self, exec: E) -> sqlx::Result<()>
|
||||
where
|
||||
E: sqlx::PgExecutor<'a>,
|
||||
{
|
||||
let query = query_scalar!(
|
||||
r#"
|
||||
INSERT INTO users_redeemals
|
||||
(user_id, offer, redeemed, status)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id"#,
|
||||
self.user_id.0,
|
||||
self.offer.as_str(),
|
||||
self.redeemed,
|
||||
self.status.as_str(),
|
||||
);
|
||||
|
||||
let id = query.fetch_one(exec).await?;
|
||||
|
||||
self.id = id;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct RedeemalLookupFields {
|
||||
pub user_id: DBUserId,
|
||||
pub redeemal_status: Option<Status>,
|
||||
}
|
||||
|
||||
impl RedeemalLookupFields {
|
||||
/// Returns the redeemal status of a user for an offer, while looking up the user
|
||||
/// itself. **This expects a single redeemal per user/offer pair**.
|
||||
///
|
||||
/// If the returned value is `Ok(None)`, the user doesn't exist.
|
||||
///
|
||||
/// If the returned value is `Ok(Some(fields))`, but `redeemal_status` is `None`,
|
||||
/// the user exists and has not redeemed the offer.
|
||||
pub async fn redeemal_status_by_user_username_and_offer<'a, E>(
|
||||
exec: E,
|
||||
user_username: &str,
|
||||
offer: Offer,
|
||||
) -> sqlx::Result<Option<RedeemalLookupFields>>
|
||||
where
|
||||
E: sqlx::PgExecutor<'a>,
|
||||
{
|
||||
let maybe_row = query!(
|
||||
r#"
|
||||
SELECT
|
||||
users.id,
|
||||
users_redeemals.status AS "status: Option<String>"
|
||||
FROM
|
||||
users
|
||||
LEFT JOIN
|
||||
users_redeemals ON users_redeemals.user_id = users.id
|
||||
AND users_redeemals.offer = $2
|
||||
WHERE
|
||||
users.username = $1
|
||||
ORDER BY
|
||||
users_redeemals.redeemed DESC
|
||||
LIMIT 1
|
||||
"#,
|
||||
user_username,
|
||||
offer.as_str(),
|
||||
)
|
||||
.fetch_optional(exec)
|
||||
.await?;
|
||||
|
||||
// If no row was returned, the user doesn't exist.
|
||||
// If a row NULL status was returned, the user exists but has no redeemed the offer.
|
||||
|
||||
Ok(maybe_row.map(|row| RedeemalLookupFields {
|
||||
user_id: DBUserId(row.id),
|
||||
redeemal_status: row
|
||||
.status
|
||||
.as_deref()
|
||||
.map(|s| Status::from_str_or_default(s)),
|
||||
}))
|
||||
}
|
||||
}
|
||||
54
apps/labrinth/src/routes/internal/medal.rs
Normal file
54
apps/labrinth/src/routes/internal/medal.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
use actix_web::{HttpRequest, HttpResponse, post, web};
|
||||
use ariadne::ids::UserId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::database::models::users_redeemals::{Offer, RedeemalLookupFields};
|
||||
use crate::routes::ApiError;
|
||||
use crate::util::guards::medal_key_guard;
|
||||
|
||||
pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(web::scope("medal").service(verify).service(redeem));
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct MedalVerifyQuery {
|
||||
username: String,
|
||||
}
|
||||
|
||||
#[post("verify", guard = "medal_key_guard")]
|
||||
pub async fn verify(
|
||||
_req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
web::Query(MedalVerifyQuery { username }): web::Query<MedalVerifyQuery>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let maybe_fields =
|
||||
RedeemalLookupFields::redeemal_status_by_user_username_and_offer(
|
||||
&**pool,
|
||||
&username,
|
||||
Offer::Medal,
|
||||
)
|
||||
.await?;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct VerifyResponse {
|
||||
user_id: UserId,
|
||||
redeemed: bool,
|
||||
}
|
||||
|
||||
match maybe_fields {
|
||||
None => Err(ApiError::NotFound),
|
||||
Some(fields) => Ok(HttpResponse::Ok().json(VerifyResponse {
|
||||
user_id: fields.user_id.into(),
|
||||
redeemed: fields.redeemal_status.is_some(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
#[post("redeem")]
|
||||
pub async fn redeem(
|
||||
_req: HttpRequest,
|
||||
_pool: web::Data<PgPool>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
Ok(HttpResponse::NotImplemented().finish())
|
||||
}
|
||||
@@ -2,6 +2,7 @@ pub(crate) mod admin;
|
||||
pub mod billing;
|
||||
pub mod flows;
|
||||
pub mod gdpr;
|
||||
pub mod medal;
|
||||
pub mod moderation;
|
||||
pub mod pats;
|
||||
pub mod session;
|
||||
@@ -24,6 +25,7 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
|
||||
.configure(moderation::config)
|
||||
.configure(billing::config)
|
||||
.configure(gdpr::config)
|
||||
.configure(statuses::config),
|
||||
.configure(statuses::config)
|
||||
.configure(medal::config),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use actix_web::guard::GuardContext;
|
||||
|
||||
pub const ADMIN_KEY_HEADER: &str = "Modrinth-Admin";
|
||||
pub const MEDAL_KEY_HEADER: &str = "X-Medal-Access-Key";
|
||||
|
||||
pub fn admin_key_guard(ctx: &GuardContext) -> bool {
|
||||
let admin_key = std::env::var("LABRINTH_ADMIN_KEY").expect(
|
||||
"No admin key provided, this should have been caught by check_env_vars",
|
||||
@@ -10,3 +12,16 @@ pub fn admin_key_guard(ctx: &GuardContext) -> bool {
|
||||
.get(ADMIN_KEY_HEADER)
|
||||
.is_some_and(|it| it.as_bytes() == admin_key.as_bytes())
|
||||
}
|
||||
|
||||
pub fn medal_key_guard(ctx: &GuardContext) -> bool {
|
||||
let maybe_medal_key = dotenvy::var("LABRINTH_MEDAL_KEY").ok();
|
||||
|
||||
match maybe_medal_key {
|
||||
None => false,
|
||||
Some(medal_key) => ctx
|
||||
.head()
|
||||
.headers()
|
||||
.get(MEDAL_KEY_HEADER)
|
||||
.is_some_and(|it| it.as_bytes() == medal_key.as_bytes()),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user