nostrclient/templates/nostrclient/index.html
Vlad Stan a119c3836a
Improve stability (#25)
* chore: increase log level

* feat: secure relays endpoint

* feat: add config for the extension

* chore: update `min_lnbits_version`

* chore: improve logging

* fix: decrypt logic

* chore: improve logs
2024-01-22 13:46:22 +02:00

574 lines
19 KiB
HTML

{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %} {% raw %}
<div class="row q-col-gutter-md">
<div class="col-12 col-md-7 q-gutter-y-md">
<q-card>
<q-form @submit="addRelay">
<div class="row">
<div class="col-12 col-md-7 q-pa-md">
<q-input outlined v-model="relayToAdd" dense filled label="Relay URL"></q-input>
</div>
<div class="col-6 col-md-3 q-pa-md">
<q-btn-dropdown unelevated split color="primary" class="float-left" type="submit" label="Add Relay Y">
<q-item v-for="relay in predefinedRelays" :key="relay" @click="addCustomRelay(relay)" clickable
v-close-popup>
<q-item-section>
<q-item-label><span v-text="relay"></span></q-item-label>
</q-item-section>
</q-item>
</q-btn-dropdown>
</div>
<div class="col-6 col-md-2 q-pa-md">
<q-btn unelevated @click="config.showDialog = true" color="primary" icon="settings"
class="float-right"></q-btn>
</div>
</div>
</q-form>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Nostrclient</h5>
</div>
<div class="col-auto">
<q-input borderless dense debounce="300" v-model="filter" placeholder="Search">
<template v-slot:append>
<q-icon name="search"></q-icon>
</template>
</q-input>
</div>
</div>
<q-table flat dense :data="nostrrelayLinks" row-key="id" :columns="relayTable.columns"
:pagination.sync="relayTable.pagination" :filter="filter">
<template v-slot:header="props">
<q-tr :props="props">
<q-th v-for="col in props.cols" :key="col.name" :props="props" auto-width>
<div v-if="col.name == 'id'"></div>
<div v-else>{{ col.label }}</div>
</q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td v-for="col in props.cols" :key="col.name" :props="props" auto-width>
<div v-if="col.name == 'connected'">
<div v-if="col.value">🟢</div>
<div v-else>🔴</div>
</div>
<div v-else-if="col.name == 'status'">
<div>
⬆️ <span v-text="col.value.sentEvents"></span> ⬇️
<span v-text="col.value.receveidEvents"></span>
<span @click="showLogDataDialog(col.value.errorList)" class="cursor-pointer">
⚠️ <span v-text="col.value.errorCount"> </span>
</span>
<span @click="showLogDataDialog(col.value.noticeList)" class="cursor-pointer float-right">
</span>
</div>
</div>
<div v-else-if="col.name == 'delete'">
<q-btn flat dense size="md" @click="showDeleteRelayDialog(props.row.url)" icon="cancel"
color="pink"></q-btn>
</div>
<div v-else>
<div>{{ col.value }}</div>
</div>
</q-td>
</q-tr>
</template>
</q-table>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<div class="row">
<div class="col">
<div class="text-weight-bold">
<q-btn flat dense size="0.6rem" class="q-px-none q-mx-none" color="grey" icon="content_copy"
@click="copyText(`wss://${host}/nostrclient/api/v1/relay`)"><q-tooltip>Copy address</q-tooltip></q-btn>
Your endpoint:
<q-badge outline class="q-ml-sm text-subtitle2" :label="`wss://${host}/nostrclient/api/v1/relay`" />
</div>
</div>
</div>
</q-card-section>
<q-expansion-item group="advanced" icon="settings" label="Test this endpoint" @click="toggleTestPanel">
<q-separator></q-separator>
<q-card-section>
<div class="row">
<div class="col-3">
<span>Sender Private Key:</span>
</div>
<div class="col-9">
<q-input outlined v-model="testData.senderPrivateKey" dense filled
label="Private Key (optional)"></q-input>
</div>
</div>
<div class="row q-mt-sm q-mb-lg">
<div class="col-3"></div>
<div class="col-9">
<q-badge color="yellow" text-color="black">
<span>
No not use your real private key! Leave empty for a randomly
generated key.</span>
</q-badge>
</div>
</div>
<div v-if="testData.senderPublicKey" class="row">
<div class="col-3">
<span>Sender Public Key:</span>
</div>
<div class="col-9">
<q-input outlined v-model="testData.senderPublicKey" dense readonly filled></q-input>
</div>
</div>
<div class="row q-mt-md">
<div class="col-3">
<span>Test Message:</span>
</div>
<div class="col-9">
<q-input outlined v-model="testData.message" dense filled rows="3" type="textarea"
label="Test Message *"></q-input>
</div>
</div>
<div class="row q-mt-md">
<div class="col-3">
<span>Receiver Public Key:</span>
</div>
<div class="col-9">
<q-input outlined v-model="testData.recieverPublicKey" dense filled
label="Public Key (hex or npub) *"></q-input>
</div>
</div>
<div class="row q-mt-sm q-mb-lg">
<div class="col-3"></div>
<div class="col-9">
<q-badge color="yellow" text-color="black">
<span>This is the recipient of the message. Field required.</span>
</q-badge>
</div>
</div>
<div class="row">
<div class="col-12">
<q-btn :disabled="!testData.recieverPublicKey || !testData.message" @click="sendTestMessage" unelevated
color="primary" class="float-right">Send Message</q-btn>
</div>
</div>
</q-card-section>
<q-separator></q-separator>
<q-card-section>
<div class="row q-mt-md">
<div class="col-3">
<span>Sent Data:</span>
</div>
<div class="col-9">
<q-input outlined v-model="testData.sentData" dense filled rows="5" type="textarea"></q-input>
</div>
</div>
<div class="row q-mt-md">
<div class="col-3">
<span>Received Data:</span>
</div>
<div class="col-9">
<q-input outlined v-model="testData.receivedData" dense filled rows="5" type="textarea"></q-input>
</div>
</div>
</q-card-section>
</q-expansion-item>
</q-card>
</div>
<div class="col-12 col-md-5 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">Nostrclient Extension</h6>
<p>
This extension is a always-on nostr client that other extensions can
use to send and receive events on nostr. Add multiple nostr relays to
connect to. The extension then opens a websocket for you to use at
</p>
<p>
<q-badge outline class="q-ml-sm text-subtitle2" color="primary"
:label="`wss://${host}/nostrclient/api/v1/relay`" />
</p>
Only Admin users can manage this extension.
<q-card-section></q-card-section>
</q-card-section>
</q-card>
</div>
<q-dialog v-model="logData.show" position="top">
<q-card class="q-pa-lg q-pt-xl">
<q-input filled dense v-model.trim="logData.data" type="textarea" rows="25" cols="200" label="Log Data"></q-input>
<div class="row q-mt-lg">
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
</q-card>
</q-dialog>
<q-dialog v-model="config.showDialog" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="updateConfig" class="q-gutter-md">
<q-toggle label="Expose Private Websocket" color="secodary" v-model="config.data.private_ws"></q-toggle>
<br />
<q-toggle label="Expose Public Websocket" color="secodary" v-model="config.data.public_ws"></q-toggle>
<div class="row q-mt-lg">
<q-btn unelevated color="primary" type="submit">Update</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
</q-form>
</q-card>
</q-dialog>
</div>
{% endraw %} {% endblock %} {% block scripts %} {{ window_vars(user) }}
<script>
Vue.component(VueQrcode.name, VueQrcode)
var maplrelays = obj => {
obj._data = _.clone(obj)
obj.theTime = obj.time * 60 - (Date.now() / 1000 - obj.timestamp)
obj.time = obj.time + 'mins'
obj.status = {
sentEvents: obj.status.num_sent_events,
receveidEvents: obj.status.num_received_events,
errorCount: obj.status.error_counter,
errorList: obj.status.error_list,
noticeList: obj.status.notice_list
}
obj.ping = obj.ping + ' ms'
if (obj.time_elapsed) {
obj.date = 'Time elapsed'
} else {
obj.date = Quasar.utils.date.formatDate(
new Date((obj.theTime - 3600) * 1000),
'HH:mm:ss'
)
}
return obj
}
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
base_url: location.protocol + '//' + location.host,
host: location.host,
relayToAdd: '',
nostrrelayLinks: [],
filter: '',
logData: {
show: false,
data: null
},
config: {
showDialog: false,
data: {},
},
testData: {
show: false,
wsConnection: null,
senderPrivateKey: null,
senderPublicKey: null,
recieverPublicKey: null,
message: null,
sentData: '',
receivedData: ''
},
relayTable: {
columns: [
{
name: 'connected',
align: 'left',
label: '',
field: 'connected'
},
{
name: 'relay',
align: 'left',
label: 'URL',
field: 'url'
},
{
name: 'status',
align: 'center',
label: 'Status',
field: 'status'
},
{
name: 'ping',
align: 'center',
label: 'Ping',
field: 'ping'
},
{
name: 'delete',
align: 'center',
label: '',
field: ''
}
],
pagination: {
rowsPerPage: 10
}
},
predefinedRelays: [
'wss://relay.damus.io',
'wss://nostr-pub.wellorder.net',
'wss://nostr.zebedee.cloud',
'wss://nodestr.fmt.wiz.biz',
'wss://nostr.oxtr.dev',
'wss://nostr.wine'
]
}
},
methods: {
getRelays: function () {
var self = this
LNbits.api
.request(
'GET',
'/nostrclient/api/v1/relays?usr=' + this.g.user.id,
this.g.user.wallets[0].adminkey
)
.then(function (response) {
if (response.data) {
response.data.map(maplrelays)
self.nostrrelayLinks = response.data
}
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
addRelay() {
if (
!this.relayToAdd.startsWith('wss://') &&
!this.relayToAdd.startsWith('ws://')
) {
this.relayToAdd = ''
this.$q.notify({
timeout: 5000,
type: 'warning',
message: `Invalid relay URL.`,
caption: "Should start with 'wss://'' or 'ws://'"
})
return false
}
console.log('ADD RELAY ' + this.relayToAdd)
let that = this
LNbits.api
.request(
'POST',
'/nostrclient/api/v1/relay?usr=' + this.g.user.id,
this.g.user.wallets[0].adminkey,
{ url: this.relayToAdd }
)
.then(function (response) {
console.log('response:', response)
if (response.data) {
response.data.map(maplrelays)
that.nostrrelayLinks = response.data
that.relayToAdd = ''
}
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
return false
},
async addCustomRelay(relayUrl) {
this.relayToAdd = relayUrl
await this.addRelay()
},
showDeleteRelayDialog: function (url) {
LNbits.utils
.confirmDialog(' Are you sure you want to remove this relay?')
.onOk(async () => {
this.deleteRelay(url)
})
},
deleteRelay(url) {
LNbits.api
.request(
'DELETE',
'/nostrclient/api/v1/relay?usr=' + this.g.user.id,
this.g.user.wallets[0].adminkey,
{ url: url }
)
.then(response => {
const relayIndex = this.nostrrelayLinks.indexOf(r => r.url === url)
if (relayIndex !== -1) {
this.nostrrelayLinks.splice(relayIndex, 1)
}
})
.catch(error => {
console.error(error)
LNbits.utils.notifyApiError(error)
})
},
getConfig: async function () {
try {
const { data } = await LNbits.api
.request(
'GET',
'/nostrclient/api/v1/config',
this.g.user.wallets[0].adminkey
)
this.config.data = data
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
updateConfig: async function () {
try {
const { data } = await LNbits.api.request(
'PUT',
'/nostrclient/api/v1/config',
this.g.user.wallets[0].adminkey,
this.config.data
)
this.config.data = data
} catch (error) {
LNbits.utils.notifyApiError(error)
}
this.config.showDialog = false
},
toggleTestPanel: async function () {
if (this.testData.show) {
await this.hideTestPannel()
} else {
await this.showTestPanel()
}
},
showTestPanel: async function () {
this.testData = {
show: true,
wsConnection: null,
senderPrivateKey:
this.$q.localStorage.getItem(
'lnbits.nostrclient.senderPrivateKey'
) || '',
recieverPublicKey: null,
message: null,
sentData: '',
receivedData: ''
}
await this.closeWebsocket()
this.connectToWebsocket()
},
hideTestPannel: async function () {
await this.closeWebsocket()
this.testData = {
show: false,
wsConnection: null,
senderPrivateKey: null,
recieverPublicKey: null,
message: null,
sentData: '',
receivedData: ''
}
},
sendTestMessage: async function () {
try {
const { data } = await LNbits.api.request(
'PUT',
'/nostrclient/api/v1/relay/test?usr=' + this.g.user.id,
this.g.user.wallets[0].adminkey,
{
sender_private_key: this.testData.senderPrivateKey,
reciever_public_key: this.testData.recieverPublicKey,
message: this.testData.message
}
)
this.testData.senderPrivateKey = data.private_key
this.$q.localStorage.set(
'lnbits.nostrclient.senderPrivateKey',
data.private_key || ''
)
const event = JSON.parse(data.event_json)[1]
this.testData.senderPublicKey = event.pubkey
await this.sendDataToWebSocket(data.event_json)
const subscription = JSON.stringify([
'REQ',
'test-dms',
{ kinds: [4], '#p': [event.pubkey] }
])
this.testData.wsConnection.send(subscription)
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
sendDataToWebSocket: async function (data) {
try {
if (!this.testData.wsConnection) {
this.connectToWebsocket()
await this.sleep(500)
}
this.testData.wsConnection.send(data)
const separator = '='.repeat(80)
this.testData.sentData =
data + `\n\n${separator}\n` + this.testData.sentData
} catch (error) {
this.$q.notify({
timeout: 5000,
type: 'warning',
message: 'Failed to connect to websocket',
caption: `${error}`
})
}
},
connectToWebsocket: function () {
const scheme = location.protocol === 'http:' ? 'ws' : 'wss'
const port = location.port ? `:${location.port}` : ''
const wsUrl = `${scheme}://${document.domain}${port}/nostrclient/api/v1/relay`
this.testData.wsConnection = new WebSocket(wsUrl)
const updateReciveData = async e => {
const separator = '='.repeat(80)
this.testData.receivedData =
e.data + `\n\n${separator}\n` + this.testData.receivedData
}
this.testData.wsConnection.onmessage = updateReciveData
this.testData.wsConnection.onerror = updateReciveData
this.testData.wsConnection.onclose = updateReciveData
},
closeWebsocket: async function () {
try {
if (this.testData.wsConnection) {
this.testData.wsConnection.close()
await this.sleep(100)
}
} catch (error) {
console.warn(error)
}
},
showLogDataDialog: function (data = []) {
this.logData.data = data.join('\n')
this.logData.show = true
},
exportlnurldeviceCSV: function () {
var self = this
LNbits.utils.exportCSV(self.relayTable.columns, this.nostrLinks)
},
sleep: ms => new Promise(r => setTimeout(r, ms))
},
created: async function () {
this.getRelays()
await this.getConfig()
setInterval(this.getRelays, 5000)
}
})
</script>
{% endblock %}