Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,3 @@
*.db
*.db-wal
*.db-shm
/.direnv
81 changes: 80 additions & 1 deletion database/src/repos/achievement.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use sqlx::{SqlitePool, query, query_as};
use sqlx::{SqlitePool, query, query_as, query_scalar};

use crate::{
error::DatabaseError,
Expand Down Expand Up @@ -40,6 +40,30 @@ impl<'a> AchievementRepo<'a> {
.await?)
}

pub async fn by_goal_id(&self, goal_id: u32) -> Result<Vec<AchievementGoal>, DatabaseError> {
Ok(query_as(
"
SELECT
achievement.id as achievement_id,
achievement.name as achievement_name,
service_id,
goal2.id as goal_id,
goal2.description as goal_description,
goal2.sequence as goal_sequence

FROM
goal as goal1
inner join achievement on achievement.id = goal1.achievement_id
inner join goal as goal2 on goal2.achievement_id = achievement.id
WHERE
goal1.id = ?;
",
)
.bind(goal_id)
.fetch_all(self.db)
.await?)
}

pub async fn for_service(
&self,
service_id: u32,
Expand Down Expand Up @@ -119,4 +143,59 @@ impl<'a> AchievementRepo<'a> {
tx.commit().await?;
self.by_id(db_achievement.id).await
}

pub async fn unlock_goal(
&self,
user_id: u32,
goal_id: u32,
) -> Result<Vec<AchievementGoal>, DatabaseError> {
query(
"
INSERT INTO
unlock (user_id, goal_id)
VALUES
(?,?);
",
)
.bind(user_id)
.bind(goal_id)
.execute(self.db)
.await?;

self.by_goal_id(goal_id).await
}

pub async fn goal_exist(&self, goal_id: u32) -> Result<bool, DatabaseError> {
Ok(query_scalar::<_, i32>(
"
SELECT
1
FROM
goal
WHERE
goal.id = ?;
",
)
.bind(goal_id)
.fetch_optional(self.db)
.await?
.is_some())
}

pub async fn goal_unlocked(&self, goal_id: u32) -> Result<bool, DatabaseError> {
Ok(query_scalar::<_, i32>(
"
SELECT
1
FROM
unlock
WHERE
goal_id = ?;
",
)
.bind(goal_id)
.fetch_optional(self.db)
.await?
.is_some())
}
}
8 changes: 8 additions & 0 deletions database/src/repos/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,4 +74,12 @@ impl<'a> ServiceRepo<'a> {
.await?
.ok_or(DatabaseError::NotFound)
}

pub async fn by_id(&self, id: u32) -> Result<Service, DatabaseError> {
sqlx::query_as("SELECT id, name, api_key FROM service WHERE id == ? LIMIT 1;")
.bind(id)
.fetch_optional(self.db)
.await?
.ok_or(DatabaseError::NotFound)
}
}
26 changes: 24 additions & 2 deletions src/dto/achievement.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,28 @@ impl AchievementPayload {

Ok(achievements)
}

pub async fn unlock_goal(
db: &Database,
user_id: u32,
goal_id: u32,
) -> Result<AchievementPayload, AppError> {
if !db.achievements().goal_exist(goal_id).await? {
return Err(AppError::NotFound);
}

let rows = if db.achievements().goal_unlocked(goal_id).await? {
// goal already unlocked
db.achievements().by_goal_id(goal_id).await?
} else {
db.achievements().unlock_goal(user_id, goal_id).await?
};

// pack rows into an achievement payload
let mut rows = rows.into_iter().peekable();
let achievement = unpack_next_achievement(&mut rows).ok_or(AppError::NotFound)?;
Ok(achievement)
}
}

#[derive(Serialize, Deserialize, Debug, PartialEq)]
Expand Down Expand Up @@ -64,7 +86,7 @@ impl AchievementCreatePayload {
}

