Skip to main content

banished_nexus/
nxm.rs

1//! `nxm://` links. The website's "Mod Manager Download" button gives one for a file:
2//! `nxm://banished/mods/19/files/49?key=…&expires=…&user_id=…`. Its `key` and `expires` let
3//! an account without Premium get that file's download link for a short time.
4
5use crate::{Error, GAME_DOMAIN};
6
7/// A parsed `nxm://` link.
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct NxmLink {
10    /// The game's domain (`banished`).
11    pub game: String,
12    /// The mod's id on Nexus Mods.
13    pub mod_id: u32,
14    /// The file's id, as in the mod's file list.
15    pub file_id: u32,
16    /// The one-time key and its expiry time (Unix seconds), passed to the download link.
17    pub key: String,
18    /// When the key stops working, in Unix seconds.
19    pub expires: String,
20    /// The account the link was made for, when the link says.
21    pub user_id: Option<String>,
22}
23
24impl NxmLink {
25    /// Read a link. It must be for Banished and carry a key and an expiry time.
26    pub fn parse(link: &str) -> Result<NxmLink, Error> {
27        let bad = |why: &str| Error::Answer(format!("not a Banished nxm:// link: {why}"));
28        let rest = link
29            .trim()
30            .strip_prefix("nxm://")
31            .ok_or_else(|| bad("it does not start with nxm://"))?;
32        let (path, query) = rest.split_once('?').unwrap_or((rest, ""));
33        let parts: Vec<&str> = path.trim_end_matches('/').split('/').collect();
34        let [game, "mods", mod_id, "files", file_id] = parts[..] else {
35            return Err(bad("expected nxm://banished/mods/ID/files/ID"));
36        };
37        if !game.eq_ignore_ascii_case(GAME_DOMAIN) {
38            return Err(bad(&format!("it is for {game}")));
39        }
40        let param = |name: &str| {
41            query
42                .split('&')
43                .filter_map(|p| p.split_once('='))
44                .find(|(k, _)| *k == name)
45                .map(|(_, v)| v.to_string())
46        };
47        Ok(NxmLink {
48            game: game.to_ascii_lowercase(),
49            mod_id: mod_id
50                .parse()
51                .map_err(|_| bad("the mod id is not a number"))?,
52            file_id: file_id
53                .parse()
54                .map_err(|_| bad("the file id is not a number"))?,
55            key: param("key").ok_or_else(|| bad("it has no key"))?,
56            expires: param("expires").ok_or_else(|| bad("it has no expiry time"))?,
57            user_id: param("user_id"),
58        })
59    }
60}
61
62#[cfg(test)]
63mod tests {
64    use super::*;
65
66    #[test]
67    fn reads_a_link() {
68        let l =
69            NxmLink::parse("nxm://Banished/mods/19/files/49?key=k1&expires=1700000000&user_id=5")
70                .unwrap();
71        assert_eq!((l.mod_id, l.file_id), (19, 49));
72        assert_eq!(l.key, "k1");
73        assert_eq!(l.expires, "1700000000");
74        assert_eq!(l.user_id.as_deref(), Some("5"));
75        assert!(NxmLink::parse("nxm://skyrim/mods/1/files/2?key=a&expires=1").is_err());
76        assert!(NxmLink::parse("nxm://banished/mods/1/files/2").is_err());
77        assert!(NxmLink::parse("https://www.nexusmods.com/banished/mods/1").is_err());
78    }
79}