Skip to main content

runlight/
links.rs

1//! Short links: create, change, delete, and import, with the rules every route shares.
2
3use crate::error::Error;
4use crate::goals::CodedError;
5use crate::hash::{new_id, random_bytes};
6use crate::http::Url;
7use crate::js::{self, Object, Value};
8use crate::re::{js_re, test};
9use crate::runlight::Runlight;
10use crate::sources::strip_www;
11use crate::store::LinkRow;
12
13/// What a person or an import may set on a new link.
14#[derive(Clone, Debug, Default, PartialEq, Eq)]
15pub struct LinkInput {
16    /// Where it goes.
17    pub url: String,
18    /// Its name.
19    pub name: Option<String>,
20    /// Its slug.
21    pub slug: Option<String>,
22    /// A link domain added in Settings, or "" (the default) for the app's own.
23    pub domain: Option<String>,
24}
25
26/// What may change on a link: each field left `None` stays.
27#[derive(Clone, Debug, Default, PartialEq, Eq)]
28pub struct LinkPatch {
29    /// Where it goes.
30    pub url: Option<String>,
31    /// Its name.
32    pub name: Option<String>,
33    /// Its slug.
34    pub slug: Option<String>,
35    /// Its domain.
36    pub domain: Option<String>,
37}
38
39const ALPHABET: &[u8] = b"abcdefghijkmnpqrstuvwxyz23456789";
40
41/// Six characters from an alphabet without look-alikes (no 0/o, 1/l).
42pub fn random_slug() -> String {
43    random_bytes(6).iter().map(|b| ALPHABET[*b as usize % ALPHABET.len()] as char).collect()
44}
45
46fn link_error(message: impl Into<String>, code: &str, params: &[(&str, &str)]) -> Error {
47    Error::Link(CodedError::new(message, code, params))
48}
49
50/// A link's destination as it is kept: a full http or https URL, up to 2,000 characters.
51pub fn clean_url(value: &str) -> Result<String, Error> {
52    let text = js::trim(value).to_string();
53    let Some(url) = Url::parse(&text) else {
54        return Err(link_error("The destination must be a full URL, starting with https://", "link_url", &[]));
55    };
56    if url.protocol() != "https:" && url.protocol() != "http:" {
57        return Err(link_error("The destination must start with http:// or https://", "link_protocol", &[]));
58    }
59    if js::len16(&text) > 2000 {
60        return Err(link_error("The destination is longer than 2,000 characters", "link_long", &[]));
61    }
62    Ok(url.href())
63}
64
65fn default_name(url: &str) -> String {
66    let u = Url::parse(url).expect("a checked URL");
67    let path = u.pathname();
68    js::head16(&format!("{}{}", strip_www(&u.hostname()), if path == "/" { "" } else { path.as_str() }), 100)
69}
70
71/// Short links over a Runlight.
72pub struct Links<'a>(pub(crate) &'a Runlight);
73
74impl Runlight {
75    /// Short links: create, change, delete, and import.
76    pub fn links(&self) -> Links<'_> {
77        Links(self)
78    }
79}
80
81impl Links<'_> {
82    async fn domain_for(&self, site: &str, value: &str) -> Result<String, Error> {
83        let domain = strip_www(js::trim(value));
84        if domain.is_empty() {
85            return Ok(String::new());
86        }
87        let known = self.0.store().link_domains().await?;
88        if !known.iter().any(|(d, s)| *d == domain && s == site) {
89            return Err(link_error(
90                format!("Add {domain} as a link domain in Settings first"),
91                "link_domain",
92                &[("domain", &domain)],
93            ));
94        }
95        Ok(domain)
96    }
97
98    /// Slugs are unique across every domain, so a link can always fall back to the app's own path.
99    async fn free_slug(&self, wanted: Option<&str>, except: Option<&str>) -> Result<String, Error> {
100        if let Some(wanted) = wanted.filter(|w| !w.is_empty()) {
101            if !test(js_re!(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,99}$"), wanted) {
102                return Err(link_error(
103                    "A slug is letters, digits, dashes, and underscores, up to 100",
104                    "link_slug",
105                    &[],
106                ));
107            }
108            if let Some(taken) = self.0.store().link_by_slug(wanted).await?
109                && Some(taken.id.as_str()) != except
110            {
111                return Err(link_error(format!("/{wanted} is already taken"), "link_taken", &[("slug", wanted)]));
112            }
113            return Ok(wanted.to_string());
114        }
115        for _ in 0..8 {
116            let slug = random_slug();
117            if self.0.store().link_by_slug(&slug).await?.is_none() {
118                return Ok(slug);
119            }
120        }
121        Err(link_error("Could not find a free slug; try again", "link_no_slug", &[]))
122    }
123
124    /// Makes a link.
125    pub async fn create(&self, site: &str, input: &LinkInput) -> Result<LinkRow, Error> {
126        self.0.init().await?;
127        let url = clean_url(&input.url)?;
128        let domain = self.domain_for(site, input.domain.as_deref().unwrap_or("")).await?;
129        let slug = self.free_slug(input.slug.as_deref().map(js::trim), None).await?;
130        let now = self.0.now();
131        let name = input
132            .name
133            .as_deref()
134            .map(js::trim)
135            .filter(|n| !n.is_empty())
136            .map_or_else(|| default_name(&url), str::to_string);
137        let link = LinkRow {
138            id: new_id(),
139            site: site.into(),
140            domain,
141            slug,
142            name: js::head16(&name, 100),
143            url,
144            created_at: now,
145            updated_at: now,
146        };
147        self.0.store().insert_link(&link).await?;
148        Ok(link)
149    }
150
151    /// Changes a link.
152    pub async fn update(&self, id: &str, input: &LinkPatch) -> Result<LinkRow, Error> {
153        self.0.init().await?;
154        let Some(link) = self.0.store().link_by_id(id).await? else { return Err(Error::Range("Unknown link".into())) };
155        let mut next = link.clone();
156        if let Some(url) = &input.url {
157            next.url = clean_url(url)?;
158        }
159        if let Some(name) = &input.name {
160            let n = js::head16(js::trim(name), 100);
161            next.name = if n.is_empty() { default_name(&next.url) } else { n };
162        }
163        // Keeping a link's domain needs no check, even while that domain is removed.
164        if let Some(domain) = &input.domain
165            && strip_www(js::trim(domain)) != link.domain
166        {
167            next.domain = self.domain_for(&link.site, domain).await?;
168        }
169        if let Some(slug) = &input.slug {
170            next.slug = self.free_slug(Some(js::trim(slug)), Some(&link.id)).await?;
171        }
172        next.updated_at = self.0.now();
173        self.0.store().update_link(&next).await?;
174        Ok(next)
175    }
176
177    /// Deletes a link; its clicks stay in the history.
178    pub async fn remove(&self, id: &str) -> Result<(), Error> {
179        self.0.init().await?;
180        if self.0.store().link_by_id(id).await?.is_none() {
181            return Err(Error::Range("Unknown link".into()));
182        }
183        self.0.store().delete_link(id, self.0.now()).await?;
184        Ok(())
185    }
186
187    /// Creates many links at once, as from a CSV. Rows that fail are reported with their reason and the
188    /// rest go in. Answers `{ created, failed }`.
189    pub async fn import(&self, site: &str, rows: &[Value]) -> Result<Value, Error> {
190        let mut failed = Vec::new();
191        let mut created = 0;
192        for (i, raw) in rows.iter().enumerate() {
193            let pick = |keys: &[&str]| {
194                keys.iter().find_map(|k| match raw.get(k) {
195                    Some(Value::String(s)) if !js::trim(s).is_empty() => Some(js::trim(s).to_string()),
196                    _ => None,
197                })
198            };
199            let input = LinkInput {
200                url: pick(&["url", "destination_url"]).unwrap_or_default(),
201                name: pick(&["name", "link_name"]),
202                slug: pick(&["slug", "link_slug"]),
203                domain: pick(&["domain", "tracking_domain"]),
204            };
205            match self.create(site, &input).await {
206                Ok(_) => created += 1,
207                // A bad row is reported and skipped; a failing database stops the whole import.
208                Err(Error::Link(e)) => {
209                    let mut o = Object::new();
210                    o.set("row", i + 1);
211                    o.set("reason", e.message.clone());
212                    o.set("code", e.code.clone());
213                    o.set("params", e.params_value());
214                    failed.push(Value::Object(o));
215                }
216                Err(e) => return Err(e),
217            }
218        }
219        Ok(crate::obj! { "created" => created, "failed" => Value::Array(failed) })
220    }
221}