feat(core): migrate setup to vue
Signed-off-by: skjnldsv <skjnldsv@protonmail.com>pull/51464/head
parent
9dea6185ad
commit
cc12719df5
@ -1,156 +0,0 @@
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import $ from 'jquery'
|
||||
import { translate as t } from '@nextcloud/l10n'
|
||||
import { linkTo } from '@nextcloud/router'
|
||||
|
||||
import { getToken } from './OC/requesttoken.js'
|
||||
import getURLParameter from './Util/get-url-parameter.js'
|
||||
|
||||
import './jquery/showpassword.js'
|
||||
|
||||
import 'jquery-ui/ui/widgets/button.js'
|
||||
import 'jquery-ui/themes/base/theme.css'
|
||||
import 'jquery-ui/themes/base/button.css'
|
||||
|
||||
import 'strengthify'
|
||||
import 'strengthify/strengthify.css'
|
||||
|
||||
window.addEventListener('DOMContentLoaded', function() {
|
||||
const dbtypes = {
|
||||
sqlite: !!$('#hasSQLite').val(),
|
||||
mysql: !!$('#hasMySQL').val(),
|
||||
postgresql: !!$('#hasPostgreSQL').val(),
|
||||
oracle: !!$('#hasOracle').val(),
|
||||
}
|
||||
|
||||
$('#selectDbType').buttonset()
|
||||
// change links inside an info box back to their default appearance
|
||||
$('#selectDbType p.info a').button('destroy')
|
||||
|
||||
if ($('#hasSQLite').val()) {
|
||||
$('#use_other_db').hide()
|
||||
$('#use_oracle_db').hide()
|
||||
} else {
|
||||
$('#sqliteInformation').hide()
|
||||
}
|
||||
$('#adminlogin').change(function() {
|
||||
$('#adminlogin').val($.trim($('#adminlogin').val()))
|
||||
})
|
||||
$('#sqlite').click(function() {
|
||||
$('#use_other_db').slideUp(250)
|
||||
$('#use_oracle_db').slideUp(250)
|
||||
$('#sqliteInformation').show()
|
||||
$('#dbname').attr('pattern', '[0-9a-zA-Z$_-]+')
|
||||
})
|
||||
|
||||
$('#mysql,#pgsql').click(function() {
|
||||
$('#use_other_db').slideDown(250)
|
||||
$('#use_oracle_db').slideUp(250)
|
||||
$('#sqliteInformation').hide()
|
||||
$('#dbname').attr('pattern', '[0-9a-zA-Z$_-]+')
|
||||
})
|
||||
|
||||
$('#oci').click(function() {
|
||||
$('#use_other_db').slideDown(250)
|
||||
$('#use_oracle_db').show(250)
|
||||
$('#sqliteInformation').hide()
|
||||
$('#dbname').attr('pattern', '[0-9a-zA-Z$_-.]+')
|
||||
})
|
||||
|
||||
$('#showAdvanced').click(function(e) {
|
||||
e.preventDefault()
|
||||
$('#datadirContent').slideToggle(250)
|
||||
$('#databaseBackend').slideToggle(250)
|
||||
$('#databaseField').slideToggle(250)
|
||||
})
|
||||
$('form').submit(function() {
|
||||
// Save form parameters
|
||||
const post = $(this).serializeArray()
|
||||
|
||||
// Show spinner while finishing setup
|
||||
$('.float-spinner').show(250)
|
||||
|
||||
// Disable inputs
|
||||
$('input[type="submit"]').attr('disabled', 'disabled').val($('input[type="submit"]').data('finishing'))
|
||||
$('input', this).addClass('ui-state-disabled').attr('disabled', 'disabled')
|
||||
// only disable buttons if they are present
|
||||
if ($('#selectDbType').find('.ui-button').length > 0) {
|
||||
$('#selectDbType').buttonset('disable')
|
||||
}
|
||||
$('.strengthify-wrapper, .tipsy')
|
||||
.css('filter', 'alpha(opacity=30)')
|
||||
.css('opacity', 0.3)
|
||||
|
||||
// Create the form
|
||||
const form = $('<form>')
|
||||
form.attr('action', $(this).attr('action'))
|
||||
form.attr('method', 'POST')
|
||||
|
||||
for (let i = 0; i < post.length; i++) {
|
||||
const input = $('<input type="hidden">')
|
||||
input.attr(post[i])
|
||||
form.append(input)
|
||||
}
|
||||
|
||||
// Add redirect_url
|
||||
const redirectURL = getURLParameter('redirect_url')
|
||||
if (redirectURL) {
|
||||
const redirectURLInput = $('<input type="hidden">')
|
||||
redirectURLInput.attr({
|
||||
name: 'redirect_url',
|
||||
value: redirectURL,
|
||||
})
|
||||
form.append(redirectURLInput)
|
||||
}
|
||||
|
||||
// Submit the form
|
||||
form.appendTo(document.body)
|
||||
form.submit()
|
||||
return false
|
||||
})
|
||||
|
||||
// Expand latest db settings if page was reloaded on error
|
||||
const currentDbType = $('input[type="radio"]:checked').val()
|
||||
|
||||
if (currentDbType === undefined) {
|
||||
$('input[type="radio"]').first().click()
|
||||
}
|
||||
|
||||
if (
|
||||
currentDbType === 'sqlite'
|
||||
|| (dbtypes.sqlite && currentDbType === undefined)
|
||||
) {
|
||||
$('#datadirContent').hide(250)
|
||||
$('#databaseBackend').hide(250)
|
||||
$('#databaseField').hide(250)
|
||||
$('.float-spinner').hide(250)
|
||||
}
|
||||
|
||||
$('#adminpass').strengthify({
|
||||
zxcvbn: linkTo('core', 'vendor/zxcvbn/dist/zxcvbn.js'),
|
||||
titles: [
|
||||
t('core', 'Very weak password'),
|
||||
t('core', 'Weak password'),
|
||||
t('core', 'So-so password'),
|
||||
t('core', 'Good password'),
|
||||
t('core', 'Strong password'),
|
||||
],
|
||||
drawTitles: true,
|
||||
nonce: btoa(getToken()),
|
||||
})
|
||||
|
||||
$('#dbpass').showPassword().keyup()
|
||||
$('.toggle-password').click(function(event) {
|
||||
event.preventDefault()
|
||||
const currentValue = $(this).parent().children('input').attr('type')
|
||||
if (currentValue === 'password') {
|
||||
$(this).parent().children('input').attr('type', 'text')
|
||||
} else {
|
||||
$(this).parent().children('input').attr('type', 'password')
|
||||
}
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,49 @@
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import Vue from 'vue'
|
||||
import Setup from './views/Setup.vue'
|
||||
|
||||
type Error = {
|
||||
error: string
|
||||
hint: string
|
||||
}
|
||||
|
||||
export type DbType = 'sqlite' | 'mysql' | 'pgsql' | 'oci'
|
||||
|
||||
export type SetupConfig = {
|
||||
adminlogin: string
|
||||
adminpass: string
|
||||
dbuser: string
|
||||
dbpass: string
|
||||
dbname: string
|
||||
dbtablespace: string
|
||||
dbhost: string
|
||||
dbtype: DbType | ''
|
||||
|
||||
hasSQLite: boolean
|
||||
hasMySQL: boolean
|
||||
hasPostgreSQL: boolean
|
||||
hasOracle: boolean
|
||||
databases: Record<DbType, string>
|
||||
|
||||
dbIsSet: boolean
|
||||
directory: string
|
||||
directoryIsSet: boolean
|
||||
hasAutoconfig: boolean
|
||||
htaccessWorking: boolean
|
||||
serverRoot: string
|
||||
|
||||
errors: string[]|Error[]
|
||||
}
|
||||
|
||||
export type SetupLinks = {
|
||||
adminInstall: string
|
||||
adminSourceInstall: string
|
||||
adminDBConfiguration: string
|
||||
}
|
||||
|
||||
const SetupVue = Vue.extend(Setup)
|
||||
new SetupVue().$mount('#content')
|
||||
@ -0,0 +1,420 @@
|
||||
<!--
|
||||
- SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
<template>
|
||||
<form ref="form"
|
||||
class="setup-form"
|
||||
:class="{ 'setup-form--loading': loading }"
|
||||
action=""
|
||||
method="POST"
|
||||
@submit="onSubmit">
|
||||
<!-- Autoconfig info -->
|
||||
<NcNoteCard v-if="config.hasAutoconfig"
|
||||
:heading="t('core', 'Autoconfig file detected')"
|
||||
type="success">
|
||||
{{ t('core', 'The setup form below is pre-filled with the values from the config file.') }}
|
||||
</NcNoteCard>
|
||||
|
||||
<!-- Htaccess warning -->
|
||||
<NcNoteCard v-if="config.htaccessWorking === false"
|
||||
:heading="t('core', 'Security warning')"
|
||||
type="warning">
|
||||
<p v-html="htaccessWarning" />
|
||||
</NcNoteCard>
|
||||
|
||||
<!-- Various errors -->
|
||||
<NcNoteCard v-for="(error, index) in errors"
|
||||
:key="index"
|
||||
:heading="error.heading"
|
||||
type="error">
|
||||
{{ error.message }}
|
||||
</NcNoteCard>
|
||||
|
||||
<!-- Admin creation -->
|
||||
<fieldset class="setup-form__administration">
|
||||
<legend>{{ t('core', 'Create administration account') }}</legend>
|
||||
|
||||
<!-- Username -->
|
||||
<NcTextField v-model="config.adminlogin"
|
||||
:label="t('core', 'Administration account name')"
|
||||
name="adminlogin"
|
||||
required />
|
||||
|
||||
<!-- Password -->
|
||||
<NcPasswordField v-model="config.adminpass"
|
||||
:label="t('core', 'Administration account password')"
|
||||
name="adminpass"
|
||||
required />
|
||||
|
||||
<!-- Password entropy -->
|
||||
<NcNoteCard v-show="config.adminpass !== ''" :type="passwordHelperType">
|
||||
{{ passwordHelperText }}
|
||||
</NcNoteCard>
|
||||
</fieldset>
|
||||
|
||||
<!-- Autoconfig toggle -->
|
||||
<details :open="!isValidAutoconfig">
|
||||
<summary>{{ t('core', 'Advanced settings') }}</summary>
|
||||
|
||||
<!-- Data folder -->
|
||||
<fieldset class="setup-form__data-folder">
|
||||
<legend>{{ t('core', 'Data folder') }}</legend>
|
||||
<NcTextField v-model="config.directory"
|
||||
:label="t('core', 'Data folder')"
|
||||
:placeholder="config.serverRoot + '/data'"
|
||||
required
|
||||
autocomplete="off"
|
||||
autocapitalize="none"
|
||||
name="directory"
|
||||
spellcheck="false" />
|
||||
</fieldset>
|
||||
|
||||
<!-- Database -->
|
||||
<fieldset class="setup-form__database">
|
||||
<legend>{{ t('core', 'Database configuration') }}</legend>
|
||||
|
||||
<!-- Database type select -->
|
||||
<fieldset class="setup-form__database-type">
|
||||
<legend>{{ t('core', 'Database type') }}</legend>
|
||||
<p v-if="Object.keys(config.databases).length > 1" class="setup-form__database-type-select">
|
||||
<NcCheckboxRadioSwitch v-for="(name, db) in config.databases"
|
||||
:key="db"
|
||||
v-model="config.dbtype"
|
||||
:button-variant="true"
|
||||
:value="db"
|
||||
name="dbtype"
|
||||
button-variant-grouped="horizontal"
|
||||
type="radio">
|
||||
{{ name }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
</p>
|
||||
|
||||
<NcNoteCard v-else type="warning">
|
||||
{{ t('core', 'Only {db} is available.', { db: Object.values(config.databases).at(0) }) }}<br>
|
||||
{{ t('core', 'Install and activate additional PHP modules to choose other database types.') }}<br>
|
||||
<a :href="links.adminSourceInstall" target="_blank" rel="noreferrer noopener">
|
||||
{{ t('core', 'For more details check out the documentation.') }} ↗
|
||||
</a>
|
||||
</NcNoteCard>
|
||||
|
||||
<NcNoteCard v-if="config.dbtype === 'sqlite'"
|
||||
:heading="t('core', 'Performance warning')"
|
||||
type="warning">
|
||||
{{ t('core', 'You chose SQLite as database.') }}<br>
|
||||
{{ t('core', 'SQLite should only be used for minimal and development instances. For production we recommend a different database backend.') }}<br>
|
||||
{{ t('core', 'If you use clients for file syncing, the use of SQLite is highly discouraged.') }}
|
||||
</NcNoteCard>
|
||||
</fieldset>
|
||||
|
||||
<!-- Database configuration -->
|
||||
<fieldset v-if="config.dbtype !== 'sqlite'">
|
||||
<NcTextField v-model="config.dbuser"
|
||||
:label="t('core', 'Database user')"
|
||||
autocapitalize="none"
|
||||
autocomplete="off"
|
||||
name="dbuser"
|
||||
spellcheck="false"
|
||||
required />
|
||||
|
||||
<NcPasswordField v-model="config.dbpass"
|
||||
:label="t('core', 'Database password')"
|
||||
autocapitalize="none"
|
||||
autocomplete="off"
|
||||
name="dbpass"
|
||||
spellcheck="false"
|
||||
required />
|
||||
|
||||
<NcTextField v-model="config.dbname"
|
||||
:label="t('core', 'Database name')"
|
||||
autocapitalize="none"
|
||||
autocomplete="off"
|
||||
name="dbname"
|
||||
pattern="[0-9a-zA-Z\$_\-]+"
|
||||
spellcheck="false"
|
||||
required />
|
||||
|
||||
<NcTextField v-if="config.dbtype === 'oci'"
|
||||
v-model="config.dbtablespace"
|
||||
:label="t('core', 'Database tablespace')"
|
||||
autocapitalize="none"
|
||||
autocomplete="off"
|
||||
name="dbtablespace"
|
||||
spellcheck="false" />
|
||||
|
||||
<NcTextField v-model="config.dbhost"
|
||||
:helper-text="t('core', 'Please specify the port number along with the host name (e.g., localhost:5432).')"
|
||||
:label="t('core', 'Database host')"
|
||||
:placeholder="t('core', 'localhost')"
|
||||
autocapitalize="none"
|
||||
autocomplete="off"
|
||||
name="dbhost"
|
||||
spellcheck="false" />
|
||||
</fieldset>
|
||||
</fieldset>
|
||||
</details>
|
||||
|
||||
<!-- Submit -->
|
||||
<NcButton class="setup-form__button"
|
||||
:class="{ 'setup-form__button--loading': loading }"
|
||||
:disabled="loading"
|
||||
:loading="loading"
|
||||
:wide="true"
|
||||
alignment="center-reverse"
|
||||
native-type="submit"
|
||||
type="primary">
|
||||
<template #icon>
|
||||
<NcLoadingIcon v-if="loading" />
|
||||
<IconArrowRight v-else />
|
||||
</template>
|
||||
{{ loading ? t('core', 'Installing …') : t('core', 'Install') }}
|
||||
</NcButton>
|
||||
|
||||
<!-- Help note -->
|
||||
<NcNoteCard type="info">
|
||||
{{ t('core', 'Need help?') }}
|
||||
<a target="_blank" rel="noreferrer noopener" :href="links.adminInstall">{{ t('core', 'See the documentation') }} ↗</a>
|
||||
</NcNoteCard>
|
||||
</form>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import type { DbType, SetupConfig, SetupLinks } from '../install'
|
||||
|
||||
import { defineComponent } from 'vue'
|
||||
import { loadState } from '@nextcloud/initial-state'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import DomPurify from 'dompurify'
|
||||
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
|
||||
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||||
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
|
||||
import NcPasswordField from '@nextcloud/vue/components/NcPasswordField'
|
||||
import NcTextField from '@nextcloud/vue/components/NcTextField'
|
||||
|
||||
import IconArrowRight from 'vue-material-design-icons/ArrowRight.vue'
|
||||
|
||||
const config = loadState<SetupConfig>('core', 'config')
|
||||
const links = loadState<SetupLinks>('core', 'links')
|
||||
|
||||
enum PasswordStrength {
|
||||
VeryWeak,
|
||||
Weak,
|
||||
Moderate,
|
||||
Strong,
|
||||
VeryStrong,
|
||||
ExtremelyStrong,
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Setup',
|
||||
|
||||
components: {
|
||||
IconArrowRight,
|
||||
NcButton,
|
||||
NcCheckboxRadioSwitch,
|
||||
NcLoadingIcon,
|
||||
NcNoteCard,
|
||||
NcPasswordField,
|
||||
NcTextField,
|
||||
},
|
||||
|
||||
setup() {
|
||||
return {
|
||||
links,
|
||||
t,
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
config,
|
||||
isValidAutoconfig: false,
|
||||
loading: false,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
passwordHelperText(): string {
|
||||
if (this.config.adminpass === '') {
|
||||
return ''
|
||||
}
|
||||
|
||||
const passwordStrength = this.checkPasswordEntropy(this.config.adminpass)
|
||||
switch (passwordStrength) {
|
||||
case PasswordStrength.VeryWeak:
|
||||
return t('core', 'Password is too weak')
|
||||
case PasswordStrength.Weak:
|
||||
return t('core', 'Password is weak')
|
||||
case PasswordStrength.Moderate:
|
||||
return t('core', 'Password is average')
|
||||
case PasswordStrength.Strong:
|
||||
return t('core', 'Password is strong')
|
||||
case PasswordStrength.VeryStrong:
|
||||
return t('core', 'Password is very strong')
|
||||
case PasswordStrength.ExtremelyStrong:
|
||||
return t('core', 'Password is extremely strong')
|
||||
}
|
||||
|
||||
return t('core', 'Unknown password strength')
|
||||
},
|
||||
passwordHelperType() {
|
||||
if (this.checkPasswordEntropy(this.config.adminpass) < PasswordStrength.Moderate) {
|
||||
return 'error'
|
||||
}
|
||||
if (this.checkPasswordEntropy(this.config.adminpass) < PasswordStrength.Strong) {
|
||||
return 'warning'
|
||||
}
|
||||
return 'success'
|
||||
},
|
||||
|
||||
htaccessWarning(): string {
|
||||
// We use v-html, let's make sure we're safe
|
||||
const message = [
|
||||
t('core', 'Your data directory and files are probably accessible from the internet because the <code>.htaccess</code> file does not work.'),
|
||||
t('core', 'For information how to properly configure your server, please {linkStart}see the documentation{linkEnd}', {
|
||||
linkStart: '<a href="' + links.adminInstall + '" target="_blank" rel="noreferrer noopener">',
|
||||
linkEnd: '</a>',
|
||||
}, { escape: false }),
|
||||
].join('<br>')
|
||||
return DomPurify.sanitize(message)
|
||||
},
|
||||
|
||||
errors() {
|
||||
return this.config.errors.map(error => {
|
||||
if (typeof error === 'string') {
|
||||
return {
|
||||
heading: '',
|
||||
message: error,
|
||||
}
|
||||
}
|
||||
|
||||
// f no hint is set, we don't want to show a heading
|
||||
if (error.hint === '') {
|
||||
return {
|
||||
heading: '',
|
||||
message: error.error,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
heading: error.error,
|
||||
message: error.hint,
|
||||
}
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
if (this.config.dbtype === '') {
|
||||
this.config.dbtype = Object.keys(this.config.databases).at(0) as DbType
|
||||
}
|
||||
|
||||
// Validate the legitimacy of the autoconfig
|
||||
if (this.config.hasAutoconfig) {
|
||||
const form = this.$refs.form as HTMLFormElement
|
||||
|
||||
// Check the form without the administration account fields
|
||||
form.querySelectorAll('input[name="adminlogin"], input[name="adminpass"]').forEach(input => {
|
||||
input.removeAttribute('required')
|
||||
})
|
||||
|
||||
if (form.checkValidity() && this.config.errors.length === 0) {
|
||||
this.isValidAutoconfig = true
|
||||
} else {
|
||||
this.isValidAutoconfig = false
|
||||
}
|
||||
|
||||
// Restore the required attribute
|
||||
// Check the form without the administration account fields
|
||||
form.querySelectorAll('input[name="adminlogin"], input[name="adminpass"]').forEach(input => {
|
||||
input.setAttribute('required', 'true')
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
async onSubmit() {
|
||||
this.loading = true
|
||||
},
|
||||
|
||||
checkPasswordEntropy(password: string): PasswordStrength {
|
||||
const uniqueCharacters = new Set(password)
|
||||
const entropy = parseInt(Math.log2(Math.pow(parseInt(uniqueCharacters.size.toString()), password.length)).toFixed(2))
|
||||
if (entropy < 16) {
|
||||
return PasswordStrength.VeryWeak
|
||||
} else if (entropy < 31) {
|
||||
return PasswordStrength.Weak
|
||||
} else if (entropy < 46) {
|
||||
return PasswordStrength.Moderate
|
||||
} else if (entropy < 61) {
|
||||
return PasswordStrength.Strong
|
||||
} else if (entropy < 76) {
|
||||
return PasswordStrength.VeryStrong
|
||||
}
|
||||
|
||||
return PasswordStrength.ExtremelyStrong
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<style lang="scss">
|
||||
form {
|
||||
padding: calc(3 * var(--default-grid-baseline));
|
||||
color: var(--color-main-text);
|
||||
border-radius: var(--border-radius-container);
|
||||
background-color: var(--color-main-background-blur);
|
||||
box-shadow: 0 0 10px var(--color-box-shadow);
|
||||
-webkit-backdrop-filter: var(--filter-background-blur);
|
||||
backdrop-filter: var(--filter-background-blur);
|
||||
|
||||
max-width: 300px;
|
||||
margin-bottom: 30px;
|
||||
|
||||
> fieldset:first-child,
|
||||
> .notecard:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
> .notecard:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
> fieldset,
|
||||
> details {
|
||||
margin-block: 1rem;
|
||||
}
|
||||
|
||||
.setup-form__button:not(.setup-form__button--loading) {
|
||||
.material-design-icon {
|
||||
transition: all linear var(--animation-quick);
|
||||
}
|
||||
|
||||
&:hover .material-design-icon {
|
||||
transform: translateX(0.2em);
|
||||
}
|
||||
}
|
||||
|
||||
// Db select required styling
|
||||
.setup-form__database-type-select {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
code {
|
||||
background-color: var(--color-background-dark);
|
||||
margin-top: 1rem;
|
||||
padding: 0 0.3em;
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
// Various overrides
|
||||
.input-field {
|
||||
margin-block-start: 1rem !important;
|
||||
}
|
||||
|
||||
.notecard__heading {
|
||||
font-size: inherit !important;
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in New Issue