mirror of
https://github.com/netbirdio/plugins.git
synced 2026-05-22 18:44:07 -07:00
os-firewall: initial wireframe for https://github.com/opnsense/plugins/issues/1749
- reuse filter template, link endpoint to selected controller (filter/snat) - push shared code to FilterBaseController
This commit is contained in:
+99
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* Copyright (C) 2020 Deciso B.V.
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without
|
||||
* modification, are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice,
|
||||
* this list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright
|
||||
* notice, this list of conditions and the following disclaimer in the
|
||||
* documentation and/or other materials provided with the distribution.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,
|
||||
* INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
* AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
|
||||
* AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
|
||||
* OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
* POSSIBILITY OF SUCH DAMAGE.
|
||||
*/
|
||||
namespace OPNsense\Firewall\Api;
|
||||
|
||||
use OPNsense\Base\ApiMutableModelControllerBase;
|
||||
use OPNsense\Core\Backend;
|
||||
use OPNsense\Core\Config;
|
||||
|
||||
/**
|
||||
* Class FilterBaseController implements actions for various types
|
||||
* @package OPNsense\Firewall\Api
|
||||
*/
|
||||
class FilterBaseController extends ApiMutableModelControllerBase
|
||||
{
|
||||
protected static $internalModelName = 'filter';
|
||||
protected static $internalModelClass = 'OPNsense\Firewall\Filter';
|
||||
|
||||
public function applyAction($rollback_revision = null)
|
||||
{
|
||||
if ($this->request->isPost()) {
|
||||
if ($rollback_revision != null) {
|
||||
// background rollback timer
|
||||
(new Backend())->configdpRun('pfplugin rollback_timer', [$rollback_revision], true);
|
||||
}
|
||||
return array("status" => (new Backend())->configdRun('filter reload'));
|
||||
} else {
|
||||
return array("status" => "error");
|
||||
}
|
||||
}
|
||||
|
||||
public function cancelRollbackAction($rollback_revision)
|
||||
{
|
||||
if ($this->request->isPost()) {
|
||||
return array(
|
||||
"status" => (new Backend())->configdpRun('pfplugin cancel_rollback', [$rollback_revision])
|
||||
);
|
||||
} else {
|
||||
return array("status" => "error");
|
||||
}
|
||||
}
|
||||
|
||||
public function savepointAction()
|
||||
{
|
||||
if ($this->request->isPost()) {
|
||||
// trigger a save, so we know revision->time matches our running config
|
||||
Config::getInstance()->save();
|
||||
return array(
|
||||
"status" => "ok",
|
||||
"retention" => (string)Config::getInstance()->backupCount(),
|
||||
"revision" => (string)Config::getInstance()->object()->revision->time
|
||||
);
|
||||
} else {
|
||||
return array("status" => "error");
|
||||
}
|
||||
}
|
||||
|
||||
public function revertAction($revision)
|
||||
{
|
||||
if ($this->request->isPost()) {
|
||||
Config::getInstance()->lock();
|
||||
$filename = Config::getInstance()->getBackupFilename($revision);
|
||||
if (!$filename) {
|
||||
Config::getInstance()->unlock();
|
||||
return ["status" => gettext("unknown (or removed) savepoint")];
|
||||
}
|
||||
$this->getModel()->rollback($revision);
|
||||
Config::getInstance()->unlock();
|
||||
(new Backend())->configdRun('filter reload');
|
||||
return ["status" => "ok"];
|
||||
} else {
|
||||
return array("status" => "error");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-64
@@ -27,15 +27,9 @@
|
||||
*/
|
||||
namespace OPNsense\Firewall\Api;
|
||||
|
||||
use OPNsense\Base\ApiMutableModelControllerBase;
|
||||
use OPNsense\Core\Backend;
|
||||
use OPNsense\Core\Config;
|
||||
|
||||
class FilterController extends ApiMutableModelControllerBase
|
||||
class FilterController extends FilterBaseController
|
||||
{
|
||||
protected static $internalModelName = 'filter';
|
||||
protected static $internalModelClass = 'OPNsense\Firewall\Filter';
|
||||
|
||||
public function searchRuleAction()
|
||||
{
|
||||
return $this->searchBase("rules.rule", array('enabled', 'sequence', 'description'), "sequence");
|
||||
@@ -65,61 +59,4 @@ class FilterController extends ApiMutableModelControllerBase
|
||||
{
|
||||
return $this->toggleBase("rules.rule", $uuid, $enabled);
|
||||
}
|
||||
|
||||
public function applyAction($rollback_revision = null)
|
||||
{
|
||||
if ($this->request->isPost()) {
|
||||
if ($rollback_revision != null) {
|
||||
// background rollback timer
|
||||
(new Backend())->configdpRun('pfplugin rollback_timer', [$rollback_revision], true);
|
||||
}
|
||||
return array("status" => (new Backend())->configdRun('filter reload'));
|
||||
} else {
|
||||
return array("status" => "error");
|
||||
}
|
||||
}
|
||||
|
||||
public function cancelRollbackAction($rollback_revision)
|
||||
{
|
||||
if ($this->request->isPost()) {
|
||||
return array(
|
||||
"status" => (new Backend())->configdpRun('pfplugin cancel_rollback', [$rollback_revision])
|
||||
);
|
||||
} else {
|
||||
return array("status" => "error");
|
||||
}
|
||||
}
|
||||
|
||||
public function savepointAction()
|
||||
{
|
||||
if ($this->request->isPost()) {
|
||||
// trigger a save, so we know revision->time matches our running config
|
||||
Config::getInstance()->save();
|
||||
return array(
|
||||
"status" => "ok",
|
||||
"retention" => (string)Config::getInstance()->backupCount(),
|
||||
"revision" => (string)Config::getInstance()->object()->revision->time
|
||||
);
|
||||
} else {
|
||||
return array("status" => "error");
|
||||
}
|
||||
}
|
||||
|
||||
public function revertAction($revision)
|
||||
{
|
||||
if ($this->request->isPost()) {
|
||||
Config::getInstance()->lock();
|
||||
$filename = Config::getInstance()->getBackupFilename($revision);
|
||||
if (!$filename) {
|
||||
Config::getInstance()->unlock();
|
||||
return ["status" => gettext("unknown (or removed) savepoint")];
|
||||
}
|
||||
$this->getModel()->rollback($revision);
|
||||
Config::getInstance()->unlock();
|
||||
(new Backend())->configdRun('filter reload');
|
||||
return ["status" => "ok"];
|
||||
} else {
|
||||
return array("status" => "error");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* Copyright (C) 2020 Deciso B.V.
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without
|
||||
* modification, are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice,
|
||||
* this list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright
|
||||
* notice, this list of conditions and the following disclaimer in the
|
||||
* documentation and/or other materials provided with the distribution.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,
|
||||
* INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
* AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
|
||||
* AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
|
||||
* OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
* POSSIBILITY OF SUCH DAMAGE.
|
||||
*/
|
||||
namespace OPNsense\Firewall\Api;
|
||||
|
||||
|
||||
class SourceNatController extends FilterBaseController
|
||||
{
|
||||
public function searchRuleAction()
|
||||
{
|
||||
return $this->searchBase("snatrules.rule", array('enabled', 'sequence', 'description'), "sequence");
|
||||
}
|
||||
|
||||
public function setRuleAction($uuid)
|
||||
{
|
||||
return $this->setBase("rule", "snatrules.rule", $uuid);
|
||||
}
|
||||
|
||||
public function addRuleAction()
|
||||
{
|
||||
return $this->addBase("rule", "snatrules.rule");
|
||||
}
|
||||
|
||||
public function getRuleAction($uuid = null)
|
||||
{
|
||||
return $this->getBase("rule", "snatrules.rule", $uuid);
|
||||
}
|
||||
|
||||
public function delRuleAction($uuid)
|
||||
{
|
||||
return $this->delBase("snatrules.rule", $uuid);
|
||||
}
|
||||
|
||||
public function toggleRuleAction($uuid, $enabled = null)
|
||||
{
|
||||
return $this->toggleBase("snatrules.rule", $uuid, $enabled);
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ class FilterController extends \OPNsense\Base\IndexController
|
||||
public function indexAction()
|
||||
{
|
||||
$this->view->pick('OPNsense/Firewall/filter');
|
||||
$this->view->ruleController = "filter";
|
||||
$this->view->formDialogFilterRule = $this->getForm("dialogFilterRule");
|
||||
}
|
||||
}
|
||||
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* Copyright (C) 2020 Deciso B.V.
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without
|
||||
* modification, are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice,
|
||||
* this list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright
|
||||
* notice, this list of conditions and the following disclaimer in the
|
||||
* documentation and/or other materials provided with the distribution.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,
|
||||
* INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
* AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
|
||||
* AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
|
||||
* OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
* POSSIBILITY OF SUCH DAMAGE.
|
||||
*/
|
||||
namespace OPNsense\Firewall;
|
||||
|
||||
class SourceNatController extends \OPNsense\Base\IndexController
|
||||
{
|
||||
public function indexAction()
|
||||
{
|
||||
$this->view->pick('OPNsense/Firewall/filter');
|
||||
$this->view->ruleController = "source_nat";
|
||||
$this->view->formDialogFilterRule = $this->getForm("dialogSNatRule");
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
<form>
|
||||
<field>
|
||||
<id>rule.enabled</id>
|
||||
<label>enabled</label>
|
||||
<type>checkbox</type>
|
||||
<help>Enable this rule</help>
|
||||
</field>
|
||||
<field>
|
||||
<id>rule.sequence</id>
|
||||
<label>Sequence</label>
|
||||
<type>text</type>
|
||||
</field>
|
||||
<field>
|
||||
<id>rule.interface</id>
|
||||
<label>Interface</label>
|
||||
<type>dropdown</type>
|
||||
</field>
|
||||
<field>
|
||||
<id>rule.ipprotocol</id>
|
||||
<label>TCP/IP Version</label>
|
||||
<type>dropdown</type>
|
||||
</field>
|
||||
<field>
|
||||
<id>rule.protocol</id>
|
||||
<label>Protocol</label>
|
||||
<type>dropdown</type>
|
||||
</field>
|
||||
<field>
|
||||
<id>rule.source_net</id>
|
||||
<label>Source</label>
|
||||
<type>text</type>
|
||||
</field>
|
||||
<field>
|
||||
<id>rule.source_port</id>
|
||||
<label>Source port</label>
|
||||
<type>text</type>
|
||||
<advanced>true</advanced>
|
||||
<help>Source port number or well known name (imap, imaps, http, https, ...), for ranges use a dash</help>
|
||||
</field>
|
||||
<field>
|
||||
<id>rule.source_not</id>
|
||||
<label>Source / Invert</label>
|
||||
<type>checkbox</type>
|
||||
<help>Use this option to invert the sense of the match.</help>
|
||||
</field>
|
||||
<field>
|
||||
<id>rule.destination_net</id>
|
||||
<label>Destination</label>
|
||||
<type>text</type>
|
||||
</field>
|
||||
<field>
|
||||
<id>rule.destination_not</id>
|
||||
<label>Destination / Invert</label>
|
||||
<type>checkbox</type>
|
||||
<help>Use this option to invert the sense of the match.</help>
|
||||
</field>
|
||||
<field>
|
||||
<id>rule.destination_port</id>
|
||||
<label>Destination port</label>
|
||||
<type>text</type>
|
||||
<help>Destination port number or well known name (imap, imaps, http, https, ...), for ranges use a dash</help>
|
||||
</field>
|
||||
<field>
|
||||
<id>rule.target</id>
|
||||
<label>Translation / target</label>
|
||||
<type>text</type>
|
||||
<help>
|
||||
Packets matching this rule will be mapped to the IP address given here.
|
||||
</help>
|
||||
</field>
|
||||
<field>
|
||||
<id>rule.target_port</id>
|
||||
<label>Translation port</label>
|
||||
<type>text</type>
|
||||
<help>Destination port number or well known name (imap, imaps, http, https, ...)</help>
|
||||
</field>
|
||||
<field>
|
||||
<id>rule.log</id>
|
||||
<label>Log</label>
|
||||
<type>checkbox</type>
|
||||
<help>Log packets that are handled by this rule</help>
|
||||
</field>
|
||||
<field>
|
||||
<id>rule.description</id>
|
||||
<label>Description</label>
|
||||
<type>text</type>
|
||||
</field>
|
||||
</form>
|
||||
@@ -6,4 +6,11 @@
|
||||
<pattern>api/firewall/filter/*</pattern>
|
||||
</patterns>
|
||||
</page-filter-api>
|
||||
<page-filter-snat-api>
|
||||
<name>Firewall: SourceNat: API</name>
|
||||
<patterns>
|
||||
<pattern>ui/firewall/source_nat/*</pattern>
|
||||
<pattern>api/firewall/source_nat/*</pattern>
|
||||
</patterns>
|
||||
</page-filter-snat-api>
|
||||
</acl>
|
||||
|
||||
@@ -106,5 +106,80 @@
|
||||
</description>
|
||||
</rule>
|
||||
</rules>
|
||||
<snatrules>
|
||||
<rule type="ArrayField">
|
||||
<enabled type="BooleanField">
|
||||
<default>1</default>
|
||||
<Required>Y</Required>
|
||||
</enabled>
|
||||
<sequence type="IntegerField">
|
||||
<MinimumValue>1</MinimumValue>
|
||||
<MaximumValue>99999</MaximumValue>
|
||||
<ValidationMessage>provide a valid sequence for sorting</ValidationMessage>
|
||||
<Required>Y</Required>
|
||||
<default>1</default>
|
||||
</sequence>
|
||||
<interface type="InterfaceField">
|
||||
<Required>Y</Required>
|
||||
<default>lan</default>
|
||||
<AllowDynamic>Y</AllowDynamic>
|
||||
</interface>
|
||||
<ipprotocol type="OptionField">
|
||||
<Required>Y</Required>
|
||||
<default>inet</default>
|
||||
<OptionValues>
|
||||
<inet>IPv4</inet>
|
||||
<inet6>IPv6</inet6>
|
||||
</OptionValues>
|
||||
</ipprotocol>
|
||||
<protocol type="ProtocolField">
|
||||
<Required>Y</Required>
|
||||
<default>any</default>
|
||||
</protocol>
|
||||
<source_net type="NetworkAliasField">
|
||||
<default>any</default>
|
||||
<Required>Y</Required>
|
||||
</source_net>
|
||||
<source_not type="BooleanField">
|
||||
<default>0</default>
|
||||
<Required>Y</Required>
|
||||
</source_not>
|
||||
<source_port type="PortField">
|
||||
<Required>N</Required>
|
||||
<EnableWellKnown>Y</EnableWellKnown>
|
||||
<EnableRanges>Y</EnableRanges>
|
||||
</source_port>
|
||||
<destination_net type="NetworkAliasField">
|
||||
<default>any</default>
|
||||
<Required>Y</Required>
|
||||
</destination_net>
|
||||
<destination_not type="BooleanField">
|
||||
<default>0</default>
|
||||
<Required>Y</Required>
|
||||
</destination_not>
|
||||
<destination_port type="PortField">
|
||||
<Required>N</Required>
|
||||
<EnableWellKnown>Y</EnableWellKnown>
|
||||
<EnableRanges>Y</EnableRanges>
|
||||
</destination_port>
|
||||
<target type="NetworkAliasField">
|
||||
<default>wanip</default>
|
||||
<Required>Y</Required>
|
||||
</target>
|
||||
<target_port type="PortField">
|
||||
<Required>N</Required>
|
||||
<EnableWellKnown>Y</EnableWellKnown>
|
||||
</target_port>
|
||||
<log type="BooleanField">
|
||||
<default>0</default>
|
||||
<Required>Y</Required>
|
||||
</log>
|
||||
<description type="TextField">
|
||||
<Required>N</Required>
|
||||
<mask>/^([\t\n\v\f\r 0-9a-zA-Z.\-,_\x{00A0}-\x{FFFF}]){0,255}$/u</mask>
|
||||
<ValidationMessage>Description should be a string between 1 and 255 characters</ValidationMessage>
|
||||
</description>
|
||||
</rule>
|
||||
</snatrules>
|
||||
</items>
|
||||
</model>
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
<Filter order="50" url="/ui/firewall/filter/">
|
||||
<FilterRef url="/ui/firewall/filter#*"/>
|
||||
</Filter>
|
||||
<SourceNat order="100" VisibleName="Source NAT" url="/ui/firewall/source_nat/">
|
||||
<FilterRef url="/ui/firewall/source_nat#*"/>
|
||||
</SourceNat>
|
||||
</Automation>
|
||||
</Firewall>
|
||||
</menu>
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
$( document ).ready(function() {
|
||||
let initial_load = true;
|
||||
let grid = $("#grid-rules").UIBootgrid({
|
||||
search:'/api/firewall/filter/searchRule/',
|
||||
get:'/api/firewall/filter/getRule/',
|
||||
set:'/api/firewall/filter/setRule/',
|
||||
add:'/api/firewall/filter/addRule/',
|
||||
del:'/api/firewall/filter/delRule/',
|
||||
toggle:'/api/firewall/filter/toggleRule/'
|
||||
search:'/api/firewall/{{ruleController}}/searchRule/',
|
||||
get:'/api/firewall/{{ruleController}}/getRule/',
|
||||
set:'/api/firewall/{{ruleController}}/setRule/',
|
||||
add:'/api/firewall/{{ruleController}}/addRule/',
|
||||
del:'/api/firewall/{{ruleController}}/delRule/',
|
||||
toggle:'/api/firewall/{{ruleController}}/toggleRule/'
|
||||
});
|
||||
|
||||
// open edit dialog when opened with a uuid reference
|
||||
@@ -44,7 +44,7 @@
|
||||
label: "{{ lang._('Revert') }}",
|
||||
cssClass: 'btn-primary',
|
||||
action: function(dialogRef) {
|
||||
ajaxCall("/api/firewall/filter/revert/" + $("#revertToTime").val(), {}, function (data, status) {
|
||||
ajaxCall("/api/firewall/{{ruleController}}/revert/" + $("#revertToTime").val(), {}, function (data, status) {
|
||||
if (data.status !== "ok") {
|
||||
$("#revertToTime").parent().addClass("has-error");
|
||||
$("#revertToTimeError").html(data.status);
|
||||
@@ -71,7 +71,7 @@
|
||||
</ul>
|
||||
<div class="tab-content content-box">
|
||||
<div id="rules" class="tab-pane fade in active">
|
||||
<!-- tab page "filter rules" -->
|
||||
<!-- tab page "rules" -->
|
||||
<table id="grid-rules" class="table table-condensed table-hover table-striped" data-editDialog="DialogFilterRule" data-editAlert="FilterRuleChangeMessage">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -100,7 +100,7 @@
|
||||
</div>
|
||||
<hr/>
|
||||
<button class="btn btn-primary" id="reconfigureAct"
|
||||
data-endpoint='/api/firewall/filter/apply'
|
||||
data-endpoint='/api/firewall/{{ruleController}}/apply'
|
||||
data-label="{{ lang._('Apply') }}"
|
||||
data-error-title="{{ lang._('Filter load error') }}"
|
||||
type="button"
|
||||
@@ -108,7 +108,7 @@
|
||||
|
||||
<div class="pull-right">
|
||||
<button class="btn" id="savepointAct"
|
||||
data-endpoint='/api/firewall/filter/savepoint'
|
||||
data-endpoint='/api/firewall/{{ruleController}}/savepoint'
|
||||
data-label="{{ lang._('Savepoint') }}"
|
||||
data-error-title="{{ lang._('snapshot error') }}"
|
||||
type="button"
|
||||
|
||||
Reference in New Issue
Block a user