]>
Commit | Line | Data |
---|---|---|
fba0b774 | 1 | use std::path::Path; |
03380db5 | 2 | use std::collections::HashSet; |
fba0b774 | 3 | |
fb657d8e | 4 | use anyhow::{bail, format_err, Error}; |
fba0b774 | 5 | |
fb657d8e | 6 | use proxmox::{ |
8a76e711 | 7 | api::{api, Router, SubdirMap, RpcEnvironment, Permission}, |
fb657d8e | 8 | list_subdirs_api_method, |
f490dda0 | 9 | tools::Uuid, |
fb657d8e | 10 | }; |
fba0b774 | 11 | |
b2065dc7 | 12 | use pbs_datastore::backup_info::BackupDir; |
1ce8e905 DM |
13 | use pbs_api_types::{ |
14 | MEDIA_POOL_NAME_SCHEMA, MEDIA_LABEL_SCHEMA, MEDIA_UUID_SCHEMA, CHANGER_NAME_SCHEMA, | |
15 | VAULT_NAME_SCHEMA, Authid, MediaPoolConfig, MediaListEntry, MediaSetListEntry, | |
16 | MediaStatus, MediaContentEntry, MediaContentListFilter, | |
8cc3760e | 17 | PRIV_TAPE_AUDIT, |
1ce8e905 | 18 | }; |
b2065dc7 | 19 | |
fba0b774 | 20 | use crate::{ |
8cc3760e | 21 | config::cached_user_info::CachedUserInfo, |
aad2d162 | 22 | tape::{ |
fba0b774 DM |
23 | TAPE_STATUS_DIR, |
24 | Inventory, | |
fba0b774 | 25 | MediaPool, |
a33389c3 | 26 | MediaCatalog, |
855b55dc | 27 | media_catalog_snapshot_list, |
37796ff7 | 28 | changer::update_online_status, |
fba0b774 DM |
29 | }, |
30 | }; | |
31 | ||
03380db5 DC |
32 | #[api( |
33 | returns: { | |
34 | description: "List of media sets.", | |
35 | type: Array, | |
36 | items: { | |
37 | type: MediaSetListEntry, | |
38 | }, | |
39 | }, | |
40 | access: { | |
41 | description: "List of media sets filtered by Tape.Audit privileges on pool", | |
42 | permission: &Permission::Anybody, | |
43 | }, | |
44 | )] | |
45 | /// List Media sets | |
46 | pub async fn list_media_sets( | |
47 | rpcenv: &mut dyn RpcEnvironment, | |
48 | ) -> Result<Vec<MediaSetListEntry>, Error> { | |
49 | let auth_id: Authid = rpcenv.get_auth_id().unwrap().parse()?; | |
50 | let user_info = CachedUserInfo::new()?; | |
51 | ||
aad2d162 | 52 | let (config, _digest) = pbs_config::media_pool::config()?; |
03380db5 DC |
53 | |
54 | let status_path = Path::new(TAPE_STATUS_DIR); | |
55 | ||
56 | let mut media_sets: HashSet<Uuid> = HashSet::new(); | |
57 | let mut list = Vec::new(); | |
58 | ||
59 | for (_section_type, data) in config.sections.values() { | |
60 | let pool_name = match data["name"].as_str() { | |
61 | None => continue, | |
62 | Some(name) => name, | |
63 | }; | |
64 | ||
65 | let privs = user_info.lookup_privs(&auth_id, &["tape", "pool", pool_name]); | |
66 | if (privs & PRIV_TAPE_AUDIT) == 0 { | |
67 | continue; | |
68 | } | |
69 | ||
70 | let config: MediaPoolConfig = config.lookup("pool", pool_name)?; | |
71 | ||
72 | let changer_name = None; // assume standalone drive | |
73 | let pool = MediaPool::with_config(status_path, &config, changer_name, true)?; | |
74 | ||
75 | for media in pool.list_media() { | |
76 | if let Some(label) = media.media_set_label() { | |
77 | if media_sets.contains(&label.uuid) { | |
78 | continue; | |
79 | } | |
80 | ||
81 | let media_set_uuid = label.uuid.clone(); | |
82 | let media_set_ctime = label.ctime; | |
83 | let media_set_name = pool | |
84 | .generate_media_set_name(&media_set_uuid, config.template.clone()) | |
85 | .unwrap_or_else(|_| media_set_uuid.to_string()); | |
86 | ||
87 | media_sets.insert(media_set_uuid.clone()); | |
88 | list.push(MediaSetListEntry { | |
89 | media_set_name, | |
90 | media_set_uuid, | |
91 | media_set_ctime, | |
92 | pool: pool_name.to_string(), | |
93 | }); | |
94 | } | |
95 | } | |
96 | } | |
97 | ||
98 | Ok(list) | |
99 | } | |
fba0b774 DM |
100 | #[api( |
101 | input: { | |
102 | properties: { | |
103 | pool: { | |
104 | schema: MEDIA_POOL_NAME_SCHEMA, | |
105 | optional: true, | |
106 | }, | |
159100b9 DM |
107 | "update-status": { |
108 | description: "Try to update tape library status (check what tapes are online).", | |
109 | optional: true, | |
110 | default: true, | |
111 | }, | |
9bbd83b1 DM |
112 | "update-status-changer": { |
113 | // only update status for a single changer | |
114 | schema: CHANGER_NAME_SCHEMA, | |
115 | optional: true, | |
116 | }, | |
fba0b774 DM |
117 | }, |
118 | }, | |
119 | returns: { | |
120 | description: "List of registered backup media.", | |
121 | type: Array, | |
122 | items: { | |
123 | type: MediaListEntry, | |
124 | }, | |
125 | }, | |
8a76e711 DM |
126 | access: { |
127 | description: "List of registered backup media filtered by Tape.Audit privileges on pool", | |
128 | permission: &Permission::Anybody, | |
129 | }, | |
fba0b774 DM |
130 | )] |
131 | /// List pool media | |
159100b9 DM |
132 | pub async fn list_media( |
133 | pool: Option<String>, | |
134 | update_status: bool, | |
9bbd83b1 | 135 | update_status_changer: Option<String>, |
8a76e711 | 136 | rpcenv: &mut dyn RpcEnvironment, |
159100b9 | 137 | ) -> Result<Vec<MediaListEntry>, Error> { |
8a76e711 DM |
138 | let auth_id: Authid = rpcenv.get_auth_id().unwrap().parse()?; |
139 | let user_info = CachedUserInfo::new()?; | |
fba0b774 | 140 | |
aad2d162 | 141 | let (config, _digest) = pbs_config::media_pool::config()?; |
fba0b774 DM |
142 | |
143 | let status_path = Path::new(TAPE_STATUS_DIR); | |
144 | ||
0bf1c314 | 145 | let catalogs = tokio::task::spawn_blocking(move || { |
159100b9 DM |
146 | if update_status { |
147 | // update online media status | |
9bbd83b1 | 148 | if let Err(err) = update_online_status(status_path, update_status_changer.as_deref()) { |
159100b9 DM |
149 | eprintln!("{}", err); |
150 | eprintln!("update online media status failed - using old state"); | |
151 | } | |
fba0b774 | 152 | } |
0bf1c314 DM |
153 | // test what catalog files we have |
154 | MediaCatalog::media_with_catalogs(status_path) | |
155 | }).await??; | |
fba0b774 DM |
156 | |
157 | let mut list = Vec::new(); | |
158 | ||
159 | for (_section_type, data) in config.sections.values() { | |
160 | let pool_name = match data["name"].as_str() { | |
161 | None => continue, | |
162 | Some(name) => name, | |
163 | }; | |
164 | if let Some(ref name) = pool { | |
165 | if name != pool_name { | |
166 | continue; | |
167 | } | |
168 | } | |
169 | ||
8a76e711 DM |
170 | let privs = user_info.lookup_privs(&auth_id, &["tape", "pool", pool_name]); |
171 | if (privs & PRIV_TAPE_AUDIT) == 0 { | |
172 | continue; | |
173 | } | |
174 | ||
fba0b774 DM |
175 | let config: MediaPoolConfig = config.lookup("pool", pool_name)?; |
176 | ||
ab77d660 | 177 | let changer_name = None; // assume standalone drive |
30316192 | 178 | let mut pool = MediaPool::with_config(status_path, &config, changer_name, true)?; |
fba0b774 DM |
179 | |
180 | let current_time = proxmox::tools::time::epoch_i64(); | |
181 | ||
ab77d660 DM |
182 | // Call start_write_session, so that we show the same status a |
183 | // backup job would see. | |
184 | pool.force_media_availability(); | |
e953029e | 185 | pool.start_write_session(current_time, false)?; |
ab77d660 | 186 | |
fba0b774 | 187 | for media in pool.list_media() { |
fba0b774 DM |
188 | let expired = pool.media_is_expired(&media, current_time); |
189 | ||
6543214d | 190 | let media_set_uuid = media.media_set_label() |
f490dda0 | 191 | .map(|set| set.uuid.clone()); |
fba0b774 | 192 | |
6543214d | 193 | let seq_nr = media.media_set_label() |
fba0b774 DM |
194 | .map(|set| set.seq_nr); |
195 | ||
6543214d | 196 | let media_set_name = media.media_set_label() |
fba0b774 DM |
197 | .map(|set| { |
198 | pool.generate_media_set_name(&set.uuid, config.template.clone()) | |
199 | .unwrap_or_else(|_| set.uuid.to_string()) | |
200 | }); | |
201 | ||
0bf1c314 DM |
202 | let catalog_ok = if media.media_set_label().is_none() { |
203 | // Media is empty, we need no catalog | |
204 | true | |
205 | } else { | |
206 | catalogs.contains(media.uuid()) | |
207 | }; | |
208 | ||
fba0b774 | 209 | list.push(MediaListEntry { |
f490dda0 | 210 | uuid: media.uuid().clone(), |
8446fbca | 211 | label_text: media.label_text().to_string(), |
6543214d | 212 | ctime: media.ctime(), |
fba0b774 | 213 | pool: Some(pool_name.to_string()), |
c1c2c8f6 | 214 | location: media.location().clone(), |
fba0b774 | 215 | status: *media.status(), |
0bf1c314 | 216 | catalog: catalog_ok, |
fba0b774 | 217 | expired, |
6543214d | 218 | media_set_ctime: media.media_set_label().map(|set| set.ctime), |
fba0b774 DM |
219 | media_set_uuid, |
220 | media_set_name, | |
221 | seq_nr, | |
222 | }); | |
223 | } | |
224 | } | |
225 | ||
955f4aef | 226 | let inventory = Inventory::load(status_path)?; |
fba0b774 | 227 | |
8a76e711 DM |
228 | let privs = user_info.lookup_privs(&auth_id, &["tape", "pool"]); |
229 | if (privs & PRIV_TAPE_AUDIT) != 0 { | |
230 | if pool.is_none() { | |
231 | ||
232 | for media_id in inventory.list_unassigned_media() { | |
233 | ||
234 | let (mut status, location) = inventory.status_and_location(&media_id.label.uuid); | |
235 | ||
236 | if status == MediaStatus::Unknown { | |
237 | status = MediaStatus::Writable; | |
238 | } | |
239 | ||
240 | list.push(MediaListEntry { | |
241 | uuid: media_id.label.uuid.clone(), | |
242 | ctime: media_id.label.ctime, | |
243 | label_text: media_id.label.label_text.to_string(), | |
244 | location, | |
245 | status, | |
246 | catalog: true, // empty, so we do not need a catalog | |
247 | expired: false, | |
248 | media_set_uuid: None, | |
249 | media_set_name: None, | |
250 | media_set_ctime: None, | |
251 | seq_nr: None, | |
252 | pool: None, | |
253 | }); | |
fba0b774 | 254 | } |
fba0b774 DM |
255 | } |
256 | } | |
257 | ||
955f4aef DM |
258 | // add media with missing pool configuration |
259 | // set status to MediaStatus::Unknown | |
260 | for uuid in inventory.media_list() { | |
261 | let media_id = inventory.lookup_media(uuid).unwrap(); | |
262 | let media_set_label = match media_id.media_set_label { | |
263 | Some(ref set) => set, | |
264 | None => continue, | |
265 | }; | |
266 | ||
267 | if config.sections.get(&media_set_label.pool).is_some() { | |
268 | continue; | |
269 | } | |
270 | ||
8a76e711 DM |
271 | let privs = user_info.lookup_privs(&auth_id, &["tape", "pool", &media_set_label.pool]); |
272 | if (privs & PRIV_TAPE_AUDIT) == 0 { | |
273 | continue; | |
274 | } | |
275 | ||
955f4aef DM |
276 | let (_status, location) = inventory.status_and_location(uuid); |
277 | ||
278 | let media_set_name = inventory.generate_media_set_name(&media_set_label.uuid, None)?; | |
279 | ||
280 | list.push(MediaListEntry { | |
281 | uuid: media_id.label.uuid.clone(), | |
282 | label_text: media_id.label.label_text.clone(), | |
283 | ctime: media_id.label.ctime, | |
284 | pool: Some(media_set_label.pool.clone()), | |
285 | location, | |
286 | status: MediaStatus::Unknown, | |
287 | catalog: catalogs.contains(uuid), | |
288 | expired: false, | |
289 | media_set_ctime: Some(media_set_label.ctime), | |
290 | media_set_uuid: Some(media_set_label.uuid.clone()), | |
291 | media_set_name: Some(media_set_name), | |
292 | seq_nr: Some(media_set_label.seq_nr), | |
293 | }); | |
294 | ||
295 | } | |
296 | ||
297 | ||
fba0b774 DM |
298 | Ok(list) |
299 | } | |
300 | ||
56d22c66 DC |
301 | #[api( |
302 | input: { | |
303 | properties: { | |
304 | "label-text": { | |
305 | schema: MEDIA_LABEL_SCHEMA, | |
306 | }, | |
307 | "vault-name": { | |
308 | schema: VAULT_NAME_SCHEMA, | |
309 | optional: true, | |
310 | }, | |
311 | }, | |
312 | }, | |
313 | )] | |
314 | /// Change Tape location to vault (if given), or offline. | |
315 | pub fn move_tape( | |
316 | label_text: String, | |
317 | vault_name: Option<String>, | |
318 | ) -> Result<(), Error> { | |
319 | ||
320 | let status_path = Path::new(TAPE_STATUS_DIR); | |
321 | let mut inventory = Inventory::load(status_path)?; | |
322 | ||
323 | let uuid = inventory.find_media_by_label_text(&label_text) | |
324 | .ok_or_else(|| format_err!("no such media '{}'", label_text))? | |
325 | .label | |
326 | .uuid | |
327 | .clone(); | |
328 | ||
329 | if let Some(vault_name) = vault_name { | |
330 | inventory.set_media_location_vault(&uuid, &vault_name)?; | |
331 | } else { | |
332 | inventory.set_media_location_offline(&uuid)?; | |
333 | } | |
334 | ||
335 | Ok(()) | |
336 | } | |
337 | ||
fb657d8e DM |
338 | #[api( |
339 | input: { | |
340 | properties: { | |
8446fbca | 341 | "label-text": { |
fb657d8e DM |
342 | schema: MEDIA_LABEL_SCHEMA, |
343 | }, | |
344 | force: { | |
345 | description: "Force removal (even if media is used in a media set).", | |
346 | type: bool, | |
347 | optional: true, | |
348 | }, | |
349 | }, | |
350 | }, | |
351 | )] | |
352 | /// Destroy media (completely remove from database) | |
8446fbca | 353 | pub fn destroy_media(label_text: String, force: Option<bool>,) -> Result<(), Error> { |
fb657d8e DM |
354 | |
355 | let force = force.unwrap_or(false); | |
356 | ||
357 | let status_path = Path::new(TAPE_STATUS_DIR); | |
358 | let mut inventory = Inventory::load(status_path)?; | |
359 | ||
8446fbca DM |
360 | let media_id = inventory.find_media_by_label_text(&label_text) |
361 | .ok_or_else(|| format_err!("no such media '{}'", label_text))?; | |
fb657d8e DM |
362 | |
363 | if !force { | |
364 | if let Some(ref set) = media_id.media_set_label { | |
365 | let is_empty = set.uuid.as_ref() == [0u8;16]; | |
366 | if !is_empty { | |
8446fbca | 367 | bail!("media '{}' contains data (please use 'force' flag to remove.", label_text); |
fb657d8e DM |
368 | } |
369 | } | |
370 | } | |
371 | ||
372 | let uuid = media_id.label.uuid.clone(); | |
fb657d8e DM |
373 | |
374 | inventory.remove_media(&uuid)?; | |
375 | ||
fb657d8e DM |
376 | Ok(()) |
377 | } | |
378 | ||
a33389c3 DM |
379 | #[api( |
380 | input: { | |
381 | properties: { | |
382 | "filter": { | |
383 | type: MediaContentListFilter, | |
384 | flatten: true, | |
385 | }, | |
386 | }, | |
387 | }, | |
388 | returns: { | |
389 | description: "Media content list.", | |
390 | type: Array, | |
391 | items: { | |
392 | type: MediaContentEntry, | |
393 | }, | |
394 | }, | |
8a76e711 DM |
395 | access: { |
396 | description: "List content filtered by Tape.Audit privilege on pool", | |
397 | permission: &Permission::Anybody, | |
398 | }, | |
a33389c3 DM |
399 | )] |
400 | /// List media content | |
401 | pub fn list_content( | |
402 | filter: MediaContentListFilter, | |
8a76e711 | 403 | rpcenv: &mut dyn RpcEnvironment, |
a33389c3 | 404 | ) -> Result<Vec<MediaContentEntry>, Error> { |
8a76e711 DM |
405 | let auth_id: Authid = rpcenv.get_auth_id().unwrap().parse()?; |
406 | let user_info = CachedUserInfo::new()?; | |
a33389c3 | 407 | |
aad2d162 | 408 | let (config, _digest) = pbs_config::media_pool::config()?; |
a33389c3 DM |
409 | |
410 | let status_path = Path::new(TAPE_STATUS_DIR); | |
411 | let inventory = Inventory::load(status_path)?; | |
412 | ||
a33389c3 DM |
413 | let mut list = Vec::new(); |
414 | ||
415 | for media_id in inventory.list_used_media() { | |
416 | let set = media_id.media_set_label.as_ref().unwrap(); | |
417 | ||
8446fbca DM |
418 | if let Some(ref label_text) = filter.label_text { |
419 | if &media_id.label.label_text != label_text { continue; } | |
a33389c3 DM |
420 | } |
421 | ||
422 | if let Some(ref pool) = filter.pool { | |
423 | if &set.pool != pool { continue; } | |
424 | } | |
425 | ||
8a76e711 DM |
426 | let privs = user_info.lookup_privs(&auth_id, &["tape", "pool", &set.pool]); |
427 | if (privs & PRIV_TAPE_AUDIT) == 0 { | |
428 | continue; | |
429 | } | |
430 | ||
f490dda0 | 431 | if let Some(ref media_uuid) = filter.media { |
a33389c3 DM |
432 | if &media_id.label.uuid != media_uuid { continue; } |
433 | } | |
434 | ||
f490dda0 | 435 | if let Some(ref media_set_uuid) = filter.media_set { |
a33389c3 DM |
436 | if &set.uuid != media_set_uuid { continue; } |
437 | } | |
438 | ||
955f4aef DM |
439 | let template = match config.lookup::<MediaPoolConfig>("pool", &set.pool) { |
440 | Ok(pool_config) => pool_config.template.clone(), | |
441 | _ => None, // simply use default if there is no pool config | |
442 | }; | |
a33389c3 DM |
443 | |
444 | let media_set_name = inventory | |
955f4aef | 445 | .generate_media_set_name(&set.uuid, template) |
a33389c3 DM |
446 | .unwrap_or_else(|_| set.uuid.to_string()); |
447 | ||
855b55dc DC |
448 | for (store, snapshot) in media_catalog_snapshot_list(status_path, &media_id)? { |
449 | let backup_dir: BackupDir = snapshot.parse()?; | |
a33389c3 | 450 | |
855b55dc DC |
451 | if let Some(ref backup_type) = filter.backup_type { |
452 | if backup_dir.group().backup_type() != backup_type { continue; } | |
453 | } | |
454 | if let Some(ref backup_id) = filter.backup_id { | |
455 | if backup_dir.group().backup_id() != backup_id { continue; } | |
54722aca | 456 | } |
855b55dc DC |
457 | |
458 | list.push(MediaContentEntry { | |
459 | uuid: media_id.label.uuid.clone(), | |
460 | label_text: media_id.label.label_text.to_string(), | |
461 | pool: set.pool.clone(), | |
462 | media_set_name: media_set_name.clone(), | |
463 | media_set_uuid: set.uuid.clone(), | |
464 | media_set_ctime: set.ctime, | |
465 | seq_nr: set.seq_nr, | |
466 | snapshot: snapshot.to_owned(), | |
467 | store: store.to_owned(), | |
468 | backup_time: backup_dir.backup_time(), | |
469 | }); | |
a33389c3 DM |
470 | } |
471 | } | |
472 | ||
473 | Ok(list) | |
474 | } | |
475 | ||
08ec39be DM |
476 | #[api( |
477 | input: { | |
478 | properties: { | |
479 | uuid: { | |
480 | schema: MEDIA_UUID_SCHEMA, | |
481 | }, | |
482 | }, | |
483 | }, | |
484 | )] | |
485 | /// Get current media status | |
486 | pub fn get_media_status(uuid: Uuid) -> Result<MediaStatus, Error> { | |
487 | ||
488 | let status_path = Path::new(TAPE_STATUS_DIR); | |
489 | let inventory = Inventory::load(status_path)?; | |
490 | ||
491 | let (status, _location) = inventory.status_and_location(&uuid); | |
492 | ||
493 | Ok(status) | |
494 | } | |
495 | ||
496 | #[api( | |
497 | input: { | |
498 | properties: { | |
499 | uuid: { | |
500 | schema: MEDIA_UUID_SCHEMA, | |
501 | }, | |
502 | status: { | |
503 | type: MediaStatus, | |
504 | optional: true, | |
505 | }, | |
506 | }, | |
507 | }, | |
508 | )] | |
509 | /// Update media status (None, 'full', 'damaged' or 'retired') | |
510 | /// | |
511 | /// It is not allowed to set status to 'writable' or 'unknown' (those | |
d1d74c43 | 512 | /// are internally managed states). |
08ec39be DM |
513 | pub fn update_media_status(uuid: Uuid, status: Option<MediaStatus>) -> Result<(), Error> { |
514 | ||
515 | let status_path = Path::new(TAPE_STATUS_DIR); | |
516 | let mut inventory = Inventory::load(status_path)?; | |
517 | ||
518 | match status { | |
519 | None => inventory.clear_media_status(&uuid)?, | |
520 | Some(MediaStatus::Retired) => inventory.set_media_status_retired(&uuid)?, | |
521 | Some(MediaStatus::Damaged) => inventory.set_media_status_damaged(&uuid)?, | |
522 | Some(MediaStatus::Full) => inventory.set_media_status_full(&uuid)?, | |
523 | Some(status) => bail!("setting media status '{:?}' is not allowed", status), | |
524 | } | |
525 | ||
526 | Ok(()) | |
527 | } | |
528 | ||
529 | const MEDIA_SUBDIRS: SubdirMap = &[ | |
530 | ( | |
531 | "status", | |
532 | &Router::new() | |
533 | .get(&API_METHOD_GET_MEDIA_STATUS) | |
534 | .post(&API_METHOD_UPDATE_MEDIA_STATUS) | |
535 | ), | |
536 | ]; | |
537 | ||
538 | pub const MEDIA_ROUTER: Router = Router::new() | |
539 | .get(&list_subdirs_api_method!(MEDIA_SUBDIRS)) | |
540 | .subdirs(MEDIA_SUBDIRS); | |
541 | ||
542 | pub const MEDIA_LIST_ROUTER: Router = Router::new() | |
543 | .get(&API_METHOD_LIST_MEDIA) | |
544 | .match_all("uuid", &MEDIA_ROUTER); | |
545 | ||
fba0b774 | 546 | const SUBDIRS: SubdirMap = &[ |
250c29ed DM |
547 | ( |
548 | "content", | |
549 | &Router::new() | |
550 | .get(&API_METHOD_LIST_CONTENT) | |
551 | ), | |
fb657d8e DM |
552 | ( |
553 | "destroy", | |
554 | &Router::new() | |
555 | .get(&API_METHOD_DESTROY_MEDIA) | |
556 | ), | |
08ec39be | 557 | ( "list", &MEDIA_LIST_ROUTER ), |
03380db5 DC |
558 | ( |
559 | "media-sets", | |
560 | &Router::new() | |
561 | .get(&API_METHOD_LIST_MEDIA_SETS) | |
562 | ), | |
56d22c66 DC |
563 | ( |
564 | "move", | |
565 | &Router::new() | |
566 | .post(&API_METHOD_MOVE_TAPE) | |
567 | ), | |
fba0b774 DM |
568 | ]; |
569 | ||
570 | ||
571 | pub const ROUTER: Router = Router::new() | |
572 | .get(&list_subdirs_api_method!(SUBDIRS)) | |
573 | .subdirs(SUBDIRS); |