This commit is contained in:
James Read 2026-03-11 00:38:18 +00:00 committed by GitHub
commit 00cc130414
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 406 additions and 179 deletions

View File

@ -11,19 +11,19 @@
"dependencies": {
"@connectrpc/connect": "^2.1.1",
"@connectrpc/connect-web": "^2.1.1",
"@hugeicons/core-free-icons": "^3.3.0",
"@hugeicons/vue": "^1.0.4",
"@hugeicons/core-free-icons": "^4.0.0",
"@hugeicons/vue": "^1.0.5",
"@vitejs/plugin-vue": "^6.0.4",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"iconify-icon": "^3.0.2",
"picocrank": "^1.14.0",
"picocrank": "^1.14.1",
"standard": "^17.1.2",
"unplugin-vue-components": "^31.0.0",
"vite": "^7.3.1",
"vue": "^3.5.29",
"vue-i18n": "^11.2.8",
"vue": "^3.5.30",
"vue-i18n": "^11.3.0",
"vue-router": "^5.0.3"
},
"devDependencies": {
@ -906,15 +906,15 @@
}
},
"node_modules/@hugeicons/core-free-icons": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@hugeicons/core-free-icons/-/core-free-icons-3.3.0.tgz",
"integrity": "sha512-qYyr4JQ2eQIHTSTbITvnJvs6ERNK64D9gpwZnf2IyuG0exzqfyABLO/oTB71FB3RZPfu1GbwycdiGSo46apjMQ==",
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@hugeicons/core-free-icons/-/core-free-icons-4.0.0.tgz",
"integrity": "sha512-bzfbKumv3ke3ajbe2MyXi9i0I/cdsZ6n/mO9EfIPNSL++pHLqs7nSGRIVUtjF4xrrEyVkfhxssv4Jek8DPA6gA==",
"license": "MIT"
},
"node_modules/@hugeicons/vue": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@hugeicons/vue/-/vue-1.0.4.tgz",
"integrity": "sha512-OtFEXbyW5jYUig98C/n/HygktLvfF5Ga6nN6gK8R0E0jCrVw3EfgoZZVXqo+xGxyIjH5R1wdbg6nJrtf6mzLKQ==",
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@hugeicons/vue/-/vue-1.0.5.tgz",
"integrity": "sha512-kaouUZceXtdDfupfiqqfn40tIyRBF/fcEvCfY96hZIXZ3JMsqpwhCDqiqoj+B5bMEUXOuvz3npNyKI5+7iPfYA==",
"license": "MIT",
"peerDependencies": {
"vue": "^2.6.0 || ^3.0.0"
@ -962,13 +962,30 @@
"license": "MIT"
},
"node_modules/@intlify/core-base": {
"version": "11.2.8",
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.2.8.tgz",
"integrity": "sha512-nBq6Y1tVkjIUsLsdOjDSJj4AsjvD0UG3zsg9Fyc+OivwlA/oMHSKooUy9tpKj0HqZ+NWFifweHavdljlBLTwdA==",
"version": "11.3.0",
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.3.0.tgz",
"integrity": "sha512-NNX5jIwF4TJBe7RtSKDMOA6JD9mp2mRcBHAwt2X+Q8PvnZub0yj5YYXlFu2AcESdgQpEv/5Yx2uOCV/yh7YkZg==",
"license": "MIT",
"dependencies": {
"@intlify/message-compiler": "11.2.8",
"@intlify/shared": "11.2.8"
"@intlify/devtools-types": "11.3.0",
"@intlify/message-compiler": "11.3.0",
"@intlify/shared": "11.3.0"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@intlify/devtools-types": {
"version": "11.3.0",
"resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.3.0.tgz",
"integrity": "sha512-G9CNL4WpANWVdUjubOIIS7/D2j/0j+1KJmhBJxHilWNKr9mmt3IjFV3Hq4JoBP23uOoC5ynxz/FHZ42M+YxfGw==",
"license": "MIT",
"dependencies": {
"@intlify/core-base": "11.3.0",
"@intlify/shared": "11.3.0"
},
"engines": {
"node": ">= 16"
@ -978,12 +995,12 @@
}
},
"node_modules/@intlify/message-compiler": {
"version": "11.2.8",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.2.8.tgz",
"integrity": "sha512-A5n33doOjmHsBtCN421386cG1tWp5rpOjOYPNsnpjIJbQ4POF0QY2ezhZR9kr0boKwaHjbOifvyQvHj2UTrDFQ==",
"version": "11.3.0",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.3.0.tgz",
"integrity": "sha512-RAJp3TMsqohg/Wa7bVF3cChRhecSYBLrTCQSj7j0UtWVFLP+6iEJoE2zb7GU5fp+fmG5kCbUdzhmlAUCWXiUJw==",
"license": "MIT",
"dependencies": {
"@intlify/shared": "11.2.8",
"@intlify/shared": "11.3.0",
"source-map-js": "^1.0.2"
},
"engines": {
@ -994,9 +1011,9 @@
}
},
"node_modules/@intlify/shared": {
"version": "11.2.8",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.2.8.tgz",
"integrity": "sha512-l6e4NZyUgv8VyXXH4DbuucFOBmxLF56C/mqh2tvApbzl2Hrhi1aTDcuv5TKdxzfHYmpO3UB0Cz04fgDT9vszfw==",
"version": "11.3.0",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.3.0.tgz",
"integrity": "sha512-LC6P/uay7rXL5zZ5+5iRJfLs/iUN8apu9tm8YqQVmW3Uq3X4A0dOFUIDuAmB7gAC29wTHOS3EiN/IosNSz0eNQ==",
"license": "MIT",
"engines": {
"node": ">= 16"
@ -1436,53 +1453,53 @@
}
},
"node_modules/@vue/compiler-core": {
"version": "3.5.29",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.29.tgz",
"integrity": "sha512-cuzPhD8fwRHk8IGfmYaR4eEe4cAyJEL66Ove/WZL7yWNL134nqLddSLwNRIsFlnnW1kK+p8Ck3viFnC0chXCXw==",
"version": "3.5.30",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.30.tgz",
"integrity": "sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.0",
"@vue/shared": "3.5.29",
"@vue/shared": "3.5.30",
"entities": "^7.0.1",
"estree-walker": "^2.0.2",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-dom": {
"version": "3.5.29",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.29.tgz",
"integrity": "sha512-n0G5o7R3uBVmVxjTIYcz7ovr8sy7QObFG8OQJ3xGCDNhbG60biP/P5KnyY8NLd81OuT1WJflG7N4KWYHaeeaIg==",
"version": "3.5.30",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.30.tgz",
"integrity": "sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==",
"license": "MIT",
"dependencies": {
"@vue/compiler-core": "3.5.29",
"@vue/shared": "3.5.29"
"@vue/compiler-core": "3.5.30",
"@vue/shared": "3.5.30"
}
},
"node_modules/@vue/compiler-sfc": {
"version": "3.5.29",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.29.tgz",
"integrity": "sha512-oJZhN5XJs35Gzr50E82jg2cYdZQ78wEwvRO6Y63TvLVTc+6xICzJHP1UIecdSPPYIbkautNBanDiWYa64QSFIA==",
"version": "3.5.30",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.30.tgz",
"integrity": "sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.0",
"@vue/compiler-core": "3.5.29",
"@vue/compiler-dom": "3.5.29",
"@vue/compiler-ssr": "3.5.29",
"@vue/shared": "3.5.29",
"@vue/compiler-core": "3.5.30",
"@vue/compiler-dom": "3.5.30",
"@vue/compiler-ssr": "3.5.30",
"@vue/shared": "3.5.30",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.21",
"postcss": "^8.5.6",
"postcss": "^8.5.8",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-ssr": {
"version": "3.5.29",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.29.tgz",
"integrity": "sha512-Y/ARJZE6fpjzL5GH/phJmsFwx3g6t2KmHKHx5q+MLl2kencADKIrhH5MLF6HHpRMmlRAYBRSvv347Mepf1zVNw==",
"version": "3.5.30",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.30.tgz",
"integrity": "sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.29",
"@vue/shared": "3.5.29"
"@vue/compiler-dom": "3.5.30",
"@vue/shared": "3.5.30"
}
},
"node_modules/@vue/devtools-api": {
@ -1516,53 +1533,53 @@
}
},
"node_modules/@vue/reactivity": {
"version": "3.5.29",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.29.tgz",
"integrity": "sha512-zcrANcrRdcLtmGZETBxWqIkoQei8HaFpZWx/GHKxx79JZsiZ8j1du0VUJtu4eJjgFvU/iKL5lRXFXksVmI+5DA==",
"version": "3.5.30",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.30.tgz",
"integrity": "sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==",
"license": "MIT",
"dependencies": {
"@vue/shared": "3.5.29"
"@vue/shared": "3.5.30"
}
},
"node_modules/@vue/runtime-core": {
"version": "3.5.29",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.29.tgz",
"integrity": "sha512-8DpW2QfdwIWOLqtsNcds4s+QgwSaHSJY/SUe04LptianUQ/0xi6KVsu/pYVh+HO3NTVvVJjIPL2t6GdeKbS4Lg==",
"version": "3.5.30",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.30.tgz",
"integrity": "sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.29",
"@vue/shared": "3.5.29"
"@vue/reactivity": "3.5.30",
"@vue/shared": "3.5.30"
}
},
"node_modules/@vue/runtime-dom": {
"version": "3.5.29",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.29.tgz",
"integrity": "sha512-AHvvJEtcY9tw/uk+s/YRLSlxxQnqnAkjqvK25ZiM4CllCZWzElRAoQnCM42m9AHRLNJ6oe2kC5DCgD4AUdlvXg==",
"version": "3.5.30",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.30.tgz",
"integrity": "sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.29",
"@vue/runtime-core": "3.5.29",
"@vue/shared": "3.5.29",
"@vue/reactivity": "3.5.30",
"@vue/runtime-core": "3.5.30",
"@vue/shared": "3.5.30",
"csstype": "^3.2.3"
}
},
"node_modules/@vue/server-renderer": {
"version": "3.5.29",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.29.tgz",
"integrity": "sha512-G/1k6WK5MusLlbxSE2YTcqAAezS+VuwHhOvLx2KnQU7G2zCH6KIb+5Wyt6UjMq7a3qPzNEjJXs1hvAxDclQH+g==",
"version": "3.5.30",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.30.tgz",
"integrity": "sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==",
"license": "MIT",
"dependencies": {
"@vue/compiler-ssr": "3.5.29",
"@vue/shared": "3.5.29"
"@vue/compiler-ssr": "3.5.30",
"@vue/shared": "3.5.30"
},
"peerDependencies": {
"vue": "3.5.29"
"vue": "3.5.30"
}
},
"node_modules/@vue/shared": {
"version": "3.5.29",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.29.tgz",
"integrity": "sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg==",
"version": "3.5.30",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.30.tgz",
"integrity": "sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==",
"license": "MIT"
},
"node_modules/@xterm/addon-fit": {
@ -4875,19 +4892,19 @@
"license": "ISC"
},
"node_modules/picocrank": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/picocrank/-/picocrank-1.14.0.tgz",
"integrity": "sha512-ksjqPHFMFE6ENaIXjhund50wocFmaLy22jYgWlWikugHBdd/0YlHfOOuoIMn0wKV8bSrJhcM3pQug/qz45Bc4g==",
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/picocrank/-/picocrank-1.14.1.tgz",
"integrity": "sha512-N/aGK/deicXevv+n3zWaKrUe66d4QYQ/7SPinEr4fprljNpXQHSl2EBDlmTNlLoujR3QJHClfRO8Gusj2Hs5MQ==",
"license": "ISC",
"dependencies": {
"@hugeicons/core-free-icons": "^3.1.1",
"@hugeicons/vue": "^1.0.4",
"@hugeicons/core-free-icons": "^4.0.0",
"@hugeicons/vue": "^1.0.5",
"@vitejs/plugin-vue": "^6.0.4",
"femtocrank": "^2.5.0",
"unplugin-vue-components": "^31.0.0",
"vite": "^7.3.1",
"vue": "^3.5.28",
"vue-router": "^5.0.2"
"vue": "^3.5.30",
"vue-router": "^5.0.3"
}
},
"node_modules/picomatch": {
@ -5007,9 +5024,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
"funding": [
{
"type": "opencollective",
@ -6603,16 +6620,16 @@
}
},
"node_modules/vue": {
"version": "3.5.29",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.29.tgz",
"integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==",
"version": "3.5.30",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz",
"integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.29",
"@vue/compiler-sfc": "3.5.29",
"@vue/runtime-dom": "3.5.29",
"@vue/server-renderer": "3.5.29",
"@vue/shared": "3.5.29"
"@vue/compiler-dom": "3.5.30",
"@vue/compiler-sfc": "3.5.30",
"@vue/runtime-dom": "3.5.30",
"@vue/server-renderer": "3.5.30",
"@vue/shared": "3.5.30"
},
"peerDependencies": {
"typescript": "*"
@ -6624,13 +6641,14 @@
}
},
"node_modules/vue-i18n": {
"version": "11.2.8",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.2.8.tgz",
"integrity": "sha512-vJ123v/PXCZntd6Qj5Jumy7UBmIuE92VrtdX+AXr+1WzdBHojiBxnAxdfctUFL+/JIN+VQH4BhsfTtiGsvVObg==",
"version": "11.3.0",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.3.0.tgz",
"integrity": "sha512-1J+xDfDJTLhDxElkd3+XUhT7FYSZd2b8pa7IRKGxhWH/8yt6PTvi3xmWhGwhYT5EaXdatui11pF2R6tL73/zPA==",
"license": "MIT",
"dependencies": {
"@intlify/core-base": "11.2.8",
"@intlify/shared": "11.2.8",
"@intlify/core-base": "11.3.0",
"@intlify/devtools-types": "11.3.0",
"@intlify/shared": "11.3.0",
"@vue/devtools-api": "^6.5.0"
},
"engines": {

View File

@ -24,19 +24,19 @@
"dependencies": {
"@connectrpc/connect": "^2.1.1",
"@connectrpc/connect-web": "^2.1.1",
"@hugeicons/core-free-icons": "^3.3.0",
"@hugeicons/vue": "^1.0.4",
"@hugeicons/core-free-icons": "^4.0.0",
"@hugeicons/vue": "^1.0.5",
"@vitejs/plugin-vue": "^6.0.4",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"iconify-icon": "^3.0.2",
"picocrank": "^1.14.0",
"picocrank": "^1.14.1",
"standard": "^17.1.2",
"unplugin-vue-components": "^31.0.0",
"vite": "^7.3.1",
"vue": "^3.5.29",
"vue-i18n": "^11.2.8",
"vue": "^3.5.30",
"vue-i18n": "^11.3.0",
"vue-router": "^5.0.3"
}
}

View File

@ -1,7 +1,9 @@
<template>
<span id="connection-banner" v-if="!connectionState.connected" class="inline-notification critical user-info-connection">
<span class="connection-banner-sr-only" role="status">{{ staticAnnouncement }}</span>
<span aria-hidden="true">{{ bannerText }}</span>
<span aria-hidden="true">
<a :href="websocketDocsUrl" target="_blank" rel="noopener noreferrer" class="connection-banner-link">{{ linkText }}</a>{{ bannerSuffix }}
</span>
</span>
</template>
@ -12,6 +14,10 @@ import { connectionState } from '../stores/connectionState.js'
const { t } = useI18n()
const websocketDocsUrl = 'https://docs.olivetin.app/troubleshooting/err-websocket-connection.html'
const linkText = computed(() => t('disconnected-banner-link-text'))
function formatShortRelative(ms) {
if (ms < 0) return '0s'
const secs = Math.floor(ms / 1000)
@ -49,16 +55,16 @@ onUnmounted(() => {
const staticAnnouncement = computed(() => t('disconnected-banner-announcement'))
const bannerText = computed(() => {
const bannerSuffix = computed(() => {
const at = connectionState.disconnectedAt
const next = connectionState.nextReconnectAt
const n = now.value
const disconnectedSince = formatShortTime(at)
if (next != null && next > n) {
const reconnectIn = formatShortRelative(next - n)
return t('disconnected-banner', { disconnectedSince, reconnectIn })
return t('disconnected-banner-suffix', { disconnectedSince, reconnectIn })
}
return t('disconnected-banner-reconnecting', { disconnectedSince })
return t('disconnected-banner-suffix-reconnecting', { disconnectedSince })
})
</script>
@ -70,6 +76,10 @@ const bannerText = computed(() => {
border: 0;
margin: 0;
}
.connection-banner-link {
color: inherit;
text-decoration: underline;
}
.connection-banner-sr-only {
position: absolute;
width: 1px;

View File

@ -21,9 +21,10 @@
"diagnostics.useragent-data-error": "Fehler beim Abrufen von userAgentData",
"diagnostics.where-to-find-help": "Wo Sie Hilfe finden",
"disconnected": "Getrennt",
"disconnected-banner": "Events-Websocket getrennt seit {disconnectedSince}. Erneuter Verbindungsversuch in {reconnectIn}.",
"disconnected-banner-announcement": "Events-Websocket getrennt.",
"disconnected-banner-reconnecting": "Events-Websocket getrennt seit {disconnectedSince}. Verbindungsversuch…",
"disconnected-banner-link-text": "Events-Websocket getrennt",
"disconnected-banner-suffix": " seit {disconnectedSince}. Erneuter Verbindungsversuch in {reconnectIn}.",
"disconnected-banner-suffix-reconnecting": " seit {disconnectedSince}. Verbindungsversuch…",
"docs": "Dokumentation",
"language-dialog.browser-languages": "Browser-Sprachen",
"language-dialog.close": "Schließen",
@ -79,9 +80,10 @@
"diagnostics.useragent-data-error": "Error retrieving userAgentData",
"diagnostics.where-to-find-help": "Where to find help",
"disconnected": "Disconnected",
"disconnected-banner": "Events websocket disconnected since {disconnectedSince}. Trying reconnect in {reconnectIn}.",
"disconnected-banner-announcement": "Events websocket disconnected.",
"disconnected-banner-reconnecting": "Events websocket disconnected since {disconnectedSince}. Trying reconnect…",
"disconnected-banner-link-text": "Events websocket disconnected",
"disconnected-banner-suffix": " since {disconnectedSince}. Trying reconnect in {reconnectIn}.",
"disconnected-banner-suffix-reconnecting": " since {disconnectedSince}. Trying reconnect…",
"docs": "Documentation",
"language-dialog.browser-languages": "Browser languages",
"language-dialog.close": "Close",
@ -137,9 +139,10 @@
"diagnostics.useragent-data-error": "Error al recuperar userAgentData",
"diagnostics.where-to-find-help": "Dónde encontrar ayuda",
"disconnected": "Desconectado",
"disconnected-banner": "Websocket de eventos desconectado desde {disconnectedSince}. Reintentando conexión en {reconnectIn}.",
"disconnected-banner-announcement": "Websocket de eventos desconectado.",
"disconnected-banner-reconnecting": "Websocket de eventos desconectado desde {disconnectedSince}. Reintentando conexión…",
"disconnected-banner-link-text": "Websocket de eventos desconectado",
"disconnected-banner-suffix": " desde {disconnectedSince}. Reintentando conexión en {reconnectIn}.",
"disconnected-banner-suffix-reconnecting": " desde {disconnectedSince}. Reintentando conexión…",
"docs": "Documentación",
"language-dialog.browser-languages": "Idiomas del navegador",
"language-dialog.close": "Cerrar",
@ -195,9 +198,10 @@
"diagnostics.useragent-data-error": "Errore nel recupero di userAgentData",
"diagnostics.where-to-find-help": "Dove trovare aiuto",
"disconnected": "Disconnesso",
"disconnected-banner": "Websocket eventi disconnesso dalle {disconnectedSince}. Nuovo tentativo tra {reconnectIn}.",
"disconnected-banner-announcement": "Websocket eventi disconnesso.",
"disconnected-banner-reconnecting": "Websocket eventi disconnesso dalle {disconnectedSince}. Tentativo di connessione…",
"disconnected-banner-link-text": "Websocket eventi disconnesso",
"disconnected-banner-suffix": " dalle {disconnectedSince}. Nuovo tentativo tra {reconnectIn}.",
"disconnected-banner-suffix-reconnecting": " dalle {disconnectedSince}. Tentativo di connessione…",
"docs": "Documentazione",
"language-dialog.browser-languages": "Lingue del browser",
"language-dialog.close": "Chiudi",
@ -253,9 +257,10 @@
"diagnostics.useragent-data-error": "检索 userAgentData 时出错",
"diagnostics.where-to-find-help": "在哪里找到帮助",
"disconnected": "已断开连接",
"disconnected-banner": "事件 WebSocket 自 {disconnectedSince} 已断开。{reconnectIn} 后尝试重连。",
"disconnected-banner-announcement": "事件 WebSocket 已断开。",
"disconnected-banner-reconnecting": "事件 WebSocket 自 {disconnectedSince} 已断开。正在尝试重连…",
"disconnected-banner-link-text": "事件 WebSocket 已断开",
"disconnected-banner-suffix": "自 {disconnectedSince}。{reconnectIn} 后尝试重连。",
"disconnected-banner-suffix-reconnecting": "自 {disconnectedSince}。正在尝试重连…",
"docs": "文档",
"language-dialog.browser-languages": "浏览器语言",
"language-dialog.close": "关闭",

View File

@ -8,9 +8,10 @@ translations:
connected: Verbunden
disconnected: Getrennt
reconnecting: Verbinde erneut…
disconnected-banner: "Events-Websocket getrennt seit {disconnectedSince}. Erneuter Verbindungsversuch in {reconnectIn}."
disconnected-banner-reconnecting: "Events-Websocket getrennt seit {disconnectedSince}. Verbindungsversuch…"
disconnected-banner-announcement: Events-Websocket getrennt.
disconnected-banner-link-text: "Events-Websocket getrennt"
disconnected-banner-suffix: " seit {disconnectedSince}. Erneuter Verbindungsversuch in {reconnectIn}."
disconnected-banner-suffix-reconnecting: " seit {disconnectedSince}. Verbindungsversuch…"
login-button: Login
raise-issue: Ein Problem melden auf GitHub
docs: Dokumentation

View File

@ -10,9 +10,10 @@ translations:
connected: Connected
disconnected: Disconnected
reconnecting: Reconnecting…
disconnected-banner: "Events websocket disconnected since {disconnectedSince}. Trying reconnect in {reconnectIn}."
disconnected-banner-reconnecting: "Events websocket disconnected since {disconnectedSince}. Trying reconnect…"
disconnected-banner-announcement: Events websocket disconnected.
disconnected-banner-link-text: "Events websocket disconnected"
disconnected-banner-suffix: " since {disconnectedSince}. Trying reconnect in {reconnectIn}."
disconnected-banner-suffix-reconnecting: " since {disconnectedSince}. Trying reconnect…"
login-button: Login
logs.title: Logs
logs.page-description: This is a list of logs from actions that have been executed. You can filter the list by action title.

View File

@ -8,9 +8,10 @@ translations:
connected: Conectado
disconnected: Desconectado
reconnecting: Reconectando…
disconnected-banner: "Websocket de eventos desconectado desde {disconnectedSince}. Reintentando conexión en {reconnectIn}."
disconnected-banner-reconnecting: "Websocket de eventos desconectado desde {disconnectedSince}. Reintentando conexión…"
disconnected-banner-announcement: Websocket de eventos desconectado.
disconnected-banner-link-text: "Websocket de eventos desconectado"
disconnected-banner-suffix: " desde {disconnectedSince}. Reintentando conexión en {reconnectIn}."
disconnected-banner-suffix-reconnecting: " desde {disconnectedSince}. Reintentando conexión…"
login-button: Iniciar sesión
raise-issue: Reportar un problema en GitHub
docs: Documentación

View File

@ -9,9 +9,10 @@ translations:
connected: Connesso
disconnected: Disconnesso
reconnecting: Riconnessione…
disconnected-banner: "Websocket eventi disconnesso dalle {disconnectedSince}. Nuovo tentativo tra {reconnectIn}."
disconnected-banner-reconnecting: "Websocket eventi disconnesso dalle {disconnectedSince}. Tentativo di connessione…"
disconnected-banner-announcement: Websocket eventi disconnesso.
disconnected-banner-link-text: "Websocket eventi disconnesso"
disconnected-banner-suffix: " dalle {disconnectedSince}. Nuovo tentativo tra {reconnectIn}."
disconnected-banner-suffix-reconnecting: " dalle {disconnectedSince}. Tentativo di connessione…"
login-button: Login
raise-issue: Segnala un problema su GitHub
logs.title: Registri

View File

@ -8,9 +8,10 @@ translations:
connected: 已连接
disconnected: 已断开连接
reconnecting: 正在重新连接…
disconnected-banner: "事件 WebSocket 自 {disconnectedSince} 已断开。{reconnectIn} 后尝试重连。"
disconnected-banner-reconnecting: "事件 WebSocket 自 {disconnectedSince} 已断开。正在尝试重连…"
disconnected-banner-announcement: 事件 WebSocket 已断开。
disconnected-banner-link-text: "事件 WebSocket 已断开"
disconnected-banner-suffix: "自 {disconnectedSince}。{reconnectIn} 后尝试重连。"
disconnected-banner-suffix-reconnecting: "自 {disconnectedSince}。正在尝试重连…"
login-button: 登录
raise-issue: 在 GitHub 上报告问题
docs: 文档

View File

@ -60,6 +60,20 @@ type streamingClient struct {
AuthenticatedUser *authpublic.AuthenticatedUser
}
// trySendEventToClient sends msg to the client's channel. Returns false if the channel is full (client should be removed).
func (api *oliveTinAPI) trySendEventToClient(client *streamingClient, msg *apiv1.EventStreamResponse) bool {
if client == nil || msg == nil {
return false
}
select {
case client.channel <- msg:
return true
default:
log.Warnf("EventStream: client channel is full, removing client")
return false
}
}
func (api *oliveTinAPI) KillAction(ctx ctx.Context, req *connect.Request[apiv1.KillActionRequest]) (*connect.Response[apiv1.KillActionResponse], error) {
ret := &apiv1.KillActionResponse{
ExecutionTrackingId: req.Msg.ExecutionTrackingId,
@ -589,9 +603,20 @@ func isValidLogEntry(e *executor.InternalLogEntry) bool {
// isLogEntryAllowed checks if a log entry is allowed to be viewed by the user.
func (api *oliveTinAPI) isLogEntryAllowed(e *executor.InternalLogEntry, user *authpublic.AuthenticatedUser) bool {
if user == nil || !isValidLogEntry(e) {
return false
}
return acl.IsAllowedLogs(api.cfg, user, e.Binding.Action)
}
// mayViewExecutionEvent returns whether the user is allowed to receive this execution event (for EventStream ACL).
func (api *oliveTinAPI) mayViewExecutionEvent(entry *executor.InternalLogEntry, user *authpublic.AuthenticatedUser) bool {
if user == nil {
return false
}
return isValidLogEntry(entry) && api.isLogEntryAllowed(entry, user)
}
// buildEmptyPageResponse creates a response for an empty page.
func buildEmptyPageResponse(page pageInfo) *apiv1.GetActionLogsResponse {
return &apiv1.GetActionLogsResponse{
@ -886,6 +911,9 @@ func (api *oliveTinAPI) EventStream(ctx ctx.Context, req *connect.Request[apiv1.
}
func (api *oliveTinAPI) removeClient(clientToRemove *streamingClient) {
if clientToRemove == nil {
return
}
api.streamingClientsMutex.Lock()
delete(api.streamingClients, clientToRemove)
api.streamingClientsMutex.Unlock()
@ -915,50 +943,62 @@ func (api *oliveTinAPI) OnActionMapRebuilt() {
func (api *oliveTinAPI) OnExecutionStarted(ex *executor.InternalLogEntry) {
toRemove := []*streamingClient{}
for _, client := range api.copyOfStreamingClients() {
select {
case client.channel <- &apiv1.EventStreamResponse{
Event: &apiv1.EventStreamResponse_ExecutionStarted{
ExecutionStarted: &apiv1.EventExecutionStarted{
LogEntry: api.internalLogEntryToPb(ex, client.AuthenticatedUser),
},
},
}:
default:
log.Warnf("EventStream: client channel is full, removing client")
toRemove = append(toRemove, client)
}
api.maybeSendExecutionStarted(client, ex, &toRemove)
}
for _, client := range toRemove {
api.removeClient(client)
}
}
func (api *oliveTinAPI) maybeSendExecutionStarted(client *streamingClient, ex *executor.InternalLogEntry, toRemove *[]*streamingClient) {
if client == nil {
return
}
if !api.mayViewExecutionEvent(ex, client.AuthenticatedUser) {
return
}
msg := &apiv1.EventStreamResponse{
Event: &apiv1.EventStreamResponse_ExecutionStarted{
ExecutionStarted: &apiv1.EventExecutionStarted{
LogEntry: api.internalLogEntryToPb(ex, client.AuthenticatedUser),
},
},
}
if !api.trySendEventToClient(client, msg) {
*toRemove = append(*toRemove, client)
}
}
func (api *oliveTinAPI) OnExecutionFinished(ile *executor.InternalLogEntry) {
toRemove := []*streamingClient{}
for _, client := range api.copyOfStreamingClients() {
select {
case client.channel <- &apiv1.EventStreamResponse{
Event: &apiv1.EventStreamResponse_ExecutionFinished{
ExecutionFinished: &apiv1.EventExecutionFinished{
LogEntry: api.internalLogEntryToPb(ile, client.AuthenticatedUser),
},
},
}:
default:
log.Warnf("EventStream: client channel is full, removing client")
toRemove = append(toRemove, client)
}
api.maybeSendExecutionFinished(client, ile, &toRemove)
}
for _, client := range toRemove {
api.removeClient(client)
}
}
func (api *oliveTinAPI) maybeSendExecutionFinished(client *streamingClient, ile *executor.InternalLogEntry, toRemove *[]*streamingClient) {
if client == nil {
return
}
if !api.mayViewExecutionEvent(ile, client.AuthenticatedUser) {
return
}
msg := &apiv1.EventStreamResponse{
Event: &apiv1.EventStreamResponse_ExecutionFinished{
ExecutionFinished: &apiv1.EventExecutionFinished{
LogEntry: api.internalLogEntryToPb(ile, client.AuthenticatedUser),
},
},
}
if !api.trySendEventToClient(client, msg) {
*toRemove = append(*toRemove, client)
}
}
func (api *oliveTinAPI) GetDiagnostics(ctx ctx.Context, req *connect.Request[apiv1.GetDiagnosticsRequest]) (*connect.Response[apiv1.GetDiagnosticsResponse], error) {
user := auth.UserFromApiCall(ctx, req, api.cfg)
if err := api.checkDashboardAccess(user); err != nil {
@ -1128,29 +1168,47 @@ func buildAdditionalLinks(links []*config.NavigationLink) []*apiv1.AdditionalLin
}
func (api *oliveTinAPI) OnOutputChunk(content []byte, executionTrackingId string) {
toRemove := []*streamingClient{}
for _, client := range api.copyOfStreamingClients() {
select {
case client.channel <- &apiv1.EventStreamResponse{
Event: &apiv1.EventStreamResponse_OutputChunk{
OutputChunk: &apiv1.EventOutputChunk{
Output: string(content),
ExecutionTrackingId: executionTrackingId,
},
},
}:
default:
log.Warnf("EventStream: client channel is full, removing client")
toRemove = append(toRemove, client)
}
entry := api.getValidLogEntryForStreaming(executionTrackingId)
if entry == nil {
return
}
msg := &apiv1.EventStreamResponse{
Event: &apiv1.EventStreamResponse_OutputChunk{
OutputChunk: &apiv1.EventOutputChunk{
Output: string(content),
ExecutionTrackingId: executionTrackingId,
},
},
}
toRemove := []*streamingClient{}
for _, client := range api.copyOfStreamingClients() {
api.maybeSendOutputChunk(client, entry, msg, &toRemove)
}
for _, client := range toRemove {
api.removeClient(client)
}
}
func (api *oliveTinAPI) getValidLogEntryForStreaming(executionTrackingId string) *executor.InternalLogEntry {
entry, ok := api.executor.GetLog(executionTrackingId)
if !ok || !isValidLogEntry(entry) {
return nil
}
return entry
}
func (api *oliveTinAPI) maybeSendOutputChunk(client *streamingClient, entry *executor.InternalLogEntry, msg *apiv1.EventStreamResponse, toRemove *[]*streamingClient) {
if client == nil {
return
}
if !api.mayViewExecutionEvent(entry, client.AuthenticatedUser) {
return
}
if !api.trySendEventToClient(client, msg) {
*toRemove = append(*toRemove, client)
}
}
func (api *oliveTinAPI) GetEntities(ctx ctx.Context, req *connect.Request[apiv1.GetEntitiesRequest]) (*connect.Response[apiv1.GetEntitiesResponse], error) {
user := auth.UserFromApiCall(ctx, req, api.cfg)

View File

@ -2,24 +2,24 @@ package api
import (
"context"
"net/http"
"net/http/httptest"
"path"
"testing"
"time"
"connectrpc.com/connect"
"github.com/google/uuid"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
log "github.com/sirupsen/logrus"
apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
apiv1connect "github.com/OliveTin/OliveTin/gen/olivetin/api/v1/apiv1connect"
authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
config "github.com/OliveTin/OliveTin/internal/config"
"github.com/OliveTin/OliveTin/internal/entities"
"github.com/OliveTin/OliveTin/internal/executor"
"net/http"
"net/http/httptest"
"path"
)
func getNewTestServerAndClient(injectedConfig *config.Config) (*httptest.Server, apiv1connect.OliveTinApiServiceClient) {
@ -338,12 +338,13 @@ func testWithEntity(t *testing.T, binding *executor.ActionBinding, rr *Dashboard
}
// buildViewPermissionTestConfig returns config and users for GHSA view-permission tests:
// one action "secret_action", ACL "restricted" (view:false) for user "low", ACL "full" (view:true) for user "admin".
// one action "secret_action", ACL "restricted" (view:false, logs:false) for user "low", ACL "full" (view:true, logs:true) for user "admin".
func buildViewPermissionTestConfig(t *testing.T) (*config.Config, *authpublic.AuthenticatedUser, *authpublic.AuthenticatedUser) {
t.Helper()
cfg := config.DefaultConfig()
cfg.DefaultPermissions.View = false
cfg.DefaultPermissions.Exec = false
cfg.DefaultPermissions.Logs = false
cfg.Actions = append(cfg.Actions, &config.Action{
ID: "secret_action",
@ -572,3 +573,133 @@ func TestOrderTopLevelDashboardComponents_SortablesSorted(t *testing.T) {
assert.Equal(t, "Alpha", out[0].Title, "sortables ordered by title")
assert.Equal(t, "Beta", out[1].Title)
}
// TestEventStreamACLNoLeakToUnauthorizedUser (GHSA-228v-wc5r-j8m7) asserts that EventStream
// does not send execution events or output chunks to users who are not allowed to view that action's logs.
func TestEventStreamACLNoLeakToUnauthorizedUser(t *testing.T) {
cfg, lowUser, adminUser := buildViewPermissionTestConfig(t)
ex := executor.DefaultExecutor(cfg)
ex.RebuildActionMap()
api := newServer(ex)
binding := ex.FindBindingByID("secret_action")
require.NotNil(t, binding, "secret_action binding must exist")
clientLow, clientAdmin := addEventStreamTestClients(t, api, lowUser, adminUser)
defer removeEventStreamTestClients(api, clientLow, clientAdmin)
runEventStreamTestExecution(t, ex, cfg, binding, adminUser)
adminEvents := drainEventStreamUntilFinished(clientAdmin.channel, 2*time.Second)
lowEvents := drainEventStreamWithTimeout(clientLow.channel, 50*time.Millisecond)
assertEventStreamLowUserReceivesNothing(t, lowEvents)
assertEventStreamAdminReceivesSecretActionEvents(t, adminEvents)
}
func addEventStreamTestClients(t *testing.T, api *oliveTinAPI, lowUser, adminUser *authpublic.AuthenticatedUser) (*streamingClient, *streamingClient) {
t.Helper()
clientLow := &streamingClient{
channel: make(chan *apiv1.EventStreamResponse, 20),
AuthenticatedUser: lowUser,
}
clientAdmin := &streamingClient{
channel: make(chan *apiv1.EventStreamResponse, 20),
AuthenticatedUser: adminUser,
}
api.streamingClientsMutex.Lock()
api.streamingClients[clientLow] = struct{}{}
api.streamingClients[clientAdmin] = struct{}{}
api.streamingClientsMutex.Unlock()
return clientLow, clientAdmin
}
func removeEventStreamTestClients(api *oliveTinAPI, clientLow, clientAdmin *streamingClient) {
api.streamingClientsMutex.Lock()
delete(api.streamingClients, clientLow)
delete(api.streamingClients, clientAdmin)
api.streamingClientsMutex.Unlock()
close(clientLow.channel)
close(clientAdmin.channel)
}
func runEventStreamTestExecution(t *testing.T, ex *executor.Executor, cfg *config.Config, binding *executor.ActionBinding, adminUser *authpublic.AuthenticatedUser) {
t.Helper()
execReq := &executor.ExecutionRequest{
Binding: binding,
Arguments: map[string]string{},
TrackingID: uuid.NewString(),
Cfg: cfg,
AuthenticatedUser: adminUser,
}
wg, _ := ex.ExecRequest(execReq)
wg.Wait()
}
func drainEventStreamUntilFinished(ch <-chan *apiv1.EventStreamResponse, timeout time.Duration) []*apiv1.EventStreamResponse {
var out []*apiv1.EventStreamResponse
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
ev, finished := recvEventStreamOne(ch, 50*time.Millisecond)
if ev != nil {
out = append(out, ev)
}
if finished {
return out
}
}
return out
}
func recvEventStreamOne(ch <-chan *apiv1.EventStreamResponse, timeout time.Duration) (*apiv1.EventStreamResponse, bool) {
select {
case ev, ok := <-ch:
if !ok {
return nil, true
}
return ev, ev.GetExecutionFinished() != nil
case <-time.After(timeout):
return nil, true
}
}
func drainEventStreamWithTimeout(ch <-chan *apiv1.EventStreamResponse, timeout time.Duration) []*apiv1.EventStreamResponse {
var out []*apiv1.EventStreamResponse
for {
select {
case ev, ok := <-ch:
if !ok {
return out
}
out = append(out, ev)
case <-time.After(timeout):
return out
}
}
}
func assertEventStreamLowUserReceivesNothing(t *testing.T, lowEvents []*apiv1.EventStreamResponse) {
t.Helper()
for _, ev := range lowEvents {
assert.Nil(t, ev.GetExecutionStarted(), "low-privilege user must not receive ExecutionStarted")
assert.Nil(t, ev.GetExecutionFinished(), "low-privilege user must not receive ExecutionFinished")
assert.Nil(t, ev.GetOutputChunk(), "low-privilege user must not receive OutputChunk")
}
assert.Empty(t, lowEvents, "low-privilege user with Logs:false must not receive any execution events")
}
func assertEventStreamAdminReceivesSecretActionEvents(t *testing.T, adminEvents []*apiv1.EventStreamResponse) {
t.Helper()
var gotStarted, gotFinished bool
for _, ev := range adminEvents {
if ev.GetExecutionStarted() != nil {
gotStarted = true
assert.Equal(t, "secret_action", ev.GetExecutionStarted().LogEntry.GetBindingId())
}
if ev.GetExecutionFinished() != nil {
gotFinished = true
assert.Equal(t, "secret_action", ev.GetExecutionFinished().LogEntry.GetBindingId())
}
}
assert.True(t, gotStarted, "admin must receive ExecutionStarted for secret_action")
assert.True(t, gotFinished, "admin must receive ExecutionFinished for secret_action")
}

View File

@ -281,7 +281,7 @@ func DefaultConfigWithBasePort(basePort int) *Config {
config.Prometheus.Enabled = false
config.Prometheus.DefaultGoMetrics = false
config.Security.HeaderContentSecurityPolicy = true
config.Security.ContentSecurityPolicy = "default-src 'self'; script-src 'self' 'unsafe-inline' https:; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https:; frame-ancestors 'none'; base-uri 'self'"
config.Security.ContentSecurityPolicy = ContentSecurityPolicyDefault
config.Security.HeaderXContentTypeOptions = true
config.Security.HeaderXFrameOptions = true
config.Security.XFrameOptions = "DENY"

View File

@ -0,0 +1,3 @@
package config
const ContentSecurityPolicyDefault = "default-src 'self'; script-src 'self' 'unsafe-inline' https:; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https:; frame-ancestors 'none'; base-uri 'self'"

View File

@ -194,7 +194,7 @@ func (cfg *Config) sanitizeSecurityHeadersCSP() {
if !cfg.Security.HeaderContentSecurityPolicy || cfg.Security.ContentSecurityPolicy != "" {
return
}
cfg.Security.ContentSecurityPolicy = "default-src 'self'; script-src 'self' 'unsafe-inline' https:; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https:; frame-ancestors 'none'; base-uri 'self'"
cfg.Security.ContentSecurityPolicy = ContentSecurityPolicyDefault
}
func (cfg *Config) sanitizeSecurityHeadersXFrameOptions() {

View File

@ -250,13 +250,10 @@ func typecheckChoiceEntity(value string, arg *config.ActionArgument) error {
func typeSafetyCheckEmail(value string) error {
_, err := mail.ParseAddress(value)
log.Errorf("Email check: %v, %v", err, value)
if err != nil {
log.WithField("type", "email").Debugf("Email argument type check failed")
return err
}
return nil
}