1use crate::*;
2
3pub fn file_link(path: &str, zip: bool) -> Value {
4 let root = env("STUDIO_STORE_ROOT", "/srv");
5 let Some(relative) = std::path::Path::new(path).strip_prefix(&root).ok() else {
6 return Value::Null;
7 };
8 let path = relative
9 .components()
10 .map(|c| encoded(&c.as_os_str().to_string_lossy()))
11 .collect::<Vec<_>>()
12 .join("/");
13 let Ok(files) = std::env::var("STUDIO_FILES_URL") else {
14 return Value::Null;
15 };
16 json!(format!("{files}/{path}{}", if zip { "?zip" } else { "" }))
17}
18fn qbt_link(path: &str, zip: bool) -> Value {
19 file_link(
20 &if path == "/data/media" || path.starts_with("/data/media/") {
21 format!("/srv/clover/Media{}", &path[11..])
22 } else {
23 path.into()
24 },
25 zip,
26 )
27}
28async fn qbt(app: Arc<App>, route: &str, form: Option<Value>) -> Result<Value> {
29 let base = telemetry::endpoint(app.clone(), "qbittorrent")
30 .await
31 .map_err(|_| Error::new(503, "qBittorrent is unavailable. Check its service status."))?;
32 let mut request = if form.is_some() {
33 app.request(Method::POST, &format!("{base}/api/v2/{route}"))?
34 } else {
35 app.request(Method::GET, &format!("{base}/api/v2/{route}"))?
36 };
37 if let Some(form) = form {
38 request = request.form(&form);
39 }
40 let response = request.send().await?;
41 if !response.status().is_success() {
42 return Err(Error::new(
43 502,
44 format!(
45 "qBittorrent refused the request ({}). Retry from its page.",
46 response.status()
47 ),
48 ));
49 }
50 if response
51 .headers()
52 .get("content-type")
53 .and_then(|h| h.to_str().ok())
54 .is_some_and(|h| h.contains("json"))
55 {
56 Ok(response.json().await?)
57 } else {
58 Ok(json!(response.text().await?))
59 }
60}
61async fn seed_state(app: Arc<App>) -> Result<Arc<Document>> {
62 let state = app.clone();
63 app.cache
64 .get(
65 "seed-state".into(),
66 Duration::from_secs(5),
67 move || async move {
68 let (data, prefs) = tokio::try_join!(
69 qbt(state.clone(), "sync/maindata?rid=0", None),
70 qbt(state.clone(), "app/preferences", None)
71 )?;
72 let mut value = data["server_state"].clone();
73 if let (Some(value), Some(prefs)) = (value.as_object_mut(), prefs.as_object()) {
74 value.extend(prefs.clone());
75 }
76 let t = (now() / 5.0).floor() * 5.0;
77 let mut samples = state.seed_samples.lock().unwrap();
78 if samples.last().is_none_or(|s| s["t"] != t) {
79 samples.push(
80 json!({"t":t,"down":value["dl_info_speed"],"up":value["up_info_speed"]}),
81 );
82 }
83 samples.retain(|s| number(&s["t"]) >= t - 3600.0);
84 Ok(value)
85 },
86 )
87 .await
88}
89async fn vm_call(action: &str, payload: Option<Value>) -> Result<Value> {
90 let mut request = json!({"operation":format!("vm.{action}")});
91 if let Some(payload) = payload {
92 request["payload"] = payload;
93 }
94 host::call(request).await
95}
96
97fn vm_name(name: &str) -> Result<()> {
98 if regex::Regex::new(r"^[a-z0-9][a-z0-9-]{0,62}$")
99 .unwrap()
100 .is_match(name)
101 {
102 Ok(())
103 } else {
104 Err(Error::new(400, "No VM has that name."))
105 }
106}
107fn validate_vm(mut spec: Value) -> Result<Value> {
108 vm_name(string(&spec["name"]))?;
109 let description = spec["description"]
110 .as_str()
111 .ok_or_else(|| Error::new(400, "Enter a description."))?
112 .trim();
113 if description.chars().count() > 200 {
114 return Err(Error::new(
115 400,
116 "Keep the description under 200 characters.",
117 ));
118 }
119 spec["description"] = json!(description);
120 if string(&spec["image"]).is_empty() {
121 return Err(Error::new(400, "Pick an OS image."));
122 }
123 for (key, min) in [
124 ("vcpus", 1.0),
125 ("memory", 536870912.0),
126 ("disk", 1073741824.0),
127 ] {
128 let n = number(&spec[key]);
129 if !spec[key].is_number() || n.fract() != 0.0 || n < min {
130 return Err(Error::new(400, format!("Invalid {key}.")));
131 }
132 }
133 if !spec["autostart"].is_boolean() || !spec["start"].is_boolean() {
134 return Err(Error::new(
135 400,
136 "Choose whether this VM starts automatically.",
137 ));
138 }
139 Ok(spec)
140}
141async fn paper(app: Arc<App>, path: &str, body: Option<Value>) -> Result<Value> {
142 let jobs = core::scan(app.clone()).await?;
143 let host = jobs.value["clover-source-of-truth"]["job"]["Meta"]["studio_hostname"]
144 .as_str()
145 .ok_or_else(|| Error::new(502, "Paper Clover is unavailable."))?;
146 let key = host::call(
147 json!({"operation":"deploy.secret.get","service":"clover-source-of-truth","key":"key"}),
148 )
149 .await?;
150 let key = key
151 .as_str()
152 .ok_or_else(|| Error::new(502, "Paper Clover is unavailable."))?;
153 let url = format!(
154 "https://{host}/{}",
155 if path == "scan" {
156 path.into()
157 } else {
158 format!("studio/{path}")
159 }
160 );
161 let request = if let Some(body) = body {
162 app.http.post(&url).json(&body)
163 } else {
164 app.http.get(&url)
165 };
166 let response = request
167 .header("Authorization", key)
168 .timeout(Duration::from_secs(10))
169 .send()
170 .await?;
171 if path == "scan" {
172 if response.status() == 409 {
173 return Err(Error::new(
174 409,
175 "File scans are off on this host. Turn on the Paper Clover indexer to use this action.",
176 ));
177 }
178 if response.status() != 202 {
179 return Err(Error::new(
180 502,
181 "Paper Clover couldn't start the scan. Check its service logs.",
182 ));
183 }
184 return Ok(Value::Null);
185 }
186 if !response.status().is_success() {
187 return Err(Error::new(
188 502,
189 format!("Paper Clover answered {}.", response.status()),
190 ));
191 }
192 Ok(response.json().await?)
193}
194
195pub async fn route(
196 app: Arc<App>,
197 method: &Method,
198 parts: &[&str],
199 query: &HashMap<String, String>,
200 body: Value,
201) -> Result<Response> {
202 let value = match parts {
203 ["seedbox"] if method == Method::GET => {
204 let (state, mut torrents) = tokio::try_join!(
205 seed_state(app.clone()),
206 qbt(app.clone(), "torrents/info", None)
207 )?;
208 for torrent in torrents.as_array_mut().unwrap() {
209 let root = torrent["root_path"]
210 .as_str()
211 .filter(|s| !s.is_empty())
212 .unwrap_or(string(&torrent["save_path"]))
213 .to_owned();
214 torrent["folder"] = qbt_link(&root, false);
215 torrent["zip"] = if string(&torrent["root_path"]).is_empty() {
216 Value::Null
217 } else {
218 qbt_link(string(&torrent["root_path"]), true)
219 };
220 }
221 json!({"state":state.value,"downloads":qbt_link(string(&state.value["save_path"]),false),"torrents":torrents})
222 }
223 ["seedbox", "history"] if method == Method::GET => {
224 seed_state(app.clone()).await?;
225 let samples = app.seed_samples.lock().unwrap();
226 json!([("download","down"),("upload","up")].map(|(name,key)| json!({"name":name,"t":samples.iter().map(|s| s["t"].clone()).collect::<Vec<_>>(),"v":samples.iter().map(|s| s[key].clone()).collect::<Vec<_>>()})))
227 }
228 ["seedbox", "torrents"] if method == Method::POST => {
229 let url = string(&body["url"]).trim();
230 if !regex::Regex::new(r"(?i)^(magnet:\?|https?://)")
231 .unwrap()
232 .is_match(url)
233 {
234 return Err(Error::new(
235 400,
236 "Paste a magnet link or a link to a .torrent file",
237 ));
238 }
239 qbt(app, "torrents/add", Some(json!({"urls":url}))).await?;
240 Value::Null
241 }
242 ["seedbox", "torrents", hash, tail @ ..] => {
243 if !regex::Regex::new(r"^[0-9a-f]{40}$").unwrap().is_match(hash) {
244 return Err(Error::new(
245 400,
246 "Torrent hashes are 40 lowercase hex characters.",
247 ));
248 }
249 match tail {
250 ["files"] if method == Method::GET => {
251 let route = format!("torrents/files?{}", params(&[("hash", hash.to_string())]));
252 let (torrents, mut files) = tokio::try_join!(
253 qbt(app.clone(), "torrents/info", None),
254 qbt(app.clone(), &route, None)
255 )?;
256 let root = array(&torrents)
257 .iter()
258 .find(|t| t["hash"] == *hash)
259 .and_then(|t| t["save_path"].as_str());
260 for file in files.as_array_mut().unwrap() {
261 file["link"] = root
262 .map(|root| {
263 qbt_link(&format!("{root}/{}", string(&file["name"])), false)
264 })
265 .unwrap_or(Value::Null);
266 }
267 files
268 }
269 [action]
270 if method == Method::POST
271 && [
272 "stop",
273 "start",
274 "topPrio",
275 "increasePrio",
276 "decreasePrio",
277 "bottomPrio",
278 ]
279 .contains(action) =>
280 {
281 qbt(
282 app,
283 &format!("torrents/{action}"),
284 Some(json!({"hashes":hash})),
285 )
286 .await?;
287 Value::Null
288 }
289 [] if method == Method::DELETE => {
290 let files = query.get("files").map(String::as_str).unwrap_or("0");
291 if !["0", "1"].contains(&files) {
292 return Err(Error::new(400, "Invalid files option."));
293 }
294 qbt(app,"torrents/delete",Some(json!({"hashes":hash,"deleteFiles":if files=="1" {"true"} else {"false"}}))).await?;
295 Value::Null
296 }
297 _ => return Err(Error::new(404, "Not Found")),
298 }
299 }
300 ["vms"] if method == Method::GET => {
301 let mut values = Vec::new();
302 for (action, ttl) in [("node", 600), ("domains", 5), ("images", 60)] {
303 let value = app
304 .cache
305 .get(
306 format!("vms:{action}"),
307 Duration::from_secs(ttl),
308 move || async move { vm_call(action, None).await },
309 )
310 .await?;
311 values.push(value.value.clone());
312 }
313 for domain in values[1].as_array_mut().unwrap() {
314 domain["usage"] = app
315 .vm_usage
316 .lock()
317 .unwrap()
318 .get(string(&domain["name"]))
319 .cloned()
320 .unwrap_or(Value::Null);
321 }
322 json!({"node":values[0],"domains":values[1],"images":values[2]})
323 }
324 ["vms", "history"] if method == Method::GET => {
325 let range = telemetry::query_number(query, "range", 300.0, 60.0, 86400.0)?;
326 let mut query = query.clone();
327 query.insert("range".into(), range.to_string());
328 if let Some(name) = query.get("name").cloned() {
329 query.insert("service".into(), name);
330 }
331 let (cpu, memory) = tokio::try_join!(
332 telemetry::metrics(app.clone(), "vm.cpu", &query, None, None),
333 telemetry::metrics(app.clone(), "vm.memory", &query, None, None)
334 )?;
335 let mut domains = serde_json::Map::new();
336 for (values, key) in [(&cpu.value, "cpu"), (&memory.value, "memory")] {
337 for series in array(values) {
338 let domain = domains
339 .entry(string(&series["name"]).to_owned())
340 .or_insert_with(|| json!({"cpu":[],"memory":[]}));
341 domain[key] = series["v"].clone();
342 }
343 }
344 json!({"t":cpu.value[0]["t"].as_array().or(memory.value[0]["t"].as_array()).cloned().unwrap_or_default(),"domains":domains})
345 }
346 ["vms"] if method == Method::POST => {
347 vm_call("create", Some(validate_vm(body)?)).await?;
348 app.cache.invalidate("vms:domains");
349 Value::Null
350 }
351 ["vms", name, tail @ ..] => {
352 vm_name(name)?;
353 match tail {
354 [] if method == Method::PATCH => {
355 let mut payload = json!({"name":name});
356 if let Some(v) = body.get("autostart") {
357 if !v.is_boolean() {
358 return Err(Error::new(400, "Invalid autostart."));
359 }
360 payload["autostart"] = v.clone();
361 }
362 if let Some(v) = body.get("description") {
363 let v = v
364 .as_str()
365 .ok_or_else(|| Error::new(400, "Enter a description."))?
366 .trim();
367 if v.chars().count() > 200 {
368 return Err(Error::new(
369 400,
370 "Keep the description under 200 characters.",
371 ));
372 }
373 payload["description"] = json!(v);
374 }
375 vm_call("update", Some(payload)).await?;
376 }
377 [] if method == Method::DELETE => {
378 let disks = query.get("disks").map(String::as_str).unwrap_or("1");
379 if !["0", "1"].contains(&disks) {
380 return Err(Error::new(400, "Invalid disks option."));
381 }
382 vm_call("remove", Some(json!({"name":name,"disks":disks=="1"}))).await?;
383 }
384 [action]
385 if method == Method::POST
386 && ["start", "shutdown", "reboot", "destroy", "resume"]
387 .contains(action) =>
388 {
389 vm_call("act", Some(json!({"name":name,"action":action}))).await?;
390 }
391 _ => return Err(Error::new(404, "Not Found")),
392 }
393 app.cache.invalidate("vms:domains");
394 Value::Null
395 }
396 ["paper-clover"] if method == Method::GET => {
397 let mut value = paper(app, "stats", None).await?;
398 value["browse"] = std::env::var("STUDIO_PUBLISHED_ROOT")
399 .ok()
400 .map(|p| file_link(&p, false))
401 .unwrap_or(Value::Null);
402 value
403 }
404 ["paper-clover", "activity"] if method == Method::GET => {
405 paper(app, "activity", None).await?
406 }
407 ["paper-clover", "scan"] if method == Method::POST => {
408 let path = &body["path"];
409 if !path.is_null()
410 && (!path.is_string()
411 || !string(path).starts_with('/')
412 || string(path).split('/').any(|s| s == ".."))
413 {
414 return Err(Error::new(
415 400,
416 "Paths can't contain \"..\". Write the full path, like /2026/friends.",
417 ));
418 }
419 paper(
420 app,
421 "scan",
422 Some(if path.is_null() {
423 json!({})
424 } else {
425 json!({"path":path})
426 }),
427 )
428 .await?;
429 return Ok(StatusCode::ACCEPTED.into_response());
430 }
431 _ => return Err(Error::new(404, "Not Found")),
432 };
433 Ok(if value.is_null() {
434 StatusCode::NO_CONTENT.into_response()
435 } else {
436 Document::new(value).response()
437 })
438}