diff --git a/lnbits/app.py b/lnbits/app.py index b1562f629..b5fe71bd7 100644 --- a/lnbits/app.py +++ b/lnbits/app.py @@ -14,7 +14,7 @@ from .proxy_fix import ASGIProxyFix from .tasks import run_deferred_async, invoice_listener, internal_invoice_listener, webhook_handler, grab_app_for_later from .settings import WALLET -secure_headers = SecureHeaders(hsts=False) +secure_headers = SecureHeaders(hsts=False,xfo=False) def create_app(config_object="lnbits.settings") -> QuartTrio: diff --git a/lnbits/extensions/captcha/README.md b/lnbits/extensions/captcha/README.md new file mode 100644 index 000000000..277294592 --- /dev/null +++ b/lnbits/extensions/captcha/README.md @@ -0,0 +1,11 @@ +

Example Extension

+

*tagline*

+This is an example extension to help you organise and build you own. + +Try to include an image + + + +

If your extension has API endpoints, include useful ones here

+ +curl -H "Content-type: application/json" -X POST https://YOUR-LNBITS/YOUR-EXTENSION/api/v1/EXAMPLE -d '{"amount":"100","memo":"example"}' -H "X-Api-Key: YOUR_WALLET-ADMIN/INVOICE-KEY" diff --git a/lnbits/extensions/captcha/__init__.py b/lnbits/extensions/captcha/__init__.py new file mode 100644 index 000000000..66eed22b3 --- /dev/null +++ b/lnbits/extensions/captcha/__init__.py @@ -0,0 +1,10 @@ +from quart import Blueprint +from lnbits.db import Database + +db = Database("ext_captcha") + +captcha_ext: Blueprint = Blueprint("captcha", __name__, static_folder="static", template_folder="templates") + + +from .views_api import * # noqa +from .views import * # noqa diff --git a/lnbits/extensions/captcha/config.json b/lnbits/extensions/captcha/config.json new file mode 100644 index 000000000..4ef7c43fb --- /dev/null +++ b/lnbits/extensions/captcha/config.json @@ -0,0 +1,6 @@ +{ + "name": "Captcha", + "short_description": "Create captcha to stop spam", + "icon": "block", + "contributors": ["pseudozach"] +} diff --git a/lnbits/extensions/captcha/crud.py b/lnbits/extensions/captcha/crud.py new file mode 100644 index 000000000..735d05a79 --- /dev/null +++ b/lnbits/extensions/captcha/crud.py @@ -0,0 +1,43 @@ +from typing import List, Optional, Union + +from lnbits.helpers import urlsafe_short_hash + +from . import db +from .models import Captcha + + +async def create_captcha( + *, wallet_id: str, url: str, memo: str, description: Optional[str] = None, amount: int = 0, remembers: bool = True +) -> Captcha: + captcha_id = urlsafe_short_hash() + await db.execute( + """ + INSERT INTO captchas (id, wallet, url, memo, description, amount, remembers) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + (captcha_id, wallet_id, url, memo, description, amount, int(remembers)), + ) + + captcha = await get_captcha(captcha_id) + assert captcha, "Newly created captcha couldn't be retrieved" + return captcha + + +async def get_captcha(captcha_id: str) -> Optional[Captcha]: + row = await db.fetchone("SELECT * FROM captchas WHERE id = ?", (captcha_id,)) + + return Captcha.from_row(row) if row else None + + +async def get_captchas(wallet_ids: Union[str, List[str]]) -> List[Captcha]: + if isinstance(wallet_ids, str): + wallet_ids = [wallet_ids] + + q = ",".join(["?"] * len(wallet_ids)) + rows = await db.fetchall(f"SELECT * FROM captchas WHERE wallet IN ({q})", (*wallet_ids,)) + + return [Captcha.from_row(row) for row in rows] + + +async def delete_captcha(captcha_id: str) -> None: + await db.execute("DELETE FROM captchas WHERE id = ?", (captcha_id,)) diff --git a/lnbits/extensions/captcha/migrations.py b/lnbits/extensions/captcha/migrations.py new file mode 100644 index 000000000..455cf0ff3 --- /dev/null +++ b/lnbits/extensions/captcha/migrations.py @@ -0,0 +1,65 @@ +from sqlalchemy.exc import OperationalError # type: ignore + + +async def m001_initial(db): + """ + Initial captchas table. + """ + await db.execute( + """ + CREATE TABLE IF NOT EXISTS captchas ( + id TEXT PRIMARY KEY, + wallet TEXT NOT NULL, + secret TEXT NOT NULL, + url TEXT NOT NULL, + memo TEXT NOT NULL, + amount INTEGER NOT NULL, + time TIMESTAMP NOT NULL DEFAULT (strftime('%s', 'now')) + ); + """ + ) + + +async def m002_redux(db): + """ + Creates an improved captchas table and migrates the existing data. + """ + try: + await db.execute("SELECT remembers FROM captchas") + + except OperationalError: + await db.execute("ALTER TABLE captchas RENAME TO captchas_old") + await db.execute( + """ + CREATE TABLE IF NOT EXISTS captchas ( + id TEXT PRIMARY KEY, + wallet TEXT NOT NULL, + url TEXT NOT NULL, + memo TEXT NOT NULL, + description TEXT NULL, + amount INTEGER DEFAULT 0, + time TIMESTAMP NOT NULL DEFAULT (strftime('%s', 'now')), + remembers INTEGER DEFAULT 0, + extras TEXT NULL + ); + """ + ) + await db.execute("CREATE INDEX IF NOT EXISTS wallet_idx ON captchas (wallet)") + + for row in [list(row) for row in await db.fetchall("SELECT * FROM captchas_old")]: + await db.execute( + """ + INSERT INTO captchas ( + id, + wallet, + url, + memo, + amount, + time + ) + VALUES (?, ?, ?, ?, ?, ?) + """, + (row[0], row[1], row[3], row[4], row[5], row[6]), + ) + + await db.execute("DROP TABLE captchas_old") diff --git a/lnbits/extensions/captcha/models.py b/lnbits/extensions/captcha/models.py new file mode 100644 index 000000000..3179d5c18 --- /dev/null +++ b/lnbits/extensions/captcha/models.py @@ -0,0 +1,23 @@ +import json + +from sqlite3 import Row +from typing import NamedTuple, Optional + + +class Captcha(NamedTuple): + id: str + wallet: str + url: str + memo: str + description: str + amount: int + time: int + remembers: bool + extras: Optional[dict] + + @classmethod + def from_row(cls, row: Row) -> "Captcha": + data = dict(row) + data["remembers"] = bool(data["remembers"]) + data["extras"] = json.loads(data["extras"]) if data["extras"] else None + return cls(**data) diff --git a/lnbits/extensions/captcha/static/js/captcha.js b/lnbits/extensions/captcha/static/js/captcha.js new file mode 100644 index 000000000..0b09c0f4b --- /dev/null +++ b/lnbits/extensions/captcha/static/js/captcha.js @@ -0,0 +1,61 @@ +var ciframeLoaded = !1, + captchaStyleAdded = !1; + +function ccreateIframeElement(t = {}) { + const e = document.createElement("iframe"); + // e.style.marginLeft = "25px", + e.style.border = "none", e.style.width = "100%", e.style.height = "100%", e.scrolling = "no", e.id = "captcha-iframe"; + t.dest, t.amount, t.currency, t.label, t.opReturn; + var captchaid = document.getElementById("captchascript").getAttribute("data-captchaid"); + return e.src = "http://localhost:5000/captcha/" + captchaid, e +} +document.addEventListener("DOMContentLoaded", function() { + if (captchaStyleAdded) console.log("Captcha stuff already added!"); + else { + console.log("Adding captcha stuff"), captchaStyleAdded = !0; + var t = document.createElement("style"); + t.innerHTML = "\t/*Button*/\t\t.button-captcha-filled\t\t\t{\t\t\tdisplay: flex;\t\t\talign-items: center;\t\t\tjustify-content: center;\t\t\twidth: 120px;\t\t\tmin-width: 30px;\t\t\theight: 40px;\t\t\tline-height: 2.5;\t\t\ttext-align: center;\t\t\tcursor: pointer;\t\t\t/* Rectangle 2: */\t\t\tbackground: #FF7979;\t\t\tbox-shadow: 0 2px 4px 0 rgba(0,0,0,0.20);\t\t\tborder-radius: 20px;\t\t\t/* Sign up: */\t\t\tfont-family: 'Avenir-Heavy', Futura, Helvetica, Arial;\t\t\tfont-size: 16px;\t\t\tcolor: #FFFFFF;\t\t}\t\t.button-captcha-filled:hover\t\t{\t\t\tbackground:#FFFFFF;\t\t\tcolor: #FF7979;\t\t\tbox-shadow: 0 0 4px 0 rgba(0,0,0,0.20);\t\t}\t\t.button-captcha-filled:active\t\t{\t\t\tbackground:#FFFFFF;\t\t\tcolor: #FF7979;\t\t\t/*Move it down a little bit*/\t\t\tposition: relative;\t\t\ttop: 1px;\t\t}\t\t.button-captcha-filled-dark\t\t\t{\t\t\tdisplay: flex;\t\t\talign-items: center;\t\t\tjustify-content: center;\t\t\twidth: 120px;\t\t\tmin-width: 30px;\t\t\theight: 40px;\t\t\tline-height: 2.5;\t\t\ttext-align: center;\t\t\tcursor: pointer;\t\t\t/* Rectangle 2: */\t\t\tbackground: #161C38;\t\t\tbox-shadow: 0 0px 4px 0 rgba(0,0,0,0.20);\t\t\tborder-radius: 20px;\t\t\t/* Sign up: */\t\t\tfont-family: 'Avenir-Heavy', Futura, Helvetica, Arial;\t\t\tfont-size: 16px;\t\t\tcolor: #FFFFFF;\t\t}\t\t.button-captcha-filled-dark:hover\t\t{\t\t\tbackground:#FFFFFF;\t\t\tcolor: #161C38;\t\t\tbox-shadow: 0 0px 4px 0 rgba(0,0,0,0.20);\t\t}\t\t.button-captcha-filled-dark:active\t\t{\t\t\tbackground:#FFFFFF;\t\t\tcolor: #161C38;\t\t\t/*Move it down a little bit*/\t\t\tposition: relative;\t\t\ttop: 1px;\t\t}\t\t.modal-captcha-container {\t\t position: fixed;\t\t z-index: 1000;\t\t text-align: left;/*Si no aƱado esto, a veces hereda el text-align:center del body, y entonces el popup queda movido a la derecha, por center + margin left que aplico*/\t\t left: 0;\t\t top: 0;\t\t width: 100%;\t\t height: 100%;\t\t background-color: rgba(0, 0, 0, 0.5);\t\t opacity: 0;\t\t visibility: hidden;\t\t transform: scale(1.1);\t\t transition: visibility 0s linear 0.25s, opacity 0.25s 0s, transform 0.25s;\t\t}\t\t.modal-captcha-content {\t\t position: absolute;\t\t top: 50%;\t\t left: 50%;\t\t transform: translate(-50%, -50%);\t\t background-color: white;\t\t width: 100%;\t\t height: 100%;\t\t border-radius: 0.5rem;\t\t /*Rounded shadowed borders*/\t\t\tbox-shadow: 2px 2px 4px 0 rgba(0,0,0,0.15);\t\t\tborder-radius: 5px;\t\t}\t\t.close-button-captcha {\t\t float: right;\t\t width: 1.5rem;\t\t line-height: 1.5rem;\t\t text-align: center;\t\t cursor: pointer;\t\t margin-right:20px;\t\t margin-top:10px;\t\t border-radius: 0.25rem;\t\t background-color: lightgray;\t\t}\t\t.close-button-captcha:hover {\t\t background-color: darkgray;\t\t}\t\t.show-modal-captcha {\t\t opacity: 1;\t\t visibility: visible;\t\t transform: scale(1.0);\t\t transition: visibility 0s linear 0s, opacity 0.25s 0s, transform 0.25s;\t\t}\t\t/* Mobile */\t\t@media screen and (min-device-width: 160px) and ( max-width: 1077px ) /*No tendria ni por que poner un min-device, porq abarca todo lo humano...*/\t\t{\t\t}"; + var e = document.querySelector("script"); + e.parentNode.insertBefore(t, e); + var i = document.getElementById("captchacheckbox"), + n = i.dataset, + o = "true" === n.dark; + var a = document.createElement("div"); + a.className += " modal-captcha-container", a.innerHTML = '\t\t\t', document.getElementsByTagName("body")[0].appendChild(a); + var r = document.getElementsByClassName("modal-captcha-content").item(0); + document.getElementsByClassName("close-button-captcha").item(0).addEventListener("click", d), window.addEventListener("click", function(t) { + t.target === a && d() + }), i.addEventListener("change", function() { + if(this.checked){ + // console.log("checkbox checked"); + if (0 == ciframeLoaded) { + // console.log("n: ", n); + var t = ccreateIframeElement(n); + r.appendChild(t), ciframeLoaded = !0 + } + d() + } + }) + } + + function d() { + a.classList.toggle("show-modal-captcha") + } +}); + +function receiveMessage(event){ + if (event.data.includes("removetheiframe")){ + if (event.data.includes("nok")){ + //invoice was NOT paid + // console.log("receiveMessage not paid") + document.getElementById("captchacheckbox").checked = false; + } + ciframeLoaded = !1; + var element = document.getElementById('captcha-iframe'); + document.getElementsByClassName("modal-captcha-container")[0].classList.toggle("show-modal-captcha"); + element.parentNode.removeChild(element); + } +} +window.addEventListener("message", receiveMessage, false); + + diff --git a/lnbits/extensions/captcha/templates/captcha/_api_docs.html b/lnbits/extensions/captcha/templates/captcha/_api_docs.html new file mode 100644 index 000000000..dfe2f32f8 --- /dev/null +++ b/lnbits/extensions/captcha/templates/captcha/_api_docs.html @@ -0,0 +1,147 @@ + + + + + GET /captcha/api/v1/captchas +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ [<captcha_object>, ...] +
Curl example
+ curl -X GET {{ request.url_root }}captcha/api/v1/captchas -H + "X-Api-Key: {{ g.user.wallets[0].inkey }}" + +
+
+
+ + + + POST /captcha/api/v1/captchas +
Headers
+ {"X-Api-Key": <admin_key>}
+
Body (application/json)
+ {"amount": <integer>, "description": <string>, "memo": + <string>, "remembers": <boolean>, "url": + <string>} +
+ Returns 201 CREATED (application/json) +
+ {"amount": <integer>, "description": <string>, "id": + <string>, "memo": <string>, "remembers": <boolean>, + "time": <int>, "url": <string>, "wallet": + <string>} +
Curl example
+ curl -X POST {{ request.url_root }}captcha/api/v1/captchas -d + '{"url": <string>, "memo": <string>, "description": + <string>, "amount": <integer>, "remembers": + <boolean>}' -H "Content-type: application/json" -H "X-Api-Key: + {{ g.user.wallets[0].adminkey }}" + +
+
+
+ + + + POST + /captcha/api/v1/captchas/<captcha_id>/invoice +
Body (application/json)
+ {"amount": <integer>} +
+ Returns 201 CREATED (application/json) +
+ {"payment_hash": <string>, "payment_request": + <string>} +
Curl example
+ curl -X POST {{ request.url_root + }}captcha/api/v1/captchas/<captcha_id>/invoice -d '{"amount": + <integer>}' -H "Content-type: application/json" + +
+
+
+ + + + POST + /captcha/api/v1/captchas/<captcha_id>/check_invoice +
Body (application/json)
+ {"payment_hash": <string>} +
+ Returns 200 OK (application/json) +
+ {"paid": false}
+ {"paid": true, "url": <string>, "remembers": + <boolean>} +
Curl example
+ curl -X POST {{ request.url_root + }}captcha/api/v1/captchas/<captcha_id>/check_invoice -d + '{"payment_hash": <string>}' -H "Content-type: application/json" + +
+
+
+ + + + DELETE + /captcha/api/v1/captchas/<captcha_id> +
Headers
+ {"X-Api-Key": <admin_key>}
+
Returns 204 NO CONTENT
+ +
Curl example
+ curl -X DELETE {{ request.url_root + }}captcha/api/v1/captchas/<captcha_id> -H "X-Api-Key: {{ + g.user.wallets[0].adminkey }}" + +
+
+
+
diff --git a/lnbits/extensions/captcha/templates/captcha/display.html b/lnbits/extensions/captcha/templates/captcha/display.html new file mode 100644 index 000000000..08ee2a2e8 --- /dev/null +++ b/lnbits/extensions/captcha/templates/captcha/display.html @@ -0,0 +1,172 @@ +{% extends "public.html" %} {% block page %} +
+
+ + +
{{ captcha.memo }}
+ {% if captcha.description %} +

