mirror of
https://github.com/lennart-k/rustical.git
synced 2026-01-30 11:48:18 +00:00
Compare commits
9 Commits
feat/ical-
...
11a61cf8b1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11a61cf8b1 | ||
|
|
227d4bc61a | ||
|
|
d9afc85222 | ||
|
|
c9fe5706a9 | ||
|
|
1b6214d426 | ||
|
|
be34cc3091 | ||
|
|
99287f85f4 | ||
|
|
df3143cd4c | ||
|
|
92a3418f8e |
24
Cargo.lock
generated
24
Cargo.lock
generated
@@ -3317,7 +3317,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustical"
|
name = "rustical"
|
||||||
version = "0.11.17"
|
version = "0.12.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
@@ -3364,7 +3364,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustical_caldav"
|
name = "rustical_caldav"
|
||||||
version = "0.11.17"
|
version = "0.12.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-std",
|
"async-std",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -3406,7 +3406,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustical_carddav"
|
name = "rustical_carddav"
|
||||||
version = "0.11.17"
|
version = "0.12.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -3440,7 +3440,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustical_dav"
|
name = "rustical_dav"
|
||||||
version = "0.11.17"
|
version = "0.12.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -3466,7 +3466,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustical_dav_push"
|
name = "rustical_dav_push"
|
||||||
version = "0.11.17"
|
version = "0.12.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -3491,7 +3491,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustical_frontend"
|
name = "rustical_frontend"
|
||||||
version = "0.11.17"
|
version = "0.12.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"askama",
|
"askama",
|
||||||
"askama_web",
|
"askama_web",
|
||||||
@@ -3527,7 +3527,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustical_ical"
|
name = "rustical_ical"
|
||||||
version = "0.11.17"
|
version = "0.12.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -3546,7 +3546,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustical_oidc"
|
name = "rustical_oidc"
|
||||||
version = "0.11.17"
|
version = "0.12.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -3562,7 +3562,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustical_store"
|
name = "rustical_store"
|
||||||
version = "0.11.17"
|
version = "0.12.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -3595,7 +3595,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustical_store_sqlite"
|
name = "rustical_store_sqlite"
|
||||||
version = "0.11.17"
|
version = "0.12.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -3620,7 +3620,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustical_xml"
|
name = "rustical_xml"
|
||||||
version = "0.11.17"
|
version = "0.12.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"quick-xml",
|
"quick-xml",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
@@ -5442,7 +5442,7 @@ checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "xml_derive"
|
name = "xml_derive"
|
||||||
version = "0.11.17"
|
version = "0.12.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"darling 0.23.0",
|
"darling 0.23.0",
|
||||||
"heck",
|
"heck",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
members = ["crates/*"]
|
members = ["crates/*"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.11.17"
|
version = "0.12.1"
|
||||||
rust-version = "1.92"
|
rust-version = "1.92"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "A CalDAV server"
|
description = "A CalDAV server"
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
use crate::Error;
|
use crate::Error;
|
||||||
use crate::calendar::CalendarResourceService;
|
use crate::calendar::CalendarResourceService;
|
||||||
use crate::calendar::prop::SupportedCalendarComponentSet;
|
use crate::calendar::prop::SupportedCalendarComponentSet;
|
||||||
|
use crate::error::Precondition;
|
||||||
use axum::extract::{Path, State};
|
use axum::extract::{Path, State};
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use http::{Method, StatusCode};
|
use http::{Method, StatusCode};
|
||||||
@@ -84,21 +87,33 @@ pub async fn route_mkcalendar<C: CalendarStore, S: SubscriptionStore>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let timezone_id = if let Some(tzid) = request.calendar_timezone_id {
|
let timezone_id = if let Some(tzid) = request.calendar_timezone_id {
|
||||||
|
if chrono_tz::Tz::from_str(&tzid).is_err() {
|
||||||
|
return Err(Error::PreconditionFailed(Precondition::CalendarTimezone(
|
||||||
|
"Invalid timezone ID in calendar-timezone-id",
|
||||||
|
)));
|
||||||
|
}
|
||||||
Some(tzid)
|
Some(tzid)
|
||||||
} else if let Some(tz) = request.calendar_timezone {
|
} else if let Some(tz) = request.calendar_timezone {
|
||||||
// TODO: Proper error (calendar-timezone precondition)
|
|
||||||
let calendar = IcalParser::from_slice(tz.as_bytes())
|
let calendar = IcalParser::from_slice(tz.as_bytes())
|
||||||
.next()
|
.next()
|
||||||
.ok_or_else(|| rustical_dav::Error::BadRequest("No timezone data provided".to_owned()))?
|
.ok_or(Error::PreconditionFailed(Precondition::CalendarTimezone(
|
||||||
.map_err(|_| rustical_dav::Error::BadRequest("Error parsing timezone".to_owned()))?;
|
"No timezone data provided",
|
||||||
|
)))?
|
||||||
|
.map_err(|_| {
|
||||||
|
Error::PreconditionFailed(Precondition::CalendarTimezone("Error parsing timezone"))
|
||||||
|
})?;
|
||||||
|
|
||||||
let timezone = calendar.vtimezones.values().next().ok_or_else(|| {
|
let timezone = calendar
|
||||||
rustical_dav::Error::BadRequest("No timezone data provided".to_owned())
|
.vtimezones
|
||||||
})?;
|
.values()
|
||||||
|
.next()
|
||||||
|
.ok_or(Error::PreconditionFailed(Precondition::CalendarTimezone(
|
||||||
|
"No timezone data provided",
|
||||||
|
)))?;
|
||||||
let timezone: Option<chrono_tz::Tz> = timezone.into();
|
let timezone: Option<chrono_tz::Tz> = timezone.into();
|
||||||
let timezone = timezone.ok_or_else(|| {
|
let timezone = timezone.ok_or(Error::PreconditionFailed(
|
||||||
rustical_dav::Error::BadRequest("Cannot translate VTIMEZONE into IANA TZID".to_owned())
|
Precondition::CalendarTimezone("No timezone data provided"),
|
||||||
})?;
|
))?;
|
||||||
|
|
||||||
Some(timezone.name().to_owned())
|
Some(timezone.name().to_owned())
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ pub enum Precondition {
|
|||||||
#[error("valid-calendar-data")]
|
#[error("valid-calendar-data")]
|
||||||
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
|
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
|
||||||
ValidCalendarData,
|
ValidCalendarData,
|
||||||
|
#[error("calendar-timezone")]
|
||||||
|
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
|
||||||
|
CalendarTimezone(&'static str),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IntoResponse for Precondition {
|
impl IntoResponse for Precondition {
|
||||||
@@ -23,7 +26,7 @@ impl IntoResponse for Precondition {
|
|||||||
if let Err(err) = error.serialize_root(&mut writer) {
|
if let Err(err) = error.serialize_root(&mut writer) {
|
||||||
return rustical_dav::Error::from(err).into_response();
|
return rustical_dav::Error::from(err).into_response();
|
||||||
}
|
}
|
||||||
let mut res = Response::builder().status(StatusCode::PRECONDITION_FAILED);
|
let mut res = Response::builder().status(StatusCode::FORBIDDEN);
|
||||||
res.headers_mut().unwrap().typed_insert(ContentType::xml());
|
res.headers_mut().unwrap().typed_insert(ContentType::xml());
|
||||||
res.body(Body::from(output)).unwrap()
|
res.body(Body::from(output)).unwrap()
|
||||||
}
|
}
|
||||||
@@ -72,7 +75,10 @@ impl Error {
|
|||||||
Self::XmlDecodeError(_) => StatusCode::BAD_REQUEST,
|
Self::XmlDecodeError(_) => StatusCode::BAD_REQUEST,
|
||||||
Self::ChronoParseError(_) | Self::NotImplemented => StatusCode::INTERNAL_SERVER_ERROR,
|
Self::ChronoParseError(_) | Self::NotImplemented => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
Self::NotFound => StatusCode::NOT_FOUND,
|
Self::NotFound => StatusCode::NOT_FOUND,
|
||||||
Self::PreconditionFailed(_err) => StatusCode::PRECONDITION_FAILED,
|
// The correct status code for a failed precondition is not PreconditionFailed but
|
||||||
|
// Forbidden (or Conflict):
|
||||||
|
// https://datatracker.ietf.org/doc/html/rfc4791#section-1.3
|
||||||
|
Self::PreconditionFailed(_err) => StatusCode::FORBIDDEN,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,10 +88,7 @@ impl IntoResponse for Error {
|
|||||||
if let Self::PreconditionFailed(precondition) = self {
|
if let Self::PreconditionFailed(precondition) = self {
|
||||||
return precondition.into_response();
|
return precondition.into_response();
|
||||||
}
|
}
|
||||||
if matches!(
|
if matches!(self.status_code(), StatusCode::INTERNAL_SERVER_ERROR) {
|
||||||
self.status_code(),
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR | StatusCode::PRECONDITION_FAILED
|
|
||||||
) {
|
|
||||||
error!("{self}");
|
error!("{self}");
|
||||||
}
|
}
|
||||||
(self.status_code(), self.to_string()).into_response()
|
(self.status_code(), self.to_string()).into_response()
|
||||||
|
|||||||
@@ -51,19 +51,18 @@ impl Error {
|
|||||||
_ => StatusCode::BAD_REQUEST,
|
_ => StatusCode::BAD_REQUEST,
|
||||||
},
|
},
|
||||||
Self::PropReadOnly => StatusCode::CONFLICT,
|
Self::PropReadOnly => StatusCode::CONFLICT,
|
||||||
Self::PreconditionFailed => StatusCode::PRECONDITION_FAILED,
|
|
||||||
Self::InternalError | Self::IOError(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
Self::InternalError | Self::IOError(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
Self::Forbidden => StatusCode::FORBIDDEN,
|
// The correct status code for a failed precondition is not PreconditionFailed but
|
||||||
|
// Forbidden (or Conflict):
|
||||||
|
// https://datatracker.ietf.org/doc/html/rfc4791#section-1.3
|
||||||
|
Self::PreconditionFailed | Self::Forbidden => StatusCode::FORBIDDEN,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl axum::response::IntoResponse for Error {
|
impl axum::response::IntoResponse for Error {
|
||||||
fn into_response(self) -> axum::response::Response {
|
fn into_response(self) -> axum::response::Response {
|
||||||
if matches!(
|
if matches!(self.status_code(), StatusCode::INTERNAL_SERVER_ERROR) {
|
||||||
self.status_code(),
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR | StatusCode::PRECONDITION_FAILED
|
|
||||||
) {
|
|
||||||
error!("{self}");
|
error!("{self}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ pub async fn axum_route_proppatch<R: ResourceService>(
|
|||||||
route_proppatch(&path, uri.path(), &body, &principal, &resource_service).await
|
route_proppatch(&path, uri.path(), &body, &principal, &resource_service).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_lines)]
|
||||||
pub async fn route_proppatch<R: ResourceService>(
|
pub async fn route_proppatch<R: ResourceService>(
|
||||||
path_components: &R::PathComponents,
|
path_components: &R::PathComponents,
|
||||||
path: &str,
|
path: &str,
|
||||||
@@ -116,12 +117,14 @@ pub async fn route_proppatch<R: ResourceService>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
SetPropertyPropWrapper::Invalid(invalid) => {
|
SetPropertyPropWrapper::Invalid(invalid) => {
|
||||||
let propname = invalid.tag_name();
|
let Unparsed(propns, propname) = invalid;
|
||||||
|
|
||||||
if let Some(full_propname) = <R::Resource as Resource>::list_props()
|
if let Some(full_propname) = <R::Resource as Resource>::list_props()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.find_map(|(ns, tag)| {
|
.find_map(|(ns, tag)| {
|
||||||
if tag == propname.as_str() {
|
if (ns, tag)
|
||||||
|
== (propns.as_ref().map(NamespaceOwned::as_ref), &propname)
|
||||||
|
{
|
||||||
Some((ns.map(NamespaceOwned::from), tag.to_owned()))
|
Some((ns.map(NamespaceOwned::from), tag.to_owned()))
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
@@ -133,7 +136,7 @@ pub async fn route_proppatch<R: ResourceService>(
|
|||||||
// - internal properties
|
// - internal properties
|
||||||
props_conflict.push(full_propname);
|
props_conflict.push(full_propname);
|
||||||
} else {
|
} else {
|
||||||
props_not_found.push((None, propname));
|
props_not_found.push((propns, propname));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ impl Error {
|
|||||||
Self::NotFound => StatusCode::NOT_FOUND,
|
Self::NotFound => StatusCode::NOT_FOUND,
|
||||||
Self::AlreadyExists => StatusCode::CONFLICT,
|
Self::AlreadyExists => StatusCode::CONFLICT,
|
||||||
Self::ReadOnly => StatusCode::FORBIDDEN,
|
Self::ReadOnly => StatusCode::FORBIDDEN,
|
||||||
// TODO: Can also be Bad Request, depending on when this is raised
|
|
||||||
Self::IcalError(_err) => StatusCode::INTERNAL_SERVER_ERROR,
|
Self::IcalError(_err) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
Self::InvalidPrincipalType(_) => StatusCode::BAD_REQUEST,
|
Self::InvalidPrincipalType(_) => StatusCode::BAD_REQUEST,
|
||||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
@@ -53,9 +52,7 @@ impl IntoResponse for Error {
|
|||||||
fn into_response(self) -> axum::response::Response {
|
fn into_response(self) -> axum::response::Response {
|
||||||
if matches!(
|
if matches!(
|
||||||
self.status_code(),
|
self.status_code(),
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
StatusCode::INTERNAL_SERVER_ERROR | StatusCode::CONFLICT
|
||||||
| StatusCode::PRECONDITION_FAILED
|
|
||||||
| StatusCode::CONFLICT
|
|
||||||
) {
|
) {
|
||||||
error!("{self}");
|
error!("{self}");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -338,7 +338,7 @@ impl CalendarStore for SqliteAddressbookStore {
|
|||||||
out_objects.push((format!("{object_id}-birthday"), birthday));
|
out_objects.push((format!("{object_id}-birthday"), birthday));
|
||||||
}
|
}
|
||||||
if let Some(anniversary) = object.get_anniversary_object()? {
|
if let Some(anniversary) = object.get_anniversary_object()? {
|
||||||
out_objects.push((format!("{object_id}-anniversayr"), anniversary));
|
out_objects.push((format!("{object_id}-anniversary"), anniversary));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,7 +382,7 @@ impl CalendarStore for SqliteAddressbookStore {
|
|||||||
objects.push((format!("{object_id}-birthday"), birthday));
|
objects.push((format!("{object_id}-birthday"), birthday));
|
||||||
}
|
}
|
||||||
if let Some(anniversary) = object.get_anniversary_object()? {
|
if let Some(anniversary) = object.get_anniversary_object()? {
|
||||||
objects.push((format!("{object_id}-anniversayr"), anniversary));
|
objects.push((format!("{object_id}-anniversary"), anniversary));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(objects)
|
Ok(objects)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use quick_xml::name::Namespace;
|
use quick_xml::name::Namespace;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
|
||||||
pub struct NamespaceOwned(pub Vec<u8>);
|
pub struct NamespaceOwned(pub Vec<u8>);
|
||||||
|
|
||||||
impl<'a> From<Namespace<'a>> for NamespaceOwned {
|
impl<'a> From<Namespace<'a>> for NamespaceOwned {
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
use std::io::BufRead;
|
use std::io::BufRead;
|
||||||
|
|
||||||
use quick_xml::events::BytesStart;
|
use quick_xml::{events::BytesStart, name::ResolveResult};
|
||||||
|
|
||||||
use crate::{XmlDeserialize, XmlError};
|
use crate::{NamespaceOwned, XmlDeserialize, XmlError};
|
||||||
|
|
||||||
// TODO: actually implement
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
pub struct Unparsed(String);
|
pub struct Unparsed(pub Option<NamespaceOwned>, pub String);
|
||||||
|
|
||||||
impl Unparsed {
|
impl Unparsed {
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn tag_name(&self) -> String {
|
pub const fn ns(&self) -> Option<&NamespaceOwned> {
|
||||||
// TODO: respect namespace?
|
self.0.as_ref()
|
||||||
self.0.clone()
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub const fn tag_name(&self) -> &str {
|
||||||
|
self.1.as_str()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,7 +30,12 @@ impl XmlDeserialize for Unparsed {
|
|||||||
let mut buf = vec![];
|
let mut buf = vec![];
|
||||||
reader.read_to_end_into(start.name(), &mut buf)?;
|
reader.read_to_end_into(start.name(), &mut buf)?;
|
||||||
}
|
}
|
||||||
let tag_name = String::from_utf8_lossy(start.local_name().as_ref()).to_string();
|
let (ns, tag_name) = reader.resolver().resolve_element(start.name());
|
||||||
Ok(Self(tag_name))
|
let ns: Option<NamespaceOwned> = match ns {
|
||||||
|
ResolveResult::Bound(ns) => Some(ns.into()),
|
||||||
|
ResolveResult::Unbound | ResolveResult::Unknown(_) => None,
|
||||||
|
};
|
||||||
|
let tag_name = String::from_utf8_lossy(tag_name.as_ref()).to_string();
|
||||||
|
Ok(Self(ns, tag_name))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ END:VCALENDAR";
|
|||||||
.typed_insert(Authorization::basic("user", "pass"));
|
.typed_insert(Authorization::basic("user", "pass"));
|
||||||
|
|
||||||
let response = app.clone().oneshot(request).await.unwrap();
|
let response = app.clone().oneshot(request).await.unwrap();
|
||||||
assert_eq!(response.status(), StatusCode::PRECONDITION_FAILED);
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||||
let body = response.extract_string().await;
|
let body = response.extract_string().await;
|
||||||
insta::assert_snapshot!(body, @r#"
|
insta::assert_snapshot!(body, @r#"
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
|||||||
Reference in New Issue
Block a user