demo list and page

This commit is contained in:
Robin Appelman 2023-04-08 17:07:40 +02:00
commit 667f5eae04
32 changed files with 4784 additions and 5 deletions

48
src/config.rs Normal file
View file

@ -0,0 +1,48 @@
use crate::Result;
use config::{Environment, File};
use serde::Deserialize;
use sqlx::postgres::PgConnectOptions;
use sqlx::PgPool;
use std::net::IpAddr;
use std::path::PathBuf;
#[derive(Debug, Deserialize)]
pub struct Config {
pub listen: Listen,
pub database: DbConfig,
}
impl Config {
pub fn load(path: &str) -> Result<Self> {
let s = config::Config::builder()
.add_source(File::with_name(path))
.add_source(Environment::default().separator("_"))
.build()?;
Ok(s.try_deserialize()?)
}
}
#[derive(Debug, Deserialize)]
pub struct DbConfig {
hostname: String,
username: String,
password: String,
}
impl DbConfig {
pub async fn connect(&self) -> Result<PgPool> {
let opt = PgConnectOptions::new()
.host(&self.hostname)
.username(&self.username)
.password(&self.password);
Ok(PgPool::connect_with(opt).await?)
}
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub enum Listen {
Socket { path: PathBuf },
Tcp { address: IpAddr, port: u16 },
}

296
src/data/demo.rs Normal file
View file

@ -0,0 +1,296 @@
use crate::data::player::Player;
use crate::data::steam_id::SteamId;
use crate::Result;
use maud::Render;
use sqlx::{query_as, Executor, FromRow, Postgres};
use std::fmt::Write;
use time::format_description::well_known::Iso8601;
use time::{OffsetDateTime, PrimitiveDateTime, UtcOffset};
use tracing::instrument;
pub struct Demo {
pub id: i32,
pub name: String,
pub url: String,
pub map: String,
pub red: String,
pub blu: String,
pub uploader: i32,
pub uploader_name: Option<String>,
pub uploader_name_preferred: Option<String>,
pub uploader_steam_id: Option<SteamId>,
pub duration: i32,
pub created_at: PrimitiveDateTime,
pub score_red: i32,
pub score_blue: i32,
pub server: String,
pub nick: String,
pub player_count: i32,
pub players: Vec<Player>,
}
impl Demo {
#[instrument(skip(connection))]
pub async fn by_id(
connection: impl Executor<'_, Database = Postgres> + Copy,
id: u32,
) -> Result<Option<Self>> {
struct RawDemo {
pub id: i32,
pub name: String,
pub url: String,
pub map: String,
pub red: String,
pub blu: String,
pub uploader: i32,
pub uploader_name: Option<String>,
pub uploader_name_preferred: Option<String>,
pub uploader_steam_id: Option<SteamId>,
pub duration: i32,
pub created_at: PrimitiveDateTime,
pub score_red: i32,
pub score_blue: i32,
pub server: String,
pub nick: String,
pub player_count: i32,
}
let Some(raw) = query_as!(
RawDemo,
r#"SELECT
demos.id, demos.name, url, map, red, blu, uploader, duration, demos.created_at,
"scoreRed" as score_red, "scoreBlue" as score_blue, server, nick,
"playerCount" as player_count,
users_named.name as uploader_name_preferred,
users.steamid as "uploader_steam_id?: SteamId",
users.name as "uploader_name?"
FROM demos
LEFT JOIN users_named ON uploader = users_named.id
LEFT JOIN users ON uploader = users.id
WHERE deleted_at IS NULL AND demos.id = $1"#,
id as i32
)
.fetch_optional(connection)
.await? else {
return Ok(None);
};
let players = Player::for_demo(connection, id).await?;
Ok(Some(Demo {
id: raw.id,
name: raw.name,
url: raw.url,
map: raw.map,
red: raw.red,
blu: raw.blu,
uploader: raw.uploader,
uploader_name: raw.uploader_name,
uploader_name_preferred: raw.uploader_name_preferred,
uploader_steam_id: raw.uploader_steam_id,
duration: raw.duration,
created_at: raw.created_at,
score_red: raw.score_red,
score_blue: raw.score_blue,
server: raw.server,
nick: raw.nick,
player_count: raw.player_count,
players,
}))
}
pub fn uploader_steam_id(&self) -> &SteamId {
self.uploader_steam_id.as_ref().unwrap_or_default()
}
pub fn date(&self) -> Date {
Date(self.created_at)
}
pub fn relative_date(&self) -> RelativeDate {
RelativeDate(self.created_at)
}
pub fn uploader_name(&self) -> &str {
self.uploader_name_preferred
.as_deref()
.or(self.uploader_name.as_deref())
.unwrap_or("unknown")
}
}
#[derive(Debug, FromRow)]
pub struct ListDemo {
pub id: i32,
pub name: String,
pub map: String,
pub red: String,
pub blu: String,
pub duration: i32,
pub created_at: PrimitiveDateTime,
pub server: String,
pub player_count: i32,
}
impl ListDemo {
#[instrument(skip(connection))]
pub async fn list(
connection: impl Executor<'_, Database = Postgres>,
before: Option<u32>,
) -> Result<Vec<Self>> {
if let Some(before) = before {
Ok(query_as!(
ListDemo,
r#"SELECT
id, name, map, red, blu, duration, created_at, server, "playerCount" as player_count
FROM demos WHERE deleted_at IS NULL and id < $1 ORDER BY id DESC LIMIT 50"#,
before as i32
)
.fetch_all(connection)
.await?)
} else {
Ok(query_as!(
ListDemo,
r#"SELECT
id, name, map, red, blu, duration, created_at, server, "playerCount" as player_count
FROM demos WHERE deleted_at IS NULL ORDER BY id DESC LIMIT 50"#
)
.fetch_all(connection)
.await?)
}
}
pub fn url(&self) -> DemoUrl {
DemoUrl(self.id)
}
pub fn format(&self) -> DemoFormat {
DemoFormat {
player_count: self.player_count,
mode: MapMode::from_map(&self.map),
}
}
pub fn duration(&self) -> Duration {
Duration(self.duration)
}
pub fn date(&self) -> Date {
Date(self.created_at)
}
pub fn relative_date(&self) -> RelativeDate {
RelativeDate(self.created_at)
}
}
pub struct DemoUrl(i32);
impl Render for DemoUrl {
fn render_to(&self, buffer: &mut String) {
write!(buffer, "/{}", self.0).unwrap();
}
}
pub struct DemoFormat {
player_count: i32,
mode: MapMode,
}
enum MapMode {
Other,
Bball,
Ultiduo,
}
impl MapMode {
fn from_map(map: &str) -> Self {
if map.contains("bball") || map.contains("ballin") {
Self::Bball
} else if map.contains("ultiduo") {
Self::Ultiduo
} else {
Self::Other
}
}
}
impl Render for DemoFormat {
fn render_to(&self, buffer: &mut String) {
let name = match self.mode {
MapMode::Ultiduo => "Ultiduo",
MapMode::Bball => "BBall",
MapMode::Other => match self.player_count {
17 | 18 | 19 => "HL",
15 | 14 => "Prolander",
13 | 12 | 11 => "6v6",
7 | 8 | 9 => "4v4",
_ => "Other",
},
};
write!(buffer, "{name}").unwrap();
}
}
pub struct Duration(i32);
impl Render for Duration {
fn render_to(&self, buffer: &mut String) {
if self.0 < 1 {
write!(buffer, "0:00").unwrap();
return;
}
let hours = self.0 / 3600;
let minutes = (self.0 - (hours * 3600)) / 60;
let seconds = self.0 - (hours * 3600) - (minutes * 60);
if hours == 0 {
write!(buffer, "{minutes:02}:{seconds:02}").unwrap();
} else {
write!(buffer, "{hours:02}:{minutes:02}:{seconds:02}").unwrap();
}
}
}
pub struct Date(PrimitiveDateTime);
impl Render for Date {
fn render_to(&self, buffer: &mut String) {
buffer.push_str(
&self
.0
.assume_offset(UtcOffset::UTC)
.format(&Iso8601::DEFAULT)
.unwrap(),
);
}
}
pub struct RelativeDate(PrimitiveDateTime);
impl Render for RelativeDate {
fn render_to(&self, buffer: &mut String) {
let date = self.0.assume_offset(UtcOffset::UTC);
let now = OffsetDateTime::now_utc();
let elapsed = now - date;
if elapsed.is_positive() {
if elapsed.whole_minutes() < 1 {
write!(buffer, "seconds ago").unwrap();
} else if elapsed.whole_hours() < 1 {
write!(buffer, "{} minutes ago", elapsed.whole_minutes()).unwrap();
} else if elapsed.whole_days() < 1 {
write!(buffer, "{} hours ago", elapsed.whole_hours()).unwrap();
} else if elapsed.whole_days() < 32 {
write!(buffer, "{} days ago", elapsed.whole_days()).unwrap();
} else if elapsed.whole_days() < 365 {
write!(buffer, "{} days ago", elapsed.whole_days() / 30).unwrap();
} else {
write!(buffer, "{} years go", elapsed.whole_days() / 365).unwrap();
}
} else {
write!(buffer, "now").unwrap();
}
}
}

3
src/data/mod.rs Normal file
View file

@ -0,0 +1,3 @@
pub mod demo;
pub mod player;
pub mod steam_id;

89
src/data/player.rs Normal file
View file

@ -0,0 +1,89 @@
use crate::data::steam_id::SteamId;
use crate::Result;
use maud::Render;
use sqlx::{query_as, Executor, FromRow, Postgres};
use tracing::instrument;
#[derive(sqlx::Type, Debug, Eq, PartialEq, Copy, Clone)]
#[sqlx(rename_all = "lowercase")]
pub enum Team {
Red,
Blue,
Other,
Spectator,
}
#[derive(sqlx::Type, Debug, Ord, PartialOrd, Eq, PartialEq, Copy, Clone)]
#[sqlx(rename_all = "lowercase")]
pub enum Class {
Scout,
Soldier,
Pyro,
Demoman,
HeavyWeapons,
Engineer,
Medic,
Sniper,
Spy,
Unknown,
}
impl Class {
pub fn as_str(&self) -> &'static str {
match self {
Class::Scout => "scout",
Class::Soldier => "soldier",
Class::Pyro => "pyro",
Class::Demoman => "demoman",
Class::HeavyWeapons => "heavyweapons",
Class::Engineer => "engineer",
Class::Medic => "medic",
Class::Sniper => "sniper",
Class::Spy => "spy",
Class::Unknown => "unknown",
}
}
}
impl Render for Class {
fn render_to(&self, buffer: &mut String) {
buffer.push_str(self.as_str())
}
}
#[derive(Debug, FromRow)]
pub struct Player {
pub id: i32,
pub steam_id: SteamId,
pub name: String,
pub team: Team,
pub class: Class,
pub kills: Option<i32>,
pub deaths: Option<i32>,
pub assists: Option<i32>,
}
impl Player {
#[instrument(skip(connection))]
pub async fn for_demo(
connection: impl Executor<'_, Database = Postgres>,
id: u32,
) -> Result<Vec<Player>> {
let mut players = query_as!(
Player,
r#"SELECT
max(players.id) as "id!", max(players.name) as "name!", max(team) as "team!: Team", max(class) as "class!: Class",
max(kills) as "kills", max(deaths) as "deaths", max(assists) as "assists", max(steamid) as "steam_id!: SteamId"
FROM players
INNER JOIN users ON user_id = users.id
WHERE demo_id = $1
GROUP BY user_id"#,
id as i32
)
.fetch_all(connection)
.await?;
players.sort_by_key(|player| player.class);
Ok(players)
}
}

