1use 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#[derive(Clone, Debug, Default, PartialEq, Eq)]
15pub struct LinkInput {
16 pub url: String,
18 pub name: Option<String>,
20 pub slug: Option<String>,
22 pub domain: Option<String>,
24}
25
26#[derive(Clone, Debug, Default, PartialEq, Eq)]
28pub struct LinkPatch {
29 pub url: Option<String>,
31 pub name: Option<String>,
33 pub slug: Option<String>,
35 pub domain: Option<String>,
37}
38
39const ALPHABET: &[u8] = b"abcdefghijkmnpqrstuvwxyz23456789";
40
41pub 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
50pub 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
71pub struct Links<'a>(pub(crate) &'a Runlight);
73
74impl Runlight {
75 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 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 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 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 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 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 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 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}