{{ captcha.description }}

+ {% endif %} +
+ + + + + +
+ + + + + +
+ Copy invoice + Cancel +
+
+
+
+ +

+ Captcha accepted. You are probably human.
+ +

+ +
+
+
+
+
+{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/lnbits/extensions/captcha/templates/captcha/index.html b/lnbits/extensions/captcha/templates/captcha/index.html new file mode 100644 index 000000000..5fb50513a --- /dev/null +++ b/lnbits/extensions/captcha/templates/captcha/index.html @@ -0,0 +1,415 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + + New captcha + + + + + +
+
+
Captchas
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
+
+ +
+ + +
LNbits captcha extension
+
+ + + {% include "captcha/_api_docs.html" %} + +
+
+ + + + + + + + + + + + + + + + + Remember payments + A succesful payment will be registered in the browser's + storage, so the user doesn't need to pay again to prove they are human. + + + +
+ Create captcha + Cancel +
+
+
+
+ + + + {% raw %} + + + + {{ qrCodeDialog.data.snippet }} + +

Copy the snippet above and paste into your website/form. The checkbox can be in checked state only after user pays.

+
+

+ ID: {{ qrCodeDialog.data.id }}
+ Amount: {{ qrCodeDialog.data.amount }}
+ +

+ {% endraw %} +
+ Copy Snippet + + Close +
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/lnbits/extensions/captcha/views.py b/lnbits/extensions/captcha/views.py new file mode 100644 index 000000000..9e4cbe9eb --- /dev/null +++ b/lnbits/extensions/captcha/views.py @@ -0,0 +1,20 @@ +from quart import g, abort, render_template +from http import HTTPStatus + +from lnbits.decorators import check_user_exists, validate_uuids + +from . import captcha_ext +from .crud import get_captcha + + +@captcha_ext.route("/") +@validate_uuids(["usr"], required=True) +@check_user_exists() +async def index(): + return await render_template("captcha/index.html", user=g.user) + + +@captcha_ext.route("/") +async def display(captcha_id): + captcha = await get_captcha(captcha_id) or abort(HTTPStatus.NOT_FOUND, "captcha does not exist.") + return await render_template("captcha/display.html", captcha=captcha) diff --git a/lnbits/extensions/captcha/views_api.py b/lnbits/extensions/captcha/views_api.py new file mode 100644 index 000000000..2062e541d --- /dev/null +++ b/lnbits/extensions/captcha/views_api.py @@ -0,0 +1,95 @@ +from quart import g, jsonify, request +from http import HTTPStatus + +from lnbits.core.crud import get_user, get_wallet +from lnbits.core.services import create_invoice, check_invoice_status +from lnbits.decorators import api_check_wallet_key, api_validate_post_request + +from . import captcha_ext +from .crud import create_captcha, get_captcha, get_captchas, delete_captcha + + +@captcha_ext.route("/api/v1/captchas", methods=["GET"]) +@api_check_wallet_key("invoice") +async def api_captchas(): + wallet_ids = [g.wallet.id] + + if "all_wallets" in request.args: + wallet_ids = (await get_user(g.wallet.user)).wallet_ids + + return jsonify([captcha._asdict() for captcha in await get_captchas(wallet_ids)]), HTTPStatus.OK + + +@captcha_ext.route("/api/v1/captchas", methods=["POST"]) +@api_check_wallet_key("invoice") +@api_validate_post_request( + schema={ + "url": {"type": "string", "empty": False, "required": True}, + "memo": {"type": "string", "empty": False, "required": True}, + "description": {"type": "string", "empty": True, "nullable": True, "required": False}, + "amount": {"type": "integer", "min": 0, "required": True}, + "remembers": {"type": "boolean", "required": True}, + } +) +async def api_captcha_create(): + captcha = await create_captcha(wallet_id=g.wallet.id, **g.data) + return jsonify(captcha._asdict()), HTTPStatus.CREATED + + +@captcha_ext.route("/api/v1/captchas/", methods=["DELETE"]) +@api_check_wallet_key("invoice") +async def api_captcha_delete(captcha_id): + captcha = await get_captcha(captcha_id) + + if not captcha: + return jsonify({"message": "captcha does not exist."}), HTTPStatus.NOT_FOUND + + if captcha.wallet != g.wallet.id: + return jsonify({"message": "Not your captcha."}), HTTPStatus.FORBIDDEN + + await delete_captcha(captcha_id) + + return "", HTTPStatus.NO_CONTENT + + +@captcha_ext.route("/api/v1/captchas//invoice", methods=["POST"]) +@api_validate_post_request(schema={"amount": {"type": "integer", "min": 1, "required": True}}) +async def api_captcha_create_invoice(captcha_id): + captcha = await get_captcha(captcha_id) + + if g.data["amount"] < captcha.amount: + return jsonify({"message": f"Minimum amount is {captcha.amount} sat."}), HTTPStatus.BAD_REQUEST + + try: + amount = g.data["amount"] if g.data["amount"] > captcha.amount else captcha.amount + payment_hash, payment_request = await create_invoice( + wallet_id=captcha.wallet, amount=amount, memo=f"{captcha.memo}", extra={"tag": "captcha"} + ) + except Exception as e: + return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR + + return jsonify({"payment_hash": payment_hash, "payment_request": payment_request}), HTTPStatus.CREATED + + +@captcha_ext.route("/api/v1/captchas//check_invoice", methods=["POST"]) +@api_validate_post_request(schema={"payment_hash": {"type": "string", "empty": False, "required": True}}) +async def api_paywal_check_invoice(captcha_id): + captcha = await get_captcha(captcha_id) + + if not captcha: + return jsonify({"message": "captcha does not exist."}), HTTPStatus.NOT_FOUND + + try: + status = await check_invoice_status(captcha.wallet, g.data["payment_hash"]) + is_paid = not status.pending + except Exception: + return jsonify({"paid": False}), HTTPStatus.OK + + if is_paid: + wallet = await get_wallet(captcha.wallet) + payment = await wallet.get_payment(g.data["payment_hash"]) + await payment.set_pending(False) + + return jsonify({"paid": True, "url": captcha.url, "remembers": captcha.remembers}), HTTPStatus.OK + + return jsonify({"paid": False}), HTTPStatus.OK