mirror of
https://github.com/jwinterm/modoboa.git
synced 2026-10-17 05:41:25 +02:00
@@ -32,3 +32,4 @@ test_project/modoboa.db
|
||||
|
||||
# Misc
|
||||
.ash_history
|
||||
modoboa.kdev4
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<navbar />
|
||||
<navbar v-if="isAuthenticated" />
|
||||
<v-main>
|
||||
<v-container fluid>
|
||||
<transition name="fade">
|
||||
|
||||
@@ -122,7 +122,9 @@
|
||||
</v-chip>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row v-if="value.enable_dkim">
|
||||
</template>
|
||||
<template v-if="value.enable_dkim">
|
||||
<v-row>
|
||||
<v-col cols="3"><translate>DKIM key</translate></v-col>
|
||||
<v-col cols="9">
|
||||
<div v-if="value.dkim_private_key_path && value.dkim_public_key">
|
||||
|
||||
@@ -92,7 +92,7 @@ export default {
|
||||
items: [
|
||||
{ key: this.$gettext('Quota'), value: this.domain.quota },
|
||||
{ key: this.$gettext('Default mailbox quota'), value: this.domain.default_mailbox_quota },
|
||||
{ key: this.$gettext('Message sending limit'), value: this.domain.message_sending_limit }
|
||||
{ key: this.$gettext('Message sending limit'), value: this.domain.message_limit }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
<v-switch
|
||||
:label="'Enable DNS checks' | translate"
|
||||
v-model="form.enable_dns_checks"
|
||||
@input="update"
|
||||
@change="update"
|
||||
/>
|
||||
<v-switch
|
||||
:label="'Enable DKIM signing' | translate"
|
||||
v-model="form.enable_dkim"
|
||||
@input="update"
|
||||
@change="update"
|
||||
/>
|
||||
<v-text-field
|
||||
:label="'DKIM key selector' | translate"
|
||||
|
||||
@@ -89,8 +89,8 @@
|
||||
<v-col cols="6">
|
||||
<translate class="mr-2">Quota: </translate> {{ domain.quota }}
|
||||
</v-col>
|
||||
<v-col cols="6" v-if="domain.message_sending_limit">
|
||||
<translate class="mr-2">Sending limit: </translate> {{ domain.message_sending_limit }}
|
||||
<v-col cols="6" v-if="domain.message_limit">
|
||||
<translate class="mr-2">Sending limit: </translate> {{ domain.message_limit }}
|
||||
</v-col>
|
||||
<v-col cols="6" v-else>
|
||||
<translate class="mr-2">No sending limit</translate>
|
||||
@@ -207,7 +207,7 @@ export default {
|
||||
}
|
||||
this.working = true
|
||||
try {
|
||||
const data = { ...this.editedDomain }
|
||||
const data = JSON.parse(JSON.stringify(this.editedDomain))
|
||||
if (data.transport === null) {
|
||||
delete data.transport
|
||||
}
|
||||
@@ -231,7 +231,7 @@ export default {
|
||||
domain: {
|
||||
handler: function (val) {
|
||||
if (val) {
|
||||
this.editedDomain = JSON.parse(JSON.stringify(val))
|
||||
this.editedDomain = { ...val }
|
||||
}
|
||||
},
|
||||
immediate: true
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
:label="'Message sending limit' | translate"
|
||||
:hint="'Number of messages this domain can send per day. Leave empty for no limit.' | translate"
|
||||
persistent-hint
|
||||
v-model="form.message_sending_limit"
|
||||
v-model="form.message_limit"
|
||||
:error-messages="errors"
|
||||
outlined
|
||||
@input="update"
|
||||
@@ -49,9 +49,7 @@
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Object
|
||||
},
|
||||
props: ['value'],
|
||||
data () {
|
||||
return {
|
||||
form: {}
|
||||
@@ -65,12 +63,18 @@ export default {
|
||||
this.$emit('input', this.form)
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.form = { ...this.value }
|
||||
},
|
||||
watch: {
|
||||
value: {
|
||||
handler: function (newValue) {
|
||||
this.form = { ...this.value }
|
||||
handler (newValue) {
|
||||
if (newValue.message_limit === '') {
|
||||
newValue.message_limit = null
|
||||
}
|
||||
this.form = { ...newValue }
|
||||
},
|
||||
immediate: true
|
||||
deep: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,15 +14,17 @@
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col cols="6"><translate>Quota</translate></v-col>
|
||||
<v-col cols="6">{{ domain.quota }} <translate>MB</translate></v-col>
|
||||
<v-col cols="6" v-if="domain.quota === '0'"><translate>Unlimited</translate></v-col>
|
||||
<v-col cols="6" v-else>{{ domain.quota }} <translate>MB</translate></v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col cols="6"><translate>Default mailbox quota</translate></v-col>
|
||||
<v-col cols="6">{{ domain.default_mailbox_quota }} MB</v-col>
|
||||
<v-col cols="6" v-if="domain.default_mailbox_quota === '0'"><translate>Unlimited</translate></v-col>
|
||||
<v-col cols="6" v-else>{{ domain.default_mailbox_quota }} MB</v-col>
|
||||
</v-row>
|
||||
<v-row v-if="domain.sending_limit !== undefined">
|
||||
<v-row v-if="domain.message_limit !== undefined">
|
||||
<v-col cols="6"><translate>Daily sending limit</translate></v-col>
|
||||
<v-col cols="6">{{ domain.sending_limit }} <translate>messages</translate></v-col>
|
||||
<v-col cols="6">{{ domain.message_limit }} <translate>messages</translate></v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col cols="6"><translate>Mailboxes</translate></v-col>
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex'
|
||||
import { bus } from '@/main'
|
||||
import accounts from '@/api/accounts'
|
||||
import AccountAliasForm from './AccountAliasForm'
|
||||
@@ -70,6 +71,9 @@ export default {
|
||||
needsMailbox () {
|
||||
return this.account.role !== 'SuperAdmins' || (this.account.username && this.account.username.indexOf('@') !== -1)
|
||||
},
|
||||
...mapGetters({
|
||||
authUser: 'auth/authUser'
|
||||
}),
|
||||
summarySections () {
|
||||
const result = [
|
||||
{
|
||||
|
||||
@@ -137,15 +137,37 @@
|
||||
<v-expansion-panel
|
||||
v-if="limitsConfig.params && limitsConfig.params.enable_admin_limits && account.role !== 'SimpleUsers' && account.role !== 'SuperAdmins'"
|
||||
>
|
||||
<v-expansion-panel-header>
|
||||
<v-expansion-panel-header v-slot="{ open }">
|
||||
<v-row no-gutters>
|
||||
<v-col cols="4">
|
||||
<translate>Resources</translate>
|
||||
</v-col>
|
||||
<v-col
|
||||
cols="8"
|
||||
class="text--secondary"
|
||||
>
|
||||
<v-fade-transition leave-absolute>
|
||||
<span v-if="open"></span>
|
||||
<v-row
|
||||
v-else
|
||||
no-gutters
|
||||
style="width: 100%"
|
||||
>
|
||||
<template v-if="account.resources != undefined && account.resources.length == 2">
|
||||
<v-col cols="6">
|
||||
<translate class="mr-2">Mailbox: </translate> <translate>Number of allowed mailboxes: </translate> {{ account.resources[0].max_value}}
|
||||
</v-col>
|
||||
<v-col cols="6">
|
||||
<translate class="mr-2">Mailbox aliases: </translate> <translate>Number of allowed mailbox aliases: </translate> {{ account.resources[1].max_value}}
|
||||
</v-col>
|
||||
</template>
|
||||
</v-row>
|
||||
</v-fade-transition>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-expansion-panel-header>
|
||||
<v-expansion-panel-content>
|
||||
<resources-form ref="resourcesForm" :resources="editedAccount.resources" />
|
||||
<resources-form ref="resourcesForm" v-model="editedAccount.resources" />
|
||||
</v-expansion-panel-content>
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
@@ -161,6 +183,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex'
|
||||
import _isEmpty from 'lodash/isEmpty'
|
||||
import { bus } from '@/main'
|
||||
import accounts from '@/api/accounts'
|
||||
@@ -183,7 +206,10 @@ export default {
|
||||
computed: {
|
||||
usernameIsEmail () {
|
||||
return this.editedAccount.username && this.editedAccount.username.indexOf('@') !== -1
|
||||
}
|
||||
},
|
||||
...mapGetters({
|
||||
authUser: 'auth/authUser'
|
||||
})
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
|
||||
@@ -29,12 +29,13 @@
|
||||
|
||||
<script>
|
||||
import ChoiceField from '@/components/tools/ChoiceField'
|
||||
import { mapGetters } from 'vuex'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ChoiceField
|
||||
},
|
||||
props: ['value', 'account'],
|
||||
props: ['value', 'account', 'role'],
|
||||
computed: {
|
||||
roleLabel () {
|
||||
const role = this.accountRoles.find(role => role.value === this.account.role)
|
||||
@@ -43,29 +44,49 @@ export default {
|
||||
roleHelp () {
|
||||
const role = this.accountRoles.find(role => role.value === this.account.role)
|
||||
return role !== undefined ? role.help : ''
|
||||
}
|
||||
},
|
||||
accountRoles () {
|
||||
if (this.authUser.role === 'SuperAdmins') {
|
||||
return [...this.domainAdminsRole, ...this.resellerRole, ...this.simpleUserRole, ...this.superAdminsRole]
|
||||
} else if (this.authUser.role === 'DomainAdmins') {
|
||||
return this.simpleUserRole
|
||||
} else if (this.authUser.role === 'Resellers') {
|
||||
return [...this.domainAdminsRole, ...this.simpleUserRole]
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
},
|
||||
...mapGetters({
|
||||
authUser: 'auth/authUser'
|
||||
})
|
||||
},
|
||||
methods: {
|
||||
reset () { }
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
accountRoles: [
|
||||
{
|
||||
label: this.$gettext('Domain administrator'),
|
||||
value: 'DomainAdmins',
|
||||
help: this.$gettext('A user with privileges on one or more domain. He will be allowed to administer mailboxes and he can also have a mailbox.')
|
||||
},
|
||||
{
|
||||
label: this.$gettext('Reseller'),
|
||||
value: 'Resellers',
|
||||
help: this.$gettext('help')
|
||||
},
|
||||
simpleUserRole: [
|
||||
{
|
||||
label: this.$gettext('Simple user'),
|
||||
value: 'SimpleUsers',
|
||||
help: this.$gettext('A user with no privileges but with a mailbox. He will be allowed to use all end-user features: webmail, calendar, contacts, filters.')
|
||||
},
|
||||
}
|
||||
],
|
||||
domainAdminsRole: [
|
||||
{
|
||||
label: this.$gettext('Domain administrator'),
|
||||
value: 'DomainAdmins',
|
||||
help: this.$gettext('A user with privileges on one or more domain. He will be allowed to administer mailboxes and he can also have a mailbox.')
|
||||
}
|
||||
],
|
||||
resellerRole: [
|
||||
{
|
||||
label: this.$gettext('Reseller'),
|
||||
value: 'Resellers',
|
||||
help: this.$gettext('help')
|
||||
}
|
||||
],
|
||||
superAdminsRole: [
|
||||
{
|
||||
label: this.$gettext('Super administrator'),
|
||||
value: 'SuperAdmins',
|
||||
|
||||
@@ -32,7 +32,10 @@
|
||||
<template v-if="account.mailbox">
|
||||
<v-row>
|
||||
<v-col cols="6"><translate>Quota</translate></v-col>
|
||||
<v-col cols="6">
|
||||
<v-col cols="6" v-if="account.mailbox.quota === '0'">
|
||||
<translate>Unlimited</translate>
|
||||
</v-col>
|
||||
<v-col cols="6" v-else>
|
||||
<v-progress-linear
|
||||
:color="account.mailbox.quota_usage < 80 ? 'primary' : 'warning'"
|
||||
:value="account.mailbox.quota_usage"
|
||||
|
||||
@@ -284,20 +284,18 @@ export default {
|
||||
}
|
||||
},
|
||||
created () {
|
||||
if (this.isAuthenticated) {
|
||||
parameters.getApplications().then(response => {
|
||||
response.data.forEach(item => {
|
||||
this.mainMenuItems[6].children.push({
|
||||
text: item.label,
|
||||
to: { name: 'ParametersEdit', params: { app: item.name } }
|
||||
})
|
||||
parameters.getApplications().then(response => {
|
||||
response.data.forEach(item => {
|
||||
this.mainMenuItems[6].children.push({
|
||||
text: item.label,
|
||||
to: { name: 'ParametersEdit', params: { app: item.name } }
|
||||
})
|
||||
})
|
||||
parameters.getApplication('imap_migration').then(response => {
|
||||
this.imapMigration = response.data.params.enabled_imapmigration
|
||||
})
|
||||
bus.$on('imapSettingsChanged', (status) => { this.imapMigration = status })
|
||||
}
|
||||
})
|
||||
parameters.getApplication('imap_migration').then(response => {
|
||||
this.imapMigration = response.data.params.enabled_imapmigration
|
||||
})
|
||||
bus.$on('imapSettingsChanged', (status) => { this.imapMigration = status })
|
||||
},
|
||||
methods: {
|
||||
displayMenuItem (item) {
|
||||
|
||||
@@ -109,6 +109,15 @@ class DomainSerializer(serializers.ModelSerializer):
|
||||
domain.save(creator=creator)
|
||||
return domain
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""Update domain and create/update transport."""
|
||||
super().update(instance, validated_data)
|
||||
_enable_dkim = validated_data.get("enable_dkim")
|
||||
if _enable_dkim is False and instance.dkim_private_key_path != "":
|
||||
instance.dkim_private_key_path = ""
|
||||
instance.save()
|
||||
return instance
|
||||
|
||||
|
||||
class DomainAliasSerializer(serializers.ModelSerializer):
|
||||
"""Base DomainAlias serializer."""
|
||||
|
||||
@@ -133,6 +133,16 @@ class DomainAPITestCase(ModoAPITestCase):
|
||||
domain.refresh_from_db()
|
||||
self.assertEqual(domain.name, "toto.test")
|
||||
|
||||
def test_remove_dkim_domain(self):
|
||||
"""Try to remove DKIM from a domain."""
|
||||
domain = models.Domain.objects.get(name="test.com")
|
||||
url = reverse("v1:domain-detail", args=[domain.pk])
|
||||
response = self.client.put(
|
||||
url, {"name": "test.com", "enable_dkim": False})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
domain.refresh_from_db()
|
||||
self.assertEqual(domain.dkim_private_key_path, "")
|
||||
|
||||
def test_delete_domain(self):
|
||||
"""Try to delete a domain."""
|
||||
domain = models.Domain.objects.get(name="test.com")
|
||||
|
||||
@@ -36,7 +36,9 @@ from . import serializers
|
||||
summary="Create a new domain"
|
||||
)
|
||||
)
|
||||
class DomainViewSet(GetThrottleViewsetMixin, lib_viewsets.RevisionModelMixin, viewsets.ModelViewSet):
|
||||
class DomainViewSet(GetThrottleViewsetMixin,
|
||||
lib_viewsets.RevisionModelMixin,
|
||||
viewsets.ModelViewSet):
|
||||
"""Domain viewset."""
|
||||
|
||||
permission_classes = [IsAuthenticated, DjangoModelPermissions, ]
|
||||
|
||||
@@ -220,6 +220,9 @@ class DomainFormGeneral(forms.ModelForm, DynamicForm):
|
||||
options = {"creator": user} if domalias_post_create else {}
|
||||
DomainAlias(name=alias, target=d, enabled=d.enabled).save(
|
||||
**options)
|
||||
if self.cleaned_data.get("enable_dkim") is False and d.dkim_private_key_path != "":
|
||||
d.dkim_private_key_path = ""
|
||||
d.save()
|
||||
return d
|
||||
|
||||
|
||||
|
||||
@@ -74,13 +74,9 @@ class ManageDKIMKeys(BaseCommand):
|
||||
dkim_private_key_path="")
|
||||
if domain.exists():
|
||||
self.create_new_dkim_key(domain[0])
|
||||
signals.new_dkim_keys.send(sender=self.__class__,
|
||||
domains=domain)
|
||||
return
|
||||
|
||||
qset = models.Domain.objects.filter(
|
||||
enable_dkim=True, dkim_private_key_path="")
|
||||
for domain in qset:
|
||||
self.create_new_dkim_key(domain)
|
||||
if qset.exists():
|
||||
signals.new_dkim_keys.send(sender=self.__class__, domains=qset)
|
||||
|
||||
@@ -19,5 +19,4 @@ get_domain_form_instances = django.dispatch.Signal() # Provides user, domain
|
||||
# get_domain_tags = django.dispatch.Signal(providing_args=["domain"])
|
||||
import_object = django.dispatch.Signal() # Provides objtype
|
||||
use_external_recipients = django.dispatch.Signal() # Provides recipients
|
||||
new_dkim_keys = django.dispatch.Signal() # Provides domain
|
||||
extra_account_identities_actions = django.dispatch.Signal() # Provides account
|
||||
|
||||
@@ -485,6 +485,19 @@ class DKIMTestCase(ModoTestCase):
|
||||
domain.refresh_from_db()
|
||||
self.assertNotEqual(domain.dkim_private_key_path, "")
|
||||
|
||||
# Try disabling DKIM and checking that dkim_private_key_path is emptied
|
||||
values = {
|
||||
"name": "pouet.com", "enable_dkim": False,
|
||||
"quota": 1000, "default_mailbox_quota": 100,
|
||||
"type": "domain"
|
||||
}
|
||||
self.ajax_post(
|
||||
reverse("admin:domain_change", args=[domain.id]),
|
||||
values
|
||||
)
|
||||
domain.refresh_from_db()
|
||||
self.assertEqual(domain.dkim_private_key_path, "")
|
||||
|
||||
def test_dkim_key_length_modification(self):
|
||||
""" """
|
||||
self.set_global_parameter("dkim_keys_storage_dir", self.workdir)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""PDF credentials handlers."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from django.urls import reverse
|
||||
@@ -10,6 +11,7 @@ from django.utils.translation import gettext as _
|
||||
from modoboa.admin import signals as admin_signals
|
||||
from modoboa.core import models as core_models
|
||||
from modoboa.core import signals as core_signals
|
||||
from modoboa.lib.exceptions import InternalError
|
||||
from modoboa.parameters import tools as param_tools
|
||||
|
||||
from .documents import credentials
|
||||
@@ -25,7 +27,13 @@ def password_updated(sender, account, password, created, **kwargs):
|
||||
"generate_at_creation")
|
||||
if (generate_at_creation and not created) or account.is_superuser:
|
||||
return
|
||||
init_storage_dir()
|
||||
try:
|
||||
init_storage_dir()
|
||||
except InternalError as e:
|
||||
logger = logging.getLogger("modoboa.admin")
|
||||
logger.error(
|
||||
_("Failed to create PDF_credentials directory. "
|
||||
"Please check the permissions or the path."))
|
||||
credentials(account, password)
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Internal library."""
|
||||
|
||||
from io import BytesIO
|
||||
import logging
|
||||
import os
|
||||
import struct
|
||||
|
||||
@@ -18,7 +19,7 @@ from modoboa.parameters import tools as param_tools
|
||||
|
||||
|
||||
def init_storage_dir():
|
||||
"""Create the directory whare documents will be stored."""
|
||||
"""Create the directory where documents will be stored."""
|
||||
storage_dir = param_tools.get_global_parameter("storage_dir")
|
||||
if os.path.exists(storage_dir) and os.access(storage_dir, os.W_OK):
|
||||
return
|
||||
@@ -66,17 +67,23 @@ def crypt_and_save_to_file(content, filename, length, chunksize=64*512):
|
||||
iv = os.urandom(16)
|
||||
cipher = _get_cipher(iv)
|
||||
encryptor = cipher.encryptor()
|
||||
with open(filename, "wb") as fp:
|
||||
fp.write(struct.pack(b"<Q", length))
|
||||
fp.write(iv)
|
||||
while True:
|
||||
chunk = content.read(chunksize)
|
||||
if not len(chunk):
|
||||
break
|
||||
elif len(chunk) % 16:
|
||||
chunk += b" " * (16 - len(chunk) % 16)
|
||||
fp.write(encryptor.update(force_bytes(chunk)))
|
||||
fp.write(encryptor.finalize())
|
||||
try:
|
||||
with open(filename, "wb") as fp:
|
||||
fp.write(struct.pack(b"<Q", length))
|
||||
fp.write(iv)
|
||||
while True:
|
||||
chunk = content.read(chunksize)
|
||||
if not len(chunk):
|
||||
break
|
||||
elif len(chunk) % 16:
|
||||
chunk += b" " * (16 - len(chunk) % 16)
|
||||
fp.write(encryptor.update(force_bytes(chunk)))
|
||||
fp.write(encryptor.finalize())
|
||||
except FileNotFoundError as e:
|
||||
logger = logging.getLogger("modoboa.admin")
|
||||
logger.error(
|
||||
_("Failed to create PDF_credentials file. "
|
||||
"Please check the permissions or the path."))
|
||||
|
||||
|
||||
def decrypt_file(filename, chunksize=24*1024):
|
||||
|
||||
@@ -144,4 +144,11 @@ class EventsTestCase(ModoTestCase):
|
||||
def test_storage_dir_creation(self):
|
||||
"""Test storage directory creation."""
|
||||
self.set_global_parameter("storage_dir", "/nonexistentdir")
|
||||
self._create_account("leon@test.com", expected_status=500)
|
||||
with self.assertLogs(logger='modoboa.admin', level='ERROR') as log:
|
||||
resp = self._create_account("leon@test.com")
|
||||
self.assertIn(
|
||||
"ERROR:modoboa.admin:Failed to create PDF_credentials directory. "
|
||||
"Please check the permissions or the path.",
|
||||
log.output
|
||||
)
|
||||
|
||||
|
||||
+4
-3
@@ -14,18 +14,19 @@ authors = [
|
||||
]
|
||||
description = "Mail hosting made simple"
|
||||
readme = "README.rst"
|
||||
requires-python = ">=3.7"
|
||||
requires-python = ">=3.8"
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Environment :: Web Environment",
|
||||
"Framework :: Django :: 2.2",
|
||||
"Framework :: Django :: 4.2",
|
||||
"Intended Audience :: System Administrators",
|
||||
"License :: OSI Approved :: ISC License (ISCL)",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.7",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Topic :: Communications :: Email",
|
||||
"Topic :: Internet :: WWW/HTTP",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user