mirror of
https://codeberg.org/demostf/frontend.git
synced 2026-06-03 18:24:12 +02:00
steam login
This commit is contained in:
parent
6e456a6596
commit
fc5cd1d24f
12 changed files with 926 additions and 70 deletions
645
Cargo.lock
generated
645
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -14,7 +14,7 @@ tokio = { version = "1.27.0", features = ["full"] }
|
|||
config = { version = "0.13.3", features = ["toml"] }
|
||||
time = "0.3.20"
|
||||
maud = { version = "0.24.0", git = "https://github.com/lambda-fairy/maud", rev = "7233cda35eed7bba91c9c55564d65498067c3822", features = ["axum"] }
|
||||
axum = "0.6.12"
|
||||
axum = { version = "0.6.12", features = ["headers"] }
|
||||
hyper = "0.14.25"
|
||||
hyperlocal = "0.8.0"
|
||||
tower-http = { version = "0.4.0", features = ["trace"] }
|
||||
|
|
@ -23,6 +23,11 @@ itertools = "0.10.5"
|
|||
const-fnv1a-hash = "1.1.0"
|
||||
const_base = "0.2.0"
|
||||
const-str = "0.5.4"
|
||||
steam-openid = "0.2.0"
|
||||
async-session = "3.0.0"
|
||||
quick-xml = { version = "0.28.1", features = ["serialize"] }
|
||||
reqwest = "0.11.16"
|
||||
rand = "0.8.5"
|
||||
|
||||
[build-dependencies]
|
||||
lightningcss = { version = "1.0.0-alpha.40", features = ["browserslist", "visitor"] }
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@
|
|||
in rec {
|
||||
# `nix develop`
|
||||
devShell = pkgs.mkShell {
|
||||
OPENSSL_NO_VENDOR = 1;
|
||||
|
||||
nativeBuildInputs = with pkgs; [
|
||||
cargo
|
||||
bacon
|
||||
|
|
@ -27,6 +29,8 @@
|
|||
clippy
|
||||
cargo-audit
|
||||
cargo-watch
|
||||
pkg-config
|
||||
openssl
|
||||
];
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ use std::path::PathBuf;
|
|||
pub struct Config {
|
||||
pub listen: Listen,
|
||||
pub database: DbConfig,
|
||||
pub site: SiteConfig,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
|
|
@ -46,3 +47,8 @@ pub enum Listen {
|
|||
Socket { path: PathBuf },
|
||||
Tcp { address: IpAddr, port: u16 },
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SiteConfig {
|
||||
pub url: String,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,3 +2,4 @@ pub mod chat;
|
|||
pub mod demo;
|
||||
pub mod player;
|
||||
pub mod steam_id;
|
||||
pub mod user;
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
use maud::Render;
|
||||
use serde::{Serialize, Serializer};
|
||||
use serde::{Deserialize, Serialize};
|
||||
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 std::fmt::{Display, Write};
|
||||
use steamid_ng::SteamID;
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Hash)]
|
||||
#[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum SteamId {
|
||||
Id(u64),
|
||||
Raw(Cow<'static, str>),
|
||||
|
|
@ -51,25 +51,20 @@ impl SteamId {
|
|||
let id = SteamID::from_steam3(s)?;
|
||||
Ok(SteamId::Id(id.into()))
|
||||
}
|
||||
|
||||
pub fn steamid64(&self) -> String {
|
||||
match self {
|
||||
SteamId::Id(id) => format!("{}", id),
|
||||
SteamId::Raw(raw) => raw.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
SteamId::Id(id) => Debug::fmt(&SteamID::from(*id), f),
|
||||
SteamId::Raw(raw) => Debug::fmt(raw, f),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -107,12 +102,18 @@ where
|
|||
}
|
||||
}
|
||||
|
||||
impl Display for SteamId {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
SteamId::Id(id) => write!(f, "{id}"),
|
||||
SteamId::Raw(raw) => write!(f, "{raw}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
}
|
||||
write!(buffer, "{self}").unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
80
src/data/user.rs
Normal file
80
src/data/user.rs
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
use crate::data::steam_id::SteamId;
|
||||
use crate::Result;
|
||||
use rand::distributions::Alphanumeric;
|
||||
use rand::Rng;
|
||||
use reqwest::get;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{query, Executor, Postgres};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct User {
|
||||
pub steam_id: SteamId,
|
||||
pub name: String,
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
impl User {
|
||||
pub async fn get(
|
||||
connection: impl Executor<'_, Database = Postgres> + Copy,
|
||||
steam_id: SteamId,
|
||||
) -> Result<Self> {
|
||||
let user = query!(
|
||||
r#"SELECT
|
||||
token as "token!", name as "name!"
|
||||
FROM users_named WHERE steamid = $1"#,
|
||||
steam_id.steamid64()
|
||||
)
|
||||
.fetch_optional(connection)
|
||||
.await?;
|
||||
|
||||
if let Some(user) = user {
|
||||
Ok(User {
|
||||
steam_id,
|
||||
token: user.token,
|
||||
name: user.name,
|
||||
})
|
||||
} else {
|
||||
let profile = Self::fetch(&steam_id).await?;
|
||||
let token: String = rand::thread_rng()
|
||||
.sample_iter(&Alphanumeric)
|
||||
.take(64)
|
||||
.map(char::from)
|
||||
.collect();
|
||||
|
||||
query!(
|
||||
r#"INSERT INTO users(steamid, name, avatar, token)
|
||||
VALUES($1, $2, $3, $4)"#,
|
||||
steam_id.steamid64(),
|
||||
profile.name,
|
||||
profile.avatar,
|
||||
token
|
||||
)
|
||||
.execute(connection)
|
||||
.await?;
|
||||
Ok(User {
|
||||
steam_id,
|
||||
token,
|
||||
name: profile.name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch(steam_id: &SteamId) -> Result<Profile> {
|
||||
let response = get(format!(
|
||||
"https://steamcommunity.com/profiles/{steam_id}?xml=1"
|
||||
))
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.text()
|
||||
.await?;
|
||||
Ok(quick_xml::de::from_str(&response)?)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Profile {
|
||||
#[serde(rename = "steamID")]
|
||||
name: String,
|
||||
#[serde(rename = "avatarMedium")]
|
||||
avatar: String,
|
||||
}
|
||||
|
|
@ -13,6 +13,14 @@ pub enum Error {
|
|||
Io(#[from] std::io::Error),
|
||||
#[error("page not found")]
|
||||
NotFound,
|
||||
#[error("Failed to validate steam auth")]
|
||||
SteamAuth,
|
||||
#[error(transparent)]
|
||||
Request(#[from] reqwest::Error),
|
||||
#[error(transparent)]
|
||||
Xml(#[from] quick_xml::de::DeError),
|
||||
#[error(transparent)]
|
||||
Session(#[from] async_session::Error),
|
||||
}
|
||||
|
||||
impl IntoResponse for Error {
|
||||
|
|
|
|||
122
src/main.rs
122
src/main.rs
|
|
@ -3,19 +3,26 @@ mod config;
|
|||
mod data;
|
||||
mod error;
|
||||
mod pages;
|
||||
mod session;
|
||||
|
||||
pub use crate::config::Config;
|
||||
use crate::config::Listen;
|
||||
use crate::data::demo::{Demo, ListDemo};
|
||||
use crate::data::steam_id::SteamId;
|
||||
use crate::data::user::User;
|
||||
use crate::pages::about::AboutPage;
|
||||
use crate::pages::demo::DemoPage;
|
||||
use crate::pages::index::Index;
|
||||
use crate::pages::render;
|
||||
use crate::session::{SessionData, COOKIE_NAME};
|
||||
use asset::{serve_compiled, serve_static};
|
||||
use axum::extract::{MatchedPath, Path};
|
||||
use axum::http::Request;
|
||||
use async_session::{MemoryStore, Session, SessionStore};
|
||||
use axum::extract::{MatchedPath, Path, RawQuery};
|
||||
use axum::headers::Cookie;
|
||||
use axum::http::header::{LOCATION, SET_COOKIE};
|
||||
use axum::http::{HeaderValue, Request, StatusCode};
|
||||
use axum::response::IntoResponse;
|
||||
use axum::{extract::State, routing::get, Router, Server};
|
||||
use axum::{extract::State, routing::get, Router, Server, TypedHeader};
|
||||
pub use error::Error;
|
||||
use hyperlocal::UnixServerExt;
|
||||
use maud::Markup;
|
||||
|
|
@ -25,8 +32,9 @@ use std::fs::{remove_file, set_permissions, Permissions};
|
|||
use std::net::SocketAddr;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::sync::Arc;
|
||||
use steam_openid::SteamOpenId;
|
||||
use tower_http::trace::TraceLayer;
|
||||
use tracing::{info, info_span};
|
||||
use tracing::{error, info, info_span};
|
||||
use tracing_subscriber::{
|
||||
fmt::layer, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter, Layer,
|
||||
};
|
||||
|
|
@ -35,6 +43,8 @@ pub type Result<T, E = Error> = std::result::Result<T, E>;
|
|||
|
||||
struct App {
|
||||
connection: PgPool,
|
||||
openid: SteamOpenId,
|
||||
pub session_store: MemoryStore,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
|
|
@ -50,7 +60,14 @@ async fn main() -> Result<()> {
|
|||
let config = Config::load(&config)?;
|
||||
let connection = config.database.connect().await?;
|
||||
|
||||
let state = Arc::new(App { connection });
|
||||
let session_store = MemoryStore::new();
|
||||
|
||||
let state = Arc::new(App {
|
||||
connection,
|
||||
openid: SteamOpenId::new(&config.site.url, "/login/callback")
|
||||
.expect("invalid steam login url"),
|
||||
session_store: session_store.clone(),
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
.route("/", get(index))
|
||||
|
|
@ -58,6 +75,9 @@ async fn main() -> Result<()> {
|
|||
.route("/images/logo.png", get(serve_static!("../images/logo.png")))
|
||||
.route("/images/logo.svg", get(serve_static!("../images/logo.svg")))
|
||||
.route("/about", get(about))
|
||||
.route("/login/callback", get(login_callback))
|
||||
.route("/login", get(login))
|
||||
.route("/logout", get(logout))
|
||||
.route("/:id", get(demo))
|
||||
.layer(
|
||||
TraceLayer::new_for_http().make_span_with(|request: &Request<_>| {
|
||||
|
|
@ -99,20 +119,100 @@ async fn main() -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
async fn index(State(app): State<Arc<App>>) -> Result<Markup> {
|
||||
async fn index(State(app): State<Arc<App>>, session: SessionData) -> Result<Markup> {
|
||||
let demos = ListDemo::list(&app.connection, None).await?;
|
||||
Ok(render(Index { demos }))
|
||||
Ok(render(Index { demos }, session))
|
||||
}
|
||||
|
||||
async fn about(State(_app): State<Arc<App>>) -> Result<Markup> {
|
||||
Ok(render(AboutPage { key: None }))
|
||||
async fn about(State(_app): State<Arc<App>>, session: SessionData) -> Result<Markup> {
|
||||
Ok(render(
|
||||
AboutPage {
|
||||
key: session.token(),
|
||||
},
|
||||
session,
|
||||
))
|
||||
}
|
||||
|
||||
async fn demo(State(app): State<Arc<App>>, Path(id): Path<u32>) -> Result<Markup> {
|
||||
async fn demo(
|
||||
State(app): State<Arc<App>>,
|
||||
Path(id): Path<u32>,
|
||||
session: SessionData,
|
||||
) -> Result<Markup> {
|
||||
let demo = Demo::by_id(&app.connection, id)
|
||||
.await?
|
||||
.ok_or(Error::NotFound)?;
|
||||
Ok(render(DemoPage { demo }))
|
||||
Ok(render(DemoPage { demo }, session))
|
||||
}
|
||||
|
||||
async fn login_callback(
|
||||
State(app): State<Arc<App>>,
|
||||
RawQuery(query): RawQuery,
|
||||
) -> Result<impl IntoResponse> {
|
||||
let query = query.as_deref().unwrap_or_default();
|
||||
let steam_id = app.openid.verify(query).await.map_err(|e| {
|
||||
error!("{e:?}");
|
||||
Error::SteamAuth
|
||||
})?;
|
||||
let steam_id = SteamId::new(steam_id);
|
||||
let user = User::get(&app.connection, steam_id).await?;
|
||||
let mut session = Session::new();
|
||||
session
|
||||
.insert("user", user)
|
||||
.expect("failed to serialize user");
|
||||
let cookie = app
|
||||
.session_store
|
||||
.store_session(session)
|
||||
.await?
|
||||
.unwrap_or_default();
|
||||
Ok((
|
||||
StatusCode::FOUND,
|
||||
[
|
||||
(
|
||||
SET_COOKIE,
|
||||
HeaderValue::from_str(&format!(
|
||||
"{}={}; HttpOnly; SameSite=Lax; Path=/",
|
||||
COOKIE_NAME, cookie
|
||||
))
|
||||
.expect("invalid cookie"),
|
||||
),
|
||||
(LOCATION, HeaderValue::from_static("/")),
|
||||
],
|
||||
))
|
||||
}
|
||||
|
||||
async fn login(State(app): State<Arc<App>>) -> impl IntoResponse {
|
||||
(
|
||||
StatusCode::FOUND,
|
||||
[(
|
||||
LOCATION,
|
||||
HeaderValue::from_str(app.openid.get_redirect_url()).unwrap(),
|
||||
)],
|
||||
)
|
||||
}
|
||||
|
||||
async fn logout(
|
||||
State(app): State<Arc<App>>,
|
||||
cookie: Option<TypedHeader<Cookie>>,
|
||||
) -> impl IntoResponse {
|
||||
if let Some(session_cookie) = cookie.as_deref().and_then(|cookie| cookie.get(COOKIE_NAME)) {
|
||||
if let Ok(Some(cookie)) = app.session_store.load_session(session_cookie.into()).await {
|
||||
let _ = app.session_store.destroy_session(cookie);
|
||||
}
|
||||
}
|
||||
(
|
||||
StatusCode::FOUND,
|
||||
[
|
||||
(
|
||||
SET_COOKIE,
|
||||
HeaderValue::from_str(&format!(
|
||||
"{}=; HttpOnly; SameSite=Lax; expires=Thu, 01 Jan 1970 00:00:00 GMT",
|
||||
COOKIE_NAME
|
||||
))
|
||||
.expect("invalid cookie"),
|
||||
),
|
||||
(LOCATION, HeaderValue::from_str("/").unwrap()),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
async fn handler_404() -> impl IntoResponse {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ pub mod index;
|
|||
mod plugin_section;
|
||||
|
||||
use crate::asset::saved_asset_url;
|
||||
use crate::session::SessionData;
|
||||
use maud::{html, Markup, DOCTYPE};
|
||||
use std::borrow::Cow;
|
||||
|
||||
|
|
@ -12,7 +13,7 @@ pub trait Page {
|
|||
fn render(&self) -> Markup;
|
||||
}
|
||||
|
||||
pub fn render<T: Page>(page: T) -> Markup {
|
||||
pub fn render<T: Page>(page: T, session: SessionData) -> Markup {
|
||||
let style_url = saved_asset_url!("style.css");
|
||||
html! {
|
||||
(DOCTYPE)
|
||||
|
|
@ -30,8 +31,14 @@ pub fn render<T: Page>(page: T) -> Markup {
|
|||
span { a href = "/about" { "about" } }
|
||||
span { a href = "/viewer" { "viewer" } }
|
||||
span.beta { a href = "/editor" { "editor" } }
|
||||
@if let SessionData::Authenticated(user) = session {
|
||||
span.right { a href = "/logout" { "Logout" } }
|
||||
span.right { a href = "/upload" { "Upload" } }
|
||||
span.right { a href = (user.steam_id.profile_link()) { (user.name) } }
|
||||
} @else {
|
||||
span.right { a.steam-login href = "/login" { "Sign in through Steam" } }
|
||||
}
|
||||
}
|
||||
.page { (page.render()) }
|
||||
}
|
||||
footer {
|
||||
|
|
|
|||
65
src/session.rs
Normal file
65
src/session.rs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
use crate::data::user::User;
|
||||
use crate::{App, Result};
|
||||
use async_session::SessionStore as _;
|
||||
use axum::extract::{FromRef, FromRequestParts};
|
||||
use axum::http::request::Parts;
|
||||
use axum::{async_trait, headers::Cookie, RequestPartsExt, TypedHeader};
|
||||
use std::convert::Infallible;
|
||||
use std::sync::Arc;
|
||||
use tracing::debug;
|
||||
|
||||
pub const COOKIE_NAME: &str = "tf_session";
|
||||
|
||||
pub enum SessionData {
|
||||
Authenticated(User),
|
||||
UnAuthenticated,
|
||||
}
|
||||
|
||||
impl SessionData {
|
||||
pub fn token(&self) -> Option<String> {
|
||||
match self {
|
||||
SessionData::Authenticated(user) => Some(user.token.clone()),
|
||||
SessionData::UnAuthenticated => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<S> FromRequestParts<S> for SessionData
|
||||
where
|
||||
Arc<App>: FromRef<S>,
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = Infallible;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||
let app: Arc<App> = Arc::from_ref(state);
|
||||
let store = &app.session_store;
|
||||
|
||||
let cookie: Option<TypedHeader<Cookie>> = parts.extract().await.unwrap();
|
||||
|
||||
let session_cookie = cookie.as_ref().and_then(|cookie| cookie.get(COOKIE_NAME));
|
||||
|
||||
// return the new created session cookie for client
|
||||
if session_cookie.is_none() {
|
||||
return Ok(Self::UnAuthenticated);
|
||||
}
|
||||
|
||||
debug!(
|
||||
"SessionData: got session cookie from user agent, {}={}",
|
||||
COOKIE_NAME,
|
||||
session_cookie.unwrap()
|
||||
);
|
||||
// continue to decode the session cookie
|
||||
let Ok(Some(session)) = store
|
||||
.load_session(session_cookie.unwrap().to_owned())
|
||||
.await else {
|
||||
return Ok(Self::UnAuthenticated);
|
||||
};
|
||||
let Some(user) = session.get::<User>("user") else {
|
||||
return Ok(Self::UnAuthenticated);
|
||||
};
|
||||
|
||||
Ok(Self::Authenticated(user))
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ header {
|
|||
padding: .5em 1em;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.main a {
|
||||
|
|
@ -68,6 +69,10 @@ header {
|
|||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
& .right {
|
||||
text-transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
a.steam-login:before {
|
||||
|
|
@ -84,8 +89,7 @@ a.steam-login:before {
|
|||
top: 0;
|
||||
}
|
||||
|
||||
a.steam-login {
|
||||
text-transform: none;
|
||||
a.steam-login, a.steam-login:visited {
|
||||
display: inline-block;
|
||||
height: 41px;
|
||||
margin: -1px 0;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue