Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

proper svg v2 #108

Merged
merged 3 commits into from
May 29, 2024
Merged
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
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)))
}
Loading