aboutsummaryrefslogtreecommitdiff
path: root/planetwars-cli/src/web/mod.rs
blob: f66b0c6987a4bbe1e74e7d33a7bd82eca0855a39 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
use axum::{
    body::{boxed, Full},
    extract::{ws::WebSocket, Extension, Path, WebSocketUpgrade},
    handler::Handler,
    http::{header, StatusCode, Uri},
    response::{IntoResponse, Response},
    routing::{get, Router},
    AddExtensionLayer, Json,
};
use mime_guess;
use planetwars_matchrunner::MatchMeta;
use rust_embed::RustEmbed;
use serde::{Deserialize, Serialize};
use std::{
    fs,
    io::{self, BufRead},
    net::SocketAddr,
    path,
    sync::Arc,
};

use crate::workspace::Workspace;

struct State {
    workspace: Workspace,
}

impl State {
    fn new(workspace: Workspace) -> Self {
        Self { workspace }
    }
}

pub async fn run(workspace: Workspace) {
    let shared_state = Arc::new(State::new(workspace));

    // build our application with a route
    let app = Router::new()
        .route("/", get(index_handler))
        .route("/ws", get(ws_handler))
        .route("/api/matches", get(list_matches))
        .route("/api/matches/:match_id", get(get_match))
        .fallback(static_handler.into_service())
        .layer(AddExtensionLayer::new(shared_state));

    // run it
    let addr = SocketAddr::from(([127, 0, 0, 1], 5000));
    println!("serving at http://{}", addr);
    axum::Server::bind(&addr)
        .serve(app.into_make_service())
        .await
        .unwrap();
}

async fn ws_handler(ws: WebSocketUpgrade) -> impl IntoResponse {
    ws.on_upgrade(handle_socket)
}

async fn handle_socket(mut socket: WebSocket) {
    while let Some(msg) = socket.recv().await {
        let msg = if let Ok(msg) = msg {
            msg
        } else {
            // client disconnected
            return;
        };

        if socket.send(msg).await.is_err() {
            // client disconnected
            return;
        }
    }
}

#[derive(Serialize, Deserialize)]
struct MatchData {
    name: String,
    #[serde(flatten)]
    meta: MatchMeta,
}

async fn list_matches(Extension(state): Extension<Arc<State>>) -> Json<Vec<MatchData>> {
    let mut matches = state
        .workspace
        .matches_dir()
        .read_dir()
        .unwrap()
        .filter_map(|entry| {
            let entry = entry.unwrap();
            get_match_data(&entry).ok()
        })
        .collect::<Vec<_>>();
    matches.sort_by(|a, b| {
        let a = a.meta.timestamp;
        let b = b.meta.timestamp;
        a.cmp(&b).reverse()
    });
    Json(matches)
}

// extracts 'filename' if the entry matches'$filename.log'.
fn get_match_data(entry: &fs::DirEntry) -> io::Result<MatchData> {
    let file_name = entry.file_name();
    let path = path::Path::new(&file_name);

    let name = get_match_name(&path)
        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "invalid match name"))?;

    let meta = read_match_meta(&entry.path())?;

    Ok(MatchData { name, meta })
}

fn get_match_name(path: &path::Path) -> Option<String> {
    if path.extension() != Some("log".as_ref()) {
        return None;
    }

    path.file_stem()
        .and_then(|name| name.to_str())
        .map(|name| name.to_string())
}

fn read_match_meta(path: &path::Path) -> io::Result<MatchMeta> {
    let file = fs::File::open(path)?;
    let mut reader = io::BufReader::new(file);
    let mut line = String::new();
    reader.read_line(&mut line)?;
    let meta: MatchMeta = serde_json::from_str(&line)?;
    Ok(meta)
}

async fn get_match(Extension(state): Extension<Arc<State>>, Path(id): Path<String>) -> String {
    let mut match_path = state.workspace.matches_dir().join(id);
    match_path.set_extension("log");
    fs::read_to_string(match_path).unwrap()
}

async fn index_handler() -> impl IntoResponse {
    static_handler("/index.html".parse::<Uri>().unwrap()).await
}

// static_handler is a handler that serves static files from the
async fn static_handler(uri: Uri) -> impl IntoResponse {
    let path = uri.path().trim_start_matches('/').to_string();
    StaticFile(path)
}

#[derive(RustEmbed)]
#[folder = "../web/pw-frontend/dist/"]
struct Asset;
pub struct StaticFile<T>(pub T);

impl<T> IntoResponse for StaticFile<T>
where
    T: Into<String>,
{
    fn into_response(self) -> Response {
        let path = self.0.into();
        match Asset::get(path.as_str()) {
            Some(content) => {
                let body = boxed(Full::from(content.data));
                let mime = mime_guess::from_path(path).first_or_octet_stream();
                Response::builder()
                    .header(header::CONTENT_TYPE, mime.as_ref())
                    .body(body)
                    .unwrap()
            }
            None => Response::builder()
                .status(StatusCode::NOT_FOUND)
                .body(boxed(Full::from("404")))
                .unwrap(),
        }
    }
}