[WIP] A simple wake-on-lan service
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}