Updating worker to support using a self host key, a basic level of security, stopping builder from asking for API key if currently configured for self hosting, made the default values for self hosting make sense for a basic local installation, this should be final.
This commit is contained in:
parent
1c553a75df
commit
882cfa700b
|
@ -14,8 +14,8 @@ services:
|
||||||
LOGO_URL: ${LOGO_URL}
|
LOGO_URL: ${LOGO_URL}
|
||||||
PORT: 4002
|
PORT: 4002
|
||||||
HOSTING_URL: ${HOSTING_URL}
|
HOSTING_URL: ${HOSTING_URL}
|
||||||
MINIO_PORT: ${MINIO_PORT}
|
|
||||||
JWT_SECRET: ${JWT_SECRET}
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
|
PROXY_PORT: ${MAIN_PORT}
|
||||||
depends_on:
|
depends_on:
|
||||||
- worker-service
|
- worker-service
|
||||||
|
|
||||||
|
@ -33,6 +33,7 @@ services:
|
||||||
COUCH_DB_USERNAME: ${COUCH_DB_USER}
|
COUCH_DB_USERNAME: ${COUCH_DB_USER}
|
||||||
COUCH_DB_PASSWORD: ${COUCH_DB_PASSWORD}
|
COUCH_DB_PASSWORD: ${COUCH_DB_PASSWORD}
|
||||||
RAW_COUCH_DB_URL: http://couchdb-service:5984
|
RAW_COUCH_DB_URL: http://couchdb-service:5984
|
||||||
|
SELF_HOST_KEY: ${HOSTING_KEY}
|
||||||
depends_on:
|
depends_on:
|
||||||
- minio-service
|
- minio-service
|
||||||
- couch-init
|
- couch-init
|
||||||
|
@ -40,7 +41,7 @@ services:
|
||||||
minio-service:
|
minio-service:
|
||||||
image: minio/minio
|
image: minio/minio
|
||||||
volumes:
|
volumes:
|
||||||
- data1:/data
|
- minio_data:/data
|
||||||
ports:
|
ports:
|
||||||
- "${MINIO_PORT}:9000"
|
- "${MINIO_PORT}:9000"
|
||||||
environment:
|
environment:
|
||||||
|
@ -89,4 +90,5 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
couchdb_data:
|
couchdb_data:
|
||||||
driver: local
|
driver: local
|
||||||
data1:
|
minio_data:
|
||||||
|
driver: local
|
||||||
|
|
|
@ -33,8 +33,8 @@ static_resources:
|
||||||
|
|
||||||
- match: { prefix: "/db/" }
|
- match: { prefix: "/db/" }
|
||||||
route:
|
route:
|
||||||
prefix_rewrite: "/"
|
|
||||||
cluster: couchdb-service
|
cluster: couchdb-service
|
||||||
|
prefix_rewrite: "/"
|
||||||
|
|
||||||
# minio is on the default route because this works
|
# minio is on the default route because this works
|
||||||
# best, minio + AWS SDK doesn't handle path proxy
|
# best, minio + AWS SDK doesn't handle path proxy
|
||||||
|
|
|
@ -1,14 +1,27 @@
|
||||||
|
# Use the main port in the builder for your self hosting URL, e.g. localhost:10000
|
||||||
|
MAIN_PORT=10000
|
||||||
|
|
||||||
|
# Use this password when configuring your self hosting settings
|
||||||
|
# This should be updated
|
||||||
|
HOSTING_KEY=budibase
|
||||||
|
|
||||||
|
# This section contains customisation options
|
||||||
|
HOSTING_URL=http://localhost
|
||||||
|
LOGO_URL=https://logoipsum.com/logo/logo-15.svg
|
||||||
|
HOSTING_URL=http://localhost
|
||||||
|
|
||||||
|
# This section contains all secrets pertaining to the system
|
||||||
|
# These should be updated
|
||||||
|
JWT_SECRET=testsecret
|
||||||
MINIO_ACCESS_KEY=budibase
|
MINIO_ACCESS_KEY=budibase
|
||||||
MINIO_SECRET_KEY=budibase
|
MINIO_SECRET_KEY=budibase
|
||||||
COUCH_DB_PASSWORD=budibase
|
COUCH_DB_PASSWORD=budibase
|
||||||
COUCH_DB_USER=budibase
|
COUCH_DB_USER=budibase
|
||||||
WORKER_API_KEY=budibase
|
WORKER_API_KEY=budibase
|
||||||
BUDIBASE_ENVIRONMENT=PRODUCTION
|
|
||||||
HOSTING_URL=http://localhost
|
# This section contains variables that do not need to be altered under normal circumstances
|
||||||
LOGO_URL=https://logoipsum.com/logo/logo-15.svg
|
|
||||||
MAIN_PORT=10000
|
|
||||||
APP_PORT=4002
|
APP_PORT=4002
|
||||||
WORKER_PORT=4003
|
WORKER_PORT=4003
|
||||||
MINIO_PORT=4004
|
MINIO_PORT=4004
|
||||||
COUCH_DB_PORT=4005
|
COUCH_DB_PORT=4005
|
||||||
JWT_SECRET=testsecret
|
BUDIBASE_ENVIRONMENT=PRODUCTION
|
||||||
|
|
|
@ -1,2 +1,2 @@
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
docker-compose --env-file hosting.properties up
|
docker-compose --env-file hosting.properties up --build
|
||||||
|
|
|
@ -6,14 +6,18 @@
|
||||||
import analytics from "analytics"
|
import analytics from "analytics"
|
||||||
import { onMount } from "svelte"
|
import { onMount } from "svelte"
|
||||||
|
|
||||||
let selfhosted = false
|
|
||||||
let hostingInfo
|
let hostingInfo
|
||||||
|
let selfhosted = false
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
if (!selfhosted) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
hostingInfo.type = selfhosted ? "self" : "cloud"
|
hostingInfo.type = selfhosted ? "self" : "cloud"
|
||||||
|
if (!selfhosted && hostingInfo._rev) {
|
||||||
|
hostingInfo = {
|
||||||
|
type: hostingInfo.type,
|
||||||
|
_id: hostingInfo._id,
|
||||||
|
_rev: hostingInfo._rev,
|
||||||
|
}
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await hostingStore.actions.save(hostingInfo)
|
await hostingStore.actions.save(hostingInfo)
|
||||||
notifier.success(`Settings saved.`)
|
notifier.success(`Settings saved.`)
|
||||||
|
@ -22,6 +26,15 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateSelfHosting(event) {
|
||||||
|
|
||||||
|
if (hostingInfo.type === "cloud" && event.target.checked) {
|
||||||
|
hostingInfo.hostingUrl = "localhost:10000"
|
||||||
|
hostingInfo.useHttps = false
|
||||||
|
hostingInfo.selfHostKey = "budibase"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
hostingInfo = await hostingStore.actions.fetch()
|
hostingInfo = await hostingStore.actions.fetch()
|
||||||
selfhosted = hostingInfo.type === "self"
|
selfhosted = hostingInfo.type === "self"
|
||||||
|
@ -31,8 +44,7 @@
|
||||||
<ModalContent
|
<ModalContent
|
||||||
title="Builder settings"
|
title="Builder settings"
|
||||||
confirmText="Save"
|
confirmText="Save"
|
||||||
onConfirm={save}
|
onConfirm={save}>
|
||||||
showConfirmButton={selfhosted}>
|
|
||||||
<h5>Theme</h5>
|
<h5>Theme</h5>
|
||||||
<ThemeEditor />
|
<ThemeEditor />
|
||||||
<h5>Hosting</h5>
|
<h5>Hosting</h5>
|
||||||
|
@ -40,9 +52,10 @@
|
||||||
This section contains settings that relate to the deployment and hosting of
|
This section contains settings that relate to the deployment and hosting of
|
||||||
apps made in this builder.
|
apps made in this builder.
|
||||||
</p>
|
</p>
|
||||||
<Toggle thin text="Self hosted" bind:checked={selfhosted} />
|
<Toggle thin text="Self hosted" on:change={updateSelfHosting} bind:checked={selfhosted} />
|
||||||
{#if selfhosted}
|
{#if selfhosted}
|
||||||
<Input bind:value={hostingInfo.hostingUrl} label="Apps URL" />
|
<Input bind:value={hostingInfo.hostingUrl} label="Hosting URL" />
|
||||||
|
<Input bind:value={hostingInfo.selfHostKey} label="Hosting Key" />
|
||||||
<Toggle thin text="HTTPS" bind:checked={hostingInfo.useHttps} />
|
<Toggle thin text="HTTPS" bind:checked={hostingInfo.useHttps} />
|
||||||
{/if}
|
{/if}
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<script>
|
<script>
|
||||||
import { writable } from "svelte/store"
|
import { writable } from "svelte/store"
|
||||||
import { store, automationStore, backendUiStore } from "builderStore"
|
import { store, automationStore, backendUiStore, hostingStore } from "builderStore"
|
||||||
import { string, object } from "yup"
|
import { string, object } from "yup"
|
||||||
import api, { get } from "builderStore/api"
|
import api, { get } from "builderStore/api"
|
||||||
import Form from "@svelteschool/svelte-forms"
|
import Form from "@svelteschool/svelte-forms"
|
||||||
|
@ -12,6 +12,7 @@
|
||||||
import { fade } from "svelte/transition"
|
import { fade } from "svelte/transition"
|
||||||
import { post } from "builderStore/api"
|
import { post } from "builderStore/api"
|
||||||
import analytics from "analytics"
|
import analytics from "analytics"
|
||||||
|
import {onMount} from "svelte"
|
||||||
|
|
||||||
//Move this to context="module" once svelte-forms is updated so that it can bind to stores correctly
|
//Move this to context="module" once svelte-forms is updated so that it can bind to stores correctly
|
||||||
const createAppStore = writable({ currentStep: 0, values: {} })
|
const createAppStore = writable({ currentStep: 0, values: {} })
|
||||||
|
@ -62,20 +63,27 @@
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
let steps = [
|
let steps = []
|
||||||
{
|
|
||||||
component: API,
|
onMount(async () => {
|
||||||
errors,
|
let hostingInfo = await hostingStore.actions.fetch()
|
||||||
},
|
steps = []
|
||||||
{
|
// only validate API key for Cloud
|
||||||
|
if (hostingInfo.type === "cloud") {
|
||||||
|
steps.push({
|
||||||
|
component: API,
|
||||||
|
errors,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
steps.push({
|
||||||
component: Info,
|
component: Info,
|
||||||
errors,
|
errors,
|
||||||
},
|
})
|
||||||
{
|
steps.push({
|
||||||
component: User,
|
component: User,
|
||||||
errors,
|
errors,
|
||||||
},
|
})
|
||||||
]
|
})
|
||||||
|
|
||||||
if (hasKey) {
|
if (hasKey) {
|
||||||
validationSchemas.shift()
|
validationSchemas.shift()
|
||||||
|
|
|
@ -84,7 +84,10 @@ async function deployApp(deployment) {
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
deployment.setStatus(DeploymentStatus.FAILURE, err.message)
|
deployment.setStatus(DeploymentStatus.FAILURE, err.message)
|
||||||
await storeLocalDeploymentHistory(deployment)
|
await storeLocalDeploymentHistory(deployment)
|
||||||
throw new Error(`Deployment Failed: ${err.message}`)
|
throw {
|
||||||
|
...err,
|
||||||
|
message: `Deployment Failed: ${err.message}`,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
const env = require("../../../environment")
|
|
||||||
const AWS = require("aws-sdk")
|
const AWS = require("aws-sdk")
|
||||||
const {
|
const {
|
||||||
deployToObjectStore,
|
deployToObjectStore,
|
||||||
|
@ -9,26 +8,34 @@ const {
|
||||||
getWorkerUrl,
|
getWorkerUrl,
|
||||||
getCouchUrl,
|
getCouchUrl,
|
||||||
getMinioUrl,
|
getMinioUrl,
|
||||||
|
getSelfHostKey,
|
||||||
} = require("../../../utilities/builder/hosting")
|
} = require("../../../utilities/builder/hosting")
|
||||||
|
|
||||||
exports.preDeployment = async function() {
|
exports.preDeployment = async function() {
|
||||||
const url = `${await getWorkerUrl()}/api/deploy`
|
const url = `${await getWorkerUrl()}/api/deploy`
|
||||||
const json = await fetchCredentials(url, {
|
try {
|
||||||
apiKey: env.BUDIBASE_API_KEY,
|
const json = await fetchCredentials(url, {
|
||||||
})
|
selfHostKey: await getSelfHostKey(),
|
||||||
|
|
||||||
// response contains:
|
|
||||||
// couchDbSession, bucket, objectStoreSession
|
|
||||||
|
|
||||||
// set credentials here, means any time we're verified we're ready to go
|
|
||||||
if (json.objectStoreSession) {
|
|
||||||
AWS.config.update({
|
|
||||||
accessKeyId: json.objectStoreSession.accessKeyId,
|
|
||||||
secretAccessKey: json.objectStoreSession.secretAccessKey,
|
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
return json
|
// response contains:
|
||||||
|
// couchDbSession, bucket, objectStoreSession
|
||||||
|
|
||||||
|
// set credentials here, means any time we're verified we're ready to go
|
||||||
|
if (json.objectStoreSession) {
|
||||||
|
AWS.config.update({
|
||||||
|
accessKeyId: json.objectStoreSession.accessKeyId,
|
||||||
|
secretAccessKey: json.objectStoreSession.secretAccessKey,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return json
|
||||||
|
} catch (err) {
|
||||||
|
throw {
|
||||||
|
message: "Unauthorised to deploy, check self hosting key",
|
||||||
|
status: 401,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
exports.postDeployment = async function() {
|
exports.postDeployment = async function() {
|
||||||
|
|
|
@ -17,6 +17,7 @@ exports.fetchCredentials = async function(url, body) {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
})
|
})
|
||||||
|
|
||||||
const json = await response.json()
|
const json = await response.json()
|
||||||
|
@ -26,7 +27,7 @@ exports.fetchCredentials = async function(url, body) {
|
||||||
|
|
||||||
if (response.status !== 200) {
|
if (response.status !== 200) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Error fetching temporary credentials for api key: ${body.apiKey}`
|
`Error fetching temporary credentials: ${JSON.stringify(json)}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -15,13 +15,17 @@ exports.fetchInfo = async ctx => {
|
||||||
exports.save = async ctx => {
|
exports.save = async ctx => {
|
||||||
const db = new CouchDB(BUILDER_CONFIG_DB)
|
const db = new CouchDB(BUILDER_CONFIG_DB)
|
||||||
const { type } = ctx.request.body
|
const { type } = ctx.request.body
|
||||||
if (type === HostingTypes.CLOUD) {
|
if (type === HostingTypes.CLOUD && ctx.request.body._rev) {
|
||||||
ctx.throw(400, "Cannot update Cloud hosting information")
|
ctx.body = await db.remove({
|
||||||
|
...ctx.request.body,
|
||||||
|
_id: HOSTING_DOC,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
ctx.body = await db.put({
|
||||||
|
...ctx.request.body,
|
||||||
|
_id: HOSTING_DOC,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
ctx.body = await db.put({
|
|
||||||
...ctx.request.body,
|
|
||||||
_id: HOSTING_DOC,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
exports.fetch = async ctx => {
|
exports.fetch = async ctx => {
|
||||||
|
|
|
@ -20,7 +20,7 @@ const env = require("../../../environment")
|
||||||
function objectStoreUrl() {
|
function objectStoreUrl() {
|
||||||
if (env.SELF_HOSTED) {
|
if (env.SELF_HOSTED) {
|
||||||
// TODO: need a better way to handle this, probably reverse proxy
|
// TODO: need a better way to handle this, probably reverse proxy
|
||||||
return `${env.HOSTING_URL}:${env.MINIO_PORT}/app-assets/assets`
|
return `${env.HOSTING_URL}:${env.PROXY_PORT}/app-assets/assets`
|
||||||
} else {
|
} else {
|
||||||
return "https://cdn.app.budi.live/assets"
|
return "https://cdn.app.budi.live/assets"
|
||||||
}
|
}
|
||||||
|
|
|
@ -38,7 +38,7 @@ module.exports = {
|
||||||
LOCAL_TEMPLATES: process.env.LOCAL_TEMPLATES,
|
LOCAL_TEMPLATES: process.env.LOCAL_TEMPLATES,
|
||||||
// self hosting features
|
// self hosting features
|
||||||
HOSTING_URL: process.env.HOSTING_URL,
|
HOSTING_URL: process.env.HOSTING_URL,
|
||||||
MINIO_PORT: process.env.MINIO_PORT,
|
PROXY_PORT: process.env.PROXY_PORT,
|
||||||
LOGO_URL: process.env.LOGO_URL,
|
LOGO_URL: process.env.LOGO_URL,
|
||||||
_set(key, value) {
|
_set(key, value) {
|
||||||
process.env[key] = value
|
process.env[key] = value
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
const CouchDB = require("../../db")
|
const CouchDB = require("../../db")
|
||||||
const { BUILDER_CONFIG_DB, HOSTING_DOC } = require("../../constants")
|
const { BUILDER_CONFIG_DB, HOSTING_DOC } = require("../../constants")
|
||||||
|
|
||||||
|
const PROD_HOSTING_URL = "app.budi.live"
|
||||||
|
|
||||||
function getProtocol(hostingInfo) {
|
function getProtocol(hostingInfo) {
|
||||||
return hostingInfo.useHttps ? "https://" : "http://"
|
return hostingInfo.useHttps ? "https://" : "http://"
|
||||||
}
|
}
|
||||||
|
@ -30,7 +32,8 @@ exports.getHostingInfo = async () => {
|
||||||
doc = {
|
doc = {
|
||||||
_id: HOSTING_DOC,
|
_id: HOSTING_DOC,
|
||||||
type: exports.HostingTypes.CLOUD,
|
type: exports.HostingTypes.CLOUD,
|
||||||
hostingUrl: "app.budi.live",
|
hostingUrl: PROD_HOSTING_URL,
|
||||||
|
selfHostKey: "",
|
||||||
templatesUrl: "prod-budi-templates.s3-eu-west-1.amazonaws.com",
|
templatesUrl: "prod-budi-templates.s3-eu-west-1.amazonaws.com",
|
||||||
useHttps: true,
|
useHttps: true,
|
||||||
}
|
}
|
||||||
|
@ -62,6 +65,11 @@ exports.getCouchUrl = async () => {
|
||||||
return getURLWithPath("/db")
|
return getURLWithPath("/db")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
exports.getSelfHostKey = async () => {
|
||||||
|
const hostingInfo = await exports.getHostingInfo()
|
||||||
|
return hostingInfo.selfHostKey
|
||||||
|
}
|
||||||
|
|
||||||
exports.getTemplatesUrl = async (appId, type, name) => {
|
exports.getTemplatesUrl = async (appId, type, name) => {
|
||||||
const hostingInfo = await exports.getHostingInfo()
|
const hostingInfo = await exports.getHostingInfo()
|
||||||
const protocol = getProtocol(hostingInfo)
|
const protocol = getProtocol(hostingInfo)
|
||||||
|
|
|
@ -10,9 +10,11 @@ const PUBLIC_READ_POLICY = {
|
||||||
Statement: [
|
Statement: [
|
||||||
{
|
{
|
||||||
Effect: "Allow",
|
Effect: "Allow",
|
||||||
Principal: "*",
|
Principal: {
|
||||||
|
AWS: ["*"]
|
||||||
|
},
|
||||||
Action: "s3:GetObject",
|
Action: "s3:GetObject",
|
||||||
Resource: `arn:aws:s3:::${APP_BUCKET}/*`,
|
Resource: [`arn:aws:s3:::${APP_BUCKET}/*`],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
@ -63,19 +65,18 @@ async function getMinioSession() {
|
||||||
Bucket: APP_BUCKET,
|
Bucket: APP_BUCKET,
|
||||||
})
|
})
|
||||||
.promise()
|
.promise()
|
||||||
} else if (err.statusCode === 403) {
|
|
||||||
await objClient
|
|
||||||
.putBucketPolicy({
|
|
||||||
Bucket: APP_BUCKET,
|
|
||||||
Policy: JSON.stringify(PUBLIC_READ_POLICY),
|
|
||||||
})
|
|
||||||
.promise()
|
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// TODO: need to do something better than this
|
// always make sure policy is correct
|
||||||
|
await objClient
|
||||||
|
.putBucketPolicy({
|
||||||
|
Bucket: APP_BUCKET,
|
||||||
|
Policy: JSON.stringify(PUBLIC_READ_POLICY),
|
||||||
|
})
|
||||||
|
.promise()
|
||||||
// Ideally want to send back some pre-signed URLs for files that are to be uploaded
|
// Ideally want to send back some pre-signed URLs for files that are to be uploaded
|
||||||
return {
|
return {
|
||||||
accessKeyId: env.MINIO_ACCESS_KEY,
|
accessKeyId: env.MINIO_ACCESS_KEY,
|
||||||
|
|
|
@ -10,6 +10,7 @@ module.exports = {
|
||||||
RAW_MINIO_URL: process.env.RAW_MINIO_URL,
|
RAW_MINIO_URL: process.env.RAW_MINIO_URL,
|
||||||
COUCH_DB_PORT: process.env.COUCH_DB_PORT,
|
COUCH_DB_PORT: process.env.COUCH_DB_PORT,
|
||||||
MINIO_PORT: process.env.MINIO_PORT,
|
MINIO_PORT: process.env.MINIO_PORT,
|
||||||
|
SELF_HOST_KEY: process.env.SELF_HOST_KEY,
|
||||||
_set(key, value) {
|
_set(key, value) {
|
||||||
process.env[key] = value
|
process.env[key] = value
|
||||||
module.exports[key] = value
|
module.exports[key] = value
|
||||||
|
|
|
@ -1,4 +1,9 @@
|
||||||
|
const env = require("../environment")
|
||||||
|
|
||||||
module.exports = async (ctx, next) => {
|
module.exports = async (ctx, next) => {
|
||||||
// TODO: need to check the API key provided in the header
|
if (!ctx.request.body.selfHostKey || env.SELF_HOST_KEY !== ctx.request.body.selfHostKey) {
|
||||||
await next()
|
ctx.throw(401, "Deployment unauthorised")
|
||||||
|
} else {
|
||||||
|
await next()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue