Compare commits
No commits in common. "main" and "v1.1.0" have entirely different histories.
11 changed files with 209 additions and 476 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -118,7 +118,7 @@ checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mcstatusface"
|
name = "mcstatusface"
|
||||||
version = "1.2.0"
|
version = "1.0.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"serde",
|
"serde",
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
[package]
|
[package]
|
||||||
name = "mcstatusface"
|
name = "mcstatusface"
|
||||||
authors = ["ari melody <ari@arimelody.space>"]
|
authors = ["ari melody <ari@arimelody.me>"]
|
||||||
repository = "https://codeberg.org/arimelody/mcstatusface"
|
repository = "https://git.arimelody.me/ari/mcstatusface"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
keywords = ["minecraft", "server", "query", "web"]
|
keywords = ["minecraft", "server", "query", "web"]
|
||||||
version = "1.2.0"
|
version = "1.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|
|
||||||
128
src/dns.rs
128
src/dns.rs
|
|
@ -1,128 +0,0 @@
|
||||||
use std::{io::{Error, ErrorKind, Result}, net::UdpSocket};
|
|
||||||
|
|
||||||
const CLOUDFLARE_DNS: &str = "1.1.1.1";
|
|
||||||
|
|
||||||
const RECURSION_DESIRED: u16 = 1 << 8;
|
|
||||||
|
|
||||||
const RECORD_TYPE_SRV: u16 = 33;
|
|
||||||
const RECORD_CLASS_IN: u16 = 1;
|
|
||||||
|
|
||||||
pub fn create_dns_query(qname: &String, qtype: u16, qclass: u16) -> Result<Vec<u8>> {
|
|
||||||
let qname = match qname.is_ascii() {
|
|
||||||
true => qname,
|
|
||||||
false => return Err(Error::new(ErrorKind::InvalidInput, "domain is not valid ASCII")),
|
|
||||||
};
|
|
||||||
|
|
||||||
let txid = 1u16;
|
|
||||||
let flags = RECURSION_DESIRED;
|
|
||||||
let qdcount = 1u16;
|
|
||||||
let ancount = 0u16;
|
|
||||||
let nscount = 0u16;
|
|
||||||
let arcount = 0u16;
|
|
||||||
|
|
||||||
let header: [u16; 6] =
|
|
||||||
[txid, flags, qdcount, ancount, nscount, arcount]
|
|
||||||
.map(|el| el.to_be());
|
|
||||||
|
|
||||||
// let mut qname_bytes = bitvec![u8, Msb0;];
|
|
||||||
let mut query = Vec::<u8>::new();
|
|
||||||
for mut label in qname.split(".") {
|
|
||||||
label = match label.len() > u8::MAX.into() {
|
|
||||||
true => label.split_at(u8::MAX.into()).0,
|
|
||||||
false => label
|
|
||||||
};
|
|
||||||
let len: u8 = label.len().try_into().unwrap();
|
|
||||||
query.push(len);
|
|
||||||
query.append(&mut label.as_bytes().to_vec());
|
|
||||||
}
|
|
||||||
query.push(0);
|
|
||||||
query.append(&mut qtype.to_be_bytes().to_vec());
|
|
||||||
query.append(&mut qclass.to_be_bytes().to_vec());
|
|
||||||
|
|
||||||
let mut request = Vec::<u8>::new();
|
|
||||||
for el in header {
|
|
||||||
request.push(el as u8);
|
|
||||||
request.push((el >> 8) as u8);
|
|
||||||
}
|
|
||||||
request.append(&mut query);
|
|
||||||
|
|
||||||
Ok(request)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct DnsSrvResponse {
|
|
||||||
qref: u16,
|
|
||||||
rtype: u16,
|
|
||||||
class: u16,
|
|
||||||
ttl: u32,
|
|
||||||
priority: u16,
|
|
||||||
weight: u16,
|
|
||||||
port: u16,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn parse_srv_response(mut ptr: usize, recv: &[u8]) -> Option<DnsSrvResponse> {
|
|
||||||
let mut data = DnsSrvResponse {
|
|
||||||
qref: 0,
|
|
||||||
rtype: 0,
|
|
||||||
class: 0,
|
|
||||||
ttl: 0,
|
|
||||||
priority: 0,
|
|
||||||
weight: 0,
|
|
||||||
port: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
fn read16(buf: &[u8], offset: usize) -> u16 {
|
|
||||||
let left = (buf[offset] as u16) << 8;
|
|
||||||
let right = buf[offset + 1] as u16;
|
|
||||||
left + right
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read32(buf: &[u8], offset: usize) -> u32 {
|
|
||||||
let left = (read16(buf, offset) as u32) << 16;
|
|
||||||
let right = read16(buf, offset + 2) as u32;
|
|
||||||
left + right
|
|
||||||
}
|
|
||||||
|
|
||||||
data.qref = read16(recv, ptr);
|
|
||||||
ptr += 2;
|
|
||||||
data.rtype = read16(recv, ptr);
|
|
||||||
ptr += 2;
|
|
||||||
if data.rtype != RECORD_TYPE_SRV {
|
|
||||||
return None
|
|
||||||
}
|
|
||||||
data.class = read16(recv, ptr);
|
|
||||||
ptr += 2;
|
|
||||||
data.ttl = read32(recv, ptr);
|
|
||||||
ptr += 4;
|
|
||||||
let _rdata_len = read16(recv, ptr);
|
|
||||||
ptr += 2;
|
|
||||||
data.priority = read16(recv, ptr);
|
|
||||||
ptr += 2;
|
|
||||||
data.weight = read16(recv, ptr);
|
|
||||||
ptr += 2;
|
|
||||||
data.port = read16(recv, ptr);
|
|
||||||
// optional: read target?
|
|
||||||
|
|
||||||
// print!("data: {:#?}\n", data);
|
|
||||||
|
|
||||||
Some(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn resolve_srv_port(domain: &String) -> Option<u16> {
|
|
||||||
let request = create_dns_query(
|
|
||||||
&("_minecraft._tcp.".to_string() + domain),
|
|
||||||
RECORD_TYPE_SRV,
|
|
||||||
RECORD_CLASS_IN).unwrap();
|
|
||||||
|
|
||||||
let socket = UdpSocket::bind("0.0.0.0:0").expect("failed to bind to port!");
|
|
||||||
socket.connect(CLOUDFLARE_DNS.to_string() + ":53").expect("failed to connect to dns server");
|
|
||||||
socket.send(&request).expect("failed to send dns request");
|
|
||||||
|
|
||||||
let mut recv_buf: [u8; 1024] = [0; 1024];
|
|
||||||
let _ = socket.recv_from(&mut recv_buf).unwrap();
|
|
||||||
|
|
||||||
match parse_srv_response(request.len(), &recv_buf) {
|
|
||||||
Some(data) => Some(data.port),
|
|
||||||
None => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -8,7 +8,7 @@ use std::{
|
||||||
|
|
||||||
use chrono::Local;
|
use chrono::Local;
|
||||||
|
|
||||||
use crate::thread::ThreadPool;
|
use crate::ThreadPool;
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
pub enum StatusCode {
|
pub enum StatusCode {
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,38 @@
|
||||||
const CONTINUE_BIT: u8 = 0x80;
|
const CONTINUE_BIT: u8 = 0x80;
|
||||||
|
|
||||||
pub fn write_leb128(buffer: &mut Vec<u8>, mut value: u64) -> usize {
|
pub struct LEB128;
|
||||||
let mut size: usize = 0;
|
|
||||||
loop {
|
impl LEB128 {
|
||||||
let mut byte: u8 = (value & (std::u8::MAX as u64)).try_into().unwrap();
|
pub fn write_leb128(buffer: &mut Vec<u8>, mut value: u64) -> usize {
|
||||||
value >>= 7;
|
let mut size: usize = 0;
|
||||||
if value != 0 {
|
loop {
|
||||||
byte |= CONTINUE_BIT;
|
let mut byte: u8 = (value & (std::u8::MAX as u64)).try_into().unwrap();
|
||||||
|
value >>= 7;
|
||||||
|
if value != 0 {
|
||||||
|
byte |= CONTINUE_BIT;
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer.push(byte);
|
||||||
|
size += 1;
|
||||||
|
|
||||||
|
if value == 0 {
|
||||||
|
return size;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
buffer.push(byte);
|
pub fn read_leb128(buffer: &[u8]) -> (u32, usize) {
|
||||||
size += 1;
|
let mut result: u32 = 0;
|
||||||
|
let mut shift: usize = 0;
|
||||||
if value == 0 {
|
let mut offset: usize = 0;
|
||||||
return size;
|
loop {
|
||||||
|
let byte: u8 = buffer[offset];
|
||||||
|
result |= ((byte & (std::i8::MAX as u8)) as u32) << (shift as u32);
|
||||||
|
offset += 1;
|
||||||
|
if byte & CONTINUE_BIT == 0 || offset == 4 {
|
||||||
|
return (result, offset);
|
||||||
|
}
|
||||||
|
shift += 7;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn read_leb128(buffer: &[u8]) -> (u32, usize) {
|
|
||||||
let mut result: u32 = 0;
|
|
||||||
let mut shift: usize = 0;
|
|
||||||
let mut offset: usize = 0;
|
|
||||||
loop {
|
|
||||||
let byte: u8 = buffer[offset];
|
|
||||||
result |= ((byte & (std::i8::MAX as u8)) as u32) << (shift as u32);
|
|
||||||
offset += 1;
|
|
||||||
if byte & CONTINUE_BIT == 0 || offset == 4 {
|
|
||||||
return (result, offset);
|
|
||||||
}
|
|
||||||
shift += 7;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
86
src/lib.rs
86
src/lib.rs
|
|
@ -1,5 +1,87 @@
|
||||||
|
use std::{sync::{mpsc, Arc, Mutex}, thread};
|
||||||
|
|
||||||
pub mod leb128;
|
pub mod leb128;
|
||||||
pub mod status;
|
pub mod status;
|
||||||
pub mod thread;
|
|
||||||
pub mod http;
|
pub mod http;
|
||||||
pub mod dns;
|
pub use status::MinecraftStatus;
|
||||||
|
|
||||||
|
pub struct ThreadPool {
|
||||||
|
workers: Vec<ThreadWorker>,
|
||||||
|
sender: Option<mpsc::Sender<Job>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
type Job = Box<dyn FnOnce() + Send + 'static>;
|
||||||
|
|
||||||
|
impl ThreadPool {
|
||||||
|
// Create a new ThreadPool with `size` available threads.
|
||||||
|
//
|
||||||
|
// # Panics
|
||||||
|
//
|
||||||
|
// `new` will panic if `size` is zero.
|
||||||
|
pub fn new(size: usize) -> ThreadPool {
|
||||||
|
assert!(size > 0);
|
||||||
|
|
||||||
|
let (sender, receiver) = mpsc::channel();
|
||||||
|
|
||||||
|
let receiver = Arc::new(Mutex::new(receiver));
|
||||||
|
|
||||||
|
let mut workers = Vec::with_capacity(size);
|
||||||
|
|
||||||
|
for id in 0..size {
|
||||||
|
workers.push(ThreadWorker::new(id, Arc::clone(&receiver)));
|
||||||
|
}
|
||||||
|
|
||||||
|
ThreadPool {
|
||||||
|
workers,
|
||||||
|
sender: Some(sender)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn execute<F>(&self, f: F)
|
||||||
|
where
|
||||||
|
F: FnOnce() + Send + 'static
|
||||||
|
{
|
||||||
|
let job = Box::new(f);
|
||||||
|
|
||||||
|
self.sender.as_ref().unwrap().send(job).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for ThreadPool {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
drop(self.sender.take());
|
||||||
|
|
||||||
|
for worker in &mut self.workers.drain(..) {
|
||||||
|
println!("Shutting down worker {}", worker.id);
|
||||||
|
|
||||||
|
worker.thread.join().unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ThreadWorker {
|
||||||
|
id: usize,
|
||||||
|
thread: thread::JoinHandle<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ThreadWorker {
|
||||||
|
fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> ThreadWorker {
|
||||||
|
let thread = thread::spawn(move || loop {
|
||||||
|
let msg = receiver.lock().unwrap().recv();
|
||||||
|
|
||||||
|
match msg {
|
||||||
|
Ok(job) => {
|
||||||
|
// println!("Job received by worker {id}");
|
||||||
|
job();
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
// println!("Worker {id} disconnected. Shutting down...");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ThreadWorker { id, thread }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
168
src/main.rs
168
src/main.rs
|
|
@ -1,24 +1,19 @@
|
||||||
use std::io::Result;
|
use std::io::{Result};
|
||||||
use std::net::{IpAddr, ToSocketAddrs};
|
use std::net::{IpAddr, ToSocketAddrs};
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use std::env;
|
use std::{env};
|
||||||
|
|
||||||
use mcstatusface::http::{HttpServer, StatusCode};
|
use mcstatusface::http::{HttpServer, StatusCode};
|
||||||
use mcstatusface::status::MinecraftStatus;
|
use mcstatusface::{MinecraftStatus};
|
||||||
use mcstatusface::dns::resolve_srv_port;
|
|
||||||
|
|
||||||
#[derive(serde::Serialize)]
|
#[derive(serde::Serialize)]
|
||||||
struct MinecraftStatusResponse<'a> {
|
struct MinecraftStatusResponse<'a> {
|
||||||
version: &'a String,
|
version: &'a String,
|
||||||
players: u32,
|
players: u32,
|
||||||
max_players: u32,
|
max_players: u32,
|
||||||
enforces_secure_chat: bool,
|
|
||||||
favicon: Option<&'a String>,
|
|
||||||
motd: String,
|
motd: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_PORT: u16 = 25565;
|
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
let args: Vec<String> = env::args().collect();
|
let args: Vec<String> = env::args().collect();
|
||||||
if args.len() < 2 {
|
if args.len() < 2 {
|
||||||
|
|
@ -36,15 +31,7 @@ env!("CARGO_PKG_VERSION"));
|
||||||
|
|
||||||
if args[1] != "serve" {
|
if args[1] != "serve" {
|
||||||
let mut address = String::from(args[1].as_str());
|
let mut address = String::from(args[1].as_str());
|
||||||
if !address.contains(":") {
|
if !address.contains(":") { address.push_str(":25565"); }
|
||||||
let port: u16 = match resolve_srv_port(&address) {
|
|
||||||
Some(port) => port,
|
|
||||||
None => DEFAULT_PORT,
|
|
||||||
};
|
|
||||||
|
|
||||||
address.push_str(":");
|
|
||||||
address.push_str(port.to_string().as_str());
|
|
||||||
}
|
|
||||||
let mut addrs_iter = address.to_socket_addrs().unwrap();
|
let mut addrs_iter = address.to_socket_addrs().unwrap();
|
||||||
let address = addrs_iter.next().unwrap();
|
let address = addrs_iter.next().unwrap();
|
||||||
|
|
||||||
|
|
@ -52,10 +39,6 @@ env!("CARGO_PKG_VERSION"));
|
||||||
|
|
||||||
println!("Version: {} ({})", status.version.name, status.version.protocol);
|
println!("Version: {} ({})", status.version.name, status.version.protocol);
|
||||||
println!("Players: {}/{}", status.players.online, status.players.max);
|
println!("Players: {}/{}", status.players.online, status.players.max);
|
||||||
println!(
|
|
||||||
"Enforces Secure Chat: {}",
|
|
||||||
if status.enforces_secure_chat() { "true" } else { "false" },
|
|
||||||
);
|
|
||||||
println!("MOTD:");
|
println!("MOTD:");
|
||||||
println!("{}", status.parse_description());
|
println!("{}", status.parse_description());
|
||||||
|
|
||||||
|
|
@ -66,20 +49,20 @@ env!("CARGO_PKG_VERSION"));
|
||||||
if args.len() > 2 { address = args[2].to_string() }
|
if args.len() > 2 { address = args[2].to_string() }
|
||||||
let trusted_proxies: Vec<IpAddr> =
|
let trusted_proxies: Vec<IpAddr> =
|
||||||
match env::var("MCSTATUSFACE_TRUSTED_PROXIES") {
|
match env::var("MCSTATUSFACE_TRUSTED_PROXIES") {
|
||||||
Err(_) => { vec![] }
|
|
||||||
Ok(envar) => {
|
Ok(envar) => {
|
||||||
let mut trusted_proxies: Vec<IpAddr> = Vec::new();
|
let mut trusted_proxies: Vec<IpAddr> = Vec::new();
|
||||||
for addr in envar.split(",") {
|
for addr in envar.split(",") {
|
||||||
match IpAddr::from_str(addr) {
|
match IpAddr::from_str(addr) {
|
||||||
Err(_) => {}
|
|
||||||
Ok(addr) => { trusted_proxies.push(addr); }
|
Ok(addr) => { trusted_proxies.push(addr); }
|
||||||
|
Err(_) => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
trusted_proxies
|
trusted_proxies
|
||||||
}
|
}
|
||||||
|
Err(_) => { vec![] }
|
||||||
};
|
};
|
||||||
|
|
||||||
HttpServer::new(address, 16, trusted_proxies).start(|request, mut response| {
|
HttpServer::new(address, 64, trusted_proxies).start(|request, mut response| {
|
||||||
response.status(StatusCode::OK);
|
response.status(StatusCode::OK);
|
||||||
response.set_header("Content-Type", "text/plain".to_string());
|
response.set_header("Content-Type", "text/plain".to_string());
|
||||||
response.set_header("x-powered-by", "GIRL FUEL".to_string());
|
response.set_header("x-powered-by", "GIRL FUEL".to_string());
|
||||||
|
|
@ -107,87 +90,56 @@ env!("CARGO_PKG_VERSION"));
|
||||||
response.status(StatusCode::OK);
|
response.status(StatusCode::OK);
|
||||||
let query_response: String;
|
let query_response: String;
|
||||||
match request.query().get("s") {
|
match request.query().get("s") {
|
||||||
None => {
|
|
||||||
query_response = String::from("");
|
|
||||||
}
|
|
||||||
Some(query_address) => {
|
Some(query_address) => {
|
||||||
let mut address = query_address.to_string();
|
let mut address = query_address.to_string();
|
||||||
address = address.replace("%3A", ":");
|
if !address.contains(":") { address.push_str(":25565"); }
|
||||||
if !address.contains(":") {
|
|
||||||
let port: u16 = match resolve_srv_port(&address) {
|
|
||||||
Some(port) => port,
|
|
||||||
None => DEFAULT_PORT,
|
|
||||||
};
|
|
||||||
|
|
||||||
address.push_str(":");
|
|
||||||
address.push_str(port.to_string().as_str());
|
|
||||||
}
|
|
||||||
match address.to_socket_addrs() {
|
match address.to_socket_addrs() {
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
|
response.set_header("Content-Type", "text/html".to_string());
|
||||||
response.status(StatusCode::BadRequest);
|
response.status(StatusCode::BadRequest);
|
||||||
query_response = format!(
|
response.body("Server address is invalid or unreachable.\n".to_string());
|
||||||
"<hr/>
|
return response.send();
|
||||||
<h2>Server Details</h2>
|
|
||||||
<pre><code>Invalid server address: {}.</pre></code>",
|
|
||||||
sanitize_html(&address.to_string()),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
Ok(mut addrs_iter) => {
|
Ok(mut addrs_iter) => {
|
||||||
let address = addrs_iter.next().unwrap();
|
let address = addrs_iter.next().unwrap();
|
||||||
|
|
||||||
match MinecraftStatus::fetch(address) {
|
let status = MinecraftStatus::fetch(address).unwrap();
|
||||||
Err(_) => {
|
|
||||||
response.status(StatusCode::InternalServerError);
|
|
||||||
query_response = format!(
|
|
||||||
"<hr/>
|
|
||||||
<h2>Server Details</h2>
|
|
||||||
<pre><code>Failed to connect to {}.</pre></code>",
|
|
||||||
sanitize_html(&address.to_string()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok(status) => {
|
|
||||||
let minecraft_status = MinecraftStatusResponse{
|
|
||||||
version: &status.version.name,
|
|
||||||
players: status.players.online,
|
|
||||||
max_players: status.players.max,
|
|
||||||
enforces_secure_chat: status.enforces_secure_chat(),
|
|
||||||
favicon: status.favicon.as_ref(),
|
|
||||||
motd: status.parse_description(),
|
|
||||||
};
|
|
||||||
|
|
||||||
query_response = format!(
|
let minecraft_status = MinecraftStatusResponse{
|
||||||
"<hr/>
|
version: &status.version.name,
|
||||||
|
players: status.players.online,
|
||||||
|
max_players: status.players.max,
|
||||||
|
motd: status.parse_description(),
|
||||||
|
};
|
||||||
|
|
||||||
|
query_response = format!(
|
||||||
|
"<hr/>
|
||||||
<h2>Server Details</h2>
|
<h2>Server Details</h2>
|
||||||
<p>
|
<p>
|
||||||
<strong>Favicon:</strong></br>
|
|
||||||
<img width=\"64\" height=\"64\" src=\"{}\"></br>
|
|
||||||
<strong>Version:</strong> <code>{}</code><br/>
|
<strong>Version:</strong> <code>{}</code><br/>
|
||||||
<strong>Players:</strong> <code>{}/{}</code><br/>
|
<strong>Players:</strong> <code>{}/{}</code><br/>
|
||||||
<strong>Enforces Secure Chat:</strong> <code>{}</code></br>
|
|
||||||
<strong>MOTD:</strong>
|
<strong>MOTD:</strong>
|
||||||
</p>
|
</p>
|
||||||
<pre id=\"motd\"><code>{}</code></pre>",
|
<pre id=\"motd\"><code>{}</code></pre>",
|
||||||
sanitize_html(&minecraft_status.favicon.map_or("", |s| s).to_string()),
|
|
||||||
sanitize_html(minecraft_status.version).to_string(),
|
sanitize_html(minecraft_status.version).to_string(),
|
||||||
minecraft_status.players,
|
minecraft_status.players,
|
||||||
minecraft_status.max_players,
|
minecraft_status.max_players,
|
||||||
if minecraft_status.enforces_secure_chat { "true" } else { "false" },
|
|
||||||
sanitize_html(&minecraft_status.motd).to_string(),
|
sanitize_html(&minecraft_status.motd).to_string(),
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
None => {
|
||||||
|
query_response = String::from("");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let response_content = content
|
let response_content = content
|
||||||
.replace("{{response}}", &query_response)
|
.replace("{{response}}", &query_response)
|
||||||
.replace("{{host}}", match request.headers().get("host") {
|
.replace("{{host}}", match request.headers().get("host") {
|
||||||
None => { "mcq.bliss.town" }
|
|
||||||
Some(host) => { host }
|
Some(host) => { host }
|
||||||
|
None => { "mcq.bliss.town" }
|
||||||
});
|
});
|
||||||
response.set_header("Content-Type", "text/html".to_string());
|
|
||||||
response.body(response_content.to_string());
|
response.body(response_content.to_string());
|
||||||
return response.send();
|
return response.send();
|
||||||
}
|
}
|
||||||
|
|
@ -201,56 +153,32 @@ env!("CARGO_PKG_VERSION"));
|
||||||
}
|
}
|
||||||
Some(query_address) => {
|
Some(query_address) => {
|
||||||
let mut address = query_address.to_string();
|
let mut address = query_address.to_string();
|
||||||
address = address.replace("%3A", ":");
|
if !address.contains(":") { address.push_str(":25565"); }
|
||||||
if !address.contains(":") {
|
let mut addrs_iter = address.to_socket_addrs().unwrap();
|
||||||
let port: u16 = match resolve_srv_port(&address) {
|
let address = addrs_iter.next().unwrap();
|
||||||
Some(port) => port,
|
|
||||||
None => DEFAULT_PORT,
|
|
||||||
};
|
|
||||||
|
|
||||||
address.push_str(":");
|
let status = MinecraftStatus::fetch(address).unwrap();
|
||||||
address.push_str(port.to_string().as_str());
|
|
||||||
}
|
let minecraft_status = MinecraftStatusResponse{
|
||||||
match address.to_socket_addrs() {
|
version: &status.version.name,
|
||||||
Err(_) => {
|
players: status.players.online,
|
||||||
response.status(StatusCode::BadRequest);
|
max_players: status.players.max,
|
||||||
response.body("Invalid server address.\n".to_string());
|
motd: status.parse_description(),
|
||||||
|
};
|
||||||
|
|
||||||
|
match serde_json::to_string(&minecraft_status) {
|
||||||
|
Ok(json) => {
|
||||||
|
response.status(StatusCode::OK);
|
||||||
|
response.set_header("Content-Type", "application/json".to_string());
|
||||||
|
response.body(json + "\n");
|
||||||
}
|
}
|
||||||
Ok(mut addrs_iter) => {
|
Err(e) => {
|
||||||
let address = addrs_iter.next().unwrap();
|
eprintln!("Request to {address} failed: {e}");
|
||||||
|
response.status(StatusCode::InternalServerError);
|
||||||
match MinecraftStatus::fetch(address) {
|
response.body("Unable to reach the requested server.\n".to_string());
|
||||||
Err(_) => {
|
|
||||||
response.status(StatusCode::InternalServerError);
|
|
||||||
response.body(format!("Failed to connect to {address}.\n"));
|
|
||||||
}
|
|
||||||
Ok(status) => {
|
|
||||||
let minecraft_status = MinecraftStatusResponse{
|
|
||||||
version: &status.version.name,
|
|
||||||
players: status.players.online,
|
|
||||||
max_players: status.players.max,
|
|
||||||
enforces_secure_chat: status.enforces_secure_chat(),
|
|
||||||
favicon: status.favicon.as_ref(),
|
|
||||||
motd: status.parse_description(),
|
|
||||||
};
|
|
||||||
|
|
||||||
match serde_json::to_string(&minecraft_status) {
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("Failed to format response from {} to JSON: {}", address, e);
|
|
||||||
response.status(StatusCode::InternalServerError);
|
|
||||||
response.body("Internal Server Error\n".to_string());
|
|
||||||
}
|
|
||||||
Ok(json) => {
|
|
||||||
response.status(StatusCode::OK);
|
|
||||||
response.set_header("Content-Type", "application/json".to_string());
|
|
||||||
response.body(json + "\n");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.send()
|
return response.send()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,47 +1,10 @@
|
||||||
:root {
|
:root {
|
||||||
--bg-0: #101010;
|
--accent: #7ca82f;
|
||||||
--bg-1: #181818;
|
|
||||||
--bg-2: #282828;
|
|
||||||
--fg-0: #c0c0c0;
|
|
||||||
--fg-1: #d0d0d0;
|
|
||||||
--fg-2: #e0e0e0;
|
|
||||||
--accent: #c0ea76;
|
|
||||||
--on-accent: #43600f;
|
|
||||||
|
|
||||||
--shadow-md: 0 2px 4px rgba(0,0,0,.5);
|
|
||||||
--shadow-sm: 0 1px 2px rgba(0,0,0,.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: light) {
|
|
||||||
:root {
|
|
||||||
--bg-0: #e8e8e8;
|
|
||||||
--bg-1: #f0f0f0;
|
|
||||||
--bg-2: #fcfcfc;
|
|
||||||
--fg-0: #606060;
|
|
||||||
--fg-1: #303030;
|
|
||||||
--fg-2: #101010;
|
|
||||||
--accent: #6a9321;
|
|
||||||
--on-accent: #f6ffe6;
|
|
||||||
|
|
||||||
--shadow-md: 0 2px 4px rgba(0,0,0,.25);;
|
|
||||||
--shadow-sm: 0 1px 2px rgba(0,0,0,.15);;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
width: 720px;
|
font-size: 16px;
|
||||||
max-width: calc(100vw - 1em);
|
font-family: 'Inter', sans-serif;
|
||||||
margin: auto;
|
|
||||||
|
|
||||||
font-size: 20px;
|
|
||||||
font-family: 'Inter', 'Arial', sans-serif;
|
|
||||||
|
|
||||||
background-color: var(--bg-0);
|
|
||||||
color: var(--fg-0);
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
transition: background-color .2s ease-out, color .2s ease-out;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
|
|
@ -54,45 +17,34 @@ a:hover {
|
||||||
|
|
||||||
form {
|
form {
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
padding: 1em;
|
padding: .5em;
|
||||||
border-radius: 8px;
|
border: 1px solid black;
|
||||||
background-color: var(--bg-1);
|
border-radius: 4px;
|
||||||
color: var(--fg-1);
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
form input[type="text"] {
|
form input[type="text"] {
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
min-width: 16em;
|
min-width: 16em;
|
||||||
padding: .2em .4em;
|
|
||||||
font-size: inherit;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
background-color: var(--bg-2);
|
|
||||||
color: var(--fg-2);
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
form button {
|
form button {
|
||||||
margin-top: .5em;
|
margin-top: .5em;
|
||||||
padding: .2em .4em;
|
padding: .2em .3em;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
border: none;
|
border: 1px solid black;
|
||||||
border-radius: 8px;
|
border-radius: 4px;
|
||||||
color: var(--on-accent);
|
color: white;
|
||||||
background-color: var(--accent);
|
background-color: var(--accent);
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pre code {
|
pre code {
|
||||||
padding: .5em;
|
padding: .5em;
|
||||||
border-radius: 8px;
|
border: 1px solid black;
|
||||||
font-size: 16px;
|
border-radius: 4px;
|
||||||
|
font-size: .8em;
|
||||||
background-color: var(--fg-1);
|
color: #e0e0e0;
|
||||||
color: var(--bg-1);
|
background-color: #303030;
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pre#motd {
|
pre#motd {
|
||||||
|
|
@ -103,7 +55,6 @@ pre#motd code {
|
||||||
display: block;
|
display: block;
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
min-width: 440px;
|
min-width: 440px;
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
footer {
|
footer {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
use std::io::{Error, ErrorKind, Read, Write, Result};
|
use std::io::{Error, ErrorKind, Read, Write, Result};
|
||||||
use std::net::{SocketAddr, TcpStream};
|
use std::net::{SocketAddr, TcpStream};
|
||||||
|
|
||||||
use crate::leb128::{read_leb128, write_leb128};
|
use crate::leb128::LEB128;
|
||||||
|
|
||||||
#[derive(serde::Serialize, serde::Deserialize)]
|
#[derive(serde::Serialize, serde::Deserialize)]
|
||||||
pub struct MinecraftVersion {
|
pub struct MinecraftVersion {
|
||||||
|
|
@ -19,7 +19,7 @@ pub struct MinecraftPlayer {
|
||||||
pub struct MinecraftPlayers {
|
pub struct MinecraftPlayers {
|
||||||
pub online: u32,
|
pub online: u32,
|
||||||
pub max: u32,
|
pub max: u32,
|
||||||
pub sample: Option<Vec<MinecraftPlayer>>,
|
pub sample: Vec<MinecraftPlayer>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(serde::Serialize, serde::Deserialize)]
|
#[derive(serde::Serialize, serde::Deserialize)]
|
||||||
|
|
@ -30,14 +30,7 @@ enum MinecraftDescriptionExtra {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(serde::Serialize, serde::Deserialize)]
|
#[derive(serde::Serialize, serde::Deserialize)]
|
||||||
#[serde(untagged)]
|
pub struct MinecraftDescription {
|
||||||
pub enum MinecraftDescription {
|
|
||||||
Rich(MinecraftRichDescription),
|
|
||||||
Plain(String)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(serde::Serialize, serde::Deserialize)]
|
|
||||||
pub struct MinecraftRichDescription {
|
|
||||||
text: String,
|
text: String,
|
||||||
extra: Option<Vec<MinecraftDescriptionExtra>>,
|
extra: Option<Vec<MinecraftDescriptionExtra>>,
|
||||||
bold: Option<bool>,
|
bold: Option<bool>,
|
||||||
|
|
@ -58,20 +51,18 @@ impl MinecraftStatus {
|
||||||
pub fn fetch(address: SocketAddr) -> Result<MinecraftStatus> {
|
pub fn fetch(address: SocketAddr) -> Result<MinecraftStatus> {
|
||||||
// println!("Connecting to {address}...");
|
// println!("Connecting to {address}...");
|
||||||
|
|
||||||
let stream = TcpStream::connect(address.to_string());
|
let mut stream = TcpStream::connect(address.to_string()).unwrap();
|
||||||
if stream.is_err() { return Err(stream.unwrap_err()); }
|
|
||||||
// println!("Connected!");
|
// println!("Connected!");
|
||||||
let mut stream = stream.unwrap();
|
|
||||||
|
|
||||||
let mut send_buffer: Vec<u8> = Vec::new();
|
let mut send_buffer: Vec<u8> = Vec::new();
|
||||||
|
|
||||||
// println!("Sending payload...");
|
// println!("Sending payload...");
|
||||||
send_buffer.push(0x00);
|
send_buffer.push(0x00);
|
||||||
write_leb128(&mut send_buffer, 769); // 1.21.4
|
LEB128::write_leb128(&mut send_buffer, 769); // 1.21.4
|
||||||
write_leb128(&mut send_buffer, address.ip().to_string().len().try_into().unwrap());
|
LEB128::write_leb128(&mut send_buffer, address.ip().to_string().len().try_into().unwrap());
|
||||||
send_buffer.extend_from_slice(address.ip().to_string().as_bytes());
|
send_buffer.extend_from_slice(address.ip().to_string().as_bytes());
|
||||||
send_buffer.extend_from_slice(&address.port().to_be_bytes());
|
send_buffer.extend_from_slice(&address.port().to_be_bytes());
|
||||||
write_leb128(&mut send_buffer, 1);
|
LEB128::write_leb128(&mut send_buffer, 1);
|
||||||
send_packet(&mut stream, &send_buffer).unwrap();
|
send_packet(&mut stream, &send_buffer).unwrap();
|
||||||
|
|
||||||
send_packet(&mut stream, &[0x00]).unwrap();
|
send_packet(&mut stream, &[0x00]).unwrap();
|
||||||
|
|
@ -89,7 +80,7 @@ impl MinecraftStatus {
|
||||||
if len > 0 {
|
if len > 0 {
|
||||||
if msg_len == 0 {
|
if msg_len == 0 {
|
||||||
let mut val: u32;
|
let mut val: u32;
|
||||||
(val, offset) = read_leb128(&recv_buffer);
|
(val, offset) = LEB128::read_leb128(&recv_buffer);
|
||||||
msg_len = val as usize;
|
msg_len = val as usize;
|
||||||
|
|
||||||
if recv_buffer[offset] != 0x00 {
|
if recv_buffer[offset] != 0x00 {
|
||||||
|
|
@ -98,7 +89,7 @@ impl MinecraftStatus {
|
||||||
offset += 1; // skip message type bit
|
offset += 1; // skip message type bit
|
||||||
|
|
||||||
let offset2: usize;
|
let offset2: usize;
|
||||||
(val, offset2) = read_leb128(&recv_buffer[offset..]);
|
(val, offset2) = LEB128::read_leb128(&recv_buffer[offset..]);
|
||||||
object_len = val as usize;
|
object_len = val as usize;
|
||||||
offset += offset2;
|
offset += offset2;
|
||||||
}
|
}
|
||||||
|
|
@ -110,6 +101,7 @@ impl MinecraftStatus {
|
||||||
}
|
}
|
||||||
let msg = std::str::from_utf8(&data[offset..]).unwrap().trim();
|
let msg = std::str::from_utf8(&data[offset..]).unwrap().trim();
|
||||||
let sanitised: String = msg.chars().filter(|&c| c >= '\u{20}' || c == '\n' || c == '\r' || c == '\t').collect();
|
let sanitised: String = msg.chars().filter(|&c| c >= '\u{20}' || c == '\n' || c == '\r' || c == '\t').collect();
|
||||||
|
// println!("{sanitised}");
|
||||||
let status: MinecraftStatus = serde_json::from_slice(sanitised.as_bytes()).unwrap();
|
let status: MinecraftStatus = serde_json::from_slice(sanitised.as_bytes()).unwrap();
|
||||||
|
|
||||||
Ok(status)
|
Ok(status)
|
||||||
|
|
@ -125,33 +117,26 @@ impl MinecraftStatus {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn _description(description: &MinecraftDescription) -> String {
|
fn _description(description: &MinecraftDescription) -> String {
|
||||||
match description {
|
if description.extra.is_some() {
|
||||||
MinecraftDescription::Rich(description) => {
|
let mut extras = String::new();
|
||||||
if description.extra.is_some() {
|
for extra in description.extra.as_ref().unwrap() {
|
||||||
let mut extras = String::new();
|
match extra {
|
||||||
for extra in description.extra.as_ref().unwrap() {
|
MinecraftDescriptionExtra::Extra(description) => {
|
||||||
match extra {
|
extras += &_description(&description);
|
||||||
MinecraftDescriptionExtra::Extra(description) => {
|
}
|
||||||
extras += &_description(&description);
|
MinecraftDescriptionExtra::String(string) => {
|
||||||
}
|
extras += &string;
|
||||||
MinecraftDescriptionExtra::String(string) => {
|
|
||||||
extras += &string;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return description.text.clone() + &extras;
|
|
||||||
}
|
}
|
||||||
description.text.clone()
|
|
||||||
}
|
|
||||||
MinecraftDescription::Plain(description) => {
|
|
||||||
description.clone()
|
|
||||||
}
|
}
|
||||||
|
return description.text.clone() + &extras;
|
||||||
}
|
}
|
||||||
|
description.text.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn send_packet(stream: &mut TcpStream, data: &[u8]) -> Result<()> {
|
fn send_packet(stream: &mut TcpStream, data: &[u8]) -> Result<()> {
|
||||||
let mut packet: Vec<u8> = Vec::new();
|
let mut packet: Vec<u8> = Vec::new();
|
||||||
write_leb128(&mut packet, data.len() as u64);
|
LEB128::write_leb128(&mut packet, data.len() as u64);
|
||||||
packet.extend_from_slice(&data);
|
packet.extend_from_slice(&data);
|
||||||
|
|
||||||
stream.write(&packet).unwrap();
|
stream.write(&packet).unwrap();
|
||||||
|
|
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
use std::{sync::{mpsc, Arc, Mutex}, thread};
|
|
||||||
|
|
||||||
pub struct ThreadPool {
|
|
||||||
workers: Vec<ThreadWorker>,
|
|
||||||
sender: Option<mpsc::Sender<Job>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
type Job = Box<dyn FnOnce() + Send + 'static>;
|
|
||||||
|
|
||||||
impl ThreadPool {
|
|
||||||
// Create a new ThreadPool with `size` available threads.
|
|
||||||
//
|
|
||||||
// # Panics
|
|
||||||
//
|
|
||||||
// `new` will panic if `size` is zero.
|
|
||||||
pub fn new(size: usize) -> ThreadPool {
|
|
||||||
assert!(size > 0);
|
|
||||||
|
|
||||||
let (sender, receiver) = mpsc::channel();
|
|
||||||
|
|
||||||
let receiver = Arc::new(Mutex::new(receiver));
|
|
||||||
|
|
||||||
let mut workers = Vec::with_capacity(size);
|
|
||||||
|
|
||||||
for id in 0..size {
|
|
||||||
workers.push(ThreadWorker::new(id, Arc::clone(&receiver)));
|
|
||||||
}
|
|
||||||
|
|
||||||
ThreadPool {
|
|
||||||
workers,
|
|
||||||
sender: Some(sender)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn execute<F>(&self, f: F)
|
|
||||||
where
|
|
||||||
F: FnOnce() + Send + 'static
|
|
||||||
{
|
|
||||||
let job = Box::new(f);
|
|
||||||
|
|
||||||
self.sender.as_ref().unwrap().send(job).unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for ThreadPool {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
drop(self.sender.take());
|
|
||||||
|
|
||||||
for worker in &mut self.workers.drain(..) {
|
|
||||||
worker.thread.join().expect(
|
|
||||||
format!("Error in worker {}", worker.id).as_str());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ThreadWorker {
|
|
||||||
id: usize,
|
|
||||||
thread: thread::JoinHandle<()>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ThreadWorker {
|
|
||||||
fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> ThreadWorker {
|
|
||||||
let thread = thread::spawn(move || loop {
|
|
||||||
let msg = receiver.lock().unwrap().recv();
|
|
||||||
|
|
||||||
match msg {
|
|
||||||
Ok(job) => {
|
|
||||||
// println!("Job received by worker {id}");
|
|
||||||
job();
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
// println!("Worker {id} disconnected. Shutting down...");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
ThreadWorker { id, thread }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -3,16 +3,8 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Crafty McStatusFace</title>
|
<title>Minecraft Server Query</title>
|
||||||
<link href="style/index.css" rel="stylesheet">
|
<link href="style/index.css" rel="stylesheet">
|
||||||
|
|
||||||
<meta name="description" content="Query Minecraft servers!">
|
|
||||||
|
|
||||||
<meta property="og:title" content="Crafty McStatusFace">
|
|
||||||
<meta property="og:type" content="website">
|
|
||||||
<meta property="og:url" content="https://{{host}}">
|
|
||||||
<meta property="og:site_name" content="Crafty McStatusFace">
|
|
||||||
<meta property="og:description" content="Query Minecraft servers!">
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
|
|
@ -21,7 +13,7 @@
|
||||||
<hr/>
|
<hr/>
|
||||||
<main>
|
<main>
|
||||||
<p>
|
<p>
|
||||||
This website retrieves query information from Minecraft servers!
|
You can use this website to retrieve query information from a Minecraft server!
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
For more information, see <a href="https://minecraft.wiki/w/Query">https://minecraft.wiki/w/Query</a>.
|
For more information, see <a href="https://minecraft.wiki/w/Query">https://minecraft.wiki/w/Query</a>.
|
||||||
|
|
@ -38,13 +30,13 @@
|
||||||
Alternatively, you can cURL this website to get a raw JSON response:
|
Alternatively, you can cURL this website to get a raw JSON response:
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<pre><code>curl 'https://{{host}}?s=<server address>' | jq .</code></pre>
|
<pre><code>curl 'https://{{host}}?s=<server address>'</code></pre>
|
||||||
|
|
||||||
{{response}}
|
{{response}}
|
||||||
</main>
|
</main>
|
||||||
<hr/>
|
<hr/>
|
||||||
<footer>
|
<footer>
|
||||||
<em>made with <span aria-label="love">♥</span> by ari, 2025. <a href="https://codeberg.org/arimelody/mcstatusface" target="_blank">source</a></em>
|
<em>made with <span aria-label="love">♥</span> by ari, 2025. <a href="https://git.arimelody.me/ari/mcstatusface" target="_blank">source</a></em>
|
||||||
</footer>
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue