This commit is contained in:
Riccardo Balbo 2024-07-16 16:20:26 +00:00
commit 299007e990
29 changed files with 2887 additions and 0 deletions

View file

@ -0,0 +1,103 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
<div class="row q-col-gutter-md" id="configTable">
<div class="col-12 q-gutter-y-md">
<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">NWC Service Provider - Config</h5>
</div>
</div>
<template>
<q-markup-table flat>
<tbody>
<q-tr v-for="entry in entries" :key="entry.key">
<q-td>
<span>
${entry.key}
</span>
</q-td>
<q-td>
<q-input v-model="entry.value" />
</q-td>
</q-tr>
</tbody>
</q-markup-table>
</template>
<q-btn @click="saveConfig" unelevated label="Save" color="primary" class="q-mt-md full-width" />
</q-card-section>
</q-card>
</div>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script>
new Vue({
el: '#vue',
mixins: [windowMixin],
delimiters: ['${', '}'],
data: function () {
return {
entries: [],
columns: [
{ name: 'key', required: true, label: 'Key', align: 'left', field: row => row.key, sortable: true },
{ name: 'value', required: true, label: 'Value', align: 'left', field: row => row.value, sortable: true },
]
}
},
methods: {
fetchConfig() {
this.entries = [];
LNbits.api
.request('GET', '/nwcprovider/api/v1/config', this.g.user.wallets[0].adminkey)
.then((response) => {
const newEntries = [];
for (const [key, value] of Object.entries(response.data)) {
newEntries.push({
key: key,
value: value,
});
}
this.entries = newEntries;
})
.catch(function (error) {
console.error("Error fetching config:", error);
});
},
async saveConfig() {
const data = {};
for (const entry of this.entries) {
data[entry.key] = entry.value;
}
try {
const response = await LNbits.api
.request('POST', '/nwcprovider/api/v1/config', this.g.user.wallets[0].adminkey, data)
this.$q.notify({
message: 'Config saved, please restart the server',
color: 'positive',
position: 'top',
timeout: 2000,
actions: [{ icon: 'close', color: 'white' }]
})
} catch (error) {
console.error("Error saving config:", error);
}
}
},
created: function () {
this.fetchConfig()
}
})
</script>
{% endblock %}

View file