self.goals.sort_by_key(|x| x.sequence);
let ordered_1_seperated = self
let ordered_1_separated = self
.goals
.iter()
.map(|x| x.sequence)
Expand All @@ -75,7 +97,7 @@ impl AchievementCreatePayload {
_ => false,
});
if let Some(goal) = self.goals.first()
&& (goal.sequence != 0 || !ordered_1_seperated)
&& (goal.sequence != 0 || !ordered_1_separated)
{
return Err(AppError::PayloadError(
"Sequence should start with 0 and count up by 1".into(),
Expand Down
6 changes: 5 additions & 1 deletion src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ pub enum AppError {
#[error("Submitted image resolution was too large")]
ImageResTooLarge,

#[error("The requested image was not found")]
#[error("Not found")]
NotFound,

#[error("Submitted file had an incorrect type")]
Expand All @@ -60,6 +60,9 @@ pub enum AppError {
#[error("User was not logged in")]
NotLoggedIn,

#[error("Wrong api key")]
BadApiKey,

#[error("Forbidden")]
Forbidden,

Expand All @@ -80,6 +83,7 @@ impl AppError {
let (status, msg) = match self {
Self::PayloadError(_) => (StatusCode::BAD_REQUEST, "Payload error"),
Self::NotLoggedIn => (StatusCode::UNAUTHORIZED, "Not logged in."),
Self::BadApiKey => (StatusCode::UNAUTHORIZED, "Bad api key."),
Self::Forbidden => (StatusCode::FORBIDDEN, "Forbidden."),
Self::NoFile => (
StatusCode::BAD_REQUEST,
Expand Down
24 changes: 24 additions & 0 deletions src/extractors/api_key.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
use axum::{extract::FromRequestParts, http::request::Parts};
use axum_extra::TypedHeader;
use headers::{Authorization, authorization::Bearer};

use crate::error::AppError;

#[derive(Debug)]
pub struct ApiKey(pub String);

impl<S> FromRequestParts<S> for ApiKey
where
S: Send + Sync,
{
type Rejection = AppError;

async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let header = TypedHeader::<Authorization<Bearer>>::from_request_parts(parts, state).await;

match header {
Ok(TypedHeader(Authorization(bearer))) => Ok(ApiKey(bearer.token().to_string())),
_ => Err(AppError::BadApiKey),
}
}
}
1 change: 1 addition & 0 deletions src/extractors/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub mod admin;
pub mod api_key;
pub mod authenticated_user;
pub mod config;
pub mod database;
Expand Down
23 changes: 21 additions & 2 deletions src/handlers/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@ use axum::{Json, extract::Path};
use database::Database;

use crate::{
dto::service::{
ServiceCreatePayload, ServicePatchPayload, ServicePayloadAdmin, ServicePayloadUser,
dto::{
achievement::AchievementPayload,
service::{
ServiceCreatePayload, ServicePatchPayload, ServicePayloadAdmin, ServicePayloadUser,
},
},
error::AppError,
extractors::api_key::ApiKey,
};

pub struct ServiceHandler;
Expand Down Expand Up @@ -42,4 +46,19 @@ impl ServiceHandler {
ServicePayloadAdmin::regenerate_api_key(&db, service_id).await?,
))
}

pub async fn unlock_goal(
db: Database,
Path((user_id, service_id, goal_id)): Path<(u32, u32, u32)>,
ApiKey(api_key): ApiKey,
) -> Result<Json<AchievementPayload>, AppError> {
let expected_api_key = db.services().by_id(service_id).await?.api_key;
if api_key != expected_api_key {
return Err(AppError::BadApiKey);
}

Ok(Json(
AchievementPayload::unlock_goal(&db, user_id, goal_id).await?,
))
}
}
4 changes: 4 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ fn open_routes() -> Router<AppState> {
.route("/oauth/callback", get(AuthHandler::callback))
.route("/image/{id}", get(ImageHandler::get))
.route("/version", get(VersionHandler::get))
.route(
"/users/{id}/unlock/{service_id}/{goal_id}",
post(ServiceHandler::unlock_goal),
)
}

fn authenticated_routes() -> Router<AppState> {
Expand Down
Loading