Merge branch 'ical-1.1.x-backport' into 'v1.1.x'
[1.1.x backport] Allow downloading ics/ical files for best choices See merge request framasoft/framadate/framadate!482
This commit is contained in:
commit
e0028dc813
3
.gitignore
vendored
3
.gitignore
vendored
@ -25,3 +25,6 @@ Thumbs.db
|
||||
.project
|
||||
.idea/
|
||||
*.iml
|
||||
|
||||
#ics temp file
|
||||
out.ics
|
||||
|
191
app/classes/Framadate/Services/ICalService.php
Normal file
191
app/classes/Framadate/Services/ICalService.php
Normal file
@ -0,0 +1,191 @@
|
||||
<?php
|
||||
/**
|
||||
* This software is governed by the CeCILL-B license. If a copy of this license
|
||||
* is not distributed with this file, you can obtain one at
|
||||
* http://www.cecill.info/licences/Licence_CeCILL-B_V1-en.txt
|
||||
*
|
||||
* Authors of STUdS (initial project): Guilhem BORGHESI (borghesi@unistra.fr) and Raphaël DROZ
|
||||
* Authors of Framadate/OpenSondage: Framasoft (https://github.com/framasoft)
|
||||
*
|
||||
* =============================
|
||||
*
|
||||
* Ce logiciel est régi par la licence CeCILL-B. Si une copie de cette licence
|
||||
* ne se trouve pas avec ce fichier vous pouvez l'obtenir sur
|
||||
* http://www.cecill.info/licences/Licence_CeCILL-B_V1-fr.txt
|
||||
*
|
||||
* Auteurs de STUdS (projet initial) : Guilhem BORGHESI (borghesi@unistra.fr) et Raphaël DROZ
|
||||
* Auteurs de Framadate/OpenSondage : Framasoft (https://github.com/framasoft)
|
||||
*/
|
||||
namespace Framadate\Services;
|
||||
|
||||
use DateTime;
|
||||
use Framadate\Repositories\RepositoryFactory;
|
||||
use Sabre\VObject;
|
||||
|
||||
class ICalService {
|
||||
/**
|
||||
* @var NotificationService
|
||||
*/
|
||||
private $notificationService;
|
||||
/**
|
||||
* @var SessionService
|
||||
*/
|
||||
private $sessionService;
|
||||
/**
|
||||
* @var LogService
|
||||
*/
|
||||
private $logService;
|
||||
|
||||
public function __construct(LogService $logService, NotificationService $notificationService, SessionService $sessionService) {
|
||||
$this->logService = $logService;
|
||||
$this->notificationService = $notificationService;
|
||||
$this->sessionService = $sessionService;
|
||||
$this->pollRepository = RepositoryFactory::pollRepository();
|
||||
$this->slotRepository = RepositoryFactory::slotRepository();
|
||||
$this->voteRepository = RepositoryFactory::voteRepository();
|
||||
$this->commentRepository = RepositoryFactory::commentRepository();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an ical-File and initiates the download. If possible, the provided time is used, else an all day event is created.
|
||||
*/
|
||||
public function getEvent($poll, string $start_day, string $start_time) {
|
||||
if(!$this->dayIsReadable($start_day)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$ical_text = "";
|
||||
$elements = explode("-", $start_time);
|
||||
$end_time = null;
|
||||
if(count($elements) === 2) {
|
||||
$start_time = trim($elements[0]);
|
||||
$end_time = trim($elements[1]);
|
||||
}
|
||||
$start_time = $this->reviseTimeString($start_time);
|
||||
if($end_time !== null) {
|
||||
$end_time = $this->reviseTimeString($end_time);
|
||||
}
|
||||
if($start_time !== null) {
|
||||
if($end_time !== null) {
|
||||
$ical_text = $this->getTimedEvent($poll, $start_day . " " . $start_time, $start_day . " " . $end_time);
|
||||
} else {
|
||||
$ical_text = $this->getTimedEvent1Hour($poll, $start_day . " " . $start_time);
|
||||
}
|
||||
}
|
||||
else {
|
||||
$date = DateTime::createFromFormat('d-m-Y', $start_day);
|
||||
$day = $date->format('Ymd');
|
||||
$ical_text = $this->getAllDayEvent($poll, $day);
|
||||
}
|
||||
$this->provideFile($poll->title, $ical_text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls getTimedEvent with one hour as a time slot, starting at $start_daytime
|
||||
*/
|
||||
function getTimedEvent1Hour($poll, string $start_daytime) {
|
||||
$end_daytime = date(DATE_ATOM, strtotime('+1 hours', strtotime($start_daytime)));
|
||||
return $this->getTimedEvent($poll, $start_daytime, $end_daytime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the text for an ical event including the time
|
||||
*/
|
||||
function getTimedEvent($poll, string $start_daytime, string $end_daytime) {
|
||||
$vcalendar = new VObject\Component\VCalendar([
|
||||
'VEVENT' => [
|
||||
'SUMMARY' => $poll->title,
|
||||
'DESCRIPTION' => $this->stripMD($poll->description),
|
||||
'DTSTART' => new \DateTime($start_daytime),
|
||||
'DTEND' => new \DateTime($end_daytime)
|
||||
],
|
||||
'PRODID' => ICAL_PRODID
|
||||
]);
|
||||
return $vcalendar->serialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the text for an ical event if the time is not known
|
||||
*/
|
||||
function getAllDayEvent($poll, string $day) {
|
||||
$vcalendar = new VObject\Component\VCalendar();
|
||||
$vevent = $vcalendar->add('VEVENT');
|
||||
$vevent->add('SUMMARY', $poll->title);
|
||||
$vevent->add('DESCRIPTION', $this->stripMD($poll->description));
|
||||
$dtstart = $vevent->add('DTSTART', $day);
|
||||
$dtstart['VALUE'] = 'DATE';
|
||||
unset($vcalendar->PRODID);
|
||||
$vcalendar->add('PRODID', ICAL_PRODID);
|
||||
return $vcalendar->serialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a file and initiates the download
|
||||
* @param string $ical_text
|
||||
*/
|
||||
function provideFile(string $title, string $ical_text) {
|
||||
header('Content-Description: File Transfer');
|
||||
header('Content-Disposition: attachment; filename=' . $this->stripTitle($title) . ICAL_ENDING);
|
||||
header('Expires: 0');
|
||||
header('Cache-Control: must-revalidate');
|
||||
header('Pragma: public');
|
||||
header("Content-Type: text/plain");
|
||||
echo $ical_text;
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reformats a string value into a time readable by DateTime
|
||||
* @param string $time
|
||||
* @return string the corrected value, null if the format is unknown
|
||||
*/
|
||||
function reviseTimeString(string $time) {
|
||||
// 24-hour clock / international format
|
||||
if (preg_match('/^\d\d(:)\d\d$/', $time)) {
|
||||
return $time;
|
||||
}
|
||||
// 12-hour clock / using am and pm
|
||||
else if (preg_match('/^\d[0-2]?:?\d{0,2}\s?[aApP][mM]$/', $time)) {
|
||||
return $this->formatTime($time);
|
||||
}
|
||||
// french format HHhMM or HHh
|
||||
else if (preg_match('/^\d\d?[hH]\d?\d?$/', $time)) {
|
||||
return $this->formatTime(str_pad(str_ireplace("H", ":", $time), 5, "0"));
|
||||
}
|
||||
// Number only
|
||||
else if (preg_match('/^\d{1,4}$/', $time)) {
|
||||
return $this->formatTime(str_pad(str_pad($time, 2, "0", STR_PAD_LEFT), 4, "0"));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $time
|
||||
* @return 1 if the day string can be parsed, 0 if not and false if an error occured
|
||||
*/
|
||||
function dayIsReadable(string $day) {
|
||||
return preg_match('/^\d{2}-\d{2}-\d{4}$/', $day);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $time
|
||||
* @return string date string in format H:i (e.g. 19:00)
|
||||
*/
|
||||
function formatTime(string $time) {
|
||||
return date("H:i", strtotime($time));
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts MD Code to HTML, then strips HTML away
|
||||
*/
|
||||
function stripMD(string $string) {
|
||||
return strip_tags(smarty_modifier_markdown($string));
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips a string so it's usable as a file name (only digits, letters and underline allowed)
|
||||
*/
|
||||
function stripTitle(string $string) {
|
||||
return preg_replace('/[^a-z0-9_]+/', '-', strtolower($string));
|
||||
}
|
||||
}
|
@ -42,3 +42,6 @@ const SESSION_EDIT_LINK_TIME = "EditLinkMail";
|
||||
|
||||
// CSRF (300s = 5min)
|
||||
const TOKEN_TIME = 300;
|
||||
|
||||
const ICAL_ENDING = ".ics";
|
||||
const ICAL_PRODID = "-//Framasoft//Framadate//EN";
|
||||
|
@ -63,7 +63,8 @@
|
||||
"ircmaxell/password-compat": "dev-master",
|
||||
"roave/security-advisories": "dev-master",
|
||||
"erusev/parsedown": "^1.7",
|
||||
"egulias/email-validator": "~2.1"
|
||||
"egulias/email-validator": "~2.1",
|
||||
"sabre/vobject": "~4.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^9",
|
||||
|
223
composer.lock
generated
223
composer.lock
generated
@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "e7fb9e571fc5d84bc2637bf28453ebe0",
|
||||
"content-hash": "09c4e4991b22cda619447a5746b01a97",
|
||||
"packages": [
|
||||
{
|
||||
"name": "doctrine/lexer",
|
||||
@ -239,7 +239,7 @@
|
||||
"version": "dev-develop",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "git@github.com:olivierperez/o80-i18n.git",
|
||||
"url": "https://github.com/olivierperez/o80-i18n.git",
|
||||
"reference": "ef98bd7bd733d23729999ac148f79ea1d7b9008c"
|
||||
},
|
||||
"dist": {
|
||||
@ -669,6 +669,221 @@
|
||||
],
|
||||
"time": "2020-12-08T15:02:56+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sabre/uri",
|
||||
"version": "2.2.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sabre-io/uri.git",
|
||||
"reference": "f502edffafea8d746825bd5f0b923a60fd2715ff"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sabre-io/uri/zipball/f502edffafea8d746825bd5f0b923a60fd2715ff",
|
||||
"reference": "f502edffafea8d746825bd5f0b923a60fd2715ff",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.1 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "~2.16.1",
|
||||
"phpstan/phpstan": "^0.12",
|
||||
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"files": [
|
||||
"lib/functions.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Sabre\\Uri\\": "lib/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-3-Clause"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Evert Pot",
|
||||
"email": "me@evertpot.com",
|
||||
"homepage": "http://evertpot.com/",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "Functions for making sense out of URIs.",
|
||||
"homepage": "http://sabre.io/uri/",
|
||||
"keywords": [
|
||||
"rfc3986",
|
||||
"uri",
|
||||
"url"
|
||||
],
|
||||
"time": "2020-10-03T10:33:23+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sabre/vobject",
|
||||
"version": "4.3.5",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sabre-io/vobject.git",
|
||||
"reference": "d8a0a9ae215a8acfb51afc29101c7344670b9c83"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sabre-io/vobject/zipball/d8a0a9ae215a8acfb51afc29101c7344670b9c83",
|
||||
"reference": "d8a0a9ae215a8acfb51afc29101c7344670b9c83",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-mbstring": "*",
|
||||
"php": "^7.1 || ^8.0",
|
||||
"sabre/xml": "^2.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "~2.17.1",
|
||||
"phpstan/phpstan": "^0.12",
|
||||
"phpunit/php-invoker": "^2.0 || ^3.1",
|
||||
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.0"
|
||||
},
|
||||
"suggest": {
|
||||
"hoa/bench": "If you would like to run the benchmark scripts"
|
||||
},
|
||||
"bin": [
|
||||
"bin/vobject",
|
||||
"bin/generate_vcards"
|
||||
],
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "4.0.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Sabre\\VObject\\": "lib/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-3-Clause"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Evert Pot",
|
||||
"email": "me@evertpot.com",
|
||||
"homepage": "http://evertpot.com/",
|
||||
"role": "Developer"
|
||||
},
|
||||
{
|
||||
"name": "Dominik Tobschall",
|
||||
"email": "dominik@fruux.com",
|
||||
"homepage": "http://tobschall.de/",
|
||||
"role": "Developer"
|
||||
},
|
||||
{
|
||||
"name": "Ivan Enderlin",
|
||||
"email": "ivan.enderlin@hoa-project.net",
|
||||
"homepage": "http://mnt.io/",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "The VObject library for PHP allows you to easily parse and manipulate iCalendar and vCard objects",
|
||||
"homepage": "http://sabre.io/vobject/",
|
||||
"keywords": [
|
||||
"availability",
|
||||
"freebusy",
|
||||
"iCalendar",
|
||||
"ical",
|
||||
"ics",
|
||||
"jCal",
|
||||
"jCard",
|
||||
"recurrence",
|
||||
"rfc2425",
|
||||
"rfc2426",
|
||||
"rfc2739",
|
||||
"rfc4770",
|
||||
"rfc5545",
|
||||
"rfc5546",
|
||||
"rfc6321",
|
||||
"rfc6350",
|
||||
"rfc6351",
|
||||
"rfc6474",
|
||||
"rfc6638",
|
||||
"rfc6715",
|
||||
"rfc6868",
|
||||
"vCalendar",
|
||||
"vCard",
|
||||
"vcf",
|
||||
"xCal",
|
||||
"xCard"
|
||||
],
|
||||
"time": "2021-02-12T06:28:04+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sabre/xml",
|
||||
"version": "2.2.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sabre-io/xml.git",
|
||||
"reference": "c3b959f821c19b36952ec4a595edd695c216bfc6"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sabre-io/xml/zipball/c3b959f821c19b36952ec4a595edd695c216bfc6",
|
||||
"reference": "c3b959f821c19b36952ec4a595edd695c216bfc6",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-dom": "*",
|
||||
"ext-xmlreader": "*",
|
||||
"ext-xmlwriter": "*",
|
||||
"lib-libxml": ">=2.6.20",
|
||||
"php": "^7.1 || ^8.0",
|
||||
"sabre/uri": ">=1.0,<3.0.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "~2.16.1",
|
||||
"phpstan/phpstan": "^0.12",
|
||||
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Sabre\\Xml\\": "lib/"
|
||||
},
|
||||
"files": [
|
||||
"lib/Deserializer/functions.php",
|
||||
"lib/Serializer/functions.php"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-3-Clause"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Evert Pot",
|
||||
"email": "me@evertpot.com",
|
||||
"homepage": "http://evertpot.com/",
|
||||
"role": "Developer"
|
||||
},
|
||||
{
|
||||
"name": "Markus Staab",
|
||||
"email": "markus.staab@redaxo.de",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "sabre/xml is an XML library that you may not hate.",
|
||||
"homepage": "https://sabre.io/xml/",
|
||||
"keywords": [
|
||||
"XMLReader",
|
||||
"XMLWriter",
|
||||
"dom",
|
||||
"xml"
|
||||
],
|
||||
"time": "2020-10-03T10:08:14+00:00"
|
||||
},
|
||||
{
|
||||
"name": "smarty/smarty",
|
||||
"version": "v3.1.36",
|
||||
@ -4518,12 +4733,12 @@
|
||||
"version": "1.9.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/webmozart/assert.git",
|
||||
"url": "https://github.com/webmozarts/assert.git",
|
||||
"reference": "bafc69caeb4d49c39fd0779086c03a3738cbb389"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/webmozart/assert/zipball/bafc69caeb4d49c39fd0779086c03a3738cbb389",
|
||||
"url": "https://api.github.com/repos/webmozarts/assert/zipball/bafc69caeb4d49c39fd0779086c03a3738cbb389",
|
||||
"reference": "bafc69caeb4d49c39fd0779086c03a3738cbb389",
|
||||
"shasum": ""
|
||||
},
|
||||
|
@ -445,6 +445,7 @@
|
||||
"studs": {
|
||||
"Adding the vote succeeded": "Die Wertung wurde hinzugefügt",
|
||||
"Deletion date:": "Löschdatum:",
|
||||
"Download as ical/ics file": "Als ical/ics-Datei herunterladen",
|
||||
"If you want to vote in this poll, you have to give your name, choose the values that fit best for you and validate with the plus button at the end of the line.": "Wenn Sie an dieser Umfrage teilnehmen möchten, müssen Sie ihren Namen angeben, die Auswahl treffen, die Ihnen am ehesten zusagt und mit dem Plus-Button am Ende der Zeile bestätigen.",
|
||||
"POLL_LOCKED_WARNING": "Die Abstimmung wurde vom Administrator gesperrt. Bewertungen und Kommentare werden eingefroren; es ist nicht mehr möglich teilzunehmen",
|
||||
"The poll is expired, it will be deleted soon.": "Der Zeitraum für die Umfrage ist überschritten, sie wird bald gelöscht werden.",
|
||||
|
@ -449,6 +449,7 @@
|
||||
"studs": {
|
||||
"Adding the vote succeeded": "Vote added",
|
||||
"Deletion date:": "Deletion date:",
|
||||
"Download as ical/ics file": "Download as ical/ics file",
|
||||
"If you want to vote in this poll, you have to give your name, choose the values that fit best for you and validate with the plus button at the end of the line.": "If you want to vote in this poll, you have to give your name, make your choice, and submit it with the plus button at the end of the line.",
|
||||
"POLL_LOCKED_WARNING": "The administrator locked this poll. Votes and comments are frozen, it is no longer possible to participate",
|
||||
"The poll is expired, it will be deleted soon.": "The poll has expired, it will soon be deleted.",
|
||||
|
16
studs.php
16
studs.php
@ -22,6 +22,7 @@ use Framadate\Exception\ConcurrentEditionException;
|
||||
use Framadate\Exception\ConcurrentVoteException;
|
||||
use Framadate\Message;
|
||||
use Framadate\Security\Token;
|
||||
use Framadate\Services\ICalService;
|
||||
use Framadate\Services\InputService;
|
||||
use Framadate\Services\LogService;
|
||||
use Framadate\Services\MailService;
|
||||
@ -62,6 +63,7 @@ $mailService = new MailService($config['use_smtp'], $config['smtp_options']);
|
||||
$notificationService = new NotificationService($mailService);
|
||||
$securityService = new SecurityService();
|
||||
$sessionService = new SessionService();
|
||||
$icalService = new ICalService($logService, $notificationService, $sessionService);
|
||||
|
||||
/* PAGE */
|
||||
/* ---- */
|
||||
@ -215,6 +217,20 @@ function getMessageForOwnVoteEditableVote(SessionService &$sessionService, Smart
|
||||
return $message;
|
||||
}
|
||||
|
||||
// -------------------------------
|
||||
// Get iCal file
|
||||
// -------------------------------
|
||||
if (isset($_GET['get_ical_file'])) {
|
||||
$dayAndTime = strval(filter_input(INPUT_GET, 'get_ical_file', FILTER_DEFAULT));
|
||||
$dayAndTime = strval(Utils::base64url_decode($dayAndTime));
|
||||
$elements = explode("|", $dayAndTime);
|
||||
if(count($elements) > 1) {
|
||||
$icalService->getEvent($poll, strval($elements[0]), strval($elements[1]));
|
||||
}
|
||||
header('HTTP/1.1 500 Internal Server Error');
|
||||
echo 'Internal error';
|
||||
}
|
||||
|
||||
// Retrieve data
|
||||
if ($resultPubliclyVisible || $accessGranted) {
|
||||
$slots = $pollService->allSlotsByPoll($poll);
|
||||
|
@ -406,7 +406,10 @@
|
||||
{foreach $slots as $slot}
|
||||
{foreach $slot->moments as $moment}
|
||||
{if $best_choices['y'][$i] == $max}
|
||||
<li><strong>{$slot->day|date_format:$date_format.txt_full|html} - {$moment|html}</strong></li>
|
||||
{assign var="space" value="`$slot->day|date_format:'d-m-Y'|html`|`$moment`"}
|
||||
<li><strong>{$slot->day|date_format:$date_format.txt_full|html} - {$moment|html}</strong>
|
||||
<a href="{poll_url id=$poll_id action='get_ical_file' action_value=($space)}" class="btn btn-link btn-sm" title="{__('studs', 'Download as ical/ics file')}">
|
||||
<i class="fa fa-calendar text-muted" aria-hidden="true"></i></a></li>
|
||||
{/if}
|
||||
{$i = $i+1}
|
||||
{/foreach}
|
||||
|
Loading…
Reference in New Issue
Block a user