@ -0,0 +1,659 @@
<!--/////////////////////////////////////////////////-->
<!--//PAGE FOR THE EXTENSIONS BACKEND IN LNBITS//////-->
<!--/////////////////////////////////////////////////-->
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
<div class="row q-col-gutter-md">
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
<q-card>
<q-card-section>
<!-- <div class="q-gutter-x-md row items-center"> -->
<!-- <div class="col"> -->
<q-select v-model="selectedWallet" filled dense emit-value :options="g.user.walletOptions"
label="Wallet *"></q-select>
<!-- </div>
<div class="col">
<q-btn unelevated color="primary" icon="add" @click="formDialog.show = true">Connect app</q-btn>
</div> -->
<!-- </div> -->
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col items-center">
<h5 class="text-subtitle1">Connected Apps</h5>
</div>
<q-btn @click="showConnectDialog()" aria-label="Connect" icon="add" color="primary" size="sm" round />
</div>
<q-table dense flat :data="nwcEntries" row-key="id" :columns="nwcsTable.columns"
:pagination.sync="nwcsTable.pagination">
<nwcprovider v-slot:header="props">
<q-tr :props="props">
<q-th v-for="col in props.cols" :key="col.name" :props="props">
${ col.label }
</q-th>
</q-tr>
</nwcprovider>
<template v-slot:body="props">
<q-tr :props="props">
<q-td v-for="col in props.cols" :key="col.name" :props="props">
<div>${ col.value }</div>
</q-td>
<q-td>
<q-btn flat dense size="xs" @click="openConnectionInfoDialog(props.row)" icon="info" color="primary">
<q-tooltip> Info </q-tooltip>
</q-btn>
<q-btn flat dense size="xs" @click="deleteNWC(props.row.pubkey)" icon="cancel" color="pink">
<q-tooltip> Delete </q-tooltip>
</q-btn>
</q-td>
</q-tr>
</template>
</q-table>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-4 col-lg-5 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">{{SITE_TITLE}} NWC Service provider extension</h6>
<p>
An extension that turns LNBits into a Nostr Wallet Connect service provider.
</p>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list>
<q-item clickable @click="go('../docs#/NWC%20Service%20Provider')"
class="q-flex items-center justify-between">
<q-icon name="swap_vertical_circle" class="q-mr-md"></q-icon>
<q-item-section>Swagger API</q-item-section>
</q-item>
<q-separator></q-separator>
<q-item v-if="this.g.user.admin" clickable @click="go('/nwcprovider/admin')"
class="q-flex items-center justify-between">
<q-icon name="settings" class="q-mr-md"></q-icon>
<q-item-section>Settings</q-item-section>
</q-item>
<!-- <q-expansion-item v-if="this.g.user.admin" group="extras" icon="admin" label="Admin">
<q-card>
<q-card-section>
<p>
Manage wallets using NWC
</p>
<small>Created by
<a class="text-secondary" href="https://github.com/riccardobl" target="_blank">Riccardo Balbo</a>.</small>
<small>Repo
<a class="text-secondary" href="https://github.com/riccardobl/nwcprovider" target="_blank">NWC Service
Providers</a>.</small>
</q-card-section>
</q-card>
</q-expansion-item> -->
</q-list>
</q-card-section>
</q-card>
</div>
<!--/////////////////////////////////////////////////-->
<!--//////////////FORM DIALOG////////////////////////-->
<!--/////////////////////////////////////////////////-->
<q-dialog v-model="connectionInfoDialog.show" @hide="closeConnectionInfoDialog">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<q-card-section class="q-ma-none">
<h3 class="q-ma-none q-mb-md text-center">Info</h3>
<q-list>
<q-item>
<q-item-section>Description</q-item-section>
<q-item-section side>${connectionInfoDialog.data.description}</q-item-section>
</q-item>
<q-item>
<q-item-section>Last used</q-item-section>
<q-item-section side>${connectionInfoDialog.data.last_used}</q-item-section>
</q-item>
<q-item>
<q-item-section>Expires</q-item-section>
<q-item-section side>${connectionInfoDialog.data.expires_at}</q-item-section>
</q-item>
<q-item>
<q-item-section>Created</q-item-section>
<q-item-section side>${connectionInfoDialog.data.created_at}</q-item-section>
</q-item>
<q-item>
<q-item-section>Permissions</q-item-section>
<q-item-section side>
${connectionInfoDialog.data.permissions}
</q-item-section>
</q-item>
<q-item>
<q-item-section>Limits
<q-markup-table class="q-mt-md">
<thead>
<q-tr>
<q-th>
Budget (sats)
<div style="font-size: smaller;">used/max</div>
</q-th>
<q-th>Refresh</q-th>
</q-tr>
</thead>
<tbody>
<q-tr v-if="!connectionInfoDialog.data.budgets || connectionInfoDialog.data.budgets.length === 0">
<q-td colspan="2" class="text-center">No limits</q-td>
</q-tr>
<q-tr v-else v-for="budget in connectionInfoDialog.data.budgets">
<q-td class="text-center">${budget.used_budget_sats} / ${budget.budget_sats}</q-td>
<q-td class="text-center">${budget.refresh_window}</q-td>
</q-tr>
</tbody>
</q-markup-table>
</q-item-section>
</q-item>
</q-list>
<q-card-actions class="q-mt-md">
<q-btn v-close-popup flat color="grey" class="q-ml-auto full-width " @click="closePairingDialog">Close</q-btn>
</q-card-actions>
</q-card>
</q-dialog>
<q-dialog v-model="pairingQrDialog.show" @hide="closePairingQrDialog">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<q-card-section class="q-ma-none">
<q-responsive :ratio="1" class="q-mx-xl q-mb-md">
<lnbits-qrcode :value="pairingQrDialog.data.pairingUrl"></lnbits-qrcode>
</q-responsive>
</q-card-section>
<q-card-section class="q-ma-none">
<p class=" q-ma-none text-center">Please scan this QR code with a supported app</p>
<p class=" q-ma-none text-center text-caption ">Connect only with app you trust!</p>
</q-card-section>
<q-card-actions>
<q-btn label="Close" flat color="grey" class="full-width
" @click="closePairingQrDialog"></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
<q-dialog v-model="pairingDialog.show" @hide="closePairingDialog">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<q-card-section class="q-ma-none">
<h3 class="q-ma-none q-mb-md text-center">Pairing</h3>
<p class=" q-ma-none text-center">Complete the last step of the setup by pasting or scanning your connection's
pairing secret in the desired
app to
finalise the connection.</p>
<p class=" q-ma-none text-center text-caption ">Connect only with app you trust!</p>
</q-card-section>
<q-card-actions>
<q-btn label="Open in Supported App" color="primary" class="full-width" icon="launch"
@click="openPairingUrl()"></q-btn>
</q-card-actions>
<q-card-section class="q-ma-none">
<h5 class="q-ma-none text-center">Advanced</h5>
</q-card-section>
<q-card-actions>
<q-btn label="Copy Pairing URL" flat color="grey" class="full-width" icon="content_copy"
@click="copyPairingUrl()"></q-btn>
</q-card-actions>
<q-card-actions>
<q-btn label="Show QR Code" flat color="grey" class="full-width" icon="qr_code_scanner"
@click="showPairingQR()"></q-btn>
</q-card-actions>
<q-card-actions>
<q-btn v-close-popup flat color="grey" class="q-ml-auto full-width" @click="closePairingDialog">Close</q-btn>
</q-card-actions>
</q-card>
</q-dialog>
<q-dialog v-model="connectDialog.show" @hide="closeConnectDialog">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<h3 class="q-ma-none q-mb-xl text-center">Add connection</h3>
<q-form @submit="confirmConnectDialog" class="q-gutter-md">
<q-input v-model="connectDialog.data.description" filled dense label="Description"></q-input>
<div class="row">
<q-input :disable="connectDialog.data.neverExpires" v-model="connectDialog.data.expires_at" class="col"
v-model="connectDialog.data.expires_at" filled dense label="Expires at" type="datetime-local"></q-input>
<q-checkbox v-model="connectDialog.data.neverExpires" class="col-auto"
v-model="connectDialog.data.neverExpires" label="Never"></q-checkbox>
</div>
<q-list dense bordered padding>
<q-item>
<q-item-label header>Authorize the app to</q-item-label>
</q-item>
<q-item v-for="permission in connectDialog.data.permissions">
<q-checkbox v-model="permission.value" :label="permission.name"></q-checkbox>
</q-item>
</q-list>
<q-list dense bordered padding>
<q-item>
<q-item-label header>Limit the spendable amount</q-item-label>
</q-item>
<q-item v-if="!connectDialog.data.budgets || connectDialog.data.budgets.length === 0">
<q-item-section class="full-width text-center">No limit</q-item-section>
</q-item>
<template v-else>
<q-item v-for="(budget,index) in connectDialog.data.budgets">
<q-item-section>
<q-input filled dense type="number" v-model="budget.budget_sats" label="Limit (sats)"></q-input>
</q-item-section>
<q-item-section>
<q-select filled dense emit-value :options="[
{label: 'Daily', value: 'Daily'},
{label: 'Weekly', value: 'Weekly'},
{label: 'Monthly', value: 'Monthly'},
{label: 'Yearly', value: 'Yearly'},
{label: 'Never', value: 'Never'}
]" label="Expires" v-model="budget.expiry"></q-select>
</q-item-section>
<q-item-section side>
<q-btn flat icon="delete" @click="deleteBudget(index)"></q-btn>
</q-item-section>
</q-item>
</template>
<q-item>
<q-btn flat icon="add" class="full-width" label="Add limit" @click="addBudget"></q-btn>
</q-item>
</q-list>
<q-card-actions class="q-gutter-sm">
<div class="col q-mr-md">
<q-btn unelevated color="primary" type="submit" class="full-width">Connect</q-btn>
</div>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</q-card-actions>
</q-form>
</q-card>
</q-dialog>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script type="module">
import * as NobleSecp256k1 from '/nwcprovider/static/js/noble-secp256k1.min.js';
window.NobleSecp256k1 = NobleSecp256k1;
</script>
<script>
new Vue({
el: '#vue',
mixins: [windowMixin],
delimiters: ['${', '}'],
data: function () {
return {
selectedWallet: null,
nodePermissions: [],
nwcEntries: [],
nwcsTable: {
columns: [
{ name: 'description', align: 'left', label: 'Description', field: 'description' },
{ name: 'status', align: 'left', label: 'Status', field: 'status' },
{ name: 'last_used', align: 'left', label: 'Last used', field: 'last_used' },
{ name: 'created_at', align: 'left', label: 'Created', field: 'created_at' },
{ name: 'expires_at', align: 'left', label: 'Expires', field: 'expires_at' }
],
pagination: {
rowsPerPage: 10
}
},
connectDialog: {
show: false,
data: {},
},
pairingDialog: {
show: false,
data: {
pairingUrl: ''
}
},
pairingQrDialog: {
show: false,
data: {
pairingUrl: ''
}
},
connectionInfoDialog: {
show: false,
data: {}
},
}
},
methods: {
showConnectDialog() {
const wallet = this.getWallet();
if (!wallet) {
this.$q.notify({ color: 'red', message: 'Please select a wallet first' });
return;
} else {
this.connectDialog.show = true;
}
},
openConnectionInfoDialog(data) {
this.connectionInfoDialog.data = data;
this.connectionInfoDialog.show = true;
},
closeConnectionInfoDialog() {
this.connectionInfoDialog.show = false;
},
openPairingUrl() {
const url = this.pairingDialog.data.pairingUrl;
if (url) window.open(url, '_blank');
},
go(url) {
window.open(url, '_blank');
},
async copyPairingUrl() {
const url = this.pairingDialog.data.pairingUrl;
if (url) {
try {
await navigator.clipboard.writeText(url);
this.$q.notify({ color: 'primary', message: 'URL copied to clipboard!' });
} catch (err) {
this.$q.notify({ color: 'red', message: 'Failed to copy URL.' });
}
}
},
showPairingQR() {
this.pairingQrDialog.data.pairingUrl = this.pairingDialog.data.pairingUrl;
this.pairingQrDialog.show = true;
},
closePairingQrDialog() {
this.pairingQrDialog.show = false;
},
loadConnectDialogData() {
this.connectDialog.data = {
description: '',
expires_at: Date.now() + 1000 * 60 * 60 * 24 * 7,
neverExpires: true,
permissions: [],
budgets: []
};
for (const permission of this.nodePermissions) {
this.connectDialog.data.permissions.push({
key: permission.key,
name: permission.name,
value: permission.value
});
}
},
deleteBudget(index) {
this.connectDialog.data.budgets.splice(index, 1)
},
addBudget() {
this.connectDialog.data.budgets.push({
budget_sats: 1000,
used_budget_sats: 0,
created_at: new Date(new Date().setHours(0, 0, 0, 0)).getTime() / 1000,
expiration: 'never'
})
},
closeConnectDialog() {
this.connectDialog.show = false
this.loadConnectDialogData();
},
getWallet: function () {
let wallet = undefined;
for (let i = 0; i < this.g.user.wallets.length; i++) {
if (this.g.user.wallets[i].id == this.selectedWallet) {
wallet = this.g.user.wallets[i];
break;
}
}
return wallet;
},
async generateKeyPair() {
while (!window.NobleSecp256k1) {
await new Promise(resolve => setTimeout(resolve, 1));
}
const privKeyBytes = window.NobleSecp256k1.utils.randomPrivateKey();
const pubKeyBytes = window.NobleSecp256k1.getPublicKey(privKeyBytes);
const out = {
privKeyBytes: privKeyBytes,
pubKeyBytes: pubKeyBytes,
privKey: window.NobleSecp256k1.etc.bytesToHex(privKeyBytes),
pubKey: window.NobleSecp256k1.etc.bytesToHex(pubKeyBytes.slice(1))
};
return out;
},
deleteNWC: async function (pubkey) {
this.$q.dialog({
title: 'Confirm Deletion',
message: 'Are you sure you want to delete this connection?',
cancel: true,
persistent: true
}).onOk(async () => {
try {
const wallet = this.getWallet();
const response = await LNbits.api.request('DELETE', `/nwcprovider/api/v1/nwc/${pubkey}`, wallet.adminkey);
this.loadNwcs();
this.$q.notify({ type: 'positive', message: 'Deleted successfully' });
} catch (error) {
LNbits.utils.notifyApiError(error);
}
}).onCancel(() => {
// User canceled the operation
});
},
loadNwcs: async function () {
const wallet = this.getWallet();
if (!wallet) {
this.nwcs = [];
return;
}
try {
const response = await LNbits.api
.request(
'GET',
'/nwcprovider/api/v1/nwc?includeExpired=true&calculateSpendBudget=true',
wallet.adminkey
)
this.nwcs = response.data;
} catch (error) {
this.nwcs = [];
}
try {
const response = await LNbits.api.request(
'GET',
'/nwcprovider/api/v1/permissions',
wallet.adminkey
)
const permissions = [];
for (const [key, value] of Object.entries(response.data)) {
permissions.push({
key: key,
name: value.name,
value: value.default
});
}
this.nodePermissions = permissions;
} catch (error) {
Lnbits.utils.notifyApiError(error);
}
this.loadConnectDialogData();
const newTableEntries = [];
for (const nwc of this.nwcs) {
const t = Quasar.utils.date.formatDate(
new Date(nwc.data.created_at * 1000),
'YYYY-MM-DD HH:mm'
);
const e = nwc.data.expires_at > 0 ? Quasar.utils.date.formatDate(
new Date(nwc.data.expires_at * 1000),
'YYYY-MM-DD HH:mm'
) : "Never";
const l = Quasar.utils.date.formatDate(
new Date(nwc.data.last_used * 1000),
'YYYY-MM-DD HH:mm'
);
const nwcTableEntry = {
description: nwc.data.description,
created_at: t,
expires_at: e,
last_used: l,
pubkey: nwc.data.pubkey,
permissions: nwc.data.permissions,
budgets: [],
status: "Active"
}
if(nwc.data.expires_at > 0 && nwc.data.expires_at < new Date().getTime() / 1000){
nwcTableEntry.status = "Expired";
}
for (const budget of nwc.budgets) {
const createdAt = Quasar.utils.date.formatDate(
new Date(budget.created_at * 1000),
'YYYY-MM-DD HH:mm'
);
let refreshWindow = budget.refresh_window;
if (refreshWindow <= 0) {
refreshWindow = 'Never';
} else if (refreshWindow == 60 * 60 * 24) {
refreshWindow = 'Daily';
} else if (refreshWindow == 60 * 60 * 24 * 7) {
refreshWindow = 'Weekly';
} else if (refreshWindow == 60 * 60 * 24 * 30) {
refreshWindow = 'Monthly';
} else if (refreshWindow == 60 * 60 * 24 * 365) {
refreshWindow = 'Yearly';
}
nwcTableEntry.budgets.push({
budget_sats: budget.budget_msats / 1000,
used_budget_sats: budget.used_budget_msats / 1000,
created_at: createdAt,
refresh_window: refreshWindow
});
}
newTableEntries.push(nwcTableEntry);
}
this.nwcEntries = newTableEntries;
},
closePairingDialog() {
this.pairingDialog.show = false;
},
async showPairingDialog(secret) {
let response = await LNbits.api.request(
'GET',
'/nwcprovider/api/v1/pairing/{SECRET}'
);
response = response.data;
response = response.replace("{SECRET}", secret);
this.pairingDialog.data.pairingUrl = response;
this.pairingDialog.show = true;
},
async confirmConnectDialog() {
const keyPair = await this.generateKeyPair();
// timestamp
let expires_at = 0;
if (!this.connectDialog.data.neverExpires) {
expires_at = new Date(this.connectDialog.data.expires_at).getTime() / 1000;
}
const data = {
permissions: [],
description: this.connectDialog.data.description,
expires_at: expires_at,
budgets: []
};
for (const permission of this.connectDialog.data.permissions) {
if (permission.value) data.permissions.push(permission.key);
}
for (const budget of this.connectDialog.data.budgets) {
const budget_msats = budget.budget_sats * 1000;
let refresh_window = 0
switch (budget.expiry) {
case 'Daily':
refresh_window = 60 * 60 * 24;
break;
case 'Weekly':
refresh_window = 60 * 60 * 24 * 7;
break;
case 'Monthly':
refresh_window = 60 * 60 * 24 * 30;
break;
case 'Yearly':
refresh_window = 60 * 60 * 24 * 365;
break;
case 'Never':
refresh_window = 0;
break;
}
data.budgets.push({
budget_msats: budget_msats,
refresh_window: refresh_window,
created_at: new Date(new Date().setHours(0, 0, 0, 0)).getTime() / 1000,
});
}
const wallet = this.getWallet();
try {
const response = await LNbits.api
.request('PUT', '/nwcprovider/api/v1/nwc/' + keyPair.pubKey, wallet.adminkey, data);
this.closeConnectDialog()
if (!response.data || !response.data.data || !response.data.data.pubkey) {
LNbits.utils.notifyApiError("Error creating nwc pairing");
return;
}
this.showPairingDialog(keyPair.privKey);
} catch (error) {
LNbits.utils.notifyApiError(error)
}
this.loadNwcs();
}
},
created: function () {
this.loadNwcs()
},
watch: {
selectedWallet(newValue, oldValue) {
this.loadNwcs();
},
},
})
</script>
{% endblock %}