From a834a64319b53c63745047a95371ddc3c3555613 Mon Sep 17 00:00:00 2001 From: Eneko Illarramendi Date: Thu, 16 Apr 2020 17:07:53 +0200 Subject: [PATCH] refactor(withdraw): migrate extension to Vue --- lnbits/extensions/withdraw/config.json | 2 +- lnbits/extensions/withdraw/crud.py | 94 +++ lnbits/extensions/withdraw/migrations.py | 65 +- lnbits/extensions/withdraw/models.py | 46 ++ lnbits/extensions/withdraw/static/js/index.js | 169 +++++ .../templates/withdraw/_api_docs.html | 35 + .../withdraw/templates/withdraw/_lnurl.html | 10 + .../withdraw/templates/withdraw/display.html | 576 ++------------- .../withdraw/templates/withdraw/index.html | 670 +++++------------- .../withdraw/templates/withdraw/print.html | 291 -------- .../withdraw/templates/withdraw/print_qr.html | 66 ++ lnbits/extensions/withdraw/views.py | 163 +---- lnbits/extensions/withdraw/views_api.py | 331 ++++----- lnbits/templates/print.html | 48 ++ lnbits/templates/public.html | 6 + 15 files changed, 915 insertions(+), 1657 deletions(-) create mode 100644 lnbits/extensions/withdraw/crud.py create mode 100644 lnbits/extensions/withdraw/models.py create mode 100644 lnbits/extensions/withdraw/static/js/index.js create mode 100644 lnbits/extensions/withdraw/templates/withdraw/_api_docs.html create mode 100644 lnbits/extensions/withdraw/templates/withdraw/_lnurl.html delete mode 100644 lnbits/extensions/withdraw/templates/withdraw/print.html create mode 100644 lnbits/extensions/withdraw/templates/withdraw/print_qr.html create mode 100644 lnbits/templates/print.html create mode 100644 lnbits/templates/public.html diff --git a/lnbits/extensions/withdraw/config.json b/lnbits/extensions/withdraw/config.json index 1ef256c..79315c5 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 0000000..462170d --- /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 21f2420..26ec06d 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 0000000..5a1c211 --- /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 0000000..f5da36e --- /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 0000000..0ddd4f9 --- /dev/null +++ b/lnbits/extensions/withdraw/templates/withdraw/_api_docs.html @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lnbits/extensions/withdraw/templates/withdraw/_lnurl.html b/lnbits/extensions/withdraw/templates/withdraw/_lnurl.html new file mode 100644 index 0000000..7739602 --- /dev/null +++ b/lnbits/extensions/withdraw/templates/withdraw/_lnurl.html @@ -0,0 +1,10 @@ + + + + LNURL-withdraw info. + + + diff --git a/lnbits/extensions/withdraw/templates/withdraw/display.html b/lnbits/extensions/withdraw/templates/withdraw/display.html index 019fc5f..262420a 100644 --- a/lnbits/extensions/withdraw/templates/withdraw/display.html +++ b/lnbits/extensions/withdraw/templates/withdraw/display.html @@ -1,530 +1,52 @@ - - - - - - - LNBits Wallet - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
- - - - -
- - - - -
- -
-

- LNURL Withdraw Link - Use LNURL compatible bitcoin wallet -

- -
- - -


-

Withdraw Link: {{ user_fau[0][6] }}

- -




-

