mirror of
https://github.com/Sebclem/hassio-nextcloud-backup.git
synced 2024-11-05 00:52:59 +01:00
🔨 Add form for webdav settings
This commit is contained in:
parent
f3aebdd4ff
commit
96a6e22072
@ -10,7 +10,9 @@ const backupConfigPath = "/data/backupConfigV2.json";
|
||||
|
||||
export function validateBackupConfig(config: BackupConfig){
|
||||
const validator = Joi.object(backupConfigValidation);
|
||||
return validator.validateAsync(config);
|
||||
return validator.validateAsync(config, {
|
||||
abortEarly: false
|
||||
});
|
||||
}
|
||||
|
||||
export function saveBackupConfig(config: BackupConfig){
|
||||
|
@ -14,7 +14,9 @@ const NEXTCLOUD_ENDPOINT = "/remote.php/dav/files/$username";
|
||||
|
||||
export function validateWebdavConfig(config: WebdavConfig) {
|
||||
const validator = Joi.object(WebdavConfigValidation);
|
||||
return validator.validateAsync(config);
|
||||
return validator.validateAsync(config, {
|
||||
abortEarly: false
|
||||
});
|
||||
}
|
||||
|
||||
export function saveWebdavConfig(config: WebdavConfig) {
|
||||
|
@ -3,7 +3,7 @@ import { WebdavEndpointType } from "./webdavConfig.js";
|
||||
|
||||
|
||||
const WebdavConfigValidation = {
|
||||
url: Joi.string().not().empty().required(),
|
||||
url: Joi.string().not().empty().uri().required(),
|
||||
username: Joi.string().not().empty().required(),
|
||||
password: Joi.string().not().empty().required(),
|
||||
backupDir: Joi.string().required(),
|
||||
|
1
nextcloud_backup/frontend/.env.development
Normal file
1
nextcloud_backup/frontend/.env.development
Normal file
@ -0,0 +1 @@
|
||||
VITE_API_URL="http://localhost:3000/"
|
7
nextcloud_backup/frontend/env.d.ts
vendored
7
nextcloud_backup/frontend/env.d.ts
vendored
@ -1 +1,8 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL: string;
|
||||
}
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
@ -19,7 +19,7 @@
|
||||
"pinia": "^2.0.23",
|
||||
"roboto-fontface": "*",
|
||||
"vue": "^3.2.40",
|
||||
"vuetify": "3.0.0-beta.13",
|
||||
"vuetify": "3.0.0-beta.14",
|
||||
"webfontloader": "^1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
@ -24,7 +24,7 @@ specifiers:
|
||||
vite-plugin-vuetify: ^1.0.0-alpha.12
|
||||
vue: ^3.2.40
|
||||
vue-tsc: 1.0.7
|
||||
vuetify: 3.0.0-beta.13
|
||||
vuetify: 3.0.0-beta.14
|
||||
webfontloader: ^1.0.0
|
||||
|
||||
dependencies:
|
||||
@ -36,7 +36,7 @@ dependencies:
|
||||
pinia: 2.0.23_rg374xhldfcyvjtaj3qktyfz5y
|
||||
roboto-fontface: 0.10.0
|
||||
vue: 3.2.40
|
||||
vuetify: 3.0.0-beta.13_vdkwhj2kz5kpysy3rwb432nm3y
|
||||
vuetify: 3.0.0-beta.14_vdkwhj2kz5kpysy3rwb432nm3y
|
||||
webfontloader: 1.6.28
|
||||
|
||||
devDependencies:
|
||||
@ -53,7 +53,7 @@ devDependencies:
|
||||
prettier: 2.7.1
|
||||
typescript: 4.7.4
|
||||
vite: 3.1.7
|
||||
vite-plugin-vuetify: 1.0.0-alpha.17_l4pgesydsz3cvwqxhtnrp3zlwq
|
||||
vite-plugin-vuetify: 1.0.0-alpha.17_jvcjrjiyr77axku57qj2zhmwla
|
||||
vue-tsc: 1.0.7_typescript@4.7.4
|
||||
|
||||
packages:
|
||||
@ -482,7 +482,7 @@ packages:
|
||||
'@types/node': 16.11.65
|
||||
dev: true
|
||||
|
||||
/@vuetify/loader-shared/1.6.0_t3dvazyjkhimlko2vtyi5desum:
|
||||
/@vuetify/loader-shared/1.6.0_ufv4csfzemzfiqpf5bhlrg5qhm:
|
||||
resolution: {integrity: sha512-mRvswe5SIecagmKkL1c0UQx5V9ACKLyEGegOOe5vC3ccFH8rMbyI4AVZaAKm/EnGXKirWJ1umL8RQFcGd+C0tw==}
|
||||
peerDependencies:
|
||||
vue: ^3.0.0
|
||||
@ -491,7 +491,7 @@ packages:
|
||||
find-cache-dir: 3.3.2
|
||||
upath: 2.0.1
|
||||
vue: 3.2.40
|
||||
vuetify: 3.0.0-beta.13_vdkwhj2kz5kpysy3rwb432nm3y
|
||||
vuetify: 3.0.0-beta.14_vdkwhj2kz5kpysy3rwb432nm3y
|
||||
|
||||
/acorn-jsx/5.3.2_acorn@8.8.0:
|
||||
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
|
||||
@ -2150,18 +2150,18 @@ packages:
|
||||
spdx-expression-parse: 3.0.1
|
||||
dev: true
|
||||
|
||||
/vite-plugin-vuetify/1.0.0-alpha.17_l4pgesydsz3cvwqxhtnrp3zlwq:
|
||||
/vite-plugin-vuetify/1.0.0-alpha.17_jvcjrjiyr77axku57qj2zhmwla:
|
||||
resolution: {integrity: sha512-lP4vG+Z3LnIEjI8nSE8/MJwDRQK5OnS2i4zHDu36yyD4E7wmPKyIkWJdkfBIsx/O+PU7dGc/3MeLARgr+RErEQ==}
|
||||
engines: {node: '>=12'}
|
||||
peerDependencies:
|
||||
vite: ^2.7.0 || ^3.0.0
|
||||
vuetify: ^3.0.0-beta.4
|
||||
dependencies:
|
||||
'@vuetify/loader-shared': 1.6.0_t3dvazyjkhimlko2vtyi5desum
|
||||
'@vuetify/loader-shared': 1.6.0_ufv4csfzemzfiqpf5bhlrg5qhm
|
||||
debug: 4.3.4
|
||||
upath: 2.0.1
|
||||
vite: 3.1.7
|
||||
vuetify: 3.0.0-beta.13_vdkwhj2kz5kpysy3rwb432nm3y
|
||||
vuetify: 3.0.0-beta.14_vdkwhj2kz5kpysy3rwb432nm3y
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
- vue
|
||||
@ -2252,18 +2252,15 @@ packages:
|
||||
'@vue/server-renderer': 3.2.40_vue@3.2.40
|
||||
'@vue/shared': 3.2.40
|
||||
|
||||
/vuetify/3.0.0-beta.13_vdkwhj2kz5kpysy3rwb432nm3y:
|
||||
resolution: {integrity: sha512-CgDyKCIBZ0ogKUxRw3gNswx9GA6eSSp7otZcJapWJCVuon/RooQPQjf3bDuRb5ZpJ55NkX13ugjl2nq+b0U8SA==}
|
||||
/vuetify/3.0.0-beta.14_vdkwhj2kz5kpysy3rwb432nm3y:
|
||||
resolution: {integrity: sha512-7UX2rAcMPGdJ0SGAK+wUvlJTHR0KasYILhg7dV9hLyv9SfaTX4tbOQnSQVf4mXzdFB+5jDyaTs+bOYMVeoqXYg==}
|
||||
engines: {node: ^12.20 || >=14.13}
|
||||
peerDependencies:
|
||||
'@formatjs/intl': ^1.0.0 || ^2.0.0
|
||||
vite-plugin-vuetify: ^1.0.0-alpha.12
|
||||
vue: ^3.2.0
|
||||
vue-i18n: ^9.0.0
|
||||
webpack-plugin-vuetify: ^2.0.0-alpha.11
|
||||
peerDependenciesMeta:
|
||||
'@formatjs/intl':
|
||||
optional: true
|
||||
vite-plugin-vuetify:
|
||||
optional: true
|
||||
vue-i18n:
|
||||
@ -2271,7 +2268,7 @@ packages:
|
||||
webpack-plugin-vuetify:
|
||||
optional: true
|
||||
dependencies:
|
||||
vite-plugin-vuetify: 1.0.0-alpha.17_l4pgesydsz3cvwqxhtnrp3zlwq
|
||||
vite-plugin-vuetify: 1.0.0-alpha.17_jvcjrjiyr77axku57qj2zhmwla
|
||||
vue: 3.2.40
|
||||
|
||||
/webfontloader/1.6.28:
|
||||
|
@ -3,6 +3,7 @@
|
||||
<v-main>
|
||||
<navbar-component></navbar-component>
|
||||
<message-bar></message-bar>
|
||||
<webdav-settings-menu></webdav-settings-menu>
|
||||
</v-main>
|
||||
</v-app>
|
||||
</template>
|
||||
@ -10,6 +11,7 @@
|
||||
<script setup lang="ts">
|
||||
import NavbarComponent from "./components/NavbarComponent.vue";
|
||||
import MessageBar from "./components/MessageBar.vue";
|
||||
import WebdavSettingsMenu from "./components/settings/WebdavConfigMenu.vue";
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
|
@ -12,19 +12,33 @@
|
||||
<v-btn icon id="message-btn">
|
||||
<v-icon>mdi-bell</v-icon>
|
||||
</v-btn>
|
||||
<v-btn icon @click="emit('settingClick')">
|
||||
<v-icon>mdi-cog</v-icon>
|
||||
</v-btn>
|
||||
<v-menu width="210px">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn icon v-bind="props">
|
||||
<v-icon>mdi-cog</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list>
|
||||
<v-list-item
|
||||
title="Cloud settings"
|
||||
@click="dialogStatusStore.webdav = true"
|
||||
prepend-icon="mdi-cloud"
|
||||
></v-list-item>
|
||||
<v-list-item
|
||||
title="Backup settings"
|
||||
prepend-icon="mdi-rotate-left"
|
||||
@click="dialogStatusStore.backup = true"
|
||||
></v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</v-app-bar>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useDialogStatusStore } from "@/stores/dialogStatus";
|
||||
import logoUrl from "../assets/logo.svg";
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "settingClick"): void;
|
||||
(e: "notificationClick"): void;
|
||||
}>();
|
||||
const dialogStatusStore = useDialogStatusStore();
|
||||
</script>
|
||||
<style scoped>
|
||||
.shake {
|
||||
|
@ -0,0 +1,208 @@
|
||||
<template>
|
||||
<v-form class="mx-4">
|
||||
<v-row>
|
||||
<v-col>
|
||||
<div class="text-subtitle-1 text-medium-emphasis">URL</div>
|
||||
<v-text-field
|
||||
placeholder="https://exemple.com"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
prepend-inner-icon="mdi-web"
|
||||
hide-details="auto"
|
||||
v-model="data.url"
|
||||
:error-messages="errors.url"
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col>
|
||||
<div class="text-subtitle-1 text-medium-emphasis">Endpoint</div>
|
||||
<v-select
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
:items="items"
|
||||
hide-details="auto"
|
||||
v-model="data.webdavEndpoint.type"
|
||||
:error-messages="errors.type"
|
||||
>
|
||||
</v-select>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row v-if="data.webdavEndpoint.type == WebdavEndpointType.CUSTOM">
|
||||
<v-col>
|
||||
<div class="text-subtitle-1 text-medium-emphasis">Custom Endpoint</div>
|
||||
<v-text-field
|
||||
placeholder="/remote.php/dav/files/$username"
|
||||
hint="You can use the $username variable"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details="auto"
|
||||
v-model="data.webdavEndpoint.customEndpoint"
|
||||
:error-messages="errors.customEndpoint"
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col class="d-flex align-content-end">
|
||||
<v-switch
|
||||
color="warning"
|
||||
label="Allow Self Signed Certificate"
|
||||
v-model="data.allowSelfSignedCerts"
|
||||
hide-details="auto"
|
||||
density="compact"
|
||||
inset
|
||||
:error-messages="errors.allowSelfSignedCerts"
|
||||
></v-switch>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col><v-divider></v-divider></v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col>
|
||||
<v-alert type="info" variant="outlined">
|
||||
You need to use <b>App password</b>.<br />
|
||||
More info
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://github.com/Sebclem/hassio-nextcloud-backup/blob/master/nextcloud_backup/DOCS.md#nextcloud-config"
|
||||
>
|
||||
here.
|
||||
</a>
|
||||
</v-alert>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col cols="12" md="6">
|
||||
<div class="text-subtitle-1 text-medium-emphasis">Username</div>
|
||||
<v-text-field
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
placeholder="Username"
|
||||
prepend-inner-icon="mdi-account"
|
||||
hide-details="auto"
|
||||
v-model="data.username"
|
||||
:error-messages="errors.username"
|
||||
>
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<div class="text-subtitle-1 text-medium-emphasis">App password</div>
|
||||
<v-text-field
|
||||
placeholder="App password"
|
||||
type="password"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
prepend-inner-icon="mdi-key"
|
||||
hide-details="auto"
|
||||
v-model="data.password"
|
||||
:error-messages="errors.password"
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col><v-divider></v-divider></v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col>
|
||||
<div class="text-subtitle-1 text-medium-emphasis">Backup folder</div>
|
||||
<v-text-field
|
||||
placeholder="Backup folder"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
prepend-inner-icon="mdi-folder"
|
||||
hide-details="auto"
|
||||
v-model="data.backupDir"
|
||||
:error-messages="errors.backupDir"
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-form>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
WebdavEndpointType,
|
||||
type WebdavConfig,
|
||||
} from "../../types/webdavConfig";
|
||||
import {
|
||||
getWebdavConfig,
|
||||
saveWebdavConfig,
|
||||
} from "../../services/ConfigService";
|
||||
import { ref } from "vue";
|
||||
import { HTTPError } from "ky";
|
||||
|
||||
const items = [
|
||||
{
|
||||
title: "Nextcloud",
|
||||
value: WebdavEndpointType.NEXTCLOUD,
|
||||
},
|
||||
{
|
||||
title: "Custom",
|
||||
value: WebdavEndpointType.CUSTOM,
|
||||
},
|
||||
];
|
||||
|
||||
const errors = ref({
|
||||
url: [],
|
||||
username: [],
|
||||
password: [],
|
||||
backupDir: [],
|
||||
allowSelfSignedCerts: [],
|
||||
type: [],
|
||||
customEndpoint: [],
|
||||
});
|
||||
|
||||
const data = ref<WebdavConfig>({
|
||||
url: "",
|
||||
allowSelfSignedCerts: false,
|
||||
backupDir: "",
|
||||
username: "",
|
||||
password: "",
|
||||
webdavEndpoint: {
|
||||
type: WebdavEndpointType.NEXTCLOUD,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "success"): void;
|
||||
(e: "fail"): void;
|
||||
(e: "loaded"): void;
|
||||
}>();
|
||||
|
||||
function save() {
|
||||
clearErrors();
|
||||
saveWebdavConfig(data.value)
|
||||
.then(() => {
|
||||
emit("success");
|
||||
})
|
||||
.catch(async (reason) => {
|
||||
if (reason instanceof HTTPError) {
|
||||
const response = await reason.response.json();
|
||||
if (Array.isArray(response)) {
|
||||
for (let elem of response) {
|
||||
errors.value[elem.context.key as keyof typeof errors.value] =
|
||||
elem.message;
|
||||
}
|
||||
}
|
||||
}
|
||||
emit("fail");
|
||||
});
|
||||
}
|
||||
|
||||
function clearErrors() {
|
||||
for (let elem in errors.value) {
|
||||
errors.value[elem as keyof typeof errors.value] = [];
|
||||
}
|
||||
}
|
||||
|
||||
function loadData() {
|
||||
console.log("ok");
|
||||
getWebdavConfig().then((value) => {
|
||||
data.value = value;
|
||||
emit("loaded");
|
||||
});
|
||||
}
|
||||
|
||||
loadData();
|
||||
defineExpose({ save });
|
||||
</script>
|
@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<v-dialog
|
||||
v-model="dialogStatusStore.webdav"
|
||||
persistent
|
||||
:width="width"
|
||||
:fullscreen="xs"
|
||||
scrollable
|
||||
>
|
||||
<v-card>
|
||||
<v-card-title class="text-center">Cloud Settings</v-card-title>
|
||||
<v-divider></v-divider>
|
||||
<v-card-text>
|
||||
<webdav-settings-form
|
||||
ref="form"
|
||||
@fail="saving = false"
|
||||
@success="dialogStatusStore.webdav = false"
|
||||
@loaded="loading = false"
|
||||
></webdav-settings-form>
|
||||
</v-card-text>
|
||||
<v-divider></v-divider>
|
||||
<v-card-actions class="justify-end">
|
||||
<v-btn
|
||||
color="red"
|
||||
@click="dialogStatusStore.webdav = false"
|
||||
:disabled="saving"
|
||||
>Cancel</v-btn
|
||||
>
|
||||
<v-btn color="success" @click="save()" :loading="saveLoading"
|
||||
>Save</v-btn
|
||||
>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useDialogStatusStore } from "@/stores/dialogStatus";
|
||||
import { computed, ref } from "vue";
|
||||
import { useDisplay } from "vuetify/lib/framework.mjs";
|
||||
import WebdavSettingsForm from "./WebdavConfigForm.vue";
|
||||
|
||||
const dialogStatusStore = useDialogStatusStore();
|
||||
const form = ref<InstanceType<typeof WebdavSettingsForm> | null>(null);
|
||||
const loading = ref(true);
|
||||
const saving = ref(false);
|
||||
const { xs, mdAndDown } = useDisplay();
|
||||
|
||||
let saveLoading = computed(() => {
|
||||
return saving.value || loading.value;
|
||||
});
|
||||
|
||||
const width = computed(() => {
|
||||
if (xs.value) {
|
||||
return undefined;
|
||||
} else if (mdAndDown.value) {
|
||||
return "80%";
|
||||
} else {
|
||||
return "50%";
|
||||
}
|
||||
});
|
||||
|
||||
function save() {
|
||||
saving.value = true;
|
||||
form.value?.save();
|
||||
}
|
||||
</script>
|
@ -1,8 +1,9 @@
|
||||
import { createPinia } from "pinia";
|
||||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
import vuetify from "./plugins/vuetify";
|
||||
import { loadFonts } from "./plugins/webfontloader";
|
||||
|
||||
loadFonts();
|
||||
|
||||
createApp(App).use(vuetify).mount("#app");
|
||||
const pinia = createPinia();
|
||||
createApp(App).use(vuetify).use(pinia).mount("#app");
|
||||
|
14
nextcloud_backup/frontend/src/services/ConfigService.ts
Normal file
14
nextcloud_backup/frontend/src/services/ConfigService.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import type { WebdavConfig } from "@/types/webdavConfig";
|
||||
import kyClient from "./kyClient";
|
||||
|
||||
export function getWebdavConfig() {
|
||||
return kyClient.get("config/webdav").json<WebdavConfig>();
|
||||
}
|
||||
|
||||
export function saveWebdavConfig(config: WebdavConfig) {
|
||||
return kyClient
|
||||
.put("config/webdav", {
|
||||
json: config,
|
||||
})
|
||||
.json();
|
||||
}
|
@ -1,7 +1,6 @@
|
||||
import ky from "ky";
|
||||
|
||||
const kyClient = ky.create({
|
||||
prefixUrl: "http://localhost:3000/v2/api",
|
||||
prefixUrl: `${import.meta.env.VITE_API_URL}v2/api`,
|
||||
});
|
||||
|
||||
export default kyClient;
|
||||
|
8
nextcloud_backup/frontend/src/stores/dialogStatus.ts
Normal file
8
nextcloud_backup/frontend/src/stores/dialogStatus.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { ref } from "vue";
|
||||
import { defineStore } from "pinia";
|
||||
|
||||
export const useDialogStatusStore = defineStore("dialogStatus", () => {
|
||||
const webdav = ref(false);
|
||||
const backup = ref(false);
|
||||
return { webdav, backup };
|
||||
});
|
16
nextcloud_backup/frontend/src/types/webdavConfig.ts
Normal file
16
nextcloud_backup/frontend/src/types/webdavConfig.ts
Normal file
@ -0,0 +1,16 @@
|
||||
export enum WebdavEndpointType {
|
||||
NEXTCLOUD = "NEXTCLOUD",
|
||||
CUSTOM = "CUSTOM",
|
||||
}
|
||||
|
||||
export interface WebdavConfig {
|
||||
url: string;
|
||||
username: string;
|
||||
password: string;
|
||||
backupDir: string;
|
||||
allowSelfSignedCerts: boolean;
|
||||
webdavEndpoint: {
|
||||
type: WebdavEndpointType;
|
||||
customEndpoint?: string;
|
||||
};
|
||||
}
|
@ -2,7 +2,8 @@
|
||||
"extends": [
|
||||
"config:base",
|
||||
":prHourlyLimitNone",
|
||||
":prConcurrentLimitNone"
|
||||
":prConcurrentLimitNone",
|
||||
"group:allNonMajor"
|
||||
],
|
||||
"commitMessagePrefix": ":arrow_up:",
|
||||
"regexManagers": [
|
||||
@ -15,5 +16,8 @@
|
||||
"datasourceTemplate": "docker",
|
||||
"versioningTemplate": "semver"
|
||||
}
|
||||
]
|
||||
],
|
||||
"vulnerabilityAlerts": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user