use secrecy::ExposeSecret;
use url::{Url, form_urlencoded};
use crate::{LinkPermissions, LinkSecret, StreamId, ids::is_canonical_base64url_32};
pub const DEFAULT_WEB_BASE_URL: &str = "https://tail.surf";
pub const LINK_SECRET_ENCODED_LENGTH: usize = crate::ids::BASE64URL_32_ENCODED_LEN;
#[derive(Clone, Debug)]
pub struct StreamLinkParam {
pub declared_permissions: LinkPermissions,
pub secret: LinkSecret,
}
#[derive(Clone, Debug)]
pub struct StreamLocator {
pub stream_id: StreamId,
pub link: Option<StreamLinkParam>,
}
impl StreamLocator {
pub fn parse(input: &str) -> Result<Self, StreamLinkError> {
let url = Url::parse(input)?;
validate_web_scheme(&url)?;
let stream_id = parse_stream_id(&url)?;
let link = url.fragment().map(parse_fragment).transpose()?.flatten();
Ok(Self { stream_id, link })
}
pub fn link_declaring(
&self,
required: impl Fn(LinkPermissions) -> bool,
) -> Option<&LinkSecret> {
self.link
.as_ref()
.filter(|candidate| required(candidate.declared_permissions))
.map(|candidate| &candidate.secret)
}
}
pub fn stream_link(
base_url: &Url,
stream_id: &StreamId,
permissions: LinkPermissions,
secret: &LinkSecret,
) -> Result<Url, StreamLinkError> {
validate_link_secret(secret.expose_secret())?;
let mut url = base_url.clone();
validate_web_scheme(&url)?;
url.set_username("")
.map_err(|()| StreamLinkError::InvalidBaseUrl)?;
url.set_password(None)
.map_err(|()| StreamLinkError::InvalidBaseUrl)?;
url.set_path(&format!("/s/{stream_id}"));
url.set_query(None);
let fragment = form_urlencoded::Serializer::new(String::new())
.append_pair(&permissions.to_string(), secret.expose_secret())
.finish();
url.set_fragment(Some(&fragment));
Ok(url)
}
pub fn default_web_base_url() -> Url {
Url::parse(DEFAULT_WEB_BASE_URL).expect("default tsf web base URL is valid")
}
fn parse_stream_id(url: &Url) -> Result<StreamId, StreamLinkError> {
let mut segments = url
.path_segments()
.ok_or(StreamLinkError::InvalidStreamPath)?;
match (segments.next(), segments.next(), segments.next()) {
(Some("s"), Some(stream_id), None) => StreamId::decode(stream_id)
.map_err(|source| StreamLinkError::InvalidStreamId { source }),
_ => Err(StreamLinkError::InvalidStreamPath),
}
}
fn parse_fragment(fragment: &str) -> Result<Option<StreamLinkParam>, StreamLinkError> {
let mut pairs = form_urlencoded::parse(fragment.as_bytes());
let Some((permissions, link)) = pairs.next() else {
return Ok(None);
};
let permissions = permissions.parse()?;
let link = link.into_owned();
validate_link_secret(&link)?;
if pairs.next().is_some() {
return Err(StreamLinkError::MultipleLinks);
}
Ok(Some(StreamLinkParam {
declared_permissions: permissions,
secret: link.into(),
}))
}
fn validate_link_secret(secret: &str) -> Result<(), StreamLinkError> {
is_canonical_base64url_32(secret)
.then_some(())
.ok_or(StreamLinkError::InvalidLinkSecret)
}
fn validate_web_scheme(url: &Url) -> Result<(), StreamLinkError> {
if matches!(url.scheme(), "http" | "https") {
Ok(())
} else {
Err(StreamLinkError::InvalidScheme(url.scheme().to_owned()))
}
}
#[derive(Debug, thiserror::Error)]
pub enum StreamLinkError {
#[error("invalid stream URL: {0}")]
Url(#[from] url::ParseError),
#[error("stream URL scheme must be http or https, not {0:?}")]
InvalidScheme(String),
#[error("stream URL base could not be normalized")]
InvalidBaseUrl,
#[error("stream URL path must be /s/{{stream_id}}")]
InvalidStreamPath,
#[error("stream URL has invalid stream id")]
InvalidStreamId {
#[source]
source: ubid::DecodeError,
},
#[error("stream URL fragment has invalid permissions")]
InvalidPermissions(#[from] crate::PermissionsError),
#[error("stream link secret must be canonical 43-character unpadded base64url")]
InvalidLinkSecret,
#[error("stream URL fragment contains multiple links")]
MultipleLinks,
}
#[cfg(test)]
mod tests {
use super::*;
const STREAM_ID: &str = "0123456789abcdefghjkmnpqrstvwxyz";
const SECRET: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
#[test]
fn parses_stream_link() {
let locator = StreamLocator::parse(&format!(
"https://tail.surf/s/0123456789abcdefghjkmnpqrstvwxyz#w={SECRET}"
))
.expect("stream URL");
assert_eq!(
locator.stream_id,
STREAM_ID.parse::<StreamId>().expect("stream id")
);
let link = locator.link.expect("link");
assert_eq!(link.declared_permissions.to_string(), "w");
assert_eq!(link.secret.expose_secret(), SECRET);
}
#[test]
fn parses_percent_encoded_link_fragment_and_ignores_query_params() {
let encoded_secret = format!("%41{}", &SECRET[1..]);
let locator = StreamLocator::parse(&format!(
"https://tail.surf/s/0123456789abcdefghjkmnpqrstvwxyz?view=raw#o={encoded_secret}"
))
.expect("stream URL");
assert_eq!(
locator.stream_id,
STREAM_ID.parse::<StreamId>().expect("stream id")
);
let link = locator.link.expect("link");
assert_eq!(link.declared_permissions.to_string(), "o");
assert_eq!(link.secret.expose_secret(), SECRET);
}
#[test]
fn rejects_invalid_paths_permissions_empty_secrets_and_multiple_links() {
assert!(matches!(
StreamLocator::parse("https://tail.surf/not-a-stream"),
Err(StreamLinkError::InvalidStreamPath)
));
assert!(matches!(
StreamLocator::parse(&format!(
"https://tail.surf/s/0123456789abcdefghjkmnpqrstvwxyz#x={SECRET}"
)),
Err(StreamLinkError::InvalidPermissions(_))
));
assert!(matches!(
StreamLocator::parse("https://tail.surf/s/0123456789abcdefghjkmnpqrstvwxyz#r="),
Err(StreamLinkError::InvalidLinkSecret)
));
assert!(matches!(
StreamLocator::parse(
"https://tail.surf/s/0123456789abcdefghjkmnpqrstvwxyz#r=too-short"
),
Err(StreamLinkError::InvalidLinkSecret)
));
assert!(matches!(
StreamLocator::parse(
"https://tail.surf/s/0123456789abcdefghjkmnpqrstvwxyz#r=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA+"
),
Err(StreamLinkError::InvalidLinkSecret)
));
assert!(matches!(
StreamLocator::parse(
"https://tail.surf/s/0123456789abcdefghjkmnpqrstvwxyz#r=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
),
Err(StreamLinkError::InvalidLinkSecret)
));
assert!(matches!(
StreamLocator::parse(&format!(
"https://tail.surf/s/0123456789abcdefghjkmnpqrstvwxyz#w={SECRET}&r={SECRET}"
)),
Err(StreamLinkError::MultipleLinks)
));
}
#[test]
fn builds_stream_link() {
let base_url = Url::parse("http://user:password@localhost:8787/old?query=yes#fragment")
.expect("base URL");
let stream_id = STREAM_ID.parse::<StreamId>().expect("stream id");
let link = LinkSecret::from(SECRET);
let url = stream_link(&base_url, &stream_id, LinkPermissions::owner(), &link)
.expect("valid stream link");
assert_eq!(
url.as_str(),
format!("http://localhost:8787/s/0123456789abcdefghjkmnpqrstvwxyz#o={SECRET}")
);
}
#[test]
fn rejects_invalid_link_secret_when_building_stream_link() {
let base_url = Url::parse("http://localhost:8787").expect("base URL");
let stream_id = STREAM_ID.parse::<StreamId>().expect("stream id");
assert!(matches!(
stream_link(
&base_url,
&stream_id,
LinkPermissions::owner(),
&LinkSecret::from("too-short")
),
Err(StreamLinkError::InvalidLinkSecret)
));
assert!(matches!(
stream_link(
&base_url,
&stream_id,
LinkPermissions::owner(),
&LinkSecret::from("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
),
Err(StreamLinkError::InvalidLinkSecret)
));
}
#[test]
fn rejects_non_http_stream_links_when_parsing_and_building() {
assert!(matches!(
StreamLocator::parse(&format!(
"ftp://tail.surf/s/0123456789abcdefghjkmnpqrstvwxyz#r={SECRET}"
)),
Err(StreamLinkError::InvalidScheme(scheme)) if scheme == "ftp"
));
let base_url = Url::parse("ftp://tail.surf").expect("base URL");
let stream_id = STREAM_ID.parse::<StreamId>().expect("stream id");
assert!(matches!(
stream_link(
&base_url,
&stream_id,
LinkPermissions::read(),
&LinkSecret::from(SECRET)
),
Err(StreamLinkError::InvalidScheme(scheme)) if scheme == "ftp"
));
}
}