Skip to content

Commit

Permalink
proper svg v2 (#108)
Browse files Browse the repository at this point in the history
  • Loading branch information
akorchyn authored May 29, 2024
1 parent 611d22a commit b8125e9
Show file tree
Hide file tree
Showing 12 changed files with 531 additions and 110 deletions.
331 changes: 319 additions & 12 deletions Cargo.lock

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ rocket = "0.5"
rocket_db_pools = "0.2"
sqlx = "0.7"
shared = { path = "shared" }
reqwest = "0.12"
base64 = "0.22.1"
usvg = "0.41.0"

[profile.release]
codegen-units = 1
Expand Down
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@ COPY --from=builder /usr/src/app/race-of-sloths-bot /app/race-of-sloths-bot
COPY --from=builder /usr/src/app/race-of-sloths-server /app/race-of-sloths-server
COPY ./Messages.toml /app/Messages.toml
COPY ./Rocket.toml /app/Rocket.toml
COPY ./public /app/public
1 change: 1 addition & 0 deletions Rocket.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
address = "0.0.0.0"
port = 8080
workers = 2
log_level = "normal"
Binary file added public/Inter-VariableFont_slnt,wght.ttf
Binary file not shown.
86 changes: 86 additions & 0 deletions public/badge_template.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 4 additions & 2 deletions server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ envy.workspace = true
rocket = { workspace = true, features = ["json"] }
rocket_db_pools = { workspace = true, features = ["sqlx_postgres"] }
sqlx = { workspace = true, features = ["postgres", "macros"] }
tracing.workspace = true
tracing-subscriber.workspace = true
serde.workspace = true
base64.workspace = true
reqwest.workspace = true
chrono.workspace = true
usvg.workspace = true

shared = { workspace = true, features = ["client"] }
9 changes: 1 addition & 8 deletions server/src/db/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ use rocket::{
use rocket_db_pools::Database;
use shared::{StreakUserData, TimePeriodString, User, UserPeriodData};
use sqlx::PgPool;
use tracing::instrument;

#[derive(Database, Clone, Debug)]
#[database("race-of-sloths")]
Expand All @@ -18,7 +17,6 @@ use types::LeaderboardRecord;
use self::types::{StreakRecord, UserPeriodRecord, UserRecord};

impl DB {
#[instrument(skip(self))]
pub async fn upsert_user(&self, user: &User) -> anyhow::Result<i32> {
let rec = sqlx::query!(
r#"
Expand All @@ -36,7 +34,6 @@ impl DB {
Ok(rec.id)
}

#[instrument(skip(self))]
pub async fn upsert_user_period_data(
&self,
period: TimePeriodString,
Expand All @@ -61,7 +58,6 @@ impl DB {
Ok(())
}

#[instrument(skip(self))]
pub async fn upsert_streak_user_data(
&self,
data: &StreakUserData,
Expand All @@ -88,7 +84,6 @@ impl DB {
Ok(())
}

#[instrument(skip(self))]
pub async fn get_user(&self, name: &str) -> anyhow::Result<Option<UserRecord>> {
let user_rec: i32 = match sqlx::query!("SELECT id, name FROM users WHERE name = $1", name)
.fetch_optional(&self.0)
Expand Down Expand Up @@ -131,7 +126,6 @@ impl DB {
Ok(Some(user))
}

#[instrument(skip(self))]
pub async fn get_leaderboard(
&self,
period: &str,
Expand All @@ -148,7 +142,6 @@ impl DB {
"#,period,limit,page*limit).fetch_all(&self.0,).await? )
}

#[instrument(skip(self))]
pub async fn get_leaderboard_place(
&self,
period: &str,
Expand Down Expand Up @@ -178,7 +171,7 @@ async fn run_migrations(rocket: Rocket<Build>) -> fairing::Result {
Some(db) => match sqlx::migrate!("./migrations").run(&**db).await {
Ok(_) => Ok(rocket),
Err(e) => {
tracing::error!("Failed to initialize SQLx database: {}", e);
rocket::error!("Failed to initialize SQLx database: {}", e);
Err(rocket)
}
},
Expand Down
103 changes: 84 additions & 19 deletions server/src/entrypoints.rs
Original file line number Diff line number Diff line change
@@ -1,40 +1,99 @@
use base64::Engine;
use race_of_sloths_server::{
db::{
types::{LeaderboardRecord, UserRecord},
DB,
},
svg::generate_badge,
svg::generate_svg_badge,
};
use rocket::{fairing::AdHoc, http::ContentType, response::content::RawHtml, serde::json::Json};
use tracing::instrument;
use rocket::{
fairing::AdHoc,
http::{ContentType, Header, Status},
response::{self, Responder},
serde::json::Json,
Request, Response, State,
};

pub struct Badge {
svg: Option<String>,
status: Status,
}

impl Badge {
pub fn new(svg: String) -> Self {
Self {
svg: Some(svg),
status: Status::Ok,
}
}

pub fn with_status(status: Status) -> Self {
Self { status, svg: None }
}
}

impl<'r> Responder<'r, 'static> for Badge {
fn respond_to(self, _req: &'r Request<'_>) -> response::Result<'static> {
let expiration = chrono::Utc::now();
//.add(chrono::Duration::minutes(1));

match self.svg {
Some(png) => Response::build()
.header(Header::new("Cache-Control", "no-cache"))
.header(Header::new("Pragma", "no-cache"))
.header(Header::new("Expires", expiration.to_rfc2822()))
.header(ContentType::SVG)
.sized_body(png.len(), std::io::Cursor::new(png))
.ok(),
None => Err(self.status),
}
}
}

#[get("/badges/<username>")]
#[instrument]
async fn get_svg(username: &str, db: &DB) -> Option<(ContentType, RawHtml<String>)> {
async fn get_svg<'a>(
username: &str,
db: &State<DB>,
font: &State<usvg::fontdb::Database>,
) -> Badge {
let user = match db.get_user(username).await {
Ok(Some(value)) => value,
_ => return Badge::with_status(Status::NotFound),
};
let place = match db.get_leaderboard_place("all-time", &user.name).await {
Ok(Some(value)) => value,
_ => return Badge::with_status(Status::NotFound),
};

let request = match reqwest::get(format!("https://github.com/{}.png", user.name)).await {
Ok(value) => value.bytes().await,
Err(e) => {
error!("Failed to get user: {username}: {e}");
return None;
rocket::error!("Failed to fetch image for {username}: {e}");
return Badge::with_status(Status::InternalServerError);
}
Ok(value) => value?,
};
let place = match db.get_leaderboard_place("all-time", &user.name).await {

let image_base64 = match request {
Ok(value) => base64::engine::general_purpose::STANDARD.encode(value),
Err(e) => {
error!("Failed to get leaderboard place: {username}: {e}");
return None;
rocket::error!("Failed to fetch bytes from avatar of {username}: {e}");
return Badge::with_status(Status::InternalServerError);
}
Ok(value) => value?,
};
let svg = generate_badge(user, place as u64)?;

Some((ContentType::SVG, RawHtml(svg)))
let svg = match generate_svg_badge(user, place as u64, &image_base64, font) {
Ok(Some(value)) => value,
_ => return Badge::with_status(Status::InternalServerError),
};

Badge::new(svg)
}

#[get("/users/<username>")]
async fn get_user(username: &str, db: &DB) -> Option<Json<UserRecord>> {
async fn get_user(username: &str, db: &State<DB>) -> Option<Json<UserRecord>> {
let user = match db.get_user(username).await {
Err(e) => {
error!("Failed to get user: {username}: {e}");
rocket::error!("Failed to get user: {username}: {e}");
return None;
}
Ok(value) => value?,
Expand All @@ -45,15 +104,15 @@ async fn get_user(username: &str, db: &DB) -> Option<Json<UserRecord>> {
#[get("/leaderboard/<period>?<page>&<limit>")]
async fn get_leaderboard(
period: &str,
db: &DB,
db: &State<DB>,
page: Option<u32>,
limit: Option<u32>,
) -> Option<Json<Vec<LeaderboardRecord>>> {
let page = page.unwrap_or(0);
let limit = limit.unwrap_or(50);
let users = match db.get_leaderboard(period, page as i64, limit as i64).await {
Err(e) => {
error!("Failed to get leaderboard: {period}: {e}");
rocket::error!("Failed to get leaderboard: {period}: {e}");
return None;
}
Ok(value) => value,
Expand All @@ -63,6 +122,12 @@ async fn get_leaderboard(

pub fn stage() -> AdHoc {
AdHoc::on_ignite("Installing entrypoints", |rocket| async {
rocket.mount("/api", routes![get_svg, get_user, get_leaderboard])
let mut font = usvg::fontdb::Database::new();
font.load_font_file("./public/Inter-VariableFont_slnt,wght.ttf")
.expect("Failed to load font");

rocket
.mount("/api", routes![get_svg, get_user, get_leaderboard])
.manage(font)
})
}
14 changes: 1 addition & 13 deletions server/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,6 @@ use std::time::Duration;
use rocket_db_pools::Database;
use shared::near::NearClient;
use shared::TimePeriod;
use tracing::instrument;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::EnvFilter;

use race_of_sloths_server::db::{self, DB};

Expand All @@ -27,20 +24,12 @@ pub struct Env {
async fn rocket() -> _ {
dotenv::dotenv().ok();

let subscriber = tracing_subscriber::registry()
.with(EnvFilter::from_default_env())
.with(tracing_subscriber::fmt::layer().pretty());
tracing::subscriber::set_global_default(subscriber).expect("Failed to set subscriber");

let env = envy::from_env::<Env>().expect("Failed to load environment variables");
let sleep_duration =
Duration::from_secs(env.sleep_duration_in_minutes.unwrap_or(10) as u64 * 60);
let atomic_bool = Arc::new(std::sync::atomic::AtomicBool::new(true));
let atomic_bool_clone = atomic_bool.clone();

let span = tracing::info_span!("Starting Rocket");
let _enter = span.enter();

rocket::build()
.attach(db::stage())
.attach(rocket::fairing::AdHoc::on_liftoff(
Expand All @@ -66,7 +55,7 @@ async fn rocket() -> _ {

// Execute a query of some kind
if let Err(e) = fetch_and_store_users(&near_client, &db).await {
tracing::error!("Failed to fetch and store users: {:#?}", e);
rocket::error!("Failed to fetch and store users: {:#?}", e);
}
}
});
Expand All @@ -84,7 +73,6 @@ async fn rocket() -> _ {
.attach(entrypoints::stage())
}

#[instrument(skip(near_client, db))]
async fn fetch_and_store_users(near_client: &NearClient, db: &DB) -> anyhow::Result<()> {
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
Expand Down
50 changes: 0 additions & 50 deletions server/src/public/badge_template.svg

This file was deleted.

37 changes: 31 additions & 6 deletions server/src/svg.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,30 @@
use usvg::{fontdb, Options, Tree, WriteOptions};

use crate::db::types::UserRecord;

pub fn generate_badge(user_record: UserRecord, leaderboard_place: u64) -> Option<String> {
pub fn generate_svg_badge(
user_record: UserRecord,
leaderboard_place: u64,
image_base64: &str,
fontdb: &fontdb::Database,
) -> anyhow::Result<Option<String>> {
let total_period = user_record
.period_data
.iter()
.find(|p| p.period_type == "all-time")?;
let week_streak = user_record.streaks.iter().find(|s| s.streak_id == 0)?;
let month_streak = user_record.streaks.iter().find(|s| s.streak_id == 1)?;
.find(|p| p.period_type == "all-time");
let week_streak = user_record.streaks.iter().find(|s| s.streak_id == 0);
let month_streak = user_record.streaks.iter().find(|s| s.streak_id == 1);

if total_period.is_none() || week_streak.is_none() || month_streak.is_none() {
return Ok(None);
}
let (total_period, week_streak, month_streak) = (
total_period.unwrap(),
week_streak.unwrap(),
month_streak.unwrap(),
);

let svg_icon = include_str!("./public/badge_template.svg").to_owned();
let svg_icon = std::fs::read_to_string("./public/badge_template.svg")?;
let svg_icon = svg_icon.replace("{name}", &user_record.name);
let svg_icon = svg_icon.replace(
"{total-contributions}",
Expand All @@ -17,6 +33,15 @@ pub fn generate_badge(user_record: UserRecord, leaderboard_place: u64) -> Option
let svg_icon = svg_icon.replace("{total-score}", &total_period.total_score.to_string());
let svg_icon = svg_icon.replace("{week-streak}", &week_streak.amount.to_string());
let svg_icon = svg_icon.replace("{month-streak}", &month_streak.amount.to_string());
let svg_icon = svg_icon.replace("{image}", image_base64);
let svg_icon = svg_icon.replace("{place}", &leaderboard_place.to_string());

let tree = Tree::from_str(&svg_icon, &Options::default(), fontdb)?;
let write_options = WriteOptions {
use_single_quote: true,
preserve_text: false,
..Default::default()
};

Some(svg_icon.replace("{place}", &leaderboard_place.to_string()))
Ok(Some(tree.to_string(&write_options)))
}

0 comments on commit b8125e9

Please sign in to comment.