diff --git a/lnbits/extensions/withdraw/config.json b/lnbits/extensions/withdraw/config.json
index 1ef256c62..79315c567 100644
--- a/lnbits/extensions/withdraw/config.json
+++ b/lnbits/extensions/withdraw/config.json
@@ -2,5 +2,5 @@
"name": "LNURLw",
"short_description": "Make LNURL withdraw links.",
"icon": "crop_free",
- "contributors": ["arcbtc"]
+ "contributors": ["arcbtc", "eillarra"]
}
diff --git a/lnbits/extensions/withdraw/crud.py b/lnbits/extensions/withdraw/crud.py
new file mode 100644
index 000000000..462170df5
--- /dev/null
+++ b/lnbits/extensions/withdraw/crud.py
@@ -0,0 +1,94 @@
+from datetime import datetime
+from typing import List, Optional, Union
+
+from lnbits.db import open_ext_db
+from lnbits.helpers import urlsafe_short_hash
+
+from .models import WithdrawLink
+
+
+def create_withdraw_link(
+ *,
+ wallet_id: str,
+ title: str,
+ min_withdrawable: int,
+ max_withdrawable: int,
+ uses: int,
+ wait_time: int,
+ is_unique: bool,
+) -> WithdrawLink:
+ with open_ext_db("withdraw") as db:
+ link_id = urlsafe_short_hash()
+ db.execute(
+ """
+ INSERT INTO withdraw_links (
+ id,
+ wallet,
+ title,
+ min_withdrawable,
+ max_withdrawable,
+ uses,
+ wait_time,
+ is_unique,
+ unique_hash,
+ k1,
+ open_time
+ )
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ link_id,
+ wallet_id,
+ title,
+ min_withdrawable,
+ max_withdrawable,
+ uses,
+ wait_time,
+ int(is_unique),
+ urlsafe_short_hash(),
+ urlsafe_short_hash(),
+ int(datetime.now().timestamp()) + wait_time,
+ ),
+ )
+
+ return get_withdraw_link(link_id)
+
+
+def get_withdraw_link(link_id: str) -> Optional[WithdrawLink]:
+ with open_ext_db("withdraw") as db:
+ row = db.fetchone("SELECT * FROM withdraw_links WHERE id = ?", (link_id,))
+
+ return WithdrawLink(**row) if row else None
+
+
+def get_withdraw_link_by_hash(unique_hash: str) -> Optional[WithdrawLink]:
+ with open_ext_db("withdraw") as db:
+ row = db.fetchone("SELECT * FROM withdraw_links WHERE unique_hash = ?", (unique_hash,))
+
+ return WithdrawLink(**row) if row else None
+
+
+def get_withdraw_links(wallet_ids: Union[str, List[str]]) -> List[WithdrawLink]:
+ if isinstance(wallet_ids, str):
+ wallet_ids = [wallet_ids]
+
+ with open_ext_db("withdraw") as db:
+ q = ",".join(["?"] * len(wallet_ids))
+ rows = db.fetchall(f"SELECT * FROM withdraw_links WHERE wallet IN ({q})", (*wallet_ids,))
+
+ return [WithdrawLink(**row) for row in rows]
+
+
+def update_withdraw_link(link_id: str, **kwargs) -> Optional[WithdrawLink]:
+ q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
+
+ with open_ext_db("withdraw") as db:
+ db.execute(f"UPDATE withdraw_links SET {q} WHERE id = ?", (*kwargs.values(), link_id))
+ row = db.fetchone("SELECT * FROM withdraw_links WHERE id = ?", (link_id,))
+
+ return WithdrawLink(**row) if row else None
+
+
+def delete_withdraw_link(link_id: str) -> None:
+ with open_ext_db("withdraw") as db:
+ db.execute("DELETE FROM withdraw_links WHERE id = ?", (link_id,))
diff --git a/lnbits/extensions/withdraw/migrations.py b/lnbits/extensions/withdraw/migrations.py
index 21f242038..26ec06d23 100644
--- a/lnbits/extensions/withdraw/migrations.py
+++ b/lnbits/extensions/withdraw/migrations.py
@@ -1,7 +1,7 @@
from datetime import datetime
-from uuid import uuid4
from lnbits.db import open_ext_db
+from lnbits.helpers import urlsafe_short_hash
def m001_initial(db):
@@ -32,6 +32,69 @@ def m001_initial(db):
)
+def m002_change_withdraw_table(db):
+ """
+ Creates an improved withdraw table and migrates the existing data.
+ """
+ db.execute(
+ """
+ CREATE TABLE IF NOT EXISTS withdraw_links (
+ id TEXT PRIMARY KEY,
+ wallet TEXT,
+ title TEXT,
+ min_withdrawable INTEGER DEFAULT 1,
+ max_withdrawable INTEGER DEFAULT 1,
+ uses INTEGER DEFAULT 1,
+ wait_time INTEGER,
+ is_unique INTEGER DEFAULT 0,
+ unique_hash TEXT UNIQUE,
+ k1 TEXT,
+ open_time INTEGER,
+ used INTEGER DEFAULT 0
+ );
+ """
+ )
+ db.execute("CREATE INDEX IF NOT EXISTS wallet_idx ON withdraw_links (wallet)")
+ db.execute("CREATE UNIQUE INDEX IF NOT EXISTS unique_hash_idx ON withdraw_links (unique_hash)")
+
+ for row in [list(row) for row in db.fetchall("SELECT * FROM withdraws")]:
+ db.execute(
+ """
+ INSERT INTO withdraw_links (
+ id,
+ wallet,
+ title,
+ min_withdrawable,
+ max_withdrawable,
+ uses,
+ wait_time,
+ is_unique,
+ unique_hash,
+ k1,
+ open_time,
+ used
+ )
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ row[5], # uni
+ row[2], # wal
+ row[6], # tit
+ row[8], # minamt
+ row[7], # maxamt
+ row[10], # inc
+ row[11], # tme
+ row[12], # uniq
+ urlsafe_short_hash(),
+ urlsafe_short_hash(),
+ int(datetime.now().timestamp()) + row[11],
+ row[9], # spent
+ ),
+ )
+ db.execute("DROP TABLE withdraws")
+
+
def migrate():
with open_ext_db("withdraw") as db:
m001_initial(db)
+ m002_change_withdraw_table(db)
diff --git a/lnbits/extensions/withdraw/models.py b/lnbits/extensions/withdraw/models.py
new file mode 100644
index 000000000..5a1c21159
--- /dev/null
+++ b/lnbits/extensions/withdraw/models.py
@@ -0,0 +1,46 @@
+from flask import url_for
+from lnurl import Lnurl, LnurlWithdrawResponse, encode as lnurl_encode
+from os import getenv
+from typing import NamedTuple
+
+
+class WithdrawLink(NamedTuple):
+ id: str
+ wallet: str
+ title: str
+ min_withdrawable: int
+ max_withdrawable: int
+ uses: int
+ wait_time: int
+ is_unique: bool
+ unique_hash: str
+ k1: str
+ open_time: int
+ used: int
+
+ @property
+ def is_spent(self) -> bool:
+ return self.used >= self.uses
+
+ @property
+ def is_onion(self) -> bool:
+ return getenv("LNBITS_WITH_ONION", 1) == 1
+
+ @property
+ def lnurl(self) -> Lnurl:
+ scheme = None if self.is_onion else "https"
+ url = url_for("withdraw.api_lnurl_response", unique_hash=self.unique_hash, _external=True, _scheme=scheme)
+ return lnurl_encode(url)
+
+ @property
+ def lnurl_response(self) -> LnurlWithdrawResponse:
+ scheme = None if self.is_onion else "https"
+ url = url_for("withdraw.api_lnurl_callback", unique_hash=self.unique_hash, _external=True, _scheme=scheme)
+
+ return LnurlWithdrawResponse(
+ callback=url,
+ k1=self.k1,
+ min_withdrawable=self.min_withdrawable * 1000,
+ max_withdrawable=self.max_withdrawable * 1000,
+ default_description="LNbits LNURL withdraw",
+ )
diff --git a/lnbits/extensions/withdraw/static/js/index.js b/lnbits/extensions/withdraw/static/js/index.js
new file mode 100644
index 000000000..f5da36ef2
--- /dev/null
+++ b/lnbits/extensions/withdraw/static/js/index.js
@@ -0,0 +1,169 @@
+Vue.component(VueQrcode.name, VueQrcode);
+
+var locationPath = [window.location.protocol, '//', window.location.hostname, window.location.pathname].join('');
+
+var mapWithdrawLink = function (obj) {
+ obj.is_unique = obj.is_unique == 1;
+ obj._data = _.clone(obj);
+ obj.date = Quasar.utils.date.formatDate(new Date(obj.time * 1000), 'YYYY-MM-DD HH:mm');
+ obj.min_fsat = new Intl.NumberFormat(LOCALE).format(obj.min_withdrawable);
+ obj.max_fsat = new Intl.NumberFormat(LOCALE).format(obj.max_withdrawable);
+ obj.uses_left = obj.uses - obj.used;
+ obj.print_url = [locationPath, 'print/', obj.id].join('');
+ obj.withdraw_url = [locationPath, obj.id].join('');
+ return obj;
+}
+
+new Vue({
+ el: '#vue',
+ mixins: [windowMixin],
+ data: function () {
+ return {
+ withdrawLinks: [],
+ withdrawLinksTable: {
+ columns: [
+ {name: 'id', align: 'left', label: 'ID', field: 'id'},
+ {name: 'title', align: 'left', label: 'Title', field: 'title'},
+ {name: 'wait_time', align: 'right', label: 'Wait', field: 'wait_time'},
+ {name: 'uses_left', align: 'right', label: 'Uses left', field: 'uses_left'},
+ {name: 'min', align: 'right', label: 'Min (sat)', field: 'min_fsat'},
+ {name: 'max', align: 'right', label: 'Max (sat)', field: 'max_fsat'}
+ ],
+ pagination: {
+ rowsPerPage: 10
+ }
+ },
+ formDialog: {
+ show: false,
+ secondMultiplier: 'seconds',
+ secondMultiplierOptions: ['seconds', 'minutes', 'hours'],
+ data: {
+ is_unique: false
+ }
+ },
+ qrCodeDialog: {
+ show: false,
+ data: null
+ }
+ };
+ },
+ computed: {
+ sortedWithdrawLinks: function () {
+ return this.withdrawLinks.sort(function (a, b) {
+ return b.uses_left - a.uses_left;
+ });
+ }
+ },
+ methods: {
+ getWithdrawLinks: function () {
+ var self = this;
+
+ LNbits.api.request(
+ 'GET',
+ '/withdraw/api/v1/links?all_wallets',
+ this.g.user.wallets[0].inkey
+ ).then(function (response) {
+ self.withdrawLinks = response.data.map(function (obj) {
+ return mapWithdrawLink(obj);
+ });
+ });
+ },
+ closeFormDialog: function () {
+ this.formDialog.data = {
+ is_unique: false
+ };
+ },
+ openQrCodeDialog: function (linkId) {
+ var link = _.findWhere(this.withdrawLinks, {id: linkId});
+ this.qrCodeDialog.data = _.clone(link);
+ this.qrCodeDialog.show = true;
+ },
+ openUpdateDialog: function (linkId) {
+ var link = _.findWhere(this.withdrawLinks, {id: linkId});
+ this.formDialog.data = _.clone(link._data);
+ this.formDialog.show = true;
+ },
+ sendFormData: function () {
+ var wallet = _.findWhere(this.g.user.wallets, {id: this.formDialog.data.wallet});
+ var data = _.omit(this.formDialog.data, 'wallet');
+
+ data.wait_time = data.wait_time * {
+ 'seconds': 1,
+ 'minutes': 60,
+ 'hours': 3600
+ }[this.formDialog.secondMultiplier];
+
+ if (data.id) { this.updateWithdrawLink(wallet, data); }
+ else { this.createWithdrawLink(wallet, data); }
+ },
+ updateWithdrawLink: function (wallet, data) {
+ var self = this;
+
+ LNbits.api.request(
+ 'PUT',
+ '/withdraw/api/v1/links/' + data.id,
+ wallet.inkey,
+ _.pick(data, 'title', 'min_withdrawable', 'max_withdrawable', 'uses', 'wait_time', 'is_unique')
+ ).then(function (response) {
+ self.withdrawLinks = _.reject(self.withdrawLinks, function (obj) { return obj.id == data.id; });
+ self.withdrawLinks.push(mapWithdrawLink(response.data));
+ self.formDialog.show = false;
+ }).catch(function (error) {
+ LNbits.utils.notifyApiError(error);
+ });
+ },
+ createWithdrawLink: function (wallet, data) {
+ var self = this;
+
+ LNbits.api.request(
+ 'POST',
+ '/withdraw/api/v1/links',
+ wallet.inkey,
+ data
+ ).then(function (response) {
+ self.withdrawLinks.push(mapWithdrawLink(response.data));
+ self.formDialog.show = false;
+ }).catch(function (error) {
+ LNbits.utils.notifyApiError(error);
+ });
+ },
+ deleteWithdrawLink: function (linkId) {
+ var self = this;
+ var link = _.findWhere(this.withdrawLinks, {id: linkId});
+
+ this.$q.dialog({
+ message: 'Are you sure you want to delete this withdraw link?',
+ ok: {
+ flat: true,
+ color: 'orange'
+ },
+ cancel: {
+ flat: true,
+ color: 'grey'
+ }
+ }).onOk(function () {
+ LNbits.api.request(
+ 'DELETE',
+ '/withdraw/api/v1/links/' + linkId,
+ _.findWhere(self.g.user.wallets, {id: link.wallet}).inkey
+ ).then(function (response) {
+ self.withdrawLinks = _.reject(self.withdrawLinks, function (obj) { return obj.id == linkId; });
+ }).catch(function (error) {
+ LNbits.utils.notifyApiError(error);
+ });
+ });
+ },
+ exportCSV: function () {
+ LNbits.utils.exportCSV(this.paywallsTable.columns, this.paywalls);
+ }
+ },
+ created: function () {
+ if (this.g.user.wallets.length) {
+ var getWithdrawLinks = this.getWithdrawLinks;
+ getWithdrawLinks();
+ /*setInterval(function(){
+ getWithdrawLinks();
+ }, 20000);*/
+ }
+ }
+});
diff --git a/lnbits/extensions/withdraw/templates/withdraw/_api_docs.html b/lnbits/extensions/withdraw/templates/withdraw/_api_docs.html
new file mode 100644
index 000000000..0ddd4f9e8
--- /dev/null
+++ b/lnbits/extensions/withdraw/templates/withdraw/_api_docs.html
@@ -0,0 +1,35 @@
+