[WIP] A simple wake-on-lan service
at main 263 lines 8.7 kB view raw
1use std::{ 2 env::{self, VarError}, 3 fs::{self, File}, 4 io::{self, Read, Write}, 5 path::{Path, PathBuf, StripPrefixError}, 6 process::Command, 7}; 8 9use serde::Deserialize; 10use thiserror::Error; 11 12const DIST_SRC: &str = "./web/dist"; 13const WEB_SRC: &str = "./web"; 14const PACKAGE_JSON: &str = "./web/package.json"; 15 16#[derive(Deserialize, Debug)] 17struct Package { 18 scripts: Scripts, 19} 20 21#[derive(Deserialize, Debug)] 22struct Scripts { 23 #[serde(rename = "wol.build")] 24 build: String, 25} 26 27#[derive(Error, Debug)] 28enum BuildError { 29 #[error("IO Error: {}", .0)] 30 Io(#[from] io::Error), 31 #[error("Parse Error: {}", .0)] 32 Parse(#[from] serde_json::Error), 33 #[error("Command failed with error {}\nSTDOUT: {}\nSTDERR: {}", .0.map(|x| x.to_string()).unwrap_or(String::from("N/A")), .1, .2)] 34 Command(Option<i32>, String, String), 35} 36 37fn build() -> Result<(), BuildError> { 38 let mut file = File::open(PACKAGE_JSON)?; 39 let mut package = String::new(); 40 file.read_to_string(&mut package)?; 41 42 let package = serde_json::from_str::<Package>(&package)?; 43 let sh = package.scripts.build; 44 45 let cmd = Command::new("/bin/sh") 46 .arg("-c") 47 .arg(sh) 48 .current_dir(WEB_SRC) 49 .output()?; 50 51 if !cmd.status.success() { 52 return Err(BuildError::Command( 53 cmd.status.code(), 54 String::from_utf8(cmd.stdout).unwrap_or(String::from("Could not parse STDOUT")), 55 String::from_utf8(cmd.stderr).unwrap_or(String::from("Could not parse STDERR")), 56 )); 57 } 58 59 Ok(()) 60} 61 62#[derive(Error, Debug)] 63#[error("{}", .0)] 64struct CopyError(#[from] io::Error); 65 66fn copy_dir(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> Result<Vec<PathBuf>, CopyError> { 67 fs::create_dir_all(&dst)?; 68 let mut paths = Vec::new(); 69 70 for entry in fs::read_dir(&src)? { 71 let entry = entry?; 72 let r#type = entry.file_type()?; 73 if r#type.is_dir() { 74 let mut res = copy_dir(entry.path(), dst.as_ref().join(entry.file_name()))?; 75 paths.append(&mut res); 76 } else { 77 fs::copy(entry.path(), dst.as_ref().join(entry.file_name()))?; 78 paths.push(entry.path()); 79 } 80 } 81 82 Ok(paths) 83} 84 85#[derive(Error, Debug)] 86enum CodegenError { 87 #[error("Could not strip path: {}", .0)] 88 StripPath(#[from] StripPrefixError), 89 #[error("Error parsing non UTF8 string")] 90 Utf8, 91} 92 93fn codegen(paths: Vec<PathBuf>) -> Result<String, CodegenError> { 94 // in development mode, replace codegen with a proxy 95 #[cfg(debug_assertions)] 96 if let Ok(proxy) = std::env::var("PROXY") { 97 return Ok(format!( 98 r#" 99 mod dist {{ 100 async fn proxy( 101 req: axum::http::Request<axum::body::Body>, 102 ) -> Result<axum::http::Response<axum::body::Body>, reqwest::StatusCode> {{ 103 let url = req.uri().path_and_query().map(|x| x.as_str()).unwrap_or(""); 104 println!("PROXY => {{}}", url); 105 let url = reqwest::Url::parse(&format!("http://{}{{}}", url)) 106 .map_err(|_| reqwest::StatusCode::INTERNAL_SERVER_ERROR)?; 107 let req = reqwest::Request::new(req.method().to_owned(), url); 108 let res = reqwest::Client::new() 109 .execute(req) 110 .await 111 .map_err(|_| reqwest::StatusCode::INTERNAL_SERVER_ERROR)?; 112 let up_headers = res.headers(); 113 114 let mut out = axum::http::Response::builder().status(res.status()); 115 let headers = out.headers_mut().unwrap(); 116 for (k, v) in up_headers {{ 117 headers.insert(k, v.to_owned()); 118 }} 119 let out = out 120 .body( 121 res.bytes() 122 .await 123 .map_err(|_| reqwest::StatusCode::INTERNAL_SERVER_ERROR)? 124 .into(), 125 ) 126 .map_err(|_| reqwest::StatusCode::INTERNAL_SERVER_ERROR)?; 127 128 Ok(out) 129 }} 130 pub fn main() -> axum::Router {{ 131 axum::Router::new().fallback(axum::routing::get(proxy)) 132 }} 133 }} 134 "#, 135 proxy, 136 )); 137 } 138 139 // idx is a unique id for the current route 140 // this is easier than converting the path to a rust variable and avoiding collisons 141 let mut idx = 0; 142 // source path, route, id 143 let paths = paths 144 .into_iter() 145 .map(|x| { 146 idx += 1; 147 148 let route = Path::new("/").join(x.strip_prefix(DIST_SRC)?); 149 let route = if route.ends_with("index.html") { 150 route.parent().unwrap_or(&route) 151 } else { 152 &route 153 }; 154 let route = route.to_str().ok_or(CodegenError::Utf8)?; 155 let route = String::from(route); 156 157 let path = x.strip_prefix(WEB_SRC).map(|x| x.to_owned())?; 158 159 Ok((path, route, idx)) 160 }) 161 .collect::<Result<Vec<_>, CodegenError>>()?; 162 163 let routes = &paths 164 .iter() 165 .map(|(path, _, idx)| { 166 let ext = path 167 .extension() 168 .and_then(|x| x.to_str()) 169 .and_then(|x| match x { 170 // text 171 "html" | "htm" => Some("text/html"), 172 "css" => Some("text/css"), 173 "js" => Some("text/javascript"), 174 "json" => Some("application/json"), 175 "webmanifest" => Some("application/manifest+json"), 176 "txt" => Some("text/plain"), 177 178 // image 179 "png" => Some("image/png"), 180 "jpeg" | "jpg" => Some("image/jpeg"), 181 "svg" => Some("image/svg+xml"), 182 "webp" => Some("image/webp"), 183 "gif" => Some("image/gif"), 184 "ico" => Some("image/vnc.microsoft.icon"), 185 186 _ => None, 187 }) 188 .unwrap_or("application/octet-stream"); 189 190 Ok(format!( 191 r#"async fn route_{idx}() -> impl axum::response::IntoResponse {{ 192 let mut headers = axum::http::HeaderMap::new(); 193 headers.insert(axum::http::header::CONTENT_TYPE, axum::http::HeaderValue::from_static("{}")); 194 (headers, include_bytes!("{}")) 195 }}"#, 196 ext, 197 path.to_str().ok_or(CodegenError::Utf8)? 198 )) 199 }) 200 .collect::<Result<Vec<_>, CodegenError>>()? 201 .join("\n"); 202 203 let main = format!( 204 r#" 205 pub fn main() -> axum::Router {{ 206 axum::Router::new(){}.fallback(axum::routing::get(async || axum::response::Redirect::to("/"))) 207 }} 208 "#, 209 paths 210 .into_iter() 211 .map(|(_, route, idx)| format!(r#".route("{route}", axum::routing::get(route_{idx}))"#)) 212 .collect::<Vec<_>>() 213 .join("") 214 ); 215 216 Ok(format!("mod dist {{ {routes} {main} }}")) 217} 218 219#[derive(Error, Debug)] 220enum Error { 221 #[error("OUT_DIR env variable was not defined. Is this build.rs? {}", .0)] 222 OutDir(#[from] VarError), 223 #[error("Failed to compile ./web: {}", .0)] 224 Web(#[from] BuildError), 225 #[error("Failed to copy ./web/dist {}", .0)] 226 Copy(#[from] CopyError), 227 #[error("Codegen error: {}", .0)] 228 Codegen(#[from] CodegenError), 229 #[error("IO error: {}", .0)] 230 Io(#[from] io::Error), 231} 232 233fn main() -> Result<(), ()> { 234 fn main() -> Result<(), Error> { 235 let out_dir = env::var("OUT_DIR")?; 236 let dist_dst = Path::new(&out_dir).join("dist"); 237 238 // dont build the web project if the env var PROXY is set 239 // if PROXY is set it builds a reverse proxy instead 240 // also dont build for release builds 241 // in release builds we expect ./web/dist to be prebuilt 242 #[cfg(debug_assertions)] 243 if std::env::var("PROXY").is_err() { 244 build()?; 245 } 246 let paths = copy_dir(DIST_SRC, dist_dst)?; 247 let rust = codegen(paths)?; 248 let mut rust_file = fs::File::create(Path::new(&out_dir).join("mod.rs"))?; 249 rust_file.write_all(rust.as_bytes())?; 250 251 println!("cargo::rerun-if-changed=web/\ncargo::rerun-if-env-changed=PROXY"); 252 253 Ok(()) 254 } 255 256 let res = main(); 257 if let Err(err) = res { 258 eprintln!("{}", err); 259 Err(()) 260 } else { 261 Ok(()) 262 } 263}