2018-07-17 16:31:32 +02:00
|
|
|
// Copyright 2018 Parity Technologies (UK) Ltd.
|
|
|
|
//
|
|
|
|
// Permission is hereby granted, free of charge, to any person obtaining a
|
|
|
|
// copy of this software and associated documentation files (the "Software"),
|
|
|
|
// to deal in the Software without restriction, including without limitation
|
|
|
|
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
|
|
|
// and/or sell copies of the Software, and to permit persons to whom the
|
|
|
|
// Software is furnished to do so, subject to the following conditions:
|
|
|
|
//
|
|
|
|
// The above copyright notice and this permission notice shall be included in
|
|
|
|
// all copies or substantial portions of the Software.
|
|
|
|
//
|
|
|
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
|
|
|
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
|
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
|
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
|
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
|
|
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
|
|
|
// DEALINGS IN THE SOFTWARE.
|
|
|
|
|
2021-01-12 12:48:37 +01:00
|
|
|
use asynchronous_codec::{Decoder, Encoder};
|
2021-08-11 13:12:12 +02:00
|
|
|
use bytes::{BufMut, Bytes, BytesMut};
|
2020-10-15 11:39:49 +02:00
|
|
|
use libp2p_core::Endpoint;
|
2021-08-11 13:12:12 +02:00
|
|
|
use std::{
|
|
|
|
fmt,
|
|
|
|
hash::{Hash, Hasher},
|
|
|
|
io, mem,
|
|
|
|
};
|
2018-08-10 16:35:41 +02:00
|
|
|
use unsigned_varint::{codec, encode};
|
2018-07-17 16:31:32 +02:00
|
|
|
|
2018-11-12 14:57:30 +01:00
|
|
|
// Maximum size for a packet: 1MB as per the spec.
|
2018-07-17 16:31:32 +02:00
|
|
|
// Since data is entirely buffered before being dispatched, we need a limit or remotes could just
|
|
|
|
// send a 4 TB-long packet full of zeroes that we kill our process with an OOM error.
|
2019-01-04 13:25:51 +01:00
|
|
|
pub(crate) const MAX_FRAME_SIZE: usize = 1024 * 1024;
|
2018-07-17 16:31:32 +02:00
|
|
|
|
2020-09-28 10:30:49 +02:00
|
|
|
/// A unique identifier used by the local node for a substream.
|
|
|
|
///
|
|
|
|
/// `LocalStreamId`s are sent with frames to the remote, where
|
|
|
|
/// they are received as `RemoteStreamId`s.
|
|
|
|
///
|
|
|
|
/// > **Note**: Streams are identified by a number and a role encoded as a flag
|
|
|
|
/// > on each frame that is either odd (for receivers) or even (for initiators).
|
|
|
|
/// > `Open` frames do not have a flag, but are sent unidirectionally. As a
|
|
|
|
/// > consequence, we need to remember if a stream was initiated by us or remotely
|
|
|
|
/// > and we store the information from our point of view as a `LocalStreamId`,
|
|
|
|
/// > i.e. receiving an `Open` frame results in a local ID with role `Endpoint::Listener`,
|
|
|
|
/// > whilst sending an `Open` frame results in a local ID with role `Endpoint::Dialer`.
|
|
|
|
/// > Receiving a frame with a flag identifying the remote as a "receiver" means that
|
|
|
|
/// > we initiated the stream, so the local ID has the role `Endpoint::Dialer`.
|
|
|
|
/// > Conversely, when receiving a frame with a flag identifying the remote as a "sender",
|
|
|
|
/// > the corresponding local ID has the role `Endpoint::Listener`.
|
2020-10-15 11:39:49 +02:00
|
|
|
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
|
2020-09-28 10:30:49 +02:00
|
|
|
pub struct LocalStreamId {
|
2021-06-28 21:17:59 +05:30
|
|
|
num: u64,
|
2020-09-28 10:30:49 +02:00
|
|
|
role: Endpoint,
|
2018-07-17 16:31:32 +02:00
|
|
|
}
|
|
|
|
|
2020-09-28 10:30:49 +02:00
|
|
|
impl fmt::Display for LocalStreamId {
|
|
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
|
|
match self.role {
|
|
|
|
Endpoint::Dialer => write!(f, "({}/initiator)", self.num),
|
|
|
|
Endpoint::Listener => write!(f, "({}/receiver)", self.num),
|
2018-07-17 16:31:32 +02:00
|
|
|
}
|
|
|
|
}
|
2020-09-28 10:30:49 +02:00
|
|
|
}
|
2018-07-18 12:35:37 +02:00
|
|
|
|
2020-10-15 11:39:49 +02:00
|
|
|
impl Hash for LocalStreamId {
|
2020-11-24 15:59:22 +01:00
|
|
|
#![allow(clippy::derive_hash_xor_eq)]
|
2020-10-15 11:39:49 +02:00
|
|
|
fn hash<H: Hasher>(&self, state: &mut H) {
|
2021-06-28 21:17:59 +05:30
|
|
|
state.write_u64(self.num);
|
2020-10-15 11:39:49 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
impl nohash_hasher::IsEnabled for LocalStreamId {}
|
|
|
|
|
2020-09-28 10:30:49 +02:00
|
|
|
/// A unique identifier used by the remote node for a substream.
|
|
|
|
///
|
|
|
|
/// `RemoteStreamId`s are received with frames from the remote
|
|
|
|
/// and mapped by the receiver to `LocalStreamId`s via
|
|
|
|
/// [`RemoteStreamId::into_local()`].
|
|
|
|
#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
|
|
|
|
pub struct RemoteStreamId {
|
2021-06-28 21:17:59 +05:30
|
|
|
num: u64,
|
2020-09-28 10:30:49 +02:00
|
|
|
role: Endpoint,
|
|
|
|
}
|
|
|
|
|
|
|
|
impl LocalStreamId {
|
2021-06-28 21:17:59 +05:30
|
|
|
pub fn dialer(num: u64) -> Self {
|
2021-08-11 13:12:12 +02:00
|
|
|
Self {
|
|
|
|
num,
|
|
|
|
role: Endpoint::Dialer,
|
|
|
|
}
|
2020-09-28 10:30:49 +02:00
|
|
|
}
|
|
|
|
|
2020-10-13 17:58:18 +02:00
|
|
|
#[cfg(test)]
|
2021-06-28 21:17:59 +05:30
|
|
|
pub fn listener(num: u64) -> Self {
|
2021-08-11 13:12:12 +02:00
|
|
|
Self {
|
|
|
|
num,
|
|
|
|
role: Endpoint::Listener,
|
|
|
|
}
|
2020-10-13 17:58:18 +02:00
|
|
|
}
|
|
|
|
|
2020-09-28 10:30:49 +02:00
|
|
|
pub fn next(self) -> Self {
|
|
|
|
Self {
|
2021-08-11 13:12:12 +02:00
|
|
|
num: self
|
|
|
|
.num
|
|
|
|
.checked_add(1)
|
|
|
|
.expect("Mplex substream ID overflowed"),
|
|
|
|
..self
|
2018-09-06 13:59:14 +02:00
|
|
|
}
|
|
|
|
}
|
2020-10-13 17:58:18 +02:00
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
pub fn into_remote(self) -> RemoteStreamId {
|
|
|
|
RemoteStreamId {
|
|
|
|
num: self.num,
|
|
|
|
role: !self.role,
|
|
|
|
}
|
|
|
|
}
|
2020-09-28 10:30:49 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
impl RemoteStreamId {
|
2021-06-28 21:17:59 +05:30
|
|
|
fn dialer(num: u64) -> Self {
|
2021-08-11 13:12:12 +02:00
|
|
|
Self {
|
|
|
|
num,
|
|
|
|
role: Endpoint::Dialer,
|
|
|
|
}
|
2020-09-28 10:30:49 +02:00
|
|
|
}
|
|
|
|
|
2021-06-28 21:17:59 +05:30
|
|
|
fn listener(num: u64) -> Self {
|
2021-08-11 13:12:12 +02:00
|
|
|
Self {
|
|
|
|
num,
|
|
|
|
role: Endpoint::Listener,
|
|
|
|
}
|
2020-09-28 10:30:49 +02:00
|
|
|
}
|
2018-09-06 13:59:14 +02:00
|
|
|
|
2020-09-28 10:30:49 +02:00
|
|
|
/// Converts this `RemoteStreamId` into the corresponding `LocalStreamId`
|
|
|
|
/// that identifies the same substream.
|
|
|
|
pub fn into_local(self) -> LocalStreamId {
|
|
|
|
LocalStreamId {
|
|
|
|
num: self.num,
|
|
|
|
role: !self.role,
|
2018-08-13 11:29:07 +02:00
|
|
|
}
|
|
|
|
}
|
2020-09-28 10:30:49 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
/// An Mplex protocol frame.
|
2020-10-13 17:58:18 +02:00
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
2020-09-28 10:30:49 +02:00
|
|
|
pub enum Frame<T> {
|
|
|
|
Open { stream_id: T },
|
|
|
|
Data { stream_id: T, data: Bytes },
|
|
|
|
Close { stream_id: T },
|
|
|
|
Reset { stream_id: T },
|
|
|
|
}
|
2018-08-13 11:29:07 +02:00
|
|
|
|
2020-09-28 10:30:49 +02:00
|
|
|
impl Frame<RemoteStreamId> {
|
2020-10-13 17:58:18 +02:00
|
|
|
pub fn remote_id(&self) -> RemoteStreamId {
|
2020-09-28 10:30:49 +02:00
|
|
|
match *self {
|
|
|
|
Frame::Open { stream_id } => stream_id,
|
|
|
|
Frame::Data { stream_id, .. } => stream_id,
|
|
|
|
Frame::Close { stream_id, .. } => stream_id,
|
|
|
|
Frame::Reset { stream_id, .. } => stream_id,
|
2018-07-18 12:35:37 +02:00
|
|
|
}
|
|
|
|
}
|
2018-07-17 16:31:32 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
pub struct Codec {
|
2021-06-28 21:17:59 +05:30
|
|
|
varint_decoder: codec::Uvi<u64>,
|
2018-07-17 16:31:32 +02:00
|
|
|
decoder_state: CodecDecodeState,
|
|
|
|
}
|
|
|
|
|
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
enum CodecDecodeState {
|
|
|
|
Begin,
|
2021-06-28 21:17:59 +05:30
|
|
|
HasHeader(u64),
|
|
|
|
HasHeaderAndLen(u64, usize),
|
2018-07-17 16:31:32 +02:00
|
|
|
Poisoned,
|
|
|
|
}
|
|
|
|
|
|
|
|
impl Codec {
|
|
|
|
pub fn new() -> Codec {
|
|
|
|
Codec {
|
2018-08-10 16:35:41 +02:00
|
|
|
varint_decoder: codec::Uvi::default(),
|
2018-07-17 16:31:32 +02:00
|
|
|
decoder_state: CodecDecodeState::Begin,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
impl Decoder for Codec {
|
2020-09-28 10:30:49 +02:00
|
|
|
type Item = Frame<RemoteStreamId>;
|
2020-10-15 11:39:49 +02:00
|
|
|
type Error = io::Error;
|
2018-07-17 16:31:32 +02:00
|
|
|
|
|
|
|
fn decode(&mut self, src: &mut BytesMut) -> Result<Option<Self::Item>, Self::Error> {
|
|
|
|
loop {
|
|
|
|
match mem::replace(&mut self.decoder_state, CodecDecodeState::Poisoned) {
|
2021-08-11 13:12:12 +02:00
|
|
|
CodecDecodeState::Begin => match self.varint_decoder.decode(src)? {
|
|
|
|
Some(header) => {
|
|
|
|
self.decoder_state = CodecDecodeState::HasHeader(header);
|
|
|
|
}
|
|
|
|
None => {
|
|
|
|
self.decoder_state = CodecDecodeState::Begin;
|
|
|
|
return Ok(None);
|
2018-07-17 16:31:32 +02:00
|
|
|
}
|
|
|
|
},
|
2021-08-11 13:12:12 +02:00
|
|
|
CodecDecodeState::HasHeader(header) => match self.varint_decoder.decode(src)? {
|
|
|
|
Some(len) => {
|
|
|
|
if len as usize > MAX_FRAME_SIZE {
|
2022-12-14 16:45:04 +01:00
|
|
|
let msg = format!("Mplex frame length {len} exceeds maximum");
|
2021-08-11 13:12:12 +02:00
|
|
|
return Err(io::Error::new(io::ErrorKind::InvalidData, msg));
|
|
|
|
}
|
|
|
|
|
|
|
|
self.decoder_state =
|
|
|
|
CodecDecodeState::HasHeaderAndLen(header, len as usize);
|
|
|
|
}
|
|
|
|
None => {
|
|
|
|
self.decoder_state = CodecDecodeState::HasHeader(header);
|
|
|
|
return Ok(None);
|
2018-07-17 16:31:32 +02:00
|
|
|
}
|
|
|
|
},
|
2018-09-03 15:41:36 +02:00
|
|
|
CodecDecodeState::HasHeaderAndLen(header, len) => {
|
|
|
|
if src.len() < len {
|
|
|
|
self.decoder_state = CodecDecodeState::HasHeaderAndLen(header, len);
|
|
|
|
let to_reserve = len - src.len();
|
|
|
|
src.reserve(to_reserve);
|
2018-07-17 16:31:32 +02:00
|
|
|
return Ok(None);
|
|
|
|
}
|
|
|
|
|
2018-09-03 15:41:36 +02:00
|
|
|
let buf = src.split_to(len);
|
2022-11-11 21:30:58 +01:00
|
|
|
let num = header >> 3;
|
2018-07-17 16:31:32 +02:00
|
|
|
let out = match header & 7 {
|
2021-08-11 13:12:12 +02:00
|
|
|
0 => Frame::Open {
|
|
|
|
stream_id: RemoteStreamId::dialer(num),
|
|
|
|
},
|
|
|
|
1 => Frame::Data {
|
|
|
|
stream_id: RemoteStreamId::listener(num),
|
|
|
|
data: buf.freeze(),
|
|
|
|
},
|
|
|
|
2 => Frame::Data {
|
|
|
|
stream_id: RemoteStreamId::dialer(num),
|
|
|
|
data: buf.freeze(),
|
|
|
|
},
|
|
|
|
3 => Frame::Close {
|
|
|
|
stream_id: RemoteStreamId::listener(num),
|
|
|
|
},
|
|
|
|
4 => Frame::Close {
|
|
|
|
stream_id: RemoteStreamId::dialer(num),
|
|
|
|
},
|
|
|
|
5 => Frame::Reset {
|
|
|
|
stream_id: RemoteStreamId::listener(num),
|
|
|
|
},
|
|
|
|
6 => Frame::Reset {
|
|
|
|
stream_id: RemoteStreamId::dialer(num),
|
|
|
|
},
|
2018-09-03 15:41:36 +02:00
|
|
|
_ => {
|
2022-12-14 16:45:04 +01:00
|
|
|
let msg = format!("Invalid mplex header value 0x{header:x}");
|
2020-10-15 11:39:49 +02:00
|
|
|
return Err(io::Error::new(io::ErrorKind::InvalidData, msg));
|
2021-08-11 13:12:12 +02:00
|
|
|
}
|
2018-07-17 16:31:32 +02:00
|
|
|
};
|
|
|
|
|
2018-09-03 15:41:36 +02:00
|
|
|
self.decoder_state = CodecDecodeState::Begin;
|
2018-07-17 16:31:32 +02:00
|
|
|
return Ok(Some(out));
|
2021-08-11 13:12:12 +02:00
|
|
|
}
|
2018-07-17 16:31:32 +02:00
|
|
|
|
|
|
|
CodecDecodeState::Poisoned => {
|
2021-08-11 13:12:12 +02:00
|
|
|
return Err(io::Error::new(
|
|
|
|
io::ErrorKind::InvalidData,
|
|
|
|
"Mplex codec poisoned",
|
|
|
|
));
|
2018-07-17 16:31:32 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
impl Encoder for Codec {
|
2020-09-28 10:30:49 +02:00
|
|
|
type Item = Frame<LocalStreamId>;
|
2020-10-15 11:39:49 +02:00
|
|
|
type Error = io::Error;
|
2018-07-17 16:31:32 +02:00
|
|
|
|
|
|
|
fn encode(&mut self, item: Self::Item, dst: &mut BytesMut) -> Result<(), Self::Error> {
|
|
|
|
let (header, data) = match item {
|
2021-08-11 13:12:12 +02:00
|
|
|
Frame::Open { stream_id } => (stream_id.num << 3, Bytes::new()),
|
|
|
|
Frame::Data {
|
|
|
|
stream_id:
|
|
|
|
LocalStreamId {
|
|
|
|
num,
|
|
|
|
role: Endpoint::Listener,
|
|
|
|
},
|
|
|
|
data,
|
|
|
|
} => (num << 3 | 1, data),
|
|
|
|
Frame::Data {
|
|
|
|
stream_id:
|
|
|
|
LocalStreamId {
|
|
|
|
num,
|
|
|
|
role: Endpoint::Dialer,
|
|
|
|
},
|
|
|
|
data,
|
|
|
|
} => (num << 3 | 2, data),
|
|
|
|
Frame::Close {
|
|
|
|
stream_id:
|
|
|
|
LocalStreamId {
|
|
|
|
num,
|
|
|
|
role: Endpoint::Listener,
|
|
|
|
},
|
|
|
|
} => (num << 3 | 3, Bytes::new()),
|
|
|
|
Frame::Close {
|
|
|
|
stream_id:
|
|
|
|
LocalStreamId {
|
|
|
|
num,
|
|
|
|
role: Endpoint::Dialer,
|
|
|
|
},
|
|
|
|
} => (num << 3 | 4, Bytes::new()),
|
|
|
|
Frame::Reset {
|
|
|
|
stream_id:
|
|
|
|
LocalStreamId {
|
|
|
|
num,
|
|
|
|
role: Endpoint::Listener,
|
|
|
|
},
|
|
|
|
} => (num << 3 | 5, Bytes::new()),
|
|
|
|
Frame::Reset {
|
|
|
|
stream_id:
|
|
|
|
LocalStreamId {
|
|
|
|
num,
|
|
|
|
role: Endpoint::Dialer,
|
|
|
|
},
|
|
|
|
} => (num << 3 | 6, Bytes::new()),
|
2018-07-17 16:31:32 +02:00
|
|
|
};
|
|
|
|
|
2018-08-10 16:35:41 +02:00
|
|
|
let mut header_buf = encode::u64_buffer();
|
|
|
|
let header_bytes = encode::u64(header, &mut header_buf);
|
|
|
|
|
2018-07-17 16:31:32 +02:00
|
|
|
let data_len = data.as_ref().len();
|
2018-08-10 16:35:41 +02:00
|
|
|
let mut data_buf = encode::usize_buffer();
|
|
|
|
let data_len_bytes = encode::usize(data_len, &mut data_buf);
|
2018-07-17 16:31:32 +02:00
|
|
|
|
|
|
|
if data_len > MAX_FRAME_SIZE {
|
2021-08-11 13:12:12 +02:00
|
|
|
return Err(io::Error::new(
|
|
|
|
io::ErrorKind::InvalidData,
|
|
|
|
"data size exceed maximum",
|
|
|
|
));
|
2018-07-17 16:31:32 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
dst.reserve(header_bytes.len() + data_len_bytes.len() + data_len);
|
|
|
|
dst.put(header_bytes);
|
|
|
|
dst.put(data_len_bytes);
|
|
|
|
dst.put(data);
|
|
|
|
Ok(())
|
|
|
|
}
|
|
|
|
}
|
2018-11-12 14:57:30 +01:00
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
mod tests {
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
fn encode_large_messages_fails() {
|
|
|
|
let mut enc = Codec::new();
|
2020-09-28 10:30:49 +02:00
|
|
|
let role = Endpoint::Dialer;
|
2018-11-12 14:57:30 +01:00
|
|
|
let data = Bytes::from(&[123u8; MAX_FRAME_SIZE + 1][..]);
|
2021-08-11 13:12:12 +02:00
|
|
|
let bad_msg = Frame::Data {
|
|
|
|
stream_id: LocalStreamId { num: 123, role },
|
|
|
|
data,
|
|
|
|
};
|
2018-11-12 14:57:30 +01:00
|
|
|
let mut out = BytesMut::new();
|
|
|
|
match enc.encode(bad_msg, &mut out) {
|
|
|
|
Err(e) => assert_eq!(e.to_string(), "data size exceed maximum"),
|
2021-08-11 13:12:12 +02:00
|
|
|
_ => panic!("Can't send a message bigger than MAX_FRAME_SIZE"),
|
2018-11-12 14:57:30 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
let data = Bytes::from(&[123u8; MAX_FRAME_SIZE][..]);
|
2021-08-11 13:12:12 +02:00
|
|
|
let ok_msg = Frame::Data {
|
|
|
|
stream_id: LocalStreamId { num: 123, role },
|
|
|
|
data,
|
|
|
|
};
|
2018-11-12 14:57:30 +01:00
|
|
|
assert!(enc.encode(ok_msg, &mut out).is_ok());
|
|
|
|
}
|
2021-06-28 21:17:59 +05:30
|
|
|
|
|
|
|
#[test]
|
|
|
|
fn test_60bit_stream_id() {
|
|
|
|
// Create new codec object for encoding and decoding our frame.
|
|
|
|
let mut codec = Codec::new();
|
|
|
|
// Create a u64 stream ID.
|
2021-08-11 13:12:12 +02:00
|
|
|
let id: u64 = u32::MAX as u64 + 1;
|
|
|
|
let stream_id = LocalStreamId {
|
|
|
|
num: id,
|
|
|
|
role: Endpoint::Dialer,
|
|
|
|
};
|
2021-06-28 21:17:59 +05:30
|
|
|
|
|
|
|
// Open a new frame with that stream ID.
|
|
|
|
let original_frame = Frame::Open { stream_id };
|
|
|
|
|
|
|
|
// Encode that frame.
|
|
|
|
let mut enc_frame = BytesMut::new();
|
2021-08-11 13:12:12 +02:00
|
|
|
codec
|
|
|
|
.encode(original_frame, &mut enc_frame)
|
2021-06-28 21:17:59 +05:30
|
|
|
.expect("Encoding to succeed.");
|
|
|
|
|
|
|
|
// Decode encoded frame and extract stream ID.
|
2021-08-11 13:12:12 +02:00
|
|
|
let dec_string_id = codec
|
|
|
|
.decode(&mut enc_frame)
|
2021-06-28 21:17:59 +05:30
|
|
|
.expect("Decoding to succeed.")
|
|
|
|
.map(|f| f.remote_id())
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
assert_eq!(dec_string_id.num, stream_id.num);
|
|
|
|
}
|
2018-11-12 14:57:30 +01:00
|
|
|
}
|