2020-02-14 12:20:17 +01:00
|
|
|
|
//! Access control logic
|
|
|
|
|
//!
|
|
|
|
|
|
2020-10-28 23:24:02 +01:00
|
|
|
|
use std::cmp::Ordering;
|
2022-05-05 15:50:44 +02:00
|
|
|
|
use std::convert::{Into, TryFrom};
|
|
|
|
|
use std::fmt;
|
2020-11-19 14:53:14 +01:00
|
|
|
|
|
2020-10-28 23:24:02 +01:00
|
|
|
|
fn is_sep_char(c: char) -> bool {
|
|
|
|
|
c == '.'
|
|
|
|
|
}
|
|
|
|
|
|
2021-10-20 13:47:32 +02:00
|
|
|
|
#[derive(Debug, Clone, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
2020-11-10 14:56:28 +01:00
|
|
|
|
/// A set of privileges to a thing
|
|
|
|
|
pub struct PrivilegesBuf {
|
|
|
|
|
/// Which permission is required to know about the existance of this thing
|
2020-11-17 13:40:44 +01:00
|
|
|
|
pub disclose: PermissionBuf,
|
2020-11-10 14:56:28 +01:00
|
|
|
|
/// Which permission is required to read this thing
|
2020-11-17 13:40:44 +01:00
|
|
|
|
pub read: PermissionBuf,
|
2020-11-10 14:56:28 +01:00
|
|
|
|
/// Which permission is required to write parts of this thing
|
2020-11-17 13:40:44 +01:00
|
|
|
|
pub write: PermissionBuf,
|
2020-11-10 14:56:28 +01:00
|
|
|
|
/// Which permission is required to manage all parts of this thing
|
2022-05-05 15:50:44 +02:00
|
|
|
|
pub manage: PermissionBuf,
|
2020-11-10 14:56:28 +01:00
|
|
|
|
}
|
|
|
|
|
|
2021-10-20 13:47:32 +02:00
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
2020-10-28 19:22:11 +01:00
|
|
|
|
#[repr(transparent)]
|
2020-11-19 14:53:14 +01:00
|
|
|
|
#[serde(transparent)]
|
2020-10-28 19:22:11 +01:00
|
|
|
|
/// An owned permission string
|
|
|
|
|
///
|
|
|
|
|
/// This is under the hood just a fancy std::String.
|
|
|
|
|
// TODO: What is the possible fallout from homograph attacks?
|
|
|
|
|
// i.e. "bffh.perm" is not the same as "bffհ.реrm" (Armenian 'հ':Հ and Cyrillic 'е':Е)
|
|
|
|
|
// See also https://util.unicode.org/UnicodeJsps/confusables.jsp
|
|
|
|
|
pub struct PermissionBuf {
|
|
|
|
|
inner: String,
|
|
|
|
|
}
|
|
|
|
|
impl PermissionBuf {
|
2021-10-20 14:11:56 +02:00
|
|
|
|
#[inline(always)]
|
2020-10-28 19:22:11 +01:00
|
|
|
|
/// Allocate an empty `PermissionBuf`
|
|
|
|
|
pub fn new() -> Self {
|
2022-05-05 15:50:44 +02:00
|
|
|
|
PermissionBuf {
|
|
|
|
|
inner: String::new(),
|
|
|
|
|
}
|
2020-10-28 19:22:11 +01:00
|
|
|
|
}
|
|
|
|
|
|
2021-10-20 14:11:56 +02:00
|
|
|
|
#[inline(always)]
|
2020-10-28 19:22:11 +01:00
|
|
|
|
/// Allocate a `PermissionBuf` with the given capacity given to the internal [`String`]
|
2020-10-28 23:24:02 +01:00
|
|
|
|
pub fn with_capacity(cap: usize) -> Self {
|
2022-05-05 15:50:44 +02:00
|
|
|
|
PermissionBuf {
|
|
|
|
|
inner: String::with_capacity(cap),
|
|
|
|
|
}
|
2020-10-28 19:22:11 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[inline(always)]
|
|
|
|
|
pub fn as_permission(&self) -> &Permission {
|
2020-10-28 23:24:02 +01:00
|
|
|
|
self.as_ref()
|
2020-10-28 19:22:11 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn push<P: AsRef<Permission>>(&mut self, perm: P) {
|
|
|
|
|
self._push(perm.as_ref())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn _push(&mut self, perm: &Permission) {
|
|
|
|
|
// in general we always need a separator unless the last byte is one or the string is empty
|
2022-05-05 15:50:44 +02:00
|
|
|
|
let need_sep = self
|
|
|
|
|
.inner
|
|
|
|
|
.chars()
|
|
|
|
|
.rev()
|
|
|
|
|
.next()
|
|
|
|
|
.map(|c| !is_sep_char(c))
|
|
|
|
|
.unwrap_or(false);
|
2020-10-28 19:22:11 +01:00
|
|
|
|
if need_sep {
|
|
|
|
|
self.inner.push('.')
|
|
|
|
|
}
|
2020-10-28 23:24:02 +01:00
|
|
|
|
self.inner.push_str(perm.as_str())
|
2020-10-28 19:22:11 +01:00
|
|
|
|
}
|
|
|
|
|
|
2021-10-20 14:11:56 +02:00
|
|
|
|
#[inline(always)]
|
2021-10-20 13:47:32 +02:00
|
|
|
|
pub const fn from_string_unchecked(inner: String) -> Self {
|
2020-10-28 19:22:11 +01:00
|
|
|
|
Self { inner }
|
|
|
|
|
}
|
2020-11-19 14:53:14 +01:00
|
|
|
|
|
2021-10-20 14:11:56 +02:00
|
|
|
|
#[inline]
|
2020-11-24 14:16:22 +01:00
|
|
|
|
pub fn from_perm(perm: &Permission) -> Self {
|
2022-05-05 15:50:44 +02:00
|
|
|
|
Self {
|
|
|
|
|
inner: perm.as_str().to_string(),
|
|
|
|
|
}
|
2020-11-24 14:16:22 +01:00
|
|
|
|
}
|
|
|
|
|
|
2021-10-20 14:11:56 +02:00
|
|
|
|
#[inline(always)]
|
2020-11-19 14:53:14 +01:00
|
|
|
|
pub fn into_string(self) -> String {
|
|
|
|
|
self.inner
|
|
|
|
|
}
|
2021-10-20 13:47:32 +02:00
|
|
|
|
|
2021-10-20 14:11:56 +02:00
|
|
|
|
#[inline(always)]
|
2021-10-20 13:47:32 +02:00
|
|
|
|
pub fn is_empty(&self) -> bool {
|
|
|
|
|
self.inner.is_empty()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
impl AsRef<String> for PermissionBuf {
|
|
|
|
|
#[inline(always)]
|
|
|
|
|
fn as_ref(&self) -> &String {
|
|
|
|
|
&self.inner
|
|
|
|
|
}
|
2020-10-28 19:22:11 +01:00
|
|
|
|
}
|
2020-10-28 23:24:02 +01:00
|
|
|
|
impl AsRef<str> for PermissionBuf {
|
2020-10-28 19:22:11 +01:00
|
|
|
|
#[inline(always)]
|
2020-10-28 23:24:02 +01:00
|
|
|
|
fn as_ref(&self) -> &str {
|
2021-10-20 13:47:32 +02:00
|
|
|
|
&self.inner.as_str()
|
2020-10-28 23:24:02 +01:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
impl AsRef<Permission> for PermissionBuf {
|
|
|
|
|
#[inline]
|
2020-10-28 19:22:11 +01:00
|
|
|
|
fn as_ref(&self) -> &Permission {
|
2020-10-28 23:24:02 +01:00
|
|
|
|
Permission::new(self)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
impl PartialOrd for PermissionBuf {
|
|
|
|
|
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
|
|
|
|
let a: &Permission = self.as_ref();
|
|
|
|
|
a.partial_cmp(other.as_ref())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
impl fmt::Display for PermissionBuf {
|
|
|
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
|
|
|
self.inner.fmt(f)
|
2020-10-28 19:22:11 +01:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2021-01-26 15:11:50 +01:00
|
|
|
|
#[derive(PartialEq, Eq, Hash, Debug)]
|
2021-10-20 13:47:32 +02:00
|
|
|
|
#[repr(transparent)]
|
2020-10-28 19:22:11 +01:00
|
|
|
|
/// A borrowed permission string
|
2022-05-05 15:50:44 +02:00
|
|
|
|
///
|
2020-10-28 19:22:11 +01:00
|
|
|
|
/// Permissions have total equality and partial ordering.
|
|
|
|
|
/// Specifically permissions on the same path in a tree can be compared for specificity.
|
|
|
|
|
/// This means that ```(bffh.perm) > (bffh.perm.sub) == true```
|
2021-10-20 13:47:32 +02:00
|
|
|
|
/// but ```(bffh.perm) > (unrelated.but.more.specific.perm) == false```.
|
|
|
|
|
/// This allows to check if PermRule a grants Perm b by checking `a > b`.
|
2021-10-20 14:11:56 +02:00
|
|
|
|
pub struct Permission(str);
|
2020-10-28 19:22:11 +01:00
|
|
|
|
impl Permission {
|
2021-10-20 14:11:56 +02:00
|
|
|
|
#[inline(always)]
|
|
|
|
|
// We can't make this `const` just yet because `str` is always a fat pointer meaning we can't
|
|
|
|
|
// just const cast it, and `CoerceUnsized` and friends are currently unstable.
|
2020-11-24 15:57:23 +01:00
|
|
|
|
pub fn new<S: AsRef<str> + ?Sized>(s: &S) -> &Permission {
|
2021-10-20 13:47:32 +02:00
|
|
|
|
// Safe because s is a valid reference
|
2020-10-28 23:24:02 +01:00
|
|
|
|
unsafe { &*(s.as_ref() as *const str as *const Permission) }
|
|
|
|
|
}
|
|
|
|
|
|
2021-10-20 14:11:56 +02:00
|
|
|
|
#[inline(always)]
|
2020-10-28 19:22:11 +01:00
|
|
|
|
pub fn as_str(&self) -> &str {
|
2021-10-20 14:11:56 +02:00
|
|
|
|
&self.0
|
2020-10-28 19:22:11 +01:00
|
|
|
|
}
|
|
|
|
|
|
2021-10-20 14:11:56 +02:00
|
|
|
|
#[inline(always)]
|
2022-05-05 15:50:44 +02:00
|
|
|
|
pub fn iter(&self) -> std::str::Split<char> {
|
2021-10-20 14:11:56 +02:00
|
|
|
|
self.0.split('.')
|
2020-10-28 19:22:11 +01:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl PartialOrd for Permission {
|
|
|
|
|
fn partial_cmp(&self, other: &Permission) -> Option<Ordering> {
|
2020-10-28 23:24:02 +01:00
|
|
|
|
let mut i = self.iter();
|
|
|
|
|
let mut j = other.iter();
|
2021-01-26 15:47:58 +01:00
|
|
|
|
let (mut l, mut r);
|
2020-10-28 19:22:11 +01:00
|
|
|
|
while {
|
2020-10-28 23:24:02 +01:00
|
|
|
|
l = i.next();
|
|
|
|
|
r = j.next();
|
2020-10-28 19:22:11 +01:00
|
|
|
|
|
|
|
|
|
l.is_some() && r.is_some()
|
|
|
|
|
} {
|
|
|
|
|
if l.unwrap() != r.unwrap() {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2022-05-05 15:50:44 +02:00
|
|
|
|
match (l, r) {
|
2020-10-28 19:22:11 +01:00
|
|
|
|
(None, None) => Some(Ordering::Equal),
|
2020-10-28 23:24:02 +01:00
|
|
|
|
(Some(_), None) => Some(Ordering::Less),
|
2020-10-28 19:22:11 +01:00
|
|
|
|
(None, Some(_)) => Some(Ordering::Greater),
|
2022-05-05 15:50:44 +02:00
|
|
|
|
(Some(_), Some(_)) => unreachable!(
|
|
|
|
|
"Broken contract in Permission::partial_cmp: sides \
|
|
|
|
|
should never be both Some!"
|
|
|
|
|
),
|
2020-10-28 19:22:11 +01:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2020-11-19 14:53:14 +01:00
|
|
|
|
impl AsRef<Permission> for Permission {
|
|
|
|
|
#[inline]
|
|
|
|
|
fn as_ref(&self) -> &Permission {
|
|
|
|
|
self
|
|
|
|
|
}
|
|
|
|
|
}
|
2020-10-28 19:22:11 +01:00
|
|
|
|
|
2021-10-20 13:47:32 +02:00
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
2020-11-19 14:53:14 +01:00
|
|
|
|
#[serde(try_from = "String")]
|
|
|
|
|
#[serde(into = "String")]
|
2020-10-28 19:22:11 +01:00
|
|
|
|
pub enum PermRule {
|
2022-05-05 15:50:44 +02:00
|
|
|
|
/// The permission is precise,
|
2020-10-28 19:22:11 +01:00
|
|
|
|
///
|
|
|
|
|
/// i.e. `Base("bffh.perm")` grants bffh.perm but does not grant permission for bffh.perm.sub
|
|
|
|
|
Base(PermissionBuf),
|
|
|
|
|
/// The permissions is for the children of the node
|
|
|
|
|
///
|
|
|
|
|
/// i.e. `Children("bffh.perm")` grants bffh.perm.sub, bffh.perm.sub.two *BUT NOT* bffh.perm
|
|
|
|
|
/// itself.
|
|
|
|
|
Children(PermissionBuf),
|
|
|
|
|
/// The permissions is for the subtree marked by the node
|
|
|
|
|
///
|
|
|
|
|
/// i.e. `Children("bffh.perm")` grants bffh.perm.sub, bffh.perm.sub.two and also bffh.perm
|
|
|
|
|
/// itself.
|
|
|
|
|
Subtree(PermissionBuf),
|
2021-10-20 13:47:32 +02:00
|
|
|
|
// This lacks what LDAP calls "ONELEVEL": The ability to grant the exact children but not several
|
|
|
|
|
// levels deep, i.e. `Onelevel("bffh.perm")` grants bffh.perm.sub *BUT NOT* bffh.perm.sub.two or
|
2020-10-28 19:22:11 +01:00
|
|
|
|
// bffh.perm itself.
|
|
|
|
|
// I can't think of a reason to use that so I'm skipping it for now.
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl PermRule {
|
|
|
|
|
// Does this rule match that permission
|
2021-09-19 19:47:29 +02:00
|
|
|
|
pub fn match_perm<P: AsRef<Permission> + ?Sized>(&self, perm: &P) -> bool {
|
2020-11-17 13:40:44 +01:00
|
|
|
|
match self {
|
|
|
|
|
PermRule::Base(ref base) => base.as_permission() == perm.as_ref(),
|
2022-05-05 15:50:44 +02:00
|
|
|
|
PermRule::Children(ref parent) => parent.as_permission() > perm.as_ref(),
|
2020-11-17 13:40:44 +01:00
|
|
|
|
PermRule::Subtree(ref parent) => parent.as_permission() >= perm.as_ref(),
|
2020-10-28 19:22:11 +01:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl fmt::Display for PermRule {
|
|
|
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
2020-10-28 23:24:02 +01:00
|
|
|
|
match self {
|
2022-05-05 15:50:44 +02:00
|
|
|
|
PermRule::Base(perm) => write!(f, "{}", perm),
|
|
|
|
|
PermRule::Children(parent) => write!(f, "{}.+", parent),
|
|
|
|
|
PermRule::Subtree(parent) => write!(f, "{}.*", parent),
|
2020-10-28 19:22:11 +01:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2020-11-19 14:53:14 +01:00
|
|
|
|
impl Into<String> for PermRule {
|
|
|
|
|
fn into(self) -> String {
|
|
|
|
|
match self {
|
|
|
|
|
PermRule::Base(perm) => perm.into_string(),
|
|
|
|
|
PermRule::Children(mut perm) => {
|
|
|
|
|
perm.push(Permission::new("+"));
|
|
|
|
|
perm.into_string()
|
2022-05-05 15:50:44 +02:00
|
|
|
|
}
|
2020-11-19 14:53:14 +01:00
|
|
|
|
PermRule::Subtree(mut perm) => {
|
|
|
|
|
perm.push(Permission::new("+"));
|
|
|
|
|
perm.into_string()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl TryFrom<String> for PermRule {
|
|
|
|
|
type Error = &'static str;
|
|
|
|
|
|
|
|
|
|
fn try_from(mut input: String) -> std::result::Result<Self, Self::Error> {
|
|
|
|
|
// Check out specifically the last two chars
|
|
|
|
|
let len = input.len();
|
|
|
|
|
if len <= 2 {
|
|
|
|
|
Err("Input string for PermRule is too short")
|
|
|
|
|
} else {
|
2022-05-05 15:50:44 +02:00
|
|
|
|
match &input[len - 2..len] {
|
2020-11-19 14:53:14 +01:00
|
|
|
|
".+" => {
|
2022-05-05 15:50:44 +02:00
|
|
|
|
input.truncate(len - 2);
|
|
|
|
|
Ok(PermRule::Children(PermissionBuf::from_string_unchecked(
|
|
|
|
|
input,
|
|
|
|
|
)))
|
|
|
|
|
}
|
2020-11-19 14:53:14 +01:00
|
|
|
|
".*" => {
|
2022-05-05 15:50:44 +02:00
|
|
|
|
input.truncate(len - 2);
|
|
|
|
|
Ok(PermRule::Subtree(PermissionBuf::from_string_unchecked(
|
|
|
|
|
input,
|
|
|
|
|
)))
|
|
|
|
|
}
|
2021-10-20 13:47:32 +02:00
|
|
|
|
_ => Ok(PermRule::Base(PermissionBuf::from_string_unchecked(input))),
|
2020-11-19 14:53:14 +01:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2021-01-27 15:40:33 +01:00
|
|
|
|
#[cfg(test)]
|
2020-10-28 19:22:11 +01:00
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn permission_ord_test() {
|
2022-05-05 15:50:44 +02:00
|
|
|
|
assert!(
|
|
|
|
|
PermissionBuf::from_string_unchecked("bffh.perm".to_string())
|
|
|
|
|
> PermissionBuf::from_string_unchecked("bffh.perm.sub".to_string())
|
|
|
|
|
);
|
2020-10-28 19:22:11 +01:00
|
|
|
|
}
|
2020-11-17 13:40:44 +01:00
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn permission_simple_check_test() {
|
2021-10-20 13:47:32 +02:00
|
|
|
|
let perm = PermissionBuf::from_string_unchecked("test.perm".to_string());
|
2020-11-17 13:40:44 +01:00
|
|
|
|
let rule = PermRule::Base(perm.clone());
|
|
|
|
|
|
|
|
|
|
assert!(rule.match_perm(&perm));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn permission_children_checks_only_children() {
|
2021-10-20 13:47:32 +02:00
|
|
|
|
let perm = PermissionBuf::from_string_unchecked("test.perm".to_string());
|
2020-11-17 13:40:44 +01:00
|
|
|
|
let rule = PermRule::Children(perm.clone());
|
|
|
|
|
|
2021-10-20 13:47:32 +02:00
|
|
|
|
assert_eq!(rule.match_perm(&perm), false);
|
|
|
|
|
|
|
|
|
|
let perm2 = PermissionBuf::from_string_unchecked("test.perm.child".to_string());
|
|
|
|
|
let perm3 = PermissionBuf::from_string_unchecked("test.perm.child.deeper".to_string());
|
|
|
|
|
assert!(rule.match_perm(&perm2));
|
|
|
|
|
assert!(rule.match_perm(&perm3));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn permission_subtree_checks_base() {
|
|
|
|
|
let perm = PermissionBuf::from_string_unchecked("test.perm".to_string());
|
|
|
|
|
let rule = PermRule::Subtree(perm.clone());
|
|
|
|
|
|
2020-11-17 13:40:44 +01:00
|
|
|
|
assert!(rule.match_perm(&perm));
|
2021-10-20 13:47:32 +02:00
|
|
|
|
|
|
|
|
|
let perm2 = PermissionBuf::from_string_unchecked("test.perm.child".to_string());
|
|
|
|
|
let perm3 = PermissionBuf::from_string_unchecked("test.perm.child.deeper".to_string());
|
|
|
|
|
|
|
|
|
|
assert!(rule.match_perm(&perm2));
|
|
|
|
|
assert!(rule.match_perm(&perm3));
|
2020-11-17 13:40:44 +01:00
|
|
|
|
}
|
2020-11-19 14:53:14 +01:00
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn rules_from_string_test() {
|
|
|
|
|
assert_eq!(
|
2022-05-05 15:50:44 +02:00
|
|
|
|
PermRule::Base(PermissionBuf::from_string_unchecked(
|
|
|
|
|
"bffh.perm".to_string()
|
|
|
|
|
)),
|
2020-11-19 14:53:14 +01:00
|
|
|
|
PermRule::try_from("bffh.perm".to_string()).unwrap()
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
2022-05-05 15:50:44 +02:00
|
|
|
|
PermRule::Children(PermissionBuf::from_string_unchecked(
|
|
|
|
|
"bffh.perm".to_string()
|
|
|
|
|
)),
|
2020-11-19 14:53:14 +01:00
|
|
|
|
PermRule::try_from("bffh.perm.+".to_string()).unwrap()
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
2022-05-05 15:50:44 +02:00
|
|
|
|
PermRule::Subtree(PermissionBuf::from_string_unchecked(
|
|
|
|
|
"bffh.perm".to_string()
|
|
|
|
|
)),
|
2020-11-19 14:53:14 +01:00
|
|
|
|
PermRule::try_from("bffh.perm.*".to_string()).unwrap()
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn rules_from_string_edgecases_test() {
|
|
|
|
|
assert!(PermRule::try_from("*".to_string()).is_err());
|
|
|
|
|
assert!(PermRule::try_from("+".to_string()).is_err());
|
|
|
|
|
}
|
2020-10-28 19:22:11 +01:00
|
|
|
|
}
|