- -
-
+
+ Copy LNURL +
+ +
- - - + - +{% endblock %} diff --git a/lnbits/extensions/withdraw/templates/withdraw/index.html b/lnbits/extensions/withdraw/templates/withdraw/index.html index 38858d8..87cd43e 100644 --- a/lnbits/extensions/withdraw/templates/withdraw/index.html +++ b/lnbits/extensions/withdraw/templates/withdraw/index.html @@ -1,507 +1,183 @@ - +{% extends "base.html" %} -{% extends "legacy.html" %} {% block messages %} - - - ! - - -{% endblock %} - -{% block menuitems %} -
  • - - Wallets - - - -
  • -
  • - - Extensions - - - -
  • -{% endblock %} - -{% block body %} - -
    - -
    -

    - Withdraw link maker - powered by LNURL - -

    - -

    -
    -
    - - - -
    - -
    - -
    - -
    -
    -

    Make a link

    -
    - -
    -
    - -
    - - -
    - -
    - - -
    - - -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - -
    -
    - - -
    -
    -
    +{% from "macros.jinja" import window_vars with context %} +{% block scripts %} + {{ window_vars(user) }} + + {% assets filters='rjsmin', output='__bundle__/withdraw/index.js', + 'withdraw/js/index.js' %} + + {% endassets %} +{% endblock %} +{% block page %} +
    +
    + + + New withdraw link + + + + + +
    +
    +
    Withdraw links
    +
    +
    + Export to CSV +
    +
    + + {% raw %} + + + {% endraw %} + +
    +
    +
    - - -
    - -
    -
    -

    Select a link

    -
    -
    -
    -
    - - -
    - -


    -
    -
    - -
    -
    - - - +
    + + +
    LNbits LNURL-withdraw extension
    +
    + + + + {% include "withdraw/_api_docs.html" %} + + {% include "withdraw/_lnurl.html" %} + + +
    -
    -
    -
    -
    -

    Withdraw links

    + + + + + + + + + +
    +
    + + +
    +
    + + +
    - -
    - - - - - - - - - - - - -
    TitleLink/IDMax WithdrawNo. usesWaitWalletEditDel
    -
    - + + + + + + + Use unique withdraw QR codes to reduce `assmilking` + This is recommended if you are sharing the links on social media. NOT if you plan to print QR codes. + + + + Update withdraw link + Create withdraw link + Cancel +
    +
    +
    + + + + {% raw %} + + + +

    + ID: {{ qrCodeDialog.data.id }}
    + Unique: {{ qrCodeDialog.data.is_unique }} (QR code will change after each withdrawal)
    + Max. withdrawable: {{ qrCodeDialog.data.max_withdrawable }} sat
    + Wait time: {{ qrCodeDialog.data.wait_time }} seconds
    + Withdraws: {{ qrCodeDialog.data.used }} / {{ qrCodeDialog.data.uses }} +

    + {% endraw %} +
    + Copy link + Print QR codes + Close
    - -
    -
    - - - - - - -
    - - -
    + + + {% endblock %} diff --git a/lnbits/extensions/withdraw/templates/withdraw/print.html b/lnbits/extensions/withdraw/templates/withdraw/print.html deleted file mode 100644 index b8a0f5a..0000000 --- a/lnbits/extensions/withdraw/templates/withdraw/print.html +++ /dev/null @@ -1,291 +0,0 @@ - - - - - LNBits Wallet - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    - - - - - -
    - - - - - - diff --git a/lnbits/extensions/withdraw/templates/withdraw/print_qr.html b/lnbits/extensions/withdraw/templates/withdraw/print_qr.html new file mode 100644 index 0000000..c0513de --- /dev/null +++ b/lnbits/extensions/withdraw/templates/withdraw/print_qr.html @@ -0,0 +1,66 @@ +{% extends "print.html" %} + + +{% block page %} +
    +
    + {% for i in range(link.uses) %} +
    +
    + +

    + {{ SITE_TITLE }}
    + {{ link.max_withdrawable }} FREE SATS
    + Scan and follow link
    or use Lightning wallet
    +
    + +
    + {% endfor %} +
    +
    +{% endblock %} + +{% block styles %} + +{% endblock %} + +{% block scripts %} + + +{% endblock %} diff --git a/lnbits/extensions/withdraw/views.py b/lnbits/extensions/withdraw/views.py index 8bd78c6..5c9bc43 100644 --- a/lnbits/extensions/withdraw/views.py +++ b/lnbits/extensions/withdraw/views.py @@ -1,161 +1,28 @@ -import uuid +from flask import g, abort, render_template -from flask import jsonify, render_template, request, redirect, url_for -from lnurl import encode as lnurl_encode -from datetime import datetime +from lnbits.decorators import check_user_exists, validate_uuids +from lnbits.helpers import Status -from lnbits.db import open_db, open_ext_db from lnbits.extensions.withdraw import withdraw_ext +from .crud import get_withdraw_link @withdraw_ext.route("/") +@validate_uuids(["usr"], required=True) +@check_user_exists() def index(): - """Main withdraw link page.""" + return render_template("withdraw/index.html", user=g.user) - usr = request.args.get("usr") - if usr: - if not len(usr) > 20: - return redirect(url_for("home")) +@withdraw_ext.route("/") +def display(link_id): + link = get_withdraw_link(link_id) or abort(Status.NOT_FOUND, "Withdraw link does not exist.") - # Get all the data - with open_db() as db: - user_wallets = db.fetchall("SELECT * FROM wallets WHERE user = ?", (usr,)) - user_ext = db.fetchall("SELECT extension FROM extensions WHERE user = ? AND active = 1", (usr,)) - user_ext = [v[0] for v in user_ext] + return render_template("withdraw/display.html", link=link) - with open_ext_db("withdraw") as withdraw_ext_db: - user_fau = withdraw_ext_db.fetchall("SELECT * FROM withdraws WHERE usr = ?", (usr,)) - # If del is selected by user from withdraw page, the withdraw link is to be deleted - faudel = request.args.get("del") - if faudel: - withdraw_ext_db.execute("DELETE FROM withdraws WHERE uni = ?", (faudel,)) - user_fau = withdraw_ext_db.fetchall("SELECT * FROM withdraws WHERE usr = ?", (usr,)) +@withdraw_ext.route("/print/") +def print_qr(link_id): + link = get_withdraw_link(link_id) or abort(Status.NOT_FOUND, "Withdraw link does not exist.") - return render_template( - "withdraw/index.html", user_wallets=user_wallets, user=usr, user_ext=user_ext, user_fau=user_fau - ) - - -@withdraw_ext.route("/create", methods=["GET", "POST"]) -def create(): - """.""" - - data = request.json - amt = data["amt"] - tit = data["tit"] - wal = data["wal"] - minamt = data["minamt"] - maxamt = data["maxamt"] - tme = data["tme"] - uniq = data["uniq"] - usr = data["usr"] - wall = wal.split("-") - - # Form validation - if ( - int(amt) < 0 - or not tit.replace(" ", "").isalnum() - or wal == "" - or int(minamt) < 0 - or int(maxamt) < 0 - or int(minamt) > int(maxamt) - or int(tme) < 0 - ): - return jsonify({"ERROR": "FORM ERROR"}), 401 - - # If id that means its a link being edited, delet the record first - if "id" in data: - unid = data["id"].split("-") - uni = unid[1] - with open_ext_db("withdraw") as withdraw_ext_db: - withdraw_ext_db.execute("DELETE FROM withdraws WHERE uni = ?", (unid[1],)) - else: - uni = uuid.uuid4().hex - - # Randomiser for random QR option - rand = "" - if uniq > 0: - for x in range(0, int(amt)): - rand += uuid.uuid4().hex[0:5] + "," - else: - rand = uuid.uuid4().hex[0:5] + "," - - with open_db() as dbb: - user_wallets = dbb.fetchall("SELECT * FROM wallets WHERE user = ? AND id = ?", (usr, wall[1],)) - if not user_wallets: - return jsonify({"ERROR": "NO WALLET USER"}), 401 - - # Get time - dt = datetime.now() - seconds = dt.timestamp() - - with open_db() as db: - user_ext = db.fetchall("SELECT * FROM extensions WHERE user = ?", (usr,)) - user_ext = [v[0] for v in user_ext] - - # Add to DB - with open_ext_db("withdraw") as withdraw_ext_db: - withdraw_ext_db.execute( - """ - INSERT OR IGNORE INTO withdraws - (usr, wal, walnme, adm, uni, tit, maxamt, minamt, spent, inc, tme, uniq, withdrawals, tmestmp, rand) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - usr, - wall[1], - user_wallets[0][1], - user_wallets[0][3], - uni, - tit, - maxamt, - minamt, - 0, - amt, - tme, - uniq, - 0, - seconds, - rand, - ), - ) - - user_fau = withdraw_ext_db.fetchall("SELECT * FROM withdraws WHERE usr = ?", (usr,)) - - if not user_fau: - return jsonify({"ERROR": "NO WALLET USER"}), 401 - - return render_template( - "withdraw/index.html", user_wallets=user_wallets, user=usr, user_ext=user_ext, user_fau=user_fau - ) - - -@withdraw_ext.route("/display", methods=["GET", "POST"]) -def display(): - """Simple shareable link.""" - fauid = request.args.get("id") - - with open_ext_db("withdraw") as withdraw_ext_db: - user_fau = withdraw_ext_db.fetchall("SELECT * FROM withdraws WHERE uni = ?", (fauid,)) - - return render_template("withdraw/display.html", user_fau=user_fau,) - - -@withdraw_ext.route("/print//", methods=["GET", "POST"]) -def print_qr(urlstr): - """Simple printable page of links.""" - fauid = request.args.get("id") - - with open_ext_db("withdraw") as withdraw_ext_db: - user_fau = withdraw_ext_db.fetchall("SELECT * FROM withdraws WHERE uni = ?", (fauid,)) - randar = user_fau[0][15].split(",") - randar = randar[:-1] - lnurlar = [] - - for d in range(len(randar)): - url = url_for("withdraw.api_lnurlfetch", _external=True, urlstr=urlstr, parstr=fauid, rand=randar[d]) - lnurlar.append(lnurl_encode(url.replace("http://", "https://"))) - - return render_template("withdraw/print.html", lnurlar=lnurlar, user_fau=user_fau[0],) + return render_template("withdraw/print_qr.html", link=link) diff --git a/lnbits/extensions/withdraw/views_api.py b/lnbits/extensions/withdraw/views_api.py index 6acf69b..cd30abd 100644 --- a/lnbits/extensions/withdraw/views_api.py +++ b/lnbits/extensions/withdraw/views_api.py @@ -1,201 +1,148 @@ -import uuid -import json -import requests - -from flask import jsonify, request, url_for -from lnurl import LnurlWithdrawResponse, encode as lnurl_encode from datetime import datetime +from flask import g, jsonify, request +from lnbits.core.crud import get_user, get_wallet +from lnbits.core.services import pay_invoice +from lnbits.decorators import api_check_wallet_macaroon, api_validate_post_request +from lnbits.helpers import urlsafe_short_hash, Status -from lnbits.db import open_ext_db, open_db from lnbits.extensions.withdraw import withdraw_ext +from .crud import ( + create_withdraw_link, + get_withdraw_link, + get_withdraw_link_by_hash, + get_withdraw_links, + update_withdraw_link, + delete_withdraw_link, +) + + +@withdraw_ext.route("/api/v1/links", methods=["GET"]) +@api_check_wallet_macaroon(key_type="invoice") +def api_links(): + wallet_ids = [g.wallet.id] + + if "all_wallets" in request.args: + wallet_ids = get_user(g.wallet.user).wallet_ids + + return jsonify([{**link._asdict(), **{"lnurl": link.lnurl}} for link in get_withdraw_links(wallet_ids)]), Status.OK + + +@withdraw_ext.route("/api/v1/links/", methods=["GET"]) +@api_check_wallet_macaroon(key_type="invoice") +def api_link_retrieve(link_id): + link = get_withdraw_link(link_id) + + if not link: + return jsonify({"message": "Withdraw link does not exist."}), Status.NOT_FOUND + + if link.wallet != g.wallet.id: + return jsonify({"message": "Not your withdraw link."}), Status.FORBIDDEN + + return jsonify({**link._asdict(), **{"lnurl": link.lnurl}}), Status.OK -@withdraw_ext.route("/api/v1/lnurlencode//", methods=["GET"]) -def api_lnurlencode(urlstr, parstr): - """Returns encoded LNURL if web url and parameter gieven.""" - - if not urlstr: - return jsonify({"status": "FALSE"}), 200 - - with open_ext_db("withdraw") as withdraw_ext_db: - user_fau = withdraw_ext_db.fetchall("SELECT * FROM withdraws WHERE uni = ?", (parstr,)) - randar = user_fau[0][15].split(",") - print(randar) - # randar = randar[:-1] - # If "Unique links" selected get correct rand, if not there is only one rand - if user_fau[0][12] > 0: - rand = randar[user_fau[0][10] - 1] - else: - rand = randar[0] - - url = url_for("withdraw.api_lnurlfetch", _external=True, urlstr=urlstr, parstr=parstr, rand=rand) - - if "onion" in url: - return jsonify({"status": "TRUE", "lnurl": lnurl_encode(url)}), 200 - print(url) - - return jsonify({"status": "TRUE", "lnurl": lnurl_encode(url.replace("http://", "https://"))}), 200 - - - - -@withdraw_ext.route("/api/v1/lnurlfetch///", methods=["GET"]) -def api_lnurlfetch(parstr, urlstr, rand): - """Returns LNURL json.""" - - if not parstr: - return jsonify({"status": "FALSE", "ERROR": "NO WALL ID"}), 200 - - if not urlstr: - return jsonify({"status": "FALSE", "ERROR": "NO URL"}), 200 - - with open_ext_db("withdraw") as withdraw_ext_db: - user_fau = withdraw_ext_db.fetchall("SELECT * FROM withdraws WHERE uni = ?", (parstr,)) - k1str = uuid.uuid4().hex - withdraw_ext_db.execute("UPDATE withdraws SET withdrawals = ? WHERE uni = ?", (k1str, parstr,)) - - precallback = url_for("withdraw.api_lnurlwithdraw", _external=True, rand=rand) - - if "onion" in precallback: - print(precallback) +@withdraw_ext.route("/api/v1/links", methods=["POST"]) +@withdraw_ext.route("/api/v1/links/", methods=["PUT"]) +@api_check_wallet_macaroon(key_type="invoice") +@api_validate_post_request( + schema={ + "title": {"type": "string", "empty": False, "required": True}, + "min_withdrawable": {"type": "integer", "min": 1, "required": True}, + "max_withdrawable": {"type": "integer", "min": 1, "required": True}, + "uses": {"type": "integer", "min": 1, "required": True}, + "wait_time": {"type": "integer", "min": 1, "required": True}, + "is_unique": {"type": "boolean", "required": True}, + } +) +def api_link_create(link_id=None): + if g.data["max_withdrawable"] < g.data["min_withdrawable"]: + return jsonify({"message": "`max_withdrawable` needs to be at least `min_withdrawable`."}), Status.BAD_REQUEST + + if (g.data["max_withdrawable"] * g.data["uses"] * 1000) > g.wallet.balance_msat: + return jsonify({"message": "Insufficient balance."}), Status.FORBIDDEN + + if link_id: + link = get_withdraw_link(link_id) + + if not link: + return jsonify({"message": "Withdraw link does not exist."}), Status.NOT_FOUND + + if link.wallet != g.wallet.id: + return jsonify({"message": "Not your withdraw link."}), Status.FORBIDDEN + + link = update_withdraw_link(link_id, **g.data) else: - precallback = url_for("withdraw.api_lnurlwithdraw", _external=True, rand=rand).replace("http://", "https://") - - res = LnurlWithdrawResponse( - callback=precallback, - k1=k1str, - min_withdrawable=user_fau[0][8] * 1000, - max_withdrawable=user_fau[0][7] * 1000, - default_description="LNbits LNURL withdraw", - ) - - return res.json(), 200 - - -@withdraw_ext.route("/api/v1/lnurlwithdraw//", methods=["GET"]) -def api_lnurlwithdraw(rand): - """Pays invoice if passed k1 invoice and rand.""" - - k1 = request.args.get("k1") - pr = request.args.get("pr") - - if not k1: - return jsonify({"status": "FALSE", "ERROR": "NO k1"}), 200 - - if not pr: - return jsonify({"status": "FALSE", "ERROR": "NO PR"}), 200 - - with open_ext_db("withdraw") as withdraw_ext_db: - user_fau = withdraw_ext_db.fetchall("SELECT * FROM withdraws WHERE withdrawals = ?", (k1,)) - - if not user_fau: - return jsonify({"status": "ERROR", "reason": "NO AUTH"}), 400 - - if user_fau[0][10] < 1: - return jsonify({"status": "ERROR", "reason": "withdraw SPENT"}), 400 - - # Check withdraw time - dt = datetime.now() - seconds = dt.timestamp() - secspast = seconds - user_fau[0][14] - - if secspast < user_fau[0][11]: - return jsonify({"status": "ERROR", "reason": "WAIT " + str(int(user_fau[0][11] - secspast)) + "s"}), 400 - - randar = user_fau[0][15].split(",") - if rand not in randar: - print("huhhh") - return jsonify({"status": "ERROR", "reason": "BAD AUTH"}), 400 - if len(randar) > 2: - randar.remove(rand) - randstr = ",".join(randar) - - # Update time and increments - upinc = int(user_fau[0][10]) - 1 - withdraw_ext_db.execute( - "UPDATE withdraws SET inc = ?, rand = ?, tmestmp = ? WHERE withdrawals = ?", (upinc, randstr, seconds, k1,) - ) - - header = {"Content-Type": "application/json", "Grpc-Metadata-macaroon": str(user_fau[0][4])} - data = {"payment_request": pr} - # this works locally but not being served over host, bug, needs fixing - # r = requests.post(url="https://lnbits.com/api/v1/channels/transactions", headers=header, data=json.dumps(data)) - r = requests.post(url=url_for("api_transactions", _external=True), headers=header, data=json.dumps(data)) - r_json = r.json() - - if "ERROR" in r_json: - return jsonify({"status": "ERROR", "reason": r_json["ERROR"]}), 400 - - with open_ext_db("withdraw") as withdraw_ext_db: - user_fau = withdraw_ext_db.fetchall("SELECT * FROM withdraws WHERE withdrawals = ?", (k1,)) - - return jsonify({"status": "OK"}), 200 - -@withdraw_ext.route("/api/v1/lnurlmaker", methods=["GET","POST"]) -def api_lnurlmaker(): - - if request.headers["Content-Type"] != "application/json": - return jsonify({"ERROR": "MUST BE JSON"}), 400 - - with open_db() as db: - wallet = db.fetchall( - "SELECT * FROM wallets WHERE adminkey = ?", - (request.headers["Grpc-Metadata-macaroon"],), - ) - if not wallet: - return jsonify({"ERROR": "NO KEY"}), 200 - - balance = db.fetchone("SELECT balance/1000 FROM balances WHERE wallet = ?", (wallet[0][0],))[0] - print(balance) - - postedjson = request.json - print(postedjson["amount"]) - - if balance < int(postedjson["amount"]): - return jsonify({"ERROR": "NOT ENOUGH FUNDS"}), 200 - - uni = uuid.uuid4().hex - rand = uuid.uuid4().hex[0:5] - - with open_ext_db("withdraw") as withdraw_ext_db: - withdraw_ext_db.execute( - """ - INSERT OR IGNORE INTO withdraws - (usr, wal, walnme, adm, uni, tit, maxamt, minamt, spent, inc, tme, uniq, withdrawals, tmestmp, rand) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - wallet[0][2], - wallet[0][0], - wallet[0][1], - wallet[0][3], - uni, - postedjson["memo"], - postedjson["amount"], - postedjson["amount"], - 0, - 1, - 1, - 0, - 0, - 1, - rand, - ), - ) - - user_fau = withdraw_ext_db.fetchone("SELECT * FROM withdraws WHERE uni = ?", (uni,)) - - if not user_fau: - return jsonify({"ERROR": "WITHDRAW NOT MADE"}), 401 - - url = url_for("withdraw.api_lnurlfetch", _external=True, urlstr=request.host, parstr=uni, rand=rand) - - if "onion" in url: - return jsonify({"status": "TRUE", "lnurl": lnurl_encode(url)}), 200 - print(url) - - return jsonify({"status": "TRUE", "lnurl": lnurl_encode(url.replace("http://", "https://"))}), 200 - + link = create_withdraw_link(wallet_id=g.wallet.id, **g.data) + + return jsonify({**link._asdict(), **{"lnurl": link.lnurl}}), Status.OK if link_id else Status.CREATED + + +@withdraw_ext.route("/api/v1/links/", methods=["DELETE"]) +@api_check_wallet_macaroon(key_type="invoice") +def api_link_delete(link_id): + link = get_withdraw_link(link_id) + + if not link: + return jsonify({"message": "Withdraw link does not exist."}), Status.NOT_FOUND + + if link.wallet != g.wallet.id: + return jsonify({"message": "Not your withdraw link."}), Status.FORBIDDEN + + delete_withdraw_link(link_id) + + return "", Status.NO_CONTENT + + +@withdraw_ext.route("/api/v1/lnurl/", methods=["GET"]) +def api_lnurl_response(unique_hash): + link = get_withdraw_link_by_hash(unique_hash) + + if not link: + return jsonify({"status": "ERROR", "reason": "LNURL-withdraw not found."}), Status.OK + + link = update_withdraw_link(link.id, k1=urlsafe_short_hash()) + + return jsonify(link.lnurl_response.dict()), Status.OK + + +@withdraw_ext.route("/api/v1/lnurl/cb/", methods=["GET"]) +def api_lnurl_callback(unique_hash): + link = get_withdraw_link_by_hash(unique_hash) + k1 = request.args.get("k1", type=str) + payment_request = request.args.get("pr", type=str) + now = int(datetime.now().timestamp()) + + if not link: + return jsonify({"status": "ERROR", "reason": "LNURL-withdraw not found."}), Status.OK + + if link.is_spent: + return jsonify({"status": "ERROR", "reason": "Withdraw is spent."}), Status.OK + + if link.k1 != k1: + return jsonify({"status": "ERROR", "reason": "Bad request."}), Status.OK + + if now < link.open_time: + return jsonify({"status": "ERROR", "reason": f"Wait {link.open_time - now} seconds."}), Status.OK + + try: + pay_invoice(wallet=get_wallet(link.wallet), bolt11=payment_request, max_sat=link.max_withdrawable) + + changes = { + "used": link.used + 1, + "open_time": link.wait_time + now, + } + + if link.is_unique: + changes["unique_hash"] = urlsafe_short_hash() + + update_withdraw_link(link.id, **changes) + except ValueError as e: + return jsonify({"status": "ERROR", "reason": str(e)}), Status.OK + except PermissionError: + return jsonify({"status": "ERROR", "reason": "Withdraw link is empty."}), Status.OK + except Exception as e: + return jsonify({"status": "ERROR", "reason": str(e)}), Status.OK + return jsonify({"status": "OK"}), Status.OK diff --git a/lnbits/templates/print.html b/lnbits/templates/print.html new file mode 100644 index 0000000..9c50368 --- /dev/null +++ b/lnbits/templates/print.html @@ -0,0 +1,48 @@ + + + + + + + {% block styles %}{% endblock %} + + {% block title %} + {% if SITE_TITLE != 'LNbits' %}{{ SITE_TITLE }}{% else %}LNbits{% endif %} + {% endblock %} + + + + {% block head_scripts %}{% endblock %} + + + + + + + {% block page %}{% endblock %} + + + + + {% if DEBUG %} + + + {% else %} + {% assets output='__bundle__/vue-print.js', + 'vendor/quasar@1.9.12/quasar.ie.polyfills.umd.min.js', + 'vendor/vue@2.6.11/vue.min.js', + 'vendor/quasar@1.9.12/quasar.umd.min.js' %} + + {% endassets %} + {% endif %} + + {% block scripts %}{% endblock %} + + diff --git a/lnbits/templates/public.html b/lnbits/templates/public.html new file mode 100644 index 0000000..2bb98ba --- /dev/null +++ b/lnbits/templates/public.html @@ -0,0 +1,6 @@ +{% extends "base.html" %} + + +{% block beta %}{% endblock %} +{% block drawer_toggle %}{% endblock %} +{% block drawer %}{% endblock %}