generated from Nodarx/template
refactor: bunch of improvements #6
@@ -23,6 +23,8 @@ use KTXF\Resource\Provider\ResourceServiceLocationInterface;
|
|||||||
use KTXF\Resource\Delta\Delta;
|
use KTXF\Resource\Delta\Delta;
|
||||||
use KTXF\Resource\Filter\Filter;
|
use KTXF\Resource\Filter\Filter;
|
||||||
use KTXF\Resource\Filter\IFilter;
|
use KTXF\Resource\Filter\IFilter;
|
||||||
|
use KTXF\Resource\Identifier\CollectionIdentifier;
|
||||||
|
use KTXF\Resource\Identifier\EntityIdentifier;
|
||||||
use KTXF\Resource\Range\IRange;
|
use KTXF\Resource\Range\IRange;
|
||||||
use KTXF\Resource\Range\Range;
|
use KTXF\Resource\Range\Range;
|
||||||
use KTXF\Resource\Range\RangeTally;
|
use KTXF\Resource\Range\RangeTally;
|
||||||
@@ -283,6 +285,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
|
|||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── ServiceConfigurableInterface ──────────────────────────────────────────
|
// ── ServiceConfigurableInterface ──────────────────────────────────────────
|
||||||
@@ -521,4 +524,50 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
|
|||||||
$uids = array_map('intval', $identifiers);
|
$uids = array_map('intval', $identifiers);
|
||||||
return $this->mailService->entityFetch((string) $collection, ...$uids);
|
return $this->mailService->entityFetch((string) $collection, ...$uids);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function entityDelete(EntityIdentifier ...$identifiers): array
|
||||||
|
{
|
||||||
|
// validate identifiers and group by collection
|
||||||
|
$collections = [];
|
||||||
|
foreach ($identifiers as $identifier) {
|
||||||
|
if ($identifier->provider() !== $this->provider() || $identifier->service() !== (string)$this->identifier()) {
|
||||||
|
throw new \InvalidArgumentException('Entity identifier does not belong to this service: ' . (string)$identifier);
|
||||||
|
}
|
||||||
|
$collections[$identifier->collection()][] = (int) $identifier->entity();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->initialize();
|
||||||
|
|
||||||
|
// delete entities per collection and build result map
|
||||||
|
$result = [];
|
||||||
|
foreach ($collections as $collection => $uids) {
|
||||||
|
$this->mailService->entityDestroy($collection, ...$uids);
|
||||||
|
foreach ($uids as $uid) {
|
||||||
|
$result[(string) $uid] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function entityMove(CollectionIdentifier $target, EntityIdentifier ...$identifiers): array
|
||||||
|
{
|
||||||
|
// validate target belongs to this service
|
||||||
|
if ($target->provider() !== $this->provider() || $target->service() !== (string)$this->identifier()) {
|
||||||
|
throw new \InvalidArgumentException('Target collection does not belong to this service');
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate identifiers and construct ID list
|
||||||
|
$ids = [];
|
||||||
|
foreach ($identifiers as $identifier) {
|
||||||
|
if ($identifier->provider() !== $this->provider() || $identifier->service() !== (string)$this->identifier()) {
|
||||||
|
throw new \InvalidArgumentException('Entity identifier does not belong to this service: ' . (string)$identifier);
|
||||||
|
}
|
||||||
|
$ids[] = $identifier->entity();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->initialize();
|
||||||
|
|
||||||
|
return $this->mailService->entityMove($target->collection(), ...$ids);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { IdentityBasic } from '@KTXM/MailManager/models/identity'
|
||||||
|
import { ServiceObject } from '@KTXM/MailManager/models/service'
|
||||||
import type { ServiceIdentity } from '@KTXM/MailManager/types/service'
|
import type { ServiceIdentity } from '@KTXM/MailManager/types/service'
|
||||||
import type { ProviderAuthPanelProps, ProviderAuthPanelEmits } from '@KTXM/MailManager/types/integration'
|
import type { ProviderAuthPanelProps, ProviderAuthPanelEmits } from '@KTXM/MailManager/types/integration'
|
||||||
|
|
||||||
const props = defineProps<ProviderAuthPanelProps>()
|
const props = defineProps<ProviderAuthPanelProps>()
|
||||||
const emit = defineEmits<ProviderAuthPanelEmits>()
|
const emit = defineEmits<ProviderAuthPanelEmits>()
|
||||||
|
|
||||||
const identity = ref(props.prefilledIdentity || props.emailAddress || '')
|
const identity = ref('')
|
||||||
const secret = ref(props.prefilledSecret || '')
|
const secret = ref('')
|
||||||
|
|
||||||
const rules = {
|
const rules = {
|
||||||
required: (value: unknown) => !!value || 'This field is required'
|
required: (value: unknown) => !!value || 'This field is required'
|
||||||
}
|
}
|
||||||
|
|
||||||
const isValid = computed(() => !!identity.value && !!secret.value)
|
|
||||||
|
|
||||||
const currentIdentity = computed((): ServiceIdentity | null => {
|
const currentIdentity = computed((): ServiceIdentity | null => {
|
||||||
if (!isValid.value) {
|
if (!identity.value || !secret.value) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,33 +28,13 @@ const currentIdentity = computed((): ServiceIdentity | null => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
currentIdentity,
|
() => props.service,
|
||||||
value => {
|
service => {
|
||||||
if (value) {
|
syncFromService(service)
|
||||||
emit('update:modelValue', value)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true, deep: true }
|
|
||||||
)
|
|
||||||
|
|
||||||
watch(
|
|
||||||
isValid,
|
|
||||||
value => {
|
|
||||||
emit('valid', value)
|
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.modelValue,
|
|
||||||
value => {
|
|
||||||
if (value?.type === 'BA') {
|
|
||||||
identity.value = value.identity || ''
|
|
||||||
secret.value = value.secret || ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.emailAddress,
|
() => props.emailAddress,
|
||||||
value => {
|
value => {
|
||||||
@@ -64,6 +44,55 @@ watch(
|
|||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
currentIdentity,
|
||||||
|
value => {
|
||||||
|
const existingIdentity = props.service?.identity?.toJson() ?? null
|
||||||
|
if (sameIdentity(existingIdentity, value)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextService = createServiceObject(props.service)
|
||||||
|
nextService.identity = value ? new IdentityBasic(value.identity, value.secret) : null
|
||||||
|
emit('update:service', nextService)
|
||||||
|
},
|
||||||
|
{ immediate: true, deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
function syncFromService(service?: ServiceObject) {
|
||||||
|
const serviceIdentity = service?.identity?.toJson() ?? null
|
||||||
|
|
||||||
|
if (serviceIdentity?.type === 'BA') {
|
||||||
|
identity.value = serviceIdentity.identity || props.prefilledIdentity || props.emailAddress || ''
|
||||||
|
secret.value = serviceIdentity.secret || props.prefilledSecret || ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
identity.value = props.prefilledIdentity || props.emailAddress || ''
|
||||||
|
secret.value = props.prefilledSecret || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function createServiceObject(service?: ServiceObject): ServiceObject {
|
||||||
|
const nextService = new ServiceObject()
|
||||||
|
|
||||||
|
if (service) {
|
||||||
|
nextService.fromJson(service.toJson())
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextService
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameIdentity(a: ServiceIdentity | null, b: ServiceIdentity | null): boolean {
|
||||||
|
if (a === null || b === null) {
|
||||||
|
return a === b
|
||||||
|
}
|
||||||
|
|
||||||
|
return a.type === 'BA'
|
||||||
|
&& b.type === 'BA'
|
||||||
|
&& a.identity === b.identity
|
||||||
|
&& a.secret === b.secret
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -1,66 +1,26 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { LocationSocketSole } from '@KTXM/MailManager/models/location'
|
||||||
|
import { ServiceObject } from '@KTXM/MailManager/models/service'
|
||||||
import type {
|
import type {
|
||||||
ServiceLocation,
|
ServiceLocation,
|
||||||
ServiceLocationSocketSole,
|
ServiceLocationSocketSole,
|
||||||
ServiceLocationUri,
|
|
||||||
} from '@KTXM/MailManager/types/service'
|
} from '@KTXM/MailManager/types/service'
|
||||||
import type { ProviderConfigPanelProps, ProviderConfigPanelEmits } from '@KTXM/MailManager/types/integration'
|
import type {
|
||||||
|
ProviderProtocolPanelProps,
|
||||||
|
ProviderProtocolPanelEmits,
|
||||||
|
} from '@KTXM/MailManager/types/integration'
|
||||||
|
|
||||||
type ImapEncryption = 'none' | 'ssl' | 'tls' | 'starttls'
|
type ImapEncryption = 'none' | 'ssl' | 'tls' | 'starttls'
|
||||||
|
|
||||||
type ImapLocation = ServiceLocationSocketSole & {
|
const props = defineProps<ProviderProtocolPanelProps>()
|
||||||
type: 'SOCKET_SOLE' | 'URI'
|
const emit = defineEmits<ProviderProtocolPanelEmits>()
|
||||||
verifyPeerName?: boolean
|
|
||||||
allowSelfSigned?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<ProviderConfigPanelProps>()
|
const host = ref('')
|
||||||
const emit = defineEmits<ProviderConfigPanelEmits>()
|
const encryption = ref<ImapEncryption>('ssl')
|
||||||
|
const port = ref('993')
|
||||||
function asImapLocation(location?: ServiceLocation): ImapLocation | null {
|
const verifyPeer = ref(true)
|
||||||
if (!location) {
|
const verifyHost = ref(true)
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (location.type === 'SOCKET_SOLE') {
|
|
||||||
return location as ImapLocation
|
|
||||||
}
|
|
||||||
|
|
||||||
if (location.type === 'URI') {
|
|
||||||
const uriLocation = location as ServiceLocationUri & {
|
|
||||||
encryption?: ImapEncryption
|
|
||||||
verifyPeerName?: boolean
|
|
||||||
allowSelfSigned?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: 'URI',
|
|
||||||
host: uriLocation.host || '',
|
|
||||||
port: uriLocation.port || 993,
|
|
||||||
encryption: uriLocation.encryption || 'ssl',
|
|
||||||
verifyPeer: uriLocation.verifyPeer ?? true,
|
|
||||||
verifyHost: uriLocation.verifyPeerName ?? uriLocation.verifyHost ?? true,
|
|
||||||
verifyPeerName: uriLocation.verifyPeerName ?? uriLocation.verifyHost ?? true,
|
|
||||||
allowSelfSigned: uriLocation.allowSelfSigned ?? false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultPortFor(encryption: ImapEncryption): number {
|
|
||||||
return encryption === 'ssl' || encryption === 'tls' ? 993 : 143
|
|
||||||
}
|
|
||||||
|
|
||||||
const sourceLocation = computed(() => asImapLocation(props.modelValue || props.discoveredLocation))
|
|
||||||
|
|
||||||
const host = ref(sourceLocation.value?.host || '')
|
|
||||||
const encryption = ref<ImapEncryption>(sourceLocation.value?.encryption || 'ssl')
|
|
||||||
const port = ref(String(sourceLocation.value?.port || defaultPortFor(encryption.value)))
|
|
||||||
const verifyPeer = ref(sourceLocation.value?.verifyPeer ?? true)
|
|
||||||
const verifyHost = ref(sourceLocation.value?.verifyPeerName ?? sourceLocation.value?.verifyHost ?? true)
|
|
||||||
const allowSelfSigned = ref(sourceLocation.value?.allowSelfSigned ?? false)
|
|
||||||
|
|
||||||
const encryptionOptions = [
|
const encryptionOptions = [
|
||||||
{ title: 'Implicit TLS (SSL)', value: 'ssl' },
|
{ title: 'Implicit TLS (SSL)', value: 'ssl' },
|
||||||
@@ -81,93 +41,98 @@ const rules = {
|
|||||||
|
|
||||||
const isValid = computed(() => !!host.value && rules.port(port.value) === true)
|
const isValid = computed(() => !!host.value && rules.port(port.value) === true)
|
||||||
|
|
||||||
const currentLocation = computed((): ServiceLocation | null => {
|
const currentLocation = computed((): ServiceLocationSocketSole | null => {
|
||||||
if (!isValid.value) {
|
if (!isValid.value) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const numericPort = Number(port.value)
|
return {
|
||||||
const location: ImapLocation = {
|
|
||||||
type: 'SOCKET_SOLE',
|
type: 'SOCKET_SOLE',
|
||||||
host: host.value,
|
host: host.value,
|
||||||
port: numericPort,
|
port: Number(port.value),
|
||||||
encryption: encryption.value,
|
encryption: encryption.value,
|
||||||
verifyPeer: verifyPeer.value,
|
verifyPeer: verifyPeer.value,
|
||||||
verifyHost: verifyHost.value,
|
verifyHost: verifyHost.value,
|
||||||
verifyPeerName: verifyHost.value,
|
|
||||||
allowSelfSigned: allowSelfSigned.value,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return location as ServiceLocation
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.service, props.discoveredLocation] as const,
|
||||||
|
([service, discoveredLocation]) => {
|
||||||
|
syncFromLocation(service?.location?.toJson() ?? discoveredLocation ?? null)
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
currentLocation,
|
currentLocation,
|
||||||
value => {
|
location => {
|
||||||
if (value) {
|
const existingLocation = props.service?.location?.toJson() ?? null
|
||||||
emit('update:modelValue', value)
|
if (sameLocation(existingLocation, location)) {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nextService = createServiceObject(props.service)
|
||||||
|
nextService.location = location ? LocationSocketSole.fromJson(location) : null
|
||||||
|
emit('update:service', nextService)
|
||||||
},
|
},
|
||||||
{ immediate: true, deep: true }
|
{ immediate: true, deep: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
watch(
|
|
||||||
isValid,
|
|
||||||
value => {
|
|
||||||
emit('valid', value)
|
|
||||||
},
|
|
||||||
{ immediate: true }
|
|
||||||
)
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.modelValue,
|
|
||||||
value => {
|
|
||||||
const next = asImapLocation(value)
|
|
||||||
if (!next) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
host.value = next.host || ''
|
|
||||||
encryption.value = next.encryption || 'ssl'
|
|
||||||
port.value = String(next.port || defaultPortFor(encryption.value))
|
|
||||||
verifyPeer.value = next.verifyPeer ?? true
|
|
||||||
verifyHost.value = next.verifyPeerName ?? next.verifyHost ?? true
|
|
||||||
allowSelfSigned.value = next.allowSelfSigned ?? false
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.discoveredLocation,
|
|
||||||
value => {
|
|
||||||
if (props.modelValue) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const next = asImapLocation(value)
|
|
||||||
if (!next) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
host.value = next.host || ''
|
|
||||||
encryption.value = next.encryption || 'ssl'
|
|
||||||
port.value = String(next.port || defaultPortFor(encryption.value))
|
|
||||||
verifyPeer.value = next.verifyPeer ?? true
|
|
||||||
verifyHost.value = next.verifyPeerName ?? next.verifyHost ?? true
|
|
||||||
allowSelfSigned.value = next.allowSelfSigned ?? false
|
|
||||||
},
|
|
||||||
{ immediate: true }
|
|
||||||
)
|
|
||||||
|
|
||||||
watch(encryption, (next, previous) => {
|
watch(encryption, (next, previous) => {
|
||||||
const previousDefault = defaultPortFor(previous)
|
const previousDefault = defaultPortFor(previous)
|
||||||
if (!port.value || Number(port.value) === previousDefault) {
|
if (!port.value || Number(port.value) === previousDefault) {
|
||||||
port.value = String(defaultPortFor(next))
|
port.value = String(defaultPortFor(next))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function syncFromLocation(location: ServiceLocation | null) {
|
||||||
|
const socketLocation = getSocketLocation(location)
|
||||||
|
|
||||||
|
host.value = socketLocation?.host ?? ''
|
||||||
|
encryption.value = socketLocation?.encryption ?? 'ssl'
|
||||||
|
port.value = String(socketLocation?.port ?? defaultPortFor(encryption.value))
|
||||||
|
verifyPeer.value = socketLocation?.verifyPeer ?? true
|
||||||
|
verifyHost.value = socketLocation?.verifyHost ?? true
|
||||||
|
}
|
||||||
|
|
||||||
|
function createServiceObject(service?: ServiceObject): ServiceObject {
|
||||||
|
const nextService = new ServiceObject()
|
||||||
|
|
||||||
|
if (service) {
|
||||||
|
nextService.fromJson(service.toJson())
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextService
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSocketLocation(location?: ServiceLocation | null): ServiceLocationSocketSole | null {
|
||||||
|
return location?.type === 'SOCKET_SOLE' ? location : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameLocation(a: ServiceLocation | null, b: ServiceLocation | null): boolean {
|
||||||
|
if (a === null || b === null) {
|
||||||
|
return a === b
|
||||||
|
}
|
||||||
|
|
||||||
|
if (a.type !== 'SOCKET_SOLE' || b.type !== 'SOCKET_SOLE') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return a.host === b.host
|
||||||
|
&& a.port === b.port
|
||||||
|
&& a.encryption === b.encryption
|
||||||
|
&& (a.verifyPeer ?? true) === (b.verifyPeer ?? true)
|
||||||
|
&& (a.verifyHost ?? true) === (b.verifyHost ?? true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultPortFor(nextEncryption: ImapEncryption): number {
|
||||||
|
return nextEncryption === 'ssl' || nextEncryption === 'tls' ? 993 : 143
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="imap-config-panel">
|
<div class="imap-protocol-panel">
|
||||||
<h3 class="text-h6 mb-4">IMAP Connection Settings</h3>
|
<h3 class="text-h6 mb-4">IMAP Connection Settings</h3>
|
||||||
<p class="text-body-2 mb-6">Configure the server address, transport security, and certificate verification for your IMAP mailbox.</p>
|
<p class="text-body-2 mb-6">Configure the server address, transport security, and certificate verification for your IMAP mailbox.</p>
|
||||||
|
|
||||||
@@ -232,14 +197,6 @@ watch(encryption, (next, previous) => {
|
|||||||
persistent-hint
|
persistent-hint
|
||||||
class="mb-4"
|
class="mb-4"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<v-switch
|
|
||||||
v-model="allowSelfSigned"
|
|
||||||
label="Allow self-signed certificates"
|
|
||||||
color="primary"
|
|
||||||
hint="Use only when your server is intentionally deployed with a self-signed certificate"
|
|
||||||
persistent-hint
|
|
||||||
/>
|
|
||||||
</v-expansion-panel-text>
|
</v-expansion-panel-text>
|
||||||
</v-expansion-panel>
|
</v-expansion-panel>
|
||||||
</v-expansion-panels>
|
</v-expansion-panels>
|
||||||
@@ -256,7 +213,7 @@ watch(encryption, (next, previous) => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.imap-config-panel {
|
.imap-protocol-panel {
|
||||||
max-width: 800px;
|
max-width: 800px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3,13 +3,13 @@ import type { ServiceInterface } from '@KTXM/MailManager/types/service'
|
|||||||
import { ServiceObject } from '@KTXM/MailManager/models/service'
|
import { ServiceObject } from '@KTXM/MailManager/models/service'
|
||||||
|
|
||||||
const integrations: ModuleIntegrations = {
|
const integrations: ModuleIntegrations = {
|
||||||
mail_account_config_panels: [
|
mail_account_protocol_panels: [
|
||||||
{
|
{
|
||||||
id: 'imap',
|
id: 'imap',
|
||||||
label: 'IMAP',
|
label: 'IMAP',
|
||||||
icon: 'mdi-email',
|
icon: 'mdi-email',
|
||||||
caption: 'Internet Message Access Protocol',
|
caption: 'Internet Message Access Protocol',
|
||||||
component: () => import('@/components/ImapConfigPanel.vue'),
|
component: () => import('@/components/ImapProtocolPanel.vue'),
|
||||||
priority: 20,
|
priority: 20,
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user