Skip to content

Commit

Permalink
WIP savefile downloading
Browse files Browse the repository at this point in the history
  • Loading branch information
circlesabound committed Apr 14, 2024
1 parent 02900da commit 77fe150
Show file tree
Hide file tree
Showing 17 changed files with 352 additions and 48 deletions.
15 changes: 11 additions & 4 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ license = "Apache-2.0"
publish = false

[dependencies]
base64 = "0.22"
bincode = "1.3"
bytes = "1.0"
chrono = { version = "0.4", features = [ "serde" ] }
Expand Down
32 changes: 28 additions & 4 deletions openapi/mgmt-server-rest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ paths:
$ref: '#/components/schemas/ServerSavefileGetResponse'
/server/savefiles/{savefile_id}:
get:
summary: Downloads the requested savefile as a zip
summary: Generate a link to download the requested savefile as a zip
parameters:
- name: savefile_id
in: path
Expand All @@ -136,7 +136,7 @@ paths:
type: string
responses:
'200':
description: Savefile zip
description: Link ID to download the requested savefile as a zip
content:
application/octet-stream:
schema:
Expand Down Expand Up @@ -312,7 +312,7 @@ paths:
description: Request accepted, check the Location header for a websocket address to connect and monitor progress of the operation.
/server/mods/settings:
get:
summary: Gets the mod-settings.dat file used by the Factorio server.
summary: Gets the mod-settings.dat file used by the Factorio server in JSON format
responses:
'200':
description: The contents of the mod-settings.dat file, converted to a JSON format
Expand All @@ -321,7 +321,7 @@ paths:
schema:
$ref: '#/components/schemas/ModSettingsObject'
put:
summary: Pushes a mod-settings.dat file to the Factorio server for use
summary: Pushes contents of mod-settings.dat file in JSON format to the Factorio server for use
requestBody:
required: true
description: A mod-settings.dat file, converted to a JSON format
Expand All @@ -332,6 +332,30 @@ paths:
responses:
'200':
description: Ok
/server/mods/settings-dat:
get:
summary: Gets the mod-settings.dat file used by the Factorio server.
responses:
'200':
description: The binary contents of the mod-settings.dat file
content:
application/octet-stream:
schema:
type: string
format: binary
put:
summary: Pushes a mod-settings.dat file to the Factorio server for use
requestBody:
required: true
description: A mod-settings.dat file in original binary format
content:
application/octet-stream:
schema:
type: string
format: binary
responses:
'200':
description: Ok
/server/rcon:
post:
summary: Send a command over RCON to the Factorio game instance.
Expand Down
35 changes: 31 additions & 4 deletions src/agent/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ mod factorio;
mod server;
mod util;

const MAX_WS_PAYLOAD_BYTES: usize = 8000000;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
env_logger::init();
Expand Down Expand Up @@ -309,7 +311,7 @@ impl AgentController {
}

AgentRequest::SaveGet(save_name) => {
todo!()
self.save_get(save_name, operation_id).await
}

AgentRequest::SaveList => {
Expand Down Expand Up @@ -993,6 +995,31 @@ impl AgentController {
}
}

async fn save_get(&self, save_name: String, operation_id: OperationId) {
match util::saves::get_savefile(&save_name).await {
Ok(Some(savebytes)) => {
self.long_running_ack(&operation_id).await;
let chunks = savebytes.bytes.chunks(MAX_WS_PAYLOAD_BYTES);
let mut i = 0;
for chunk in chunks {
let msg = AgentOutMessage::SaveFile(SaveBytes {
multipart_seqnum: Some(i),
bytes: chunk.to_vec(),
});
self.reply(msg, &operation_id).await;
i += 1;
}
self.reply_success(AgentOutMessage::SaveFile(SaveBytes::sentinel(i)), operation_id).await;
},
Ok(None) => self.reply_failed(
AgentOutMessage::SaveNotFound,
operation_id).await,
Err(e) => self.reply_failed(
AgentOutMessage::Error(format!("Failed to get save: {:?}", e)),
operation_id).await,
}
}

async fn save_list(&self, operation_id: OperationId) {
match util::saves::list_savefiles().await {
Ok(saves) => {
Expand Down Expand Up @@ -1090,7 +1117,7 @@ impl AgentController {
match s.try_into() {
Ok(bytes) => {
self.reply_success(
AgentOutMessage::ModSettings(Some(ModSettingsBytes(bytes))),
AgentOutMessage::ModSettings(Some(ModSettingsBytes { bytes })),
operation_id,
)
.await;
Expand Down Expand Up @@ -1122,11 +1149,11 @@ impl AgentController {
}
}

async fn mod_settings_set(&self, bytes: ModSettingsBytes, operation_id: OperationId) {
async fn mod_settings_set(&self, ms_bytes: ModSettingsBytes, operation_id: OperationId) {
match ModManager::read_or_apply_default().await {
Ok(mut m) => {
// Validate by attempting to parse
match ModSettings::try_from(bytes.0.as_ref()) {
match ModSettings::try_from(ms_bytes.bytes.as_ref()) {
Ok(ms) => {
m.settings = Some(ms);
if let Err(e) = m.apply_metadata_only().await {
Expand Down
21 changes: 18 additions & 3 deletions src/agent/util/saves.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,28 @@
use std::path::{Path, PathBuf};

use fctrl::schema::Save;
use fctrl::schema::{Save, SaveBytes};
use log::warn;
use tokio::fs;

use crate::{consts::*, error::Result};

pub fn get_savefile_path(save_name: &str) -> PathBuf {
SAVEFILE_DIR.join(format!("{}.zip", save_name))
pub fn get_savefile_path(save_name: impl AsRef<str>) -> PathBuf {
SAVEFILE_DIR.join(format!("{}.zip", save_name.as_ref()))
}

pub async fn get_savefile(save_name: impl AsRef<str>) -> Result<Option<SaveBytes>> {
if !SAVEFILE_DIR.is_dir() {
return Ok(None);
}

let savefiles = list_savefiles().await?;
match savefiles.into_iter().find(|s| s.name == save_name.as_ref()) {
Some(s) => {
let bytes = fs::read(get_savefile_path(s.name)).await?;
Ok(Some(SaveBytes::new(bytes)))
},
None => Ok(None),
}
}

pub async fn list_savefiles() -> Result<Vec<Save>> {
Expand Down
13 changes: 13 additions & 0 deletions src/mgmt-server/clients.rs
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,17 @@ impl AgentApiClient {
ack_or_timeout(sub, Duration::from_millis(500), id).await
}

pub async fn save_get(&self, savefile_name: String) -> Result<(OperationId, impl Stream<Item = Event> + Unpin)> {
if savefile_name.trim().is_empty() {
return Err(Error::BadRequest("Empty savefile name".to_owned()));
}

let request = AgentRequest::SaveGet(savefile_name);
let (id, sub) = self.send_request_and_subscribe(request).await?;

ack_or_timeout(sub, Duration::from_millis(500), id).await
}

pub async fn save_list(&self) -> Result<Vec<Save>> {
let request = AgentRequest::SaveList;
let (_id, sub) = self.send_request_and_subscribe(request).await?;
Expand Down Expand Up @@ -402,6 +413,7 @@ fn default_message_handler(agent_message: AgentOutMessage) -> Error {
| AgentOutMessage::ModsList(_)
| AgentOutMessage::ModSettings(_)
| AgentOutMessage::RconResponse(_)
| AgentOutMessage::SaveFile(_)
| AgentOutMessage::SaveList(_)
| AgentOutMessage::ServerStatus(_)
| AgentOutMessage::Ok => Error::AgentCommunicationError,
Expand All @@ -413,6 +425,7 @@ fn default_message_handler(agent_message: AgentOutMessage) -> Error {
AgentOutMessage::NotInstalled => {
Error::AgentInternalError("Factorio not installed".to_owned())
}
AgentOutMessage::SaveNotFound => Error::SaveNotFound,
}
}

Expand Down
4 changes: 4 additions & 0 deletions src/mgmt-server/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,10 @@ pub enum Error {

// Specific errors
DiscordAlertingDisabled,
InvalidLink,
ModSettingsNotInitialised,
ModSettingsParseError(factorio_mod_settings_parser::Error),
SaveNotFound,
SecretsNotInitialised,

// Generic wrappers around external error types
Expand Down Expand Up @@ -127,6 +129,8 @@ impl<'r> Responder<'r, 'static> for Error {
| Error::AuthInvalid
| Error::AuthRefreshUnavailable
| Error::MetricInvalidKey(_) => Status::BadRequest,
Error::SaveNotFound
| Error::InvalidLink => Status::NotFound,
Error::ModSettingsNotInitialised | Error::SecretsNotInitialised => Status::NoContent,
};

Expand Down
36 changes: 36 additions & 0 deletions src/mgmt-server/link_download.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
use std::collections::HashMap;
use chrono::{DateTime, Utc};
use log::info;
use tokio::sync::RwLock;
use uuid::Uuid;

pub struct LinkDownloadManager {
links: RwLock<HashMap<String, (LinkDownloadTarget, DateTime<Utc>)>>,
}

#[derive(Clone, Debug)]
pub enum LinkDownloadTarget {
Savefile { id: String },
}

impl LinkDownloadManager {
pub fn new() -> LinkDownloadManager {
// TODO periodic job to expire out links
LinkDownloadManager {
links: RwLock::new(HashMap::new()),
}
}

pub async fn create_link(&self, target: LinkDownloadTarget) -> String {
let mut w_guard = self.links.write().await;
let link = Uuid::new_v4().as_simple().to_string();
info!("Generating download link: {} -> {:?}", link, target);
w_guard.insert(link.clone(), (target, Utc::now()));
link
}

pub async fn get_link(&self, link: String) -> Option<LinkDownloadTarget> {
let r_guard = self.links.read().await;
r_guard.get(&link).map(|(target, _dt)| target.clone())
}
}
21 changes: 14 additions & 7 deletions src/mgmt-server/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,7 @@ use log::{debug, error, info};
use rocket::{async_trait, catchers, fairing::Fairing, fs::FileServer, routes};

use crate::{
auth::UserIdentity,
clients::AgentApiClient,
db::{Cf, Db, Record},
discord::DiscordClient,
events::broker::EventBroker,
rpc::RpcHandler,
ws::WebSocketServer,
auth::UserIdentity, clients::AgentApiClient, db::{Cf, Db, Record}, discord::DiscordClient, events::broker::EventBroker, link_download::LinkDownloadManager, rpc::RpcHandler, ws::WebSocketServer
};

mod auth;
Expand All @@ -30,6 +24,7 @@ mod discord;
mod error;
mod events;
mod guards;
mod link_download;
mod metrics;
mod routes;
mod rpc;
Expand Down Expand Up @@ -127,6 +122,9 @@ async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
)
.await?;

info!("Creating link download manager");
let link_download_manager = Arc::new(LinkDownloadManager::new());

let ws_port = std::env::var("MGMT_SERVER_WS_PORT")?.parse()?;
let ws_addr = std::env::var("MGMT_SERVER_WS_ADDRESS")?.parse()?;
let ws_bind = SocketAddr::new(ws_addr, ws_port);
Expand All @@ -144,6 +142,7 @@ async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
.manage(event_broker)
.manage(db)
.manage(agent_client)
.manage(link_download_manager)
.manage(ws)
.mount("/", routes![routes::options::options,])
.mount(
Expand Down Expand Up @@ -176,6 +175,8 @@ async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
routes::server::apply_mods_list,
routes::server::get_mod_settings,
routes::server::put_mod_settings,
routes::server::get_mod_settings_dat,
routes::server::put_mod_settings_dat,
routes::server::send_rcon_command,
routes::logs::get,
routes::logs::stream,
Expand All @@ -190,6 +191,12 @@ async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
routes::proxy::mod_portal_full_get,
],
)
.mount(
"/download",
routes![
routes::download::download,
]
)
.mount("/", FileServer::from(get_dist_path()))
.register("/api/v0", catchers![catchers::not_found,])
.register("/", catchers![catchers::fallback_to_index_html,])
Expand Down
Loading

0 comments on commit 77fe150

Please sign in to comment.