Compare commits

...

6 Commits

7 changed files with 334 additions and 31 deletions

View File

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Version>1.0.0-alpha.1</Version>
<Version>1.0.0-alpha.2</Version>
<TargetFramework>net7.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
@ -55,6 +55,7 @@
<ItemGroup>
<PackageReference Include="DalamudPackager" Version="2.1.12"/>
<PackageReference Include="MessagePack" Version="2.5.129"/>
</ItemGroup>
</Project>

View File

@ -1,4 +1,7 @@
name: Player Map
author: Anna
punchline: Crowdsourced location maps
description: Crowdsourced location maps
description: |-
Use this plugin to upload player data to the crowdsourced location maps at
https://map.anna.lgbt/. It uploads the players around you every five seconds.
repo_url: https://map.anna.lgbt/

View File

@ -1,14 +1,12 @@
using System.Diagnostics;
using System.IO.Compression;
using System.Net.Http.Headers;
using System.Text;
using Dalamud.Game.ClientState.Objects.Enums;
using Dalamud.Game.ClientState.Objects.SubKinds;
using Dalamud.IoC;
using Dalamud.Plugin;
using Dalamud.Plugin.Services;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
using MessagePack;
namespace PlayerMap;
@ -78,22 +76,18 @@ public class Plugin : IDalamudPlugin {
players.Shuffle();
var update = new Update(territory, player.CurrentWorld.Id, players);
var json = JsonConvert.SerializeObject(update, new JsonSerializerSettings {
ContractResolver = new DefaultContractResolver {
NamingStrategy = new SnakeCaseNamingStrategy(),
},
});
var msgpack = MessagePackSerializer.Serialize(update, MessagePackSerializerOptions.Standard);
using var mem = new MemoryStream();
await using (var gz = new GZipStream(mem, CompressionLevel.Optimal)) {
await gz.WriteAsync(Encoding.UTF8.GetBytes(json));
await gz.WriteAsync(msgpack);
await gz.FlushAsync();
}
var req = new HttpRequestMessage(HttpMethod.Post, "http://localhost:30888/upload") {
var req = new HttpRequestMessage(HttpMethod.Post, "https://map.anna.lgbt/api/upload") {
Content = new ByteArrayContent(mem.ToArray()) {
Headers = {
ContentType = new MediaTypeHeaderValue("application/json"),
ContentType = new MediaTypeHeaderValue("application/msgpack"),
ContentEncoding = { "gzip" },
},
},
@ -115,13 +109,20 @@ public class Plugin : IDalamudPlugin {
}
[Serializable]
[MessagePackObject]
internal struct Update(uint territory, uint world, List<PlayerInfo> players) {
[Key(0)]
public readonly uint Territory = territory;
[Key(1)]
public readonly uint World = world;
[Key(2)]
public readonly List<PlayerInfo> Players = players;
}
[Serializable]
[MessagePackObject]
internal struct PlayerInfo(
string name,
uint world,
@ -136,16 +137,39 @@ internal struct PlayerInfo(
uint currentHp,
uint maxHp
) {
[Key(0)]
public readonly string Name = name;
[Key(1)]
public readonly uint World = world;
[Key(2)]
public readonly float X = x;
[Key(3)]
public readonly float Y = y;
[Key(4)]
public readonly float Z = z;
[Key(5)]
public readonly float W = w;
[Key(6)]
public readonly List<byte> Customize = customize;
[Key(7)]
public readonly byte Level = level;
[Key(8)]
public readonly uint Job = job;
[Key(9)]
public readonly string FreeCompany = freeCompany;
[Key(10)]
public readonly uint CurrentHp = currentHp;
[Key(11)]
public readonly uint MaxHp = maxHp;
};

View File

@ -7,6 +7,32 @@
"requested": "[2.1.12, )",
"resolved": "2.1.12",
"contentHash": "Sc0PVxvgg4NQjcI8n10/VfUQBAS4O+Fw2pZrAqBdRMbthYGeogzu5+xmIGCGmsEZ/ukMOBuAqiNiB5qA3MRalg=="
},
"MessagePack": {
"type": "Direct",
"requested": "[2.5.129, )",
"resolved": "2.5.129",
"contentHash": "1jBW0Q3qvv+PJBwer8lQ2l26/fKptJIqFgVdyKfn4zW+LSYE8xEcvd4svfh0erI5f4d+rQRIAN229I2ARI/A5w==",
"dependencies": {
"MessagePack.Annotations": "2.5.129",
"Microsoft.NET.StringTools": "17.6.3",
"System.Runtime.CompilerServices.Unsafe": "6.0.0"
}
},
"MessagePack.Annotations": {
"type": "Transitive",
"resolved": "2.5.129",
"contentHash": "wNJB3EaJKjq+5pti+0T8b444fEb2PRw3hFefp9+of/BvDdTWO0iIUWfYNZbKx5aAIrpFzhLEfMAEJnGWWq3OFQ=="
},
"Microsoft.NET.StringTools": {
"type": "Transitive",
"resolved": "17.6.3",
"contentHash": "N0ZIanl1QCgvUumEL1laasU0a7sOE5ZwLZVTn0pAePnfhq8P7SvTjF8Axq+CnavuQkmdQpGNXQ1efZtu5kDFbA=="
},
"System.Runtime.CompilerServices.Unsafe": {
"type": "Transitive",
"resolved": "6.0.0",
"contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg=="
}
}
}

29
server/Cargo.lock generated
View File

@ -236,7 +236,10 @@ checksum = "7f2c685bad3eb3d45a01354cedb7d5faa66194d1d58ba6e267a8de788f79db38"
dependencies = [
"android-tzdata",
"iana-time-zone",
"js-sys",
"num-traits",
"serde",
"wasm-bindgen",
"windows-targets",
]
@ -1040,7 +1043,11 @@ version = "0.1.0"
dependencies = [
"anyhow",
"axum",
"bytes",
"chrono",
"rmp-serde",
"serde",
"serde_path_to_error",
"sqlx",
"tokio",
"tower",
@ -1110,6 +1117,28 @@ dependencies = [
"bitflags 1.3.2",
]
[[package]]
name = "rmp"
version = "0.8.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f9860a6cc38ed1da53456442089b4dfa35e7cedaa326df63017af88385e6b20"
dependencies = [
"byteorder",
"num-traits",
"paste",
]
[[package]]
name = "rmp-serde"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bffea85eea980d8a74453e5d02a8d93028f3c34725de143085a844ebe953258a"
dependencies = [
"byteorder",
"rmp",
"serde",
]
[[package]]
name = "rsa"
version = "0.9.2"

View File

@ -8,8 +8,12 @@ edition = "2021"
[dependencies]
anyhow = "1"
axum = "0.6"
bytes = "1"
chrono = { version = "0.4", features = ["serde"] }
rmp-serde = "1"
serde = { version = "1", features = ["derive"] }
serde_path_to_error = "0.1"
sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite", "chrono"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
tower = "0.4"
tower-http = { version = "0.4", features = ["decompression-gzip"] }
tower-http = { version = "0.4", features = ["compression-gzip", "decompression-gzip", "cors"] }

View File

@ -1,15 +1,22 @@
use std::ops::{Deref, DerefMut};
use std::sync::Arc;
use anyhow::Result;
use axum::{BoxError, Json, Router, Server};
use axum::{async_trait, BoxError, Router, Server};
use axum::body::HttpBody;
use axum::error_handling::HandleErrorLayer;
use axum::extract::State;
use axum::http::StatusCode;
use axum::extract::{FromRequest, Path, State};
use axum::extract::rejection::BytesRejection;
use axum::http::{HeaderValue, Request, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use serde::Deserialize;
use bytes::Bytes;
use serde::{Deserialize, Serialize};
use serde::de::DeserializeOwned;
use sqlx::SqlitePool;
use tower::ServiceBuilder;
use tower_http::compression::CompressionLayer;
use tower_http::cors::CorsLayer;
use tower_http::decompression::RequestDecompressionLayer;
static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!();
@ -19,10 +26,23 @@ async fn main() -> Result<()> {
let pool = SqlitePool::connect("./database.sqlite").await?;
MIGRATOR.run(&pool).await?;
#[cfg(not(debug_assertions))]
let cors = CorsLayer::new()
.allow_origin([
"https://map.anna.lgbt".parse()?,
])
.allow_methods([Method::GET]);
#[cfg(debug_assertions)]
let cors = CorsLayer::permissive();
let app = Router::new()
.route("/", get(index))
.route("/:territory", get(territory))
.route("/:territory/:world", get(territory_world))
.route("/upload", post(upload))
.with_state(Arc::new(pool))
.layer(cors)
.layer(CompressionLayer::new())
.layer(
ServiceBuilder::new()
.layer(HandleErrorLayer::new(|_: BoxError| async move {
@ -37,20 +57,89 @@ async fn main() -> Result<()> {
Ok(())
}
async fn index() -> &'static str {
"hi"
async fn territory(
State(pool): State<Arc<SqlitePool>>,
Path(territory): Path<u32>,
) -> Result<MsgPack<Vec<AnonymousPlayerInfo>>, AppError>
{
let info = sqlx::query_as!(
AnonymousPlayerInfo,
// language=sqlite
r#"
select world,
x,
y,
z,
w,
customize,
level,
job,
free_company,
current_hp,
max_hp
from players
where territory = ?
--and unixepoch('now') - unixepoch(timestamp) < 30
"#,
territory,
)
.fetch_all(&*pool)
.await?;
Ok(MsgPack(info))
}
async fn territory_world(
State(pool): State<Arc<SqlitePool>>,
Path((territory, world)): Path<(u32, u32)>,
) -> Result<MsgPack<Vec<AnonymousPlayerInfo>>, AppError>
{
let info = sqlx::query_as!(
AnonymousPlayerInfo,
// language=sqlite
r#"
select world,
x,
y,
z,
w,
customize,
level,
job,
free_company,
current_hp,
max_hp
from players
where territory = ?
and current_world = ?
--and unixepoch('now') - unixepoch(timestamp) < 30
"#,
territory,
world,
)
.fetch_all(&*pool)
.await?;
Ok(MsgPack(info))
}
async fn upload(
pool: State<Arc<SqlitePool>>,
data: Json<Update>,
data: MsgPack<Update>,
) -> Result<(), AppError> {
let mut t = pool.begin().await?;
for player in &data.players {
let fc = match player.free_company.trim() {
"" => None,
x => Some(x),
if !player.is_sane() {
continue;
}
let name = player.name.trim();
let fc = match &player.free_company {
Some(x) if x.trim() == "" => None,
Some(x) => Some(x),
None => None,
};
sqlx::query!(
@ -73,8 +162,9 @@ async fn upload(
current_hp = ?,
max_hp = ?
",
player.name,
name,
player.world,
data.territory,
data.world,
player.x,
@ -120,18 +210,48 @@ struct Update {
struct PlayerInfo {
name: String,
world: u32,
x: f32,
y: f32,
z: f32,
w: f32,
x: f64,
y: f64,
z: f64,
w: f64,
customize: Vec<u8>,
level: u8,
job: u32,
free_company: String,
free_company: Option<String>,
current_hp: u32,
max_hp: u32,
}
impl PlayerInfo {
fn is_sane(&self) -> bool {
if self.name.trim().is_empty() {
return false;
}
if self.world == 65535 || self.level == 0 {
return false;
}
true
}
}
#[derive(Serialize)]
struct AnonymousPlayerInfo {
world: i64,
// timestamp: DateTime<Utc>,
x: f64,
y: f64,
z: f64,
w: f64,
customize: Vec<u8>,
level: i64,
job: i64,
free_company: Option<String>,
current_hp: i64,
max_hp: i64,
}
// Make our own error that wraps `anyhow::Error`.
struct AppError(anyhow::Error);
@ -156,3 +276,99 @@ impl<E> From<E> for AppError
Self(err.into())
}
}
struct MsgPack<T>(pub T);
impl<T> Deref for MsgPack<T> {
type Target = T;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl<T> DerefMut for MsgPack<T> {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
#[async_trait]
impl<S, B, T> FromRequest<S, B> for MsgPack<T>
where S: Send + Sync,
B: HttpBody + Send + 'static,
B::Data: Send,
B::Error: Into<BoxError>,
T: DeserializeOwned,
{
type Rejection = MsgPackRejection;
async fn from_request(req: Request<B>, state: &S) -> std::result::Result<Self, Self::Rejection> {
if req.headers().get("content-type").and_then(|val| val.to_str().ok()) != Some("application/msgpack") {
return Err(MsgPackRejection::ContentType);
}
let bytes = Bytes::from_request(req, state).await?;
let value = rmp_serde::from_slice(&bytes)?;
// let des = &mut rmp_serde::Deserializer::from_read_ref(&bytes);
// let value = match serde_path_to_error::deserialize(des) {
// Ok(v) => v,
// Err(err) => {
// let rejection = match err.inner() {
// rmp_serde::decode::Error::DepthLimitExceeded => {},
// };
//
// return Err(rejection);
// }
// };
Ok(MsgPack(value))
}
}
impl<T> IntoResponse for MsgPack<T>
where T: Serialize,
{
fn into_response(self) -> Response {
(
[(
axum::http::header::CONTENT_TYPE,
HeaderValue::from_static("application/msgpack"),
)],
rmp_serde::to_vec(&self.0)
.map_err(|e| AppError(e.into()))
.into_response()
).into_response()
}
}
enum MsgPackRejection {
ContentType,
Bytes(BytesRejection),
MsgPack(rmp_serde::decode::Error),
}
impl IntoResponse for MsgPackRejection {
fn into_response(self) -> Response {
(
StatusCode::BAD_REQUEST,
match self {
Self::ContentType => "expected application/msgpack content-type header".into_response(),
Self::Bytes(e) => e.into_response(),
Self::MsgPack(e) => format!("could not deserialize msgpack: {:#}", e).into_response(),
}
).into_response()
}
}
impl From<BytesRejection> for MsgPackRejection {
fn from(value: BytesRejection) -> Self {
Self::Bytes(value)
}
}
impl From<rmp_serde::decode::Error> for MsgPackRejection {
fn from(value: rmp_serde::decode::Error) -> Self {
Self::MsgPack(value)
}
}