use url::{Url, form_urlencoded};
use crate::{BearerToken, StreamId, TokenPermissions};
use secrecy::ExposeSecret;
pub const DEFAULT_WEB_BASE_URL: &str = "https://tail.surf";
#[derive(Clone, Debug)]
pub struct StreamTokenParam {
pub permissions: TokenPermissions,
pub token: BearerToken,
}
#[derive(Clone, Debug)]
pub struct StreamLocator {
pub stream_id: StreamId,
pub token: Option<StreamTokenParam>,
}
impl StreamLocator {
pub fn parse(input: &str) -> Result<Self, StreamUrlError> {
let url = Url::parse(input)?;
let stream_id = parse_stream_id(&url)?;
let token = url.fragment().map(parse_fragment).transpose()?.flatten();
Ok(Self { stream_id, token })
}
pub fn token_with(&self, required: impl Fn(TokenPermissions) -> bool) -> Option<&BearerToken> {
self.token
.as_ref()
.filter(|candidate| required(candidate.permissions))
.map(|candidate| &candidate.token)
}
}
pub fn stream_url(
base_url: &Url,
stream_id: &StreamId,
permissions: TokenPermissions,
token: &BearerToken,
) -> Url {
let mut url = base_url.clone();
url.set_path(&format!("/s/{stream_id}"));
url.set_query(None);
let fragment = form_urlencoded::Serializer::new(String::new())
.append_pair(&permissions.to_string(), token.expose_secret())
.finish();
url.set_fragment(Some(&fragment));
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, StreamUrlError> {
let mut segments = url
.path_segments()
.ok_or(StreamUrlError::InvalidStreamPath)?;
match (segments.next(), segments.next(), segments.next()) {
(Some("s"), Some(stream_id), None) => {
StreamId::decode(stream_id).map_err(|source| StreamUrlError::InvalidStreamId { source })
}
_ => Err(StreamUrlError::InvalidStreamPath),
}
}
fn parse_fragment(fragment: &str) -> Result<Option<StreamTokenParam>, StreamUrlError> {
let mut token = None;
for (key, value) in form_urlencoded::parse(fragment.as_bytes()) {
if token.is_some() {
return Err(StreamUrlError::MultipleTokens);
}
let permissions = key.parse()?;
let value = value.into_owned();
if value.is_empty() {
return Err(StreamUrlError::InvalidToken);
}
token = Some(StreamTokenParam {
permissions,
token: value.into(),
});
}
Ok(token)
}
#[derive(Debug, thiserror::Error)]
pub enum StreamUrlError {
#[error("invalid stream URL: {0}")]
Url(#[from] url::ParseError),
#[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 URL fragment has invalid token")]
InvalidToken,
#[error("stream URL fragment contains multiple tokens")]
MultipleTokens,
}
#[cfg(test)]
mod tests {
use super::*;
const STREAM_ID: &str = "0123456789abcdefghjkmnpqrstvwxyz";
#[test]
fn parses_share_url_token() {
let locator = StreamLocator::parse(
"https://tail.surf/s/0123456789abcdefghjkmnpqrstvwxyz#w=write-token",
)
.expect("stream URL");
assert_eq!(
locator.stream_id,
STREAM_ID.parse::<StreamId>().expect("stream id")
);
let token = locator.token.expect("token");
assert_eq!(token.permissions.to_string(), "w");
assert_eq!(token.token.expose_secret(), "write-token");
}
#[test]
fn parses_percent_encoded_fragment_token_and_ignores_query_params() {
let locator = StreamLocator::parse(
"https://tail.surf/s/0123456789abcdefghjkmnpqrstvwxyz?view=raw#o=owner%2Ftoken",
)
.expect("stream URL");
assert_eq!(
locator.stream_id,
STREAM_ID.parse::<StreamId>().expect("stream id")
);
let token = locator.token.expect("token");
assert_eq!(token.permissions.to_string(), "o");
assert_eq!(token.token.expose_secret(), "owner/token");
}
#[test]
fn rejects_invalid_paths_permissions_empty_tokens_and_multiple_tokens() {
assert!(matches!(
StreamLocator::parse("https://tail.surf/not-a-stream"),
Err(StreamUrlError::InvalidStreamPath)
));
assert!(matches!(
StreamLocator::parse("https://tail.surf/s/0123456789abcdefghjkmnpqrstvwxyz#x=token"),
Err(StreamUrlError::InvalidPermissions(_))
));
assert!(matches!(
StreamLocator::parse("https://tail.surf/s/0123456789abcdefghjkmnpqrstvwxyz#r="),
Err(StreamUrlError::InvalidToken)
));
assert!(matches!(
StreamLocator::parse(
"https://tail.surf/s/0123456789abcdefghjkmnpqrstvwxyz#w=write-token&r=read-token"
),
Err(StreamUrlError::MultipleTokens)
));
}
#[test]
fn builds_percent_encoded_share_url() {
let base_url = Url::parse("http://localhost:8787").expect("base URL");
let stream_id = STREAM_ID.parse::<StreamId>().expect("stream id");
let token = BearerToken::from("abc-123_456");
let url = stream_url(&base_url, &stream_id, TokenPermissions::owner(), &token);
assert_eq!(
url.as_str(),
"http://localhost:8787/s/0123456789abcdefghjkmnpqrstvwxyz#o=abc-123_456"
);
}
}