diff --git a/apps/labrinth/migrations/20250804221014_users-redeemals.sql b/apps/labrinth/migrations/20250804221014_users-redeemals.sql new file mode 100644 index 000000000..55b965cbf --- /dev/null +++ b/apps/labrinth/migrations/20250804221014_users-redeemals.sql @@ -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 +); \ No newline at end of file diff --git a/apps/labrinth/src/database/models/mod.rs b/apps/labrinth/src/database/models/mod.rs index 6a051b436..4ef40cf1c 100644 --- a/apps/labrinth/src/database/models/mod.rs +++ b/apps/labrinth/src/database/models/mod.rs @@ -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; diff --git a/apps/labrinth/src/database/models/users_redeemals.rs b/apps/labrinth/src/database/models/users_redeemals.rs new file mode 100644 index 000000000..2e18c4bf6 --- /dev/null +++ b/apps/labrinth/src/database/models/users_redeemals.rs @@ -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, + pub status: Status, +} + +impl UserRedeemal { + pub async fn exists_by_user_and_offer<'a, E>( + exec: E, + user_id: DBUserId, + offer: Offer, + ) -> sqlx::Result + 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, +} + +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> + where + E: sqlx::PgExecutor<'a>, + { + let maybe_row = query!( + r#" + SELECT + users.id, + users_redeemals.status AS "status: Option" + 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)), + })) + } +} diff --git a/apps/labrinth/src/routes/internal/medal.rs b/apps/labrinth/src/routes/internal/medal.rs new file mode 100644 index 000000000..5b2cb8184 --- /dev/null +++ b/apps/labrinth/src/routes/internal/medal.rs @@ -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, + web::Query(MedalVerifyQuery { username }): web::Query, +) -> Result { + 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, +) -> Result { + Ok(HttpResponse::NotImplemented().finish()) +} diff --git a/apps/labrinth/src/routes/internal/mod.rs b/apps/labrinth/src/routes/internal/mod.rs index a658a9c73..3330ab13e 100644 --- a/apps/labrinth/src/routes/internal/mod.rs +++ b/apps/labrinth/src/routes/internal/mod.rs @@ -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), ); } diff --git a/apps/labrinth/src/util/guards.rs b/apps/labrinth/src/util/guards.rs index e6401fa4f..d1fa513da 100644 --- a/apps/labrinth/src/util/guards.rs +++ b/apps/labrinth/src/util/guards.rs @@ -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()), + } +}