145
src/data/steam_id.rs Normal file
View file

@ -0,0 +1,145 @@
use maud::Render;
use serde::{Serialize, Serializer};
use sqlx::database::HasValueRef;
use sqlx::error::BoxDynError;
use sqlx::{Database, Decode, Type};
use std::borrow::Cow;
use std::fmt::Write;
use std::fmt::{Debug, Formatter};
use steamid_ng::SteamID;
#[derive(Clone, PartialEq, Eq, Hash)]
pub enum SteamId {
Id(u64),
Raw(Cow<'static, str>),
}
const UNKNOWN_STEAM_ID: SteamId = SteamId::Raw(Cow::Borrowed("unknown"));
impl Default for SteamId {
fn default() -> Self {
UNKNOWN_STEAM_ID
}
}
impl Default for &SteamId {
fn default() -> Self {
&UNKNOWN_STEAM_ID
}
}
impl SteamId {
pub const fn new(id: u64) -> SteamId {
SteamId::Id(id)
}
pub fn steam3(&self) -> String {
match self {
SteamId::Id(id) => SteamID::from(*id).steam3(),
SteamId::Raw(raw) => raw.to_string(),
}
}
pub fn steam2(&self) -> String {
match self {
SteamId::Id(id) => SteamID::from(*id).steam2(),
SteamId::Raw(raw) => raw.to_string(),
}
}
pub fn from_steam3(s: &str) -> Result<Self, steamid_ng::SteamIDError> {
let id = SteamID::from_steam3(s)?;
Ok(SteamId::Id(id.into()))
}
}
impl Debug for SteamId {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
SteamId::Id(id) => SteamID::from(*id).fmt(f),
SteamId::Raw(raw) => raw.fmt(f),
}
}
}
impl Serialize for SteamId {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match self {
SteamId::Id(id) => serializer.collect_str(&SteamID::from(*id).steam3()),
SteamId::Raw(raw) => serializer.collect_str(raw),
}
}
}
impl<DB: Database> Type<DB> for SteamId
where
i64: Type<DB>,
str: Type<DB>,
{
fn type_info() -> DB::TypeInfo {
<str as Type<DB>>::type_info()
}
fn compatible(ty: &DB::TypeInfo) -> bool {
<str as Type<DB>>::compatible(ty)
}
}
impl<'r, DB> Decode<'r, DB> for SteamId
where
DB: Database,
&'r str: Decode<'r, DB>,
{
fn decode(value: <DB as HasValueRef<'r>>::ValueRef) -> Result<Self, BoxDynError> {
let str = <&str as Decode<DB>>::decode(value)?;
if let Ok(id) = str.parse() {
Ok(Self::Id(id))
} else if str == "serveme.tf" {
Ok(Self::Raw("serveme.tf".into()))
} else if str == "essentialstf" {
Ok(Self::Raw("essentialstf".into()))
} else {
Ok(Self::Raw(str.to_string().into()))
}
}
}
impl Render for SteamId {
fn render_to(&self, buffer: &mut String) {
match self {
SteamId::Id(id) => write!(buffer, "{id}").unwrap(),
SteamId::Raw(raw) => buffer.push_str(raw),
}
}
}
pub struct ProfileLink<'a>(&'a SteamId);
impl Render for ProfileLink<'_> {
fn render_to(&self, buffer: &mut String) {
buffer.push_str("/profiles/");
self.0.render_to(buffer)
}
}
pub struct UploadsLink<'a>(&'a SteamId);
impl Render for UploadsLink<'_> {
fn render_to(&self, buffer: &mut String) {
buffer.push_str("/uploads/");
self.0.render_to(buffer)
}
}
impl SteamId {
pub fn uploads_link(&self) -> UploadsLink {
UploadsLink(self)
}
pub fn profile_link(&self) -> ProfileLink {
ProfileLink(self)
}
}

23
src/error.rs Normal file
View file

@ -0,0 +1,23 @@
use axum::response::{IntoResponse, Response};
use config::ConfigError;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error(transparent)]
Sqlx(#[from] sqlx::Error),
#[error(transparent)]
Config(#[from] ConfigError),
#[error(transparent)]
Hyper(#[from] hyper::Error),
#[error(transparent)]
Io(#[from] std::io::Error),
#[error("page not found")]
NotFound,
}
impl IntoResponse for Error {
fn into_response(self) -> Response {
dbg!(self);
todo!()
}
}

View file

@ -1,3 +1,119 @@
fn main() {
println!("Hello, world!");
mod config;
mod data;
mod error;
mod pages;
pub use crate::config::Config;
use crate::config::Listen;
use crate::data::demo::{Demo, ListDemo};
use crate::pages::demo::DemoPage;
use crate::pages::index::Index;
use crate::pages::render;
use axum::extract::{MatchedPath, Path};
use axum::http::{HeaderValue, Request};
use axum::response::IntoResponse;
use axum::{extract::State, routing::get, Router, Server};
pub use error::Error;
use hyper::header::CONTENT_TYPE;
use hyperlocal::UnixServerExt;
use maud::Markup;
use sqlx::PgPool;
use std::env::{args, var};
use std::fs::{remove_file, set_permissions, Permissions};
use std::net::SocketAddr;
use std::os::unix::fs::PermissionsExt;
use std::sync::Arc;
use tower_http::trace::TraceLayer;
use tracing::{info, info_span};
use tracing_subscriber::{
fmt::layer, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter, Layer,
};
pub type Result<T, E = Error> = std::result::Result<T, E>;
struct App {
connection: PgPool,
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::registry()
.with(layer().with_filter(EnvFilter::new(
var("RUST_LOG").unwrap_or_else(|_| "warn,frontend=info".into()),
)))
.try_init()
.expect("Failed to init tracing");
let config = args().skip(1).next().expect("no config file provided");
let config = Config::load(&config)?;
let connection = config.database.connect().await?;
let state = Arc::new(App { connection });
let app = Router::new()
.route("/", get(index))
.route("/style.css", get(style))
.route("/:id", get(demo))
.layer(
TraceLayer::new_for_http().make_span_with(|request: &Request<_>| {
let matched_path = request
.extensions()
.get::<MatchedPath>()
.map(MatchedPath::as_str);
info_span!(
"http_request",
method = ?request.method(),
matched_path,
some_other_field = tracing::field::Empty,
)
}),
)
.fallback(handler_404)
.with_state(state);
let service = app.into_make_service();
match config.listen {
Listen::Tcp { address, port } => {
let addr = SocketAddr::from((address, port));
info!("listening on {}", addr);
Server::bind(&addr).serve(service).await?;
}
Listen::Socket { path } => {
info!("listening on {}", path.display());
if path.exists() {
remove_file(&path)?;
}
let socket = Server::bind_unix(&path)?;
set_permissions(&path, Permissions::from_mode(0o666))?;
socket.serve(service).await?;
}
}
Ok(())
}
async fn style() -> impl IntoResponse {
let style = include_str!(concat!(env!("OUT_DIR"), "/style.css"));
(
[(CONTENT_TYPE, HeaderValue::from_static("text/css"))],
style,
)
}
async fn index(State(app): State<Arc<App>>) -> Result<Markup> {
let demos = ListDemo::list(&app.connection, None).await?;
Ok(render(Index { demos }))
}
async fn demo(State(app): State<Arc<App>>, Path(id): Path<u32>) -> Result<Markup> {
let demo = Demo::by_id(&app.connection, id)
.await?
.ok_or(Error::NotFound)?;
Ok(render(DemoPage { demo }))
}
async fn handler_404() -> impl IntoResponse {
Error::NotFound
}

96
src/pages/demo.rs Normal file
View file

@ -0,0 +1,96 @@
use crate::data::demo::Demo;
use crate::data::player::{Player, Team};
use crate::pages::Page;
use itertools::{EitherOrBoth, Itertools};
use maud::{html, Markup};
use std::borrow::Cow;
pub struct DemoPage {
pub demo: Demo,
}
impl Page for DemoPage {
fn title(&self) -> Cow<'static, str> {
format!("{} - demos.tf", self.demo.name).into()
}
fn render(&self) -> Markup {
html! {
h2 { (self.demo.server) " - " (self.demo.red) " vs " (self.demo.blu) }
h3 { (self.demo.name) }
p {
"Demo uploaded by "
a href = (self.demo.uploader_steam_id().uploads_link()) { (self.demo.uploader_name()) }
" "
span title = (self.demo.date()) { (self.demo.relative_date()) }
}
.teams {
.red {
span.name { (self.demo.red) }
span.score { (self.demo.score_red) }
}
.blue {
span.name { (self.demo.blu) }
span.score { (self.demo.score_blue) }
}
.clearfix {}
}
table.players {
thead {
th.team.red {}
th.name.red { "Name" }
th.stat.red { "K" }
th.stat.red { "A" }
th.stat.red { "D" }
th.class {}
th.class {}
th.stat.blue { "D" }
th.stat.blue { "A" }
th.stat.blue { "K" }
th.name.blue { "Name" }
th.team.blue {}
}
tbody {
@for player_pair in player_pairs(&self.demo.players) {
tr {
td.team.red {}
@if let Some(player) = player_pair.as_ref().left() {
td.name.red { (player.name) }
td.stat.red { (player.kills.unwrap_or_default()) }
td.stat.red { (player.assists.unwrap_or_default()) }
td.stat.red { (player.deaths.unwrap_or_default()) }
td.class.red.(player.class) {}
} @else {
td.name.red {}
td.stat.red {}
td.stat.red {}
td.stat.red {}
td.class {}
}
@if let Some(player) = player_pair.as_ref().right() {
td.class.blue.(player.class) {}
td.stat.blue { (player.deaths.unwrap_or_default()) }
td.stat.blue { (player.assists.unwrap_or_default()) }
td.stat.blue { (player.kills.unwrap_or_default()) }
td.name.blue { (player.name) }
} @else {
td.class {}
td.stat.blue {}
td.stat.blue {}
td.stat.blue {}
td.name.blue {}
}
td.team.blue {}
}
}
}
}
}
}
}
fn player_pairs(players: &[Player]) -> impl IntoIterator<Item = EitherOrBoth<&Player, &Player>> {
let red = players.iter().filter(|player| player.team == Team::Red);
let blue = players.iter().filter(|player| player.team == Team::Blue);
red.zip_longest(blue)
}

44
src/pages/index.rs Normal file
View file

@ -0,0 +1,44 @@
use crate::data::demo::ListDemo;
use crate::pages::Page;
use maud::{html, Markup};
use std::borrow::Cow;
pub struct Index {
pub demos: Vec<ListDemo>,
}
impl Page for Index {
fn title(&self) -> Cow<'static, str> {
"Demos - demos.tf".into()
}
fn render(&self) -> Markup {
html! {
h1 { "Demos" }
table.demolist {
thead {
tr {
th .title { "Title" }
th .format { "Format" }
th .map { "Map" }
th .duration { "Duration" }
th .date { "Date" }
}
}
tbody {
@for demo in &self.demos {
tr {
td .title {
a href = (demo.url()) { (demo.server) " - " (demo.red) " vs " (demo.blu) }
}
td .format { (demo.format()) }
td .map { (demo.map) }
td .duration { (demo.duration()) }
td .date title = (demo.date()) { (demo.relative_date()) }
}
}
}
}
}
}
}

39
src/pages/mod.rs Normal file
View file

@ -0,0 +1,39 @@
pub mod demo;
pub mod index;
use maud::{html, Markup, DOCTYPE};
use std::borrow::Cow;
pub trait Page {
fn title(&self) -> Cow<'static, str>;
fn render(&self) -> Markup;
}
pub fn render<T: Page>(page: T) -> Markup {
html! {
(DOCTYPE)
html {
head {
title { (page.title()) }
link rel="stylesheet" type="text/css" href="/style.css";
}
body {
header {
span .main {
a href = "/" { "demos.tf" }
}
span { a href = "/about" { "about" } }
span { a href = "/viewer" { "viewer" } }
span.beta { a href = "/editor" { "editor" } }
span.right { a.steam-login href = "/login" { "Sign in through Steam" } }
}
.page { (page.render()) }
}
footer {
"©"
a href = "https://steamcommunity.com/id/icewind1991" { "Icewind" }
" 2017."
}
}
}
}