Merge branch 'master' into cloudflared

This commit is contained in:
Louis Lam 2022-03-29 17:42:55 +08:00
commit a6b52b7ba6
11 changed files with 264 additions and 82 deletions

View File

@ -12,6 +12,10 @@ const { loginRateLimiter } = require("./rate-limiter");
* @returns {Promise<Bean|null>} * @returns {Promise<Bean|null>}
*/ */
exports.login = async function (username, password) { exports.login = async function (username, password) {
if (typeof username !== "string" || typeof password !== "string") {
return null;
}
let user = await R.findOne("user", " username = ? AND active = 1 ", [ let user = await R.findOne("user", " username = ? AND active = 1 ", [
username, username,
]); ]);

View File

@ -477,6 +477,12 @@ class Monitor extends BeanModel {
stop() { stop() {
clearTimeout(this.heartbeatInterval); clearTimeout(this.heartbeatInterval);
this.isStop = true; this.isStop = true;
this.prometheus().remove();
}
prometheus() {
return new Prometheus(this);
} }
/** /**

View File

@ -86,6 +86,16 @@ class Prometheus {
} }
} }
remove() {
try {
monitor_cert_days_remaining.remove(this.monitorLabelValues);
monitor_cert_is_valid.remove(this.monitorLabelValues);
monitor_response_time.remove(this.monitorLabelValues);
monitor_status.remove(this.monitorLabelValues);
} catch (e) {
console.error(e);
}
}
} }
module.exports = { module.exports = {

View File

@ -34,6 +34,14 @@ const loginRateLimiter = new KumaRateLimiter({
errorMessage: "Too frequently, try again later." errorMessage: "Too frequently, try again later."
}); });
const twoFaRateLimiter = new KumaRateLimiter({
tokensPerInterval: 30,
interval: "minute",
fireImmediately: true,
errorMessage: "Too frequently, try again later."
});
module.exports = { module.exports = {
loginRateLimiter loginRateLimiter,
twoFaRateLimiter,
}; };

View File

@ -52,7 +52,7 @@ console.log("Importing this project modules");
debug("Importing Monitor"); debug("Importing Monitor");
const Monitor = require("./model/monitor"); const Monitor = require("./model/monitor");
debug("Importing Settings"); debug("Importing Settings");
const { getSettings, setSettings, setting, initJWTSecret, checkLogin, startUnitTest, FBSD, errorLog } = require("./util-server"); const { getSettings, setSettings, setting, initJWTSecret, checkLogin, startUnitTest, FBSD, errorLog, doubleCheckPassword } = require("./util-server");
debug("Importing Notification"); debug("Importing Notification");
const { Notification } = require("./notification"); const { Notification } = require("./notification");
@ -63,7 +63,7 @@ const Database = require("./database");
debug("Importing Background Jobs"); debug("Importing Background Jobs");
const { initBackgroundJobs } = require("./jobs"); const { initBackgroundJobs } = require("./jobs");
const { loginRateLimiter } = require("./rate-limiter"); const { loginRateLimiter, twoFaRateLimiter } = require("./rate-limiter");
const { basicAuth } = require("./auth"); const { basicAuth } = require("./auth");
const { login } = require("./auth"); const { login } = require("./auth");
@ -306,6 +306,15 @@ exports.entryPage = "dashboard";
socket.on("login", async (data, callback) => { socket.on("login", async (data, callback) => {
console.log("Login"); console.log("Login");
// Checking
if (typeof callback !== "function") {
return;
}
if (!data) {
return;
}
// Login Rate Limit // Login Rate Limit
if (! await loginRateLimiter.pass(callback)) { if (! await loginRateLimiter.pass(callback)) {
return; return;
@ -364,14 +373,27 @@ exports.entryPage = "dashboard";
}); });
socket.on("logout", async (callback) => { socket.on("logout", async (callback) => {
// Rate Limit
if (! await loginRateLimiter.pass(callback)) {
return;
}
socket.leave(socket.userID); socket.leave(socket.userID);
socket.userID = null; socket.userID = null;
callback();
if (typeof callback === "function") {
callback();
}
}); });
socket.on("prepare2FA", async (callback) => { socket.on("prepare2FA", async (currentPassword, callback) => {
try { try {
if (! await twoFaRateLimiter.pass(callback)) {
return;
}
checkLogin(socket); checkLogin(socket);
await doubleCheckPassword(socket, currentPassword);
let user = await R.findOne("user", " id = ? AND active = 1 ", [ let user = await R.findOne("user", " id = ? AND active = 1 ", [
socket.userID, socket.userID,
@ -406,14 +428,19 @@ exports.entryPage = "dashboard";
} catch (error) { } catch (error) {
callback({ callback({
ok: false, ok: false,
msg: "Error while trying to prepare 2FA.", msg: error.message,
}); });
} }
}); });
socket.on("save2FA", async (callback) => { socket.on("save2FA", async (currentPassword, callback) => {
try { try {
if (! await twoFaRateLimiter.pass(callback)) {
return;
}
checkLogin(socket); checkLogin(socket);
await doubleCheckPassword(socket, currentPassword);
await R.exec("UPDATE `user` SET twofa_status = 1 WHERE id = ? ", [ await R.exec("UPDATE `user` SET twofa_status = 1 WHERE id = ? ", [
socket.userID, socket.userID,
@ -426,14 +453,19 @@ exports.entryPage = "dashboard";
} catch (error) { } catch (error) {
callback({ callback({
ok: false, ok: false,
msg: "Error while trying to change 2FA.", msg: error.message,
}); });
} }
}); });
socket.on("disable2FA", async (callback) => { socket.on("disable2FA", async (currentPassword, callback) => {
try { try {
if (! await twoFaRateLimiter.pass(callback)) {
return;
}
checkLogin(socket); checkLogin(socket);
await doubleCheckPassword(socket, currentPassword);
await TwoFA.disable2FA(socket.userID); await TwoFA.disable2FA(socket.userID);
callback({ callback({
@ -443,36 +475,47 @@ exports.entryPage = "dashboard";
} catch (error) { } catch (error) {
callback({ callback({
ok: false, ok: false,
msg: "Error while trying to change 2FA.", msg: error.message,
}); });
} }
}); });
socket.on("verifyToken", async (token, callback) => { socket.on("verifyToken", async (token, currentPassword, callback) => {
let user = await R.findOne("user", " id = ? AND active = 1 ", [ try {
socket.userID, checkLogin(socket);
]); await doubleCheckPassword(socket, currentPassword);
let verify = notp.totp.verify(token, user.twofa_secret, twofa_verification_opts); let user = await R.findOne("user", " id = ? AND active = 1 ", [
socket.userID,
]);
if (user.twofa_last_token !== token && verify) { let verify = notp.totp.verify(token, user.twofa_secret, twofa_verification_opts);
callback({
ok: true, if (user.twofa_last_token !== token && verify) {
valid: true, callback({
}); ok: true,
} else { valid: true,
});
} else {
callback({
ok: false,
msg: "Invalid Token.",
valid: false,
});
}
} catch (error) {
callback({ callback({
ok: false, ok: false,
msg: "Invalid Token.", msg: error.message,
valid: false,
}); });
} }
}); });
socket.on("twoFAStatus", async (callback) => { socket.on("twoFAStatus", async (callback) => {
checkLogin(socket);
try { try {
checkLogin(socket);
let user = await R.findOne("user", " id = ? AND active = 1 ", [ let user = await R.findOne("user", " id = ? AND active = 1 ", [
socket.userID, socket.userID,
]); ]);
@ -489,9 +532,10 @@ exports.entryPage = "dashboard";
}); });
} }
} catch (error) { } catch (error) {
console.log(error);
callback({ callback({
ok: false, ok: false,
msg: "Error while trying to get 2FA status.", msg: error.message,
}); });
} }
}); });
@ -580,6 +624,9 @@ exports.entryPage = "dashboard";
throw new Error("Permission denied."); throw new Error("Permission denied.");
} }
// Reset Prometheus labels
monitorList[monitor.id]?.prometheus()?.remove();
bean.name = monitor.name; bean.name = monitor.name;
bean.type = monitor.type; bean.type = monitor.type;
bean.url = monitor.url; bean.url = monitor.url;
@ -937,21 +984,13 @@ exports.entryPage = "dashboard";
throw new Error("Password is too weak. It should contain alphabetic and numeric characters. It must be at least 6 characters in length."); throw new Error("Password is too weak. It should contain alphabetic and numeric characters. It must be at least 6 characters in length.");
} }
let user = await R.findOne("user", " id = ? AND active = 1 ", [ let user = await doubleCheckPassword(socket, password.currentPassword);
socket.userID, await user.resetPassword(password.newPassword);
]);
if (user && passwordHash.verify(password.currentPassword, user.password)) { callback({
ok: true,
user.resetPassword(password.newPassword); msg: "Password has been updated successfully.",
});
callback({
ok: true,
msg: "Password has been updated successfully.",
});
} else {
throw new Error("Incorrect current password");
}
} catch (e) { } catch (e) {
callback({ callback({
@ -978,10 +1017,14 @@ exports.entryPage = "dashboard";
} }
}); });
socket.on("setSettings", async (data, callback) => { socket.on("setSettings", async (data, currentPassword, callback) => {
try { try {
checkLogin(socket); checkLogin(socket);
if (data.disableAuth) {
await doubleCheckPassword(socket, currentPassword);
}
await setSettings("general", data); await setSettings("general", data);
exports.entryPage = data.entryPage; exports.entryPage = data.entryPage;

View File

@ -1,9 +1,8 @@
const tcpp = require("tcp-ping"); const tcpp = require("tcp-ping");
const Ping = require("./ping-lite"); const Ping = require("./ping-lite");
const { R } = require("redbean-node"); const { R } = require("redbean-node");
const { debug } = require("../src/util"); const { debug, genSecret } = require("../src/util");
const passwordHash = require("./password-hash"); const passwordHash = require("./password-hash");
const dayjs = require("dayjs");
const { Resolver } = require("dns"); const { Resolver } = require("dns");
const child_process = require("child_process"); const child_process = require("child_process");
const iconv = require("iconv-lite"); const iconv = require("iconv-lite");
@ -32,7 +31,7 @@ exports.initJWTSecret = async () => {
jwtSecretBean.key = "jwtSecret"; jwtSecretBean.key = "jwtSecret";
} }
jwtSecretBean.value = passwordHash.generate(dayjs() + ""); jwtSecretBean.value = passwordHash.generate(genSecret());
await R.store(jwtSecretBean); await R.store(jwtSecretBean);
return jwtSecretBean; return jwtSecretBean;
}; };
@ -321,6 +320,28 @@ exports.checkLogin = (socket) => {
} }
}; };
/**
* For logged-in users, double-check the password
* @param socket
* @param currentPassword
* @returns {Promise<Bean>}
*/
exports.doubleCheckPassword = async (socket, currentPassword) => {
if (typeof currentPassword !== "string") {
throw new Error("Wrong data type?");
}
let user = await R.findOne("user", " id = ? AND active = 1 ", [
socket.userID,
]);
if (!user || !passwordHash.verify(currentPassword, user.password)) {
throw new Error("Incorrect current password");
}
return user;
};
exports.startUnitTest = async () => { exports.startUnitTest = async () => {
console.log("Starting unit test..."); console.log("Starting unit test...");
const npm = /^win/.test(process.platform) ? "npm.cmd" : "npm"; const npm = /^win/.test(process.platform) ? "npm.cmd" : "npm";

View File

@ -9,7 +9,9 @@
<a v-if="searchText != ''" class="search-icon" @click="clearSearchText"> <a v-if="searchText != ''" class="search-icon" @click="clearSearchText">
<font-awesome-icon icon="times" /> <font-awesome-icon icon="times" />
</a> </a>
<input v-model="searchText" class="form-control search-input" :placeholder="$t('Search...')" /> <form>
<input v-model="searchText" class="form-control search-input" :placeholder="$t('Search...')" autocomplete="off" />
</form>
</div> </div>
</div> </div>
<div class="monitor-list" :class="{ scrollbar: scrollbar }"> <div class="monitor-list" :class="{ scrollbar: scrollbar }">

View File

@ -19,6 +19,19 @@
</div> </div>
<p v-if="showURI && twoFAStatus == false" class="text-break mt-2">{{ uri }}</p> <p v-if="showURI && twoFAStatus == false" class="text-break mt-2">{{ uri }}</p>
<div v-if="!(uri && twoFAStatus == false)" class="mb-3">
<label for="current-password" class="form-label">
{{ $t("Current Password") }}
</label>
<input
id="current-password"
v-model="currentPassword"
type="password"
class="form-control"
required
/>
</div>
<button v-if="uri == null && twoFAStatus == false" class="btn btn-primary" type="button" @click="prepare2FA()"> <button v-if="uri == null && twoFAStatus == false" class="btn btn-primary" type="button" @click="prepare2FA()">
{{ $t("Enable 2FA") }} {{ $t("Enable 2FA") }}
</button> </button>
@ -59,11 +72,11 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Modal } from "bootstrap" import { Modal } from "bootstrap";
import Confirm from "./Confirm.vue"; import Confirm from "./Confirm.vue";
import VueQrcode from "vue-qrcode" import VueQrcode from "vue-qrcode";
import { useToast } from "vue-toastification" import { useToast } from "vue-toastification";
const toast = useToast() const toast = useToast();
export default { export default {
components: { components: {
@ -73,35 +86,36 @@ export default {
props: {}, props: {},
data() { data() {
return { return {
currentPassword: "",
processing: false, processing: false,
uri: null, uri: null,
tokenValid: false, tokenValid: false,
twoFAStatus: null, twoFAStatus: null,
token: null, token: null,
showURI: false, showURI: false,
} };
}, },
mounted() { mounted() {
this.modal = new Modal(this.$refs.modal) this.modal = new Modal(this.$refs.modal);
this.getStatus(); this.getStatus();
}, },
methods: { methods: {
show() { show() {
this.modal.show() this.modal.show();
}, },
confirmEnableTwoFA() { confirmEnableTwoFA() {
this.$refs.confirmEnableTwoFA.show() this.$refs.confirmEnableTwoFA.show();
}, },
confirmDisableTwoFA() { confirmDisableTwoFA() {
this.$refs.confirmDisableTwoFA.show() this.$refs.confirmDisableTwoFA.show();
}, },
prepare2FA() { prepare2FA() {
this.processing = true; this.processing = true;
this.$root.getSocket().emit("prepare2FA", (res) => { this.$root.getSocket().emit("prepare2FA", this.currentPassword, (res) => {
this.processing = false; this.processing = false;
if (res.ok) { if (res.ok) {
@ -109,49 +123,51 @@ export default {
} else { } else {
toast.error(res.msg); toast.error(res.msg);
} }
}) });
}, },
save2FA() { save2FA() {
this.processing = true; this.processing = true;
this.$root.getSocket().emit("save2FA", (res) => { this.$root.getSocket().emit("save2FA", this.currentPassword, (res) => {
this.processing = false; this.processing = false;
if (res.ok) { if (res.ok) {
this.$root.toastRes(res) this.$root.toastRes(res);
this.getStatus(); this.getStatus();
this.currentPassword = "";
this.modal.hide(); this.modal.hide();
} else { } else {
toast.error(res.msg); toast.error(res.msg);
} }
}) });
}, },
disable2FA() { disable2FA() {
this.processing = true; this.processing = true;
this.$root.getSocket().emit("disable2FA", (res) => { this.$root.getSocket().emit("disable2FA", this.currentPassword, (res) => {
this.processing = false; this.processing = false;
if (res.ok) { if (res.ok) {
this.$root.toastRes(res) this.$root.toastRes(res);
this.getStatus(); this.getStatus();
this.currentPassword = "";
this.modal.hide(); this.modal.hide();
} else { } else {
toast.error(res.msg); toast.error(res.msg);
} }
}) });
}, },
verifyToken() { verifyToken() {
this.$root.getSocket().emit("verifyToken", this.token, (res) => { this.$root.getSocket().emit("verifyToken", this.token, this.currentPassword, (res) => {
if (res.ok) { if (res.ok) {
this.tokenValid = res.valid; this.tokenValid = res.valid;
} else { } else {
toast.error(res.msg); toast.error(res.msg);
} }
}) });
}, },
getStatus() { getStatus() {
@ -161,10 +177,10 @@ export default {
} else { } else {
toast.error(res.msg); toast.error(res.msg);
} }
}) });
}, },
}, },
} };
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@ -222,7 +222,7 @@
<p>Používejte ji prosím s rozmyslem.</p> <p>Používejte ji prosím s rozmyslem.</p>
</template> </template>
<template v-else-if="$i18n.locale === 'vi-VN' "> <template v-else-if="$i18n.locale === 'vi-VN' ">
<p>Bạn muốn <strong>TẮT XÁC THỰC</strong> không?</p> <p>Bạn muốn <strong>TẮT XÁC THỰC</strong> không?</p>
<p>Điều này rất nguy hiểm<strong>BẤT KỲ AI</strong> cũng thể truy cập cướp quyền điều khiển.</p> <p>Điều này rất nguy hiểm<strong>BẤT KỲ AI</strong> cũng thể truy cập cướp quyền điều khiển.</p>
<p>Vui lòng <strong>cẩn thận</strong>.</p> <p>Vui lòng <strong>cẩn thận</strong>.</p>
@ -234,6 +234,19 @@
<p>It is designed for scenarios <strong>where you intend to implement third-party authentication</strong> in front of Uptime Kuma such as Cloudflare Access, Authelia or other authentication mechanisms.</p> <p>It is designed for scenarios <strong>where you intend to implement third-party authentication</strong> in front of Uptime Kuma such as Cloudflare Access, Authelia or other authentication mechanisms.</p>
<p>Please use this option carefully!</p> <p>Please use this option carefully!</p>
</template> </template>
<div class="mb-3">
<label for="current-password2" class="form-label">
{{ $t("Current Password") }}
</label>
<input
id="current-password2"
v-model="password.currentPassword"
type="password"
class="form-control"
required
/>
</div>
</Confirm> </Confirm>
</div> </div>
</template> </template>
@ -310,7 +323,12 @@ export default {
disableAuth() { disableAuth() {
this.settings.disableAuth = true; this.settings.disableAuth = true;
this.saveSettings();
// Need current password to disable auth
// Set it to empty if done
this.saveSettings(() => {
this.password.currentPassword = "";
}, this.password.currentPassword);
}, },
enableAuth() { enableAuth() {

View File

@ -180,8 +180,8 @@ export default {
"Add a monitor": "Добавить монитор", "Add a monitor": "Добавить монитор",
"Edit Status Page": "Редактировать", "Edit Status Page": "Редактировать",
"Go to Dashboard": "Панель управления", "Go to Dashboard": "Панель управления",
"Status Page": "Мониторинг", "Status Page": "Страница статуса",
"Status Pages": "Página de Status", "Status Pages": "Страницы статуса",
Discard: "Отмена", Discard: "Отмена",
"Create Incident": "Создать инцидент", "Create Incident": "Создать инцидент",
"Switch to Dark Theme": "Тёмная тема", "Switch to Dark Theme": "Тёмная тема",
@ -311,28 +311,28 @@ export default {
"One record": "Одна запись", "One record": "Одна запись",
steamApiKeyDescription: "Для мониторинга игрового сервера Steam вам необходим Web-API ключ Steam. Зарегистрировать его можно здесь: ", steamApiKeyDescription: "Для мониторинга игрового сервера Steam вам необходим Web-API ключ Steam. Зарегистрировать его можно здесь: ",
"Certificate Chain": "Цепочка сертификатов", "Certificate Chain": "Цепочка сертификатов",
"Valid": "Действительный", Valid: "Действительный",
"Hide Tags": "Скрыть тэги", "Hide Tags": "Скрыть тэги",
Title: "Название инцидента:", Title: "Название инцидента:",
Content: "Содержание инцидента:", Content: "Содержание инцидента:",
Post: "Опубликовать", Post: "Опубликовать",
"Cancel": "Отмена", Cancel: "Отмена",
"Created": "Создано", Created: "Создано",
"Unpin": "Открепить", Unpin: "Открепить",
"Show Tags": "Показать тэги", "Show Tags": "Показать тэги",
"recent": "Сейчас", recent: "Сейчас",
"3h": "3 часа", "3h": "3 часа",
"6h": "6 часов", "6h": "6 часов",
"24h": "24 часа", "24h": "24 часа",
"1w": "1 неделя", "1w": "1 неделя",
"No monitors available.": "Нет доступных мониторов", "No monitors available.": "Нет доступных мониторов",
"Add one": "Добавить новый", "Add one": "Добавить новый",
"Backup": "Резервная копия", Backup: "Резервная копия",
"Security": "Безопасность", Security: "Безопасность",
"Shrink Database": "Сжать Базу Данных", "Shrink Database": "Сжать Базу Данных",
"Current User": "Текущий пользователь", "Current User": "Текущий пользователь",
"About": "О программе", About: "О программе",
"Description": "Описание", Description: "Описание",
"Powered by": "Работает на основе скрипта от", "Powered by": "Работает на основе скрипта от",
shrinkDatabaseDescription: "Включает VACUUM для базы данных SQLite. Если ваша база данных была создана на версии 1.10.0 и более, AUTO_VACUUM уже включен и это действие не требуется.", shrinkDatabaseDescription: "Включает VACUUM для базы данных SQLite. Если ваша база данных была создана на версии 1.10.0 и более, AUTO_VACUUM уже включен и это действие не требуется.",
deleteStatusPageMsg: "Вы действительно хотите удалить эту страницу статуса сервисов?", deleteStatusPageMsg: "Вы действительно хотите удалить эту страницу статуса сервисов?",
@ -343,4 +343,50 @@ export default {
primary: "ОСНОВНОЙ", primary: "ОСНОВНОЙ",
light: "СВЕТЛЫЙ", light: "СВЕТЛЫЙ",
dark: "ТЕМНЫЙ", dark: "ТЕМНЫЙ",
"New Status Page": "Новая страница статуса",
"Show update if available": "Показывать доступные обновления",
"Also check beta release": "Проверять обновления для бета версий",
"Add New Status Page": "Добавить страницу статуса",
Next: "Далее",
"Accept characters: a-z 0-9 -": "Разрешены символы: a-z 0-9 -",
"Start or end with a-z 0-9 only": "Начало и окончание имени только на символы: a-z 0-9",
"No consecutive dashes --": "Запрещено использовать тире --",
"HTTP Options": "HTTP Опции",
"Basic Auth": "HTTP Авторизация",
PushByTechulus: "Push by Techulus",
clicksendsms: "ClickSend SMS",
GoogleChat: "Google Chat (только Google Workspace)",
apiCredentials: "API реквизиты",
Done: "Готово",
Info: "Инфо",
"Steam API Key": "Steam API-Ключ",
"Pick a RR-Type...": "Выберите RR-Тип...",
"Pick Accepted Status Codes...": "Выберите принятые коды состояния...",
Default: "По умолчанию",
"Please input title and content": "Пожалуйста, введите название и содержание",
"Last Updated": "Последнее Обновление",
"Untitled Group": "Группа без названия",
Services: "Сервисы",
serwersms: "SerwerSMS.pl",
serwersmsAPIUser: "API Пользователь (включая префикс webapi_)",
serwersmsAPIPassword: "API Пароль",
serwersmsPhoneNumber: "Номер телефона",
serwersmsSenderName: "SMS Имя Отправителя (регистрированный через пользовательский портал)",
stackfield: "Stackfield",
smtpDkimSettings: "DKIM Настройки",
smtpDkimDesc: "Please refer to the Nodemailer DKIM {0} for usage.",
documentation: "документация",
smtpDkimDomain: "Имя Домена",
smtpDkimKeySelector: "Ключ",
smtpDkimPrivateKey: "Приватный ключ",
smtpDkimHashAlgo: "Алгоритм хэша (опционально)",
smtpDkimheaderFieldNames: "Заголовок ключей для подписи (опционально)",
smtpDkimskipFields: "Заколовок ключей не для подписи (опционально)",
gorush: "Gorush",
alerta: "Alerta",
alertaApiEndpoint: "Конечная точка API",
alertaEnvironment: "Среда",
alertaApiKey: "Ключ API",
alertaAlertState: "Состояние алерта",
alertaRecoverState: "Состояние восстановления",
}; };

View File

@ -131,10 +131,18 @@ export default {
}); });
}, },
saveSettings() { /**
this.$root.getSocket().emit("setSettings", this.settings, (res) => { * Save Settings
* @param currentPassword (Optional) Only need for disableAuth to true
*/
saveSettings(callback, currentPassword) {
this.$root.getSocket().emit("setSettings", this.settings, currentPassword, (res) => {
this.$root.toastRes(res); this.$root.toastRes(res);
this.loadSettings(); this.loadSettings();
if (callback) {
callback();
}
}); });
}, },
} }