mirror of
https://github.com/YGGverse/hlstate-rs.git
synced 2026-03-31 09:05:42 +00:00
Merge pull request #2 from YGGverse/subscribe-game-event-rss
Subscribe game event rss
This commit is contained in:
commit
e1d9e8cf21
9 changed files with 305 additions and 99 deletions
5
Cargo.lock
generated
5
Cargo.lock
generated
|
|
@ -208,7 +208,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"js-sys",
|
||||
"num-traits",
|
||||
"wasm-bindgen",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
|
|
@ -686,8 +688,9 @@ checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
|
|||
|
||||
[[package]]
|
||||
name = "hlstate-httpd"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
"rocket",
|
||||
"rocket_dyn_templates",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "hlstate-httpd"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
readme = "README.md"
|
||||
|
|
@ -10,6 +10,7 @@ categories = ["parsing", "text-processing", "value-formatting"]
|
|||
repository = "https://github.com/YGGverse/hlstate-rs"
|
||||
|
||||
[dependencies]
|
||||
chrono = "0.4.44"
|
||||
#observer = { git = "https://github.com/YGGverse/xash3d-master.git", package = "xash3d-observer", branch = "ip6-only" }
|
||||
#protocol = { git = "https://github.com/YGGverse/xash3d-master.git", package = "xash3d-protocol", branch = "ip6-only" }
|
||||
#observer = { path = "../../../xash3d-master/observer", package = "xash3d-observer" }
|
||||
|
|
@ -18,4 +19,4 @@ clap = { version = "4.5.54", features = ["derive"] }
|
|||
#mysql = { package = "hlstate-mysql", version = "0.1.0", path = "../mysql" }
|
||||
rocket = { version = "0.5.1", features = ["json"] }
|
||||
rocket_dyn_templates = { version = "0.2.0", features = ["tera"] }
|
||||
toml = "0.9.10"
|
||||
toml = "0.9.10"
|
||||
|
|
|
|||
|
|
@ -12,5 +12,11 @@ debug = true
|
|||
# Path to `xash3d-query` bin
|
||||
query = "xash3d-query"
|
||||
|
||||
# Interval to update master scrape, in seconds
|
||||
refresh = 60
|
||||
|
||||
# Define at least one master to scrape game servers from
|
||||
masters = ["[202:68d0:f0d5:b88d:1d1a:555e:2f6b:3148]:27010", "[300:dee4:d3c0:953b::2019]:27010"]
|
||||
masters = [
|
||||
"[202:68d0:f0d5:b88d:1d1a:555e:2f6b:3148]:27010",
|
||||
"[300:dee4:d3c0:953b::2019]:27010"
|
||||
]
|
||||
|
|
@ -14,4 +14,5 @@ pub struct Config {
|
|||
pub port: u16,
|
||||
pub query: PathBuf,
|
||||
pub title: String,
|
||||
pub refresh: u64,
|
||||
}
|
||||
|
|
|
|||
69
crates/httpd/src/feed.rs
Normal file
69
crates/httpd/src/feed.rs
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
use chrono::{DateTime, Utc};
|
||||
use std::net::SocketAddr;
|
||||
|
||||
/// Export crawl results as the RSS
|
||||
pub struct Feed(String);
|
||||
|
||||
impl Feed {
|
||||
pub fn new(title: &str, description: Option<&str>) -> Self {
|
||||
let t = chrono::Utc::now().to_rfc2822();
|
||||
let mut b = String::new();
|
||||
|
||||
b.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?><rss version=\"2.0\"><channel>");
|
||||
|
||||
b.push_str("<pubDate>");
|
||||
b.push_str(&t);
|
||||
b.push_str("</pubDate>");
|
||||
|
||||
b.push_str("<lastBuildDate>");
|
||||
b.push_str(&t);
|
||||
b.push_str("</lastBuildDate>");
|
||||
|
||||
b.push_str("<title>");
|
||||
b.push_str(title);
|
||||
b.push_str("</title>");
|
||||
|
||||
if let Some(d) = description {
|
||||
b.push_str("<description>");
|
||||
b.push_str(d);
|
||||
b.push_str("</description>")
|
||||
}
|
||||
|
||||
Self(b)
|
||||
}
|
||||
|
||||
/// Appends `item` to the feed `channel`
|
||||
pub fn push(
|
||||
&mut self,
|
||||
time: &DateTime<Utc>,
|
||||
address: &SocketAddr,
|
||||
host: &String,
|
||||
map: &String,
|
||||
online: u32,
|
||||
) {
|
||||
let a = address.to_string(); // allocate once
|
||||
|
||||
self.0.push_str(&format!(
|
||||
"<item><guid>{}</guid><title>{host}</title><link>udp://{a}</link>",
|
||||
time.timestamp() // must be unique as the event
|
||||
));
|
||||
|
||||
self.0.push_str("<description>");
|
||||
self.0.push_str(&format!("connect: {a}\n"));
|
||||
self.0.push_str(&format!("map: {map}\n"));
|
||||
self.0.push_str(&format!("online: {online}\n"));
|
||||
self.0.push_str("</description>");
|
||||
|
||||
self.0.push_str("<pubDate>");
|
||||
self.0.push_str(&time.to_rfc2822());
|
||||
self.0.push_str("</pubDate>");
|
||||
|
||||
self.0.push_str("</item>")
|
||||
}
|
||||
|
||||
/// Write final bytes
|
||||
pub fn commit(mut self) -> String {
|
||||
self.0.push_str("</channel></rss>");
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
|
@ -3,68 +3,135 @@ extern crate rocket;
|
|||
|
||||
mod argument;
|
||||
mod config;
|
||||
mod feed;
|
||||
mod global;
|
||||
mod meta;
|
||||
mod scrape;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use feed::Feed;
|
||||
use global::Global;
|
||||
use meta::Meta;
|
||||
use rocket::{State, http::Status};
|
||||
use rocket::{
|
||||
State,
|
||||
form::FromForm,
|
||||
http::Status,
|
||||
response::content::RawXml,
|
||||
tokio::{spawn, sync::RwLock, time::sleep},
|
||||
};
|
||||
use rocket_dyn_templates::{Template, context};
|
||||
use std::{collections::HashSet, net::SocketAddr, path::PathBuf, sync::Arc, time::Duration};
|
||||
|
||||
struct Online {
|
||||
/// Actual state container
|
||||
current: scrape::Result,
|
||||
/// Hold previous `current` state updated to compare and notify subscribers for online change
|
||||
last: Option<scrape::Result>,
|
||||
/// Snap time
|
||||
update: DateTime<Utc>,
|
||||
}
|
||||
type Snap = Arc<RwLock<Option<Online>>>;
|
||||
|
||||
#[get("/")]
|
||||
fn index(meta: &State<Meta>, global: &State<Global>) -> Result<Template, Status> {
|
||||
// @TODO: requires library impl
|
||||
// https://github.com/FWGS/xash3d-master/issues/4
|
||||
let scrape = std::process::Command::new(&global.query)
|
||||
.arg("all")
|
||||
.arg("-M")
|
||||
.arg(
|
||||
global
|
||||
.masters
|
||||
.iter()
|
||||
.map(|a| a.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(","),
|
||||
)
|
||||
.arg("-j")
|
||||
.output()
|
||||
.map_err(|e| {
|
||||
error!("Make sure `xash3d-query` is installed: {e}");
|
||||
Status::InternalServerError
|
||||
})?;
|
||||
if scrape.status.success() {
|
||||
let result: scrape::Result = rocket::serde::json::serde_json::from_str(
|
||||
str::from_utf8(&scrape.stdout).map_err(|e| {
|
||||
error!("stdout parse error: {e}");
|
||||
Status::InternalServerError
|
||||
})?,
|
||||
)
|
||||
.map_err(|e| {
|
||||
error!("JSON parse error: {e}");
|
||||
Status::InternalServerError
|
||||
})?;
|
||||
Ok(Template::render(
|
||||
"index",
|
||||
context! {
|
||||
masters: &global.masters,
|
||||
title: &meta.title,
|
||||
version: &meta.version,
|
||||
servers: result.servers,
|
||||
},
|
||||
))
|
||||
} else {
|
||||
error!("Make sure `xash3d-query` is installed!");
|
||||
Err(Status::InternalServerError)
|
||||
async fn index(
|
||||
meta: &State<Meta>,
|
||||
global: &State<Global>,
|
||||
online: &State<Snap>,
|
||||
) -> Result<Template, Status> {
|
||||
let snap = online.read().await;
|
||||
Ok(Template::render(
|
||||
"index",
|
||||
context! {
|
||||
masters: &global.masters,
|
||||
title: &meta.title,
|
||||
version: &meta.version,
|
||||
servers: snap.as_ref().map(|s|s.current.servers.clone()),
|
||||
updated: snap.as_ref().map(|s|s.update.to_rfc2822())
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(FromForm, Debug)]
|
||||
struct RssParams {
|
||||
online: Option<u32>,
|
||||
servers: Option<Vec<SocketAddr>>,
|
||||
}
|
||||
#[get("/rss?<params..>")]
|
||||
async fn rss(
|
||||
params: RssParams,
|
||||
meta: &State<Meta>,
|
||||
online: &State<Snap>,
|
||||
) -> Result<RawXml<String>, Status> {
|
||||
let mut f = Feed::new(
|
||||
&meta.title,
|
||||
None, // @TODO service description
|
||||
);
|
||||
if let Some(state) = online.read().await.as_ref() {
|
||||
for current in &state.current.servers {
|
||||
if state.last.as_ref().is_none_or(|l| {
|
||||
l.servers
|
||||
.iter()
|
||||
.any(|last| current.address == last.address && current.numcl > last.numcl)
|
||||
}) && params.online.is_none_or(|online| current.numcl >= online)
|
||||
&& params
|
||||
.servers
|
||||
.as_ref()
|
||||
.is_none_or(|servers| servers.iter().any(|address| address == ¤t.address))
|
||||
{
|
||||
f.push(
|
||||
&state.update,
|
||||
¤t.address,
|
||||
¤t.host,
|
||||
¤t.map,
|
||||
current.numcl,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(RawXml(f.commit()))
|
||||
}
|
||||
|
||||
#[launch]
|
||||
fn rocket() -> _ {
|
||||
async fn rocket() -> _ {
|
||||
use clap::Parser;
|
||||
let argument = argument::Argument::parse();
|
||||
let config: config::Config =
|
||||
toml::from_str(&std::fs::read_to_string(argument.config).unwrap()).unwrap();
|
||||
let online: Snap = Arc::new(RwLock::new(None));
|
||||
spawn({
|
||||
let online = online.clone();
|
||||
let query = config.query.clone();
|
||||
let masters = config.masters.clone();
|
||||
async move {
|
||||
loop {
|
||||
match scrape(&query, &masters) {
|
||||
Ok(s) => match str::from_utf8(&s.stdout) {
|
||||
Ok(r) => {
|
||||
if s.status.success() {
|
||||
let mut state = online.write().await;
|
||||
*state = match rocket::serde::json::serde_json::from_str(r) {
|
||||
Ok(current) => Some(Online {
|
||||
current,
|
||||
last: state.as_ref().map(|last| last.current.clone()),
|
||||
update: Utc::now(),
|
||||
}),
|
||||
Err(e) => {
|
||||
error!("Could not decode scrape response: `{e}`");
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
error!("Scrape request failed");
|
||||
}
|
||||
}
|
||||
Err(e) => error!("Could not decode UTF-8: `{e}`"),
|
||||
},
|
||||
Err(e) => error!("Scrape error: `{e}`"),
|
||||
}
|
||||
sleep(Duration::from_secs(config.refresh)).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
rocket::build()
|
||||
.attach(Template::fairing())
|
||||
.configure(rocket::Config {
|
||||
|
|
@ -76,6 +143,7 @@ fn rocket() -> _ {
|
|||
rocket::Config::release_default()
|
||||
}
|
||||
})
|
||||
.manage(online)
|
||||
.manage(Global {
|
||||
masters: config.masters,
|
||||
query: config.query,
|
||||
|
|
@ -84,5 +152,26 @@ fn rocket() -> _ {
|
|||
title: config.title,
|
||||
version: env!("CARGO_PKG_VERSION").into(),
|
||||
})
|
||||
.mount("/", routes![index])
|
||||
.mount("/", routes![index, rss])
|
||||
}
|
||||
|
||||
/// Get servers online using `bin` from given `masters`
|
||||
fn scrape(
|
||||
bin: &PathBuf,
|
||||
masters: &HashSet<SocketAddr>,
|
||||
) -> Result<std::process::Output, std::io::Error> {
|
||||
// @TODO: requires library impl
|
||||
// https://github.com/FWGS/xash3d-master/issues/4
|
||||
std::process::Command::new(bin)
|
||||
.arg("all")
|
||||
.arg("-M")
|
||||
.arg(
|
||||
masters
|
||||
.iter()
|
||||
.map(|a| a.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(","),
|
||||
)
|
||||
.arg("-j")
|
||||
.output()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use rocket::serde::{Deserialize, Serialize};
|
||||
use std::net::SocketAddr;
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
pub struct Result {
|
||||
pub protocol: Vec<i32>,
|
||||
|
|
@ -12,7 +12,7 @@ pub struct Result {
|
|||
pub servers: Vec<Info>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
pub struct Info {
|
||||
pub time: i64,
|
||||
|
|
|
|||
|
|
@ -2,46 +2,56 @@
|
|||
{% block content %}
|
||||
<h2>Game</h2>
|
||||
{% if servers %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Address</th>
|
||||
<th>Host</th>
|
||||
<th>Ping</th>
|
||||
<th>Protocol</th>
|
||||
<th>Gamedir</th>
|
||||
<th>Map</th>
|
||||
<th>Team</th>
|
||||
<th>Coop</th>
|
||||
<th>Password</th>
|
||||
<th>Dedicated</th>
|
||||
<th>DM</th>
|
||||
<th>Max</th>
|
||||
<th>Online</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
<thead>
|
||||
</tbody>
|
||||
{% for server in servers %}
|
||||
<tr>
|
||||
<td>{{ server.address }}</td>
|
||||
<td>{{ server.host }}</td>
|
||||
<td>{{ server.ping }}</td>
|
||||
<td>{{ server.protocol }}</td>
|
||||
<td>{{ server.gamedir }}</td>
|
||||
<td>{{ server.map }}</td>
|
||||
<td>{{ server.team }}</td>
|
||||
<td>{{ server.coop }}</td>
|
||||
<td>{{ server.password }}</td>
|
||||
<td>{{ server.dedicated }}</td>
|
||||
<td>{{ server.dm }}</td>
|
||||
<td>{{ server.maxcl }}</td>
|
||||
<td>{{ server.numcl }}</td>
|
||||
<td>{{ server.status }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<form name="rss" method="get" action="/rss">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Address</th>
|
||||
<th>Host</th>
|
||||
<th>Ping</th>
|
||||
<th>Protocol</th>
|
||||
<th>Gamedir</th>
|
||||
<th>Map</th>
|
||||
<th>Team</th>
|
||||
<th>Coop</th>
|
||||
<th>Password</th>
|
||||
<th>Dedicated</th>
|
||||
<th>DM</th>
|
||||
<th>Max</th>
|
||||
<th>Online</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
<thead>
|
||||
</tbody>
|
||||
{% for server in servers %}
|
||||
<tr>
|
||||
<td><input type="checkbox" name="server[]" value="{{ server.address }}" /></td>
|
||||
<td>{{ server.address }}</td>
|
||||
<td>{{ server.host }}</td>
|
||||
<td>{{ server.ping }}</td>
|
||||
<td>{{ server.protocol }}</td>
|
||||
<td>{{ server.gamedir }}</td>
|
||||
<td>{{ server.map }}</td>
|
||||
<td>{{ server.team }}</td>
|
||||
<td>{{ server.coop }}</td>
|
||||
<td>{{ server.password }}</td>
|
||||
<td>{{ server.dedicated }}</td>
|
||||
<td>{{ server.dm }}</td>
|
||||
<td>{{ server.maxcl }}</td>
|
||||
<td>{{ server.numcl }}</td>
|
||||
<td>{{ server.status }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<fieldset>
|
||||
<label>
|
||||
min. online: <input type="number" name="online" min="0" value="1" />
|
||||
</label>
|
||||
<input type="submit" value="RSS" />
|
||||
</fieldset>
|
||||
</form>
|
||||
{% else %}
|
||||
<div>
|
||||
<p>Nobody.</p>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
padding: 0;
|
||||
font-family: monospace;
|
||||
color-scheme: light dark;
|
||||
--container-max-width: 1024px;
|
||||
--container-max-width: 1128px;
|
||||
--color-success: #4bc432;
|
||||
--color-warning: #f37b21;
|
||||
--color-error: #ff6363;
|
||||
|
|
@ -75,12 +75,39 @@
|
|||
position: relative;
|
||||
}
|
||||
|
||||
header > div {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
header > div:first-child {
|
||||
float: left;
|
||||
}
|
||||
|
||||
header > div:last-child {
|
||||
float: right;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
main {
|
||||
display: block;
|
||||
margin: 16px auto;
|
||||
max-width: var(--container-max-width);
|
||||
}
|
||||
|
||||
main > form > fieldset {
|
||||
border: 0;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
main > form > fieldset input {
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
main > form > fieldset input[type="number"] {
|
||||
margin: 0 4px;
|
||||
max-width: 36px;
|
||||
}
|
||||
|
||||
footer {
|
||||
display: block;
|
||||
margin: 16px auto;
|
||||
|
|
@ -101,10 +128,7 @@
|
|||
<body>
|
||||
<header>
|
||||
<h1></h1>
|
||||
<div class="float-left">
|
||||
|
||||
</div>
|
||||
<div class="float-right">
|
||||
<div>
|
||||
<strong>
|
||||
<a href="/">{{ title }}</a> |
|
||||
<a href="https://store.steampowered.com/app/70/HalfLife/" target="_blank">Game</a> |
|
||||
|
|
@ -113,18 +137,21 @@
|
|||
<a href="https://github.com/YGGverse/hlstate-rs" target="_blank" title="v{{ version }}">GitHub</a>
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
{% if updated %}{{ updated }}{% endif %}
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
{% block content %}{% endblock content %}
|
||||
</main>
|
||||
<footer>
|
||||
<h2>Master</h2>
|
||||
<h2>Masters</h2>
|
||||
{% if masters %}
|
||||
<ul>
|
||||
{% for master in masters %}
|
||||
<li{# class="online" #}>{{ master }}</li>
|
||||
{% endfor %}
|
||||
<ul>
|
||||
</ul>
|
||||
{% endif %}
|
||||
</footer>
|
||||
</body>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue