diff --git a/apps/frontend/src/pages/moderation/technical-review/rules.vue b/apps/frontend/src/pages/moderation/technical-review/rules.vue index 9270d102a3..4e0008a790 100644 --- a/apps/frontend/src/pages/moderation/technical-review/rules.vue +++ b/apps/frontend/src/pages/moderation/technical-review/rules.vue @@ -112,6 +112,24 @@ Regex +
  • + + URL parsing + + with url.parse(string) and + url.is_valid(string) +
    + Parsed URLs expose href, scheme, host, + domain, port, path, + path_segments, query, query_pairs, + fragment, credentials, and origin fields. +
    +
  •  {
    +    inner: CelContext<'a>,
    +}
    +
    +impl Default for Context<'_> {
    +    fn default() -> Self {
    +        let mut inner = CelContext::default();
    +        inner.add_function("url.parse", parse_url);
    +        inner.add_function("url.is_valid", is_valid_url);
    +
    +        Self { inner }
    +    }
    +}
    +
    +impl<'a> Deref for Context<'a> {
    +    type Target = CelContext<'a>;
    +
    +    fn deref(&self) -> &Self::Target {
    +        &self.inner
    +    }
    +}
    +
    +impl DerefMut for Context<'_> {
    +    fn deref_mut(&mut self) -> &mut Self::Target {
    +        &mut self.inner
    +    }
    +}
    +
    +#[derive(Serialize)]
    +struct ParsedUrl<'a> {
    +    href: &'a str,
    +    scheme: &'a str,
    +    has_authority: bool,
    +    cannot_be_a_base: bool,
    +    username: &'a str,
    +    password: Option<&'a str>,
    +    host: Option<&'a str>,
    +    host_type: Option<&'static str>,
    +    domain: Option<&'a str>,
    +    port: Option,
    +    port_or_known_default: Option,
    +    path: &'a str,
    +    path_segments: Option>,
    +    query: Option<&'a str>,
    +    query_pairs: Vec,
    +    fragment: Option<&'a str>,
    +    origin: String,
    +    origin_is_tuple: bool,
    +}
    +
    +#[derive(Serialize)]
    +struct QueryPair {
    +    key: String,
    +    value: String,
    +}
    +
    +fn parse_url(value: Arc) -> std::result::Result {
    +    let parsed = Url::parse(&value)
    +        .map_err(|error| ExecutionError::function_error("url.parse", error))?;
    +    let origin = parsed.origin();
    +    let host_type = parsed.host().map(|host| match host {
    +        Host::Domain(_) => "domain",
    +        Host::Ipv4(_) => "ipv4",
    +        Host::Ipv6(_) => "ipv6",
    +    });
    +    let query_pairs = parsed
    +        .query_pairs()
    +        .map(|(key, value)| QueryPair {
    +            key: key.into_owned(),
    +            value: value.into_owned(),
    +        })
    +        .collect();
    +    let value = ParsedUrl {
    +        href: parsed.as_str(),
    +        scheme: parsed.scheme(),
    +        has_authority: parsed.has_authority(),
    +        cannot_be_a_base: parsed.cannot_be_a_base(),
    +        username: parsed.username(),
    +        password: parsed.password(),
    +        host: parsed.host_str(),
    +        host_type,
    +        domain: parsed.domain(),
    +        port: parsed.port().map(i64::from),
    +        port_or_known_default: parsed.port_or_known_default().map(i64::from),
    +        path: parsed.path(),
    +        path_segments: parsed.path_segments().map(Iterator::collect),
    +        query: parsed.query(),
    +        query_pairs,
    +        fragment: parsed.fragment(),
    +        origin: origin.ascii_serialization(),
    +        origin_is_tuple: origin.is_tuple(),
    +    };
    +
    +    ::cel::to_value(value)
    +        .map_err(|error| ExecutionError::function_error("url.parse", error))
    +}
    +
    +fn is_valid_url(value: Arc) -> bool {
    +    Url::parse(&value).is_ok()
    +}
    +
     pub struct Program {
         inner: CelProgram,
         bindings: Vec,
    @@ -46,7 +154,7 @@ impl Program {
             &self,
             context: &Context<'a>,
         ) -> std::result::Result {
    -        let mut context = context.new_inner_scope();
    +        let mut context = context.inner.new_inner_scope();
     
             for binding in &self.bindings {
                 let value = binding.inner.execute(&context).map_err(|error| {
    @@ -568,4 +676,47 @@ mod tests {
     
             assert!(error.to_string().contains("#bind RESULT"));
         }
    +
    +    #[test]
    +    fn parses_urls_with_namespaced_functions() {
    +        let source = r#"
    +#bind URL url.parse("https://user:password@example.com:8443/a/b?first=one&first=two#fragment")
    +URL.scheme == "https" &&
    +URL.host == "example.com" &&
    +URL.host_type == "domain" &&
    +URL.port == 8443 &&
    +URL.path == "/a/b" &&
    +URL.path_segments == ["a", "b"] &&
    +URL.query_pairs[1].key == "first" &&
    +URL.query_pairs[1].value == "two" &&
    +URL.fragment == "fragment"
    +"#;
    +        let program = Program::compile(source).unwrap();
    +
    +        assert_eq!(
    +            program.execute(&Context::default()).unwrap(),
    +            Value::Bool(true)
    +        );
    +    }
    +
    +    #[test]
    +    fn checks_url_validity() {
    +        let program = Program::compile(
    +            r#"url.is_valid("https://modrinth.com/") && !url.is_valid("not a URL")"#,
    +        )
    +        .unwrap();
    +
    +        assert_eq!(
    +            program.execute(&Context::default()).unwrap(),
    +            Value::Bool(true)
    +        );
    +    }
    +
    +    #[test]
    +    fn reports_url_parse_errors() {
    +        let program = Program::compile(r#"url.parse("not a URL")"#).unwrap();
    +        let error = program.execute(&Context::default()).unwrap_err();
    +
    +        assert!(error.to_string().contains("url.parse"));
    +    }
     }