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 spec["description"] = json!("");
110 spec["autostart"] = json!(false);
111 spec["start"] = json!(spec["mode"] == "preset");
112 if string(&spec["image"]).is_empty() {
113 return Err(Error::new(400, "Pick an OS image."));
114 }
115 for (key, min) in [
116 ("vcpus", 1.0),
117 ("memory", 536870912.0),
118 ("disk", 1073741824.0),
119 ] {
120 let n = number(&spec[key]);
121 if !spec[key].is_number() || n.fract() != 0.0 || n < min {
122 return Err(Error::new(400, format!("Invalid {key}.")));
123 }
124 }
125 Ok(spec)
126}
127async fn paper(app: Arc<App>, path: &str, body: Option<Value>) -> Result<Value> {
128 let jobs = core::scan(app.clone()).await?;
129 let host = jobs.value["clover-source-of-truth"]["job"]["Meta"]["studio_hostname"]
130 .as_str()
131 .ok_or_else(|| Error::new(502, "Paper Clover is unavailable."))?;
132 let key = host::call(
133 json!({"operation":"deploy.secret.get","service":"clover-source-of-truth","key":"key"}),
134 )
135 .await?;
136 let key = key
137 .as_str()
138 .ok_or_else(|| Error::new(502, "Paper Clover is unavailable."))?;
139 let url = format!(
140 "https://{host}/{}",
141 if path == "scan" {
142 path.into()
143 } else {
144 format!("studio/{path}")
145 }
146 );
147 let request = if let Some(body) = body {
148 app.http.post(&url).json(&body)
149 } else {
150 app.http.get(&url)
151 };
152 let response = request
153 .header("Authorization", key)
154 .timeout(Duration::from_secs(10))
155 .send()
156 .await?;
157 if path == "scan" {
158 if response.status() == 409 {
159 return Err(Error::new(
160 409,
161 "File scans are off on this host. Turn on the Paper Clover indexer to use this action.",
162 ));
163 }
164 if response.status() != 202 {
165 return Err(Error::new(
166 502,
167 "Paper Clover couldn't start the scan. Check its service logs.",
168 ));
169 }
170 return Ok(Value::Null);
171 }
172 if !response.status().is_success() {
173 return Err(Error::new(
174 502,
175 format!("Paper Clover answered {}.", response.status()),
176 ));
177 }
178 Ok(response.json().await?)
179}
180
181pub async fn route(
182 app: Arc<App>,
183 method: &Method,
184 parts: &[&str],
185 query: &HashMap<String, String>,
186 me: &Value,
187 body: Value,
188) -> Result<Response> {
189 let value = match parts {
190 ["seedbox"] if method == Method::GET => {
191 let (state, mut torrents) = tokio::try_join!(
192 seed_state(app.clone()),
193 qbt(app.clone(), "torrents/info", None)
194 )?;
195 for torrent in torrents.as_array_mut().unwrap() {
196 let root = torrent["root_path"]
197 .as_str()
198 .filter(|s| !s.is_empty())
199 .unwrap_or(string(&torrent["save_path"]))
200 .to_owned();
201 torrent["folder"] = qbt_link(&root, false);
202 torrent["zip"] = if string(&torrent["root_path"]).is_empty() {
203 Value::Null
204 } else {
205 qbt_link(string(&torrent["root_path"]), true)
206 };
207 }
208 json!({"state":state.value,"downloads":qbt_link(string(&state.value["save_path"]),false),"torrents":torrents})
209 }
210 ["seedbox", "history"] if method == Method::GET => {
211 seed_state(app.clone()).await?;
212 let samples = app.seed_samples.lock().unwrap();
213 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<_>>()})))
214 }
215 ["seedbox", "torrents"] if method == Method::POST => {
216 let url = string(&body["url"]).trim();
217 if !regex::Regex::new(r"(?i)^(magnet:\?|https?://)")
218 .unwrap()
219 .is_match(url)
220 {
221 return Err(Error::new(
222 400,
223 "Paste a magnet link or a link to a .torrent file",
224 ));
225 }
226 qbt(app, "torrents/add", Some(json!({"urls":url}))).await?;
227 Value::Null
228 }
229 ["seedbox", "torrents", hash, tail @ ..] => {
230 if !regex::Regex::new(r"^[0-9a-f]{40}$").unwrap().is_match(hash) {
231 return Err(Error::new(
232 400,
233 "Torrent hashes are 40 lowercase hex characters.",
234 ));
235 }
236 match tail {
237 ["files"] if method == Method::GET => {
238 let route = format!("torrents/files?{}", params(&[("hash", hash.to_string())]));
239 let (torrents, mut files) = tokio::try_join!(
240 qbt(app.clone(), "torrents/info", None),
241 qbt(app.clone(), &route, None)
242 )?;
243 let root = array(&torrents)
244 .iter()
245 .find(|t| t["hash"] == *hash)
246 .and_then(|t| t["save_path"].as_str());
247 for file in files.as_array_mut().unwrap() {
248 file["link"] = root
249 .map(|root| {
250 qbt_link(&format!("{root}/{}", string(&file["name"])), false)
251 })
252 .unwrap_or(Value::Null);
253 }
254 files
255 }
256 [action]
257 if method == Method::POST
258 && [
259 "stop",
260 "start",
261 "topPrio",
262 "increasePrio",
263 "decreasePrio",
264 "bottomPrio",
265 ]
266 .contains(action) =>
267 {
268 qbt(
269 app,
270 &format!("torrents/{action}"),
271 Some(json!({"hashes":hash})),
272 )
273 .await?;
274 Value::Null
275 }
276 [] if method == Method::DELETE => {
277 let files = query.get("files").map(String::as_str).unwrap_or("0");
278 if !["0", "1"].contains(&files) {
279 return Err(Error::new(400, "Invalid files option."));
280 }
281 qbt(app,"torrents/delete",Some(json!({"hashes":hash,"deleteFiles":if files=="1" {"true"} else {"false"}}))).await?;
282 Value::Null
283 }
284 _ => return Err(Error::new(404, "Not Found")),
285 }
286 }
287 ["vms"] if method == Method::GET => {
288 let mut values = Vec::new();
289 for (action, ttl) in [("node", 600), ("domains", 5), ("library", 15)] {
290 let value = app
291 .cache
292 .get(
293 format!("vms:{action}"),
294 Duration::from_secs(ttl),
295 move || async move { vm_call(action, None).await },
296 )
297 .await?;
298 values.push(value.value.clone());
299 }
300 let allocated: f64 = array(&values[1])
301 .iter()
302 .filter(|vm| !["shutoff", "crashed"].contains(&string(&vm["state"])))
303 .map(|vm| number(&vm["balloon"]))
304 .sum();
305 values[0]["availableMemory"] =
306 json!((number(&values[0]["memory"]) - allocated).max(0.0));
307 values[1]
308 .as_array_mut()
309 .unwrap()
310 .retain(|domain| vms::visible(me, &domain["owner"]));
311 for domain in values[1].as_array_mut().unwrap() {
312 domain["usage"] = app
313 .vm_usage
314 .lock()
315 .unwrap()
316 .get(string(&domain["name"]))
317 .cloned()
318 .unwrap_or(Value::Null);
319 }
320 json!({"node":values[0],"domains":values[1],"library":values[2],"canPublish":array(&me["sections"]).iter().any(|section| section == "admin")})
321 }
322 ["vms", "history"] if method == Method::GET => {
323 if let Some(name) = query.get("name") {
324 vms::authorize(me, name).await?;
325 }
326 let owned = vm_call("domains", None).await?;
327 let range = telemetry::query_number(query, "range", 300.0, 60.0, 86400.0)?;
328 let mut query = query.clone();
329 query.insert("range".into(), range.to_string());
330 if let Some(name) = query.get("name").cloned() {
331 query.insert("service".into(), name);
332 }
333 let (cpu, memory) = tokio::try_join!(
334 telemetry::metrics(app.clone(), "vm.cpu", &query, None, None),
335 telemetry::metrics(app.clone(), "vm.memory", &query, None, None)
336 )?;
337 let mut domains = serde_json::Map::new();
338 for (values, key) in [(&cpu.value, "cpu"), (&memory.value, "memory")] {
339 for series in array(values) {
340 if !array(&owned).iter().any(|domain| {
341 domain["name"] == series["name"] && vms::visible(me, &domain["owner"])
342 }) {
343 continue;
344 }
345 let domain = domains
346 .entry(string(&series["name"]).to_owned())
347 .or_insert_with(|| json!({"cpu":[],"memory":[]}));
348 domain[key] = series["v"].clone();
349 }
350 }
351 json!({"t":cpu.value[0]["t"].as_array().or(memory.value[0]["t"].as_array()).cloned().unwrap_or_default(),"domains":domains})
352 }
353 ["vms"] if method == Method::POST => {
354 let mut spec = validate_vm(body)?;
355 spec["owner"] = me["id"].clone();
356 spec["username"] = me["name"].clone();
357 vm_call("create", Some(spec)).await?;
358 app.cache.invalidate("vms:domains");
359 Value::Null
360 }
361 ["vms", name, tail @ ..] => {
362 vm_name(name)?;
363 match tail {
364 ["access"] if method == Method::GET => {
365 let mut response =
366 Document::new(vm_call("access", Some(json!({"name":name}))).await?)
367 .response();
368 response
369 .headers_mut()
370 .insert("cache-control", "no-store".parse().unwrap());
371 return Ok(response);
372 }
373 ["media"] if method == Method::PUT => {
374 vm_call("media", Some(json!({"name":name,"image":body["image"]}))).await?;
375 }
376 ["preset"] if method == Method::POST => {
377 need(me, "admin")?;
378 vm_call("preset", Some(json!({"name":name,"id":body["id"],"os":body["os"],"description":body["description"]}))).await?;
379 app.cache.invalidate("vms:library");
380 }
381 [] if method == Method::PATCH => {
382 let mut payload = json!({"name":name});
383 if let Some(v) = body.get("autostart") {
384 if !v.is_boolean() {
385 return Err(Error::new(400, "Invalid autostart."));
386 }
387 payload["autostart"] = v.clone();
388 }
389 if let Some(v) = body.get("description") {
390 let v = v
391 .as_str()
392 .ok_or_else(|| Error::new(400, "Enter a description."))?
393 .trim();
394 if v.chars().count() > 200 {
395 return Err(Error::new(
396 400,
397 "Keep the description under 200 characters.",
398 ));
399 }
400 payload["description"] = json!(v);
401 }
402 vm_call("update", Some(payload)).await?;
403 }
404 [] if method == Method::DELETE => {
405 let disks = query.get("disks").map(String::as_str).unwrap_or("1");
406 if !["0", "1"].contains(&disks) {
407 return Err(Error::new(400, "Invalid disks option."));
408 }
409 vm_call("remove", Some(json!({"name":name,"disks":disks=="1"}))).await?;
410 }
411 [action]
412 if method == Method::POST
413 && ["start", "shutdown", "reboot", "destroy", "resume"]
414 .contains(action) =>
415 {
416 vm_call("act", Some(json!({"name":name,"action":action}))).await?;
417 }
418 _ => return Err(Error::new(404, "Not Found")),
419 }
420 app.cache.invalidate("vms:domains");
421 Value::Null
422 }
423 ["paper-clover"] if method == Method::GET => {
424 let mut value = paper(app, "stats", None).await?;
425 value["browse"] = std::env::var("STUDIO_PUBLISHED_ROOT")
426 .ok()
427 .map(|p| file_link(&p, false))
428 .unwrap_or(Value::Null);
429 value
430 }
431 ["paper-clover", "activity"] if method == Method::GET => {
432 paper(app, "activity", None).await?
433 }
434 ["paper-clover", "scan"] if method == Method::POST => {
435 let path = &body["path"];
436 if !path.is_null()
437 && (!path.is_string()
438 || !string(path).starts_with('/')
439 || string(path).split('/').any(|s| s == ".."))
440 {
441 return Err(Error::new(
442 400,
443 "Paths can't contain \"..\". Write the full path, like /2026/friends.",
444 ));
445 }
446 paper(
447 app,
448 "scan",
449 Some(if path.is_null() {
450 json!({})
451 } else {
452 json!({"path":path})
453 }),
454 )
455 .await?;
456 return Ok(StatusCode::ACCEPTED.into_response());
457 }
458 _ => return Err(Error::new(404, "Not Found")),
459 };
460 Ok(if value.is_null() {
461 StatusCode::NO_CONTENT.into_response()
462 } else {
463 Document::new(value).response()
464 })
465}