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 @@ +
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\tGET /captcha/api/v1/captchas
+ {"X-Api-Key": <invoice_key>}
[<captcha_object>, ...]
+ curl -X GET {{ request.url_root }}captcha/api/v1/captchas -H
+ "X-Api-Key: {{ g.user.wallets[0].inkey }}"
+
+ POST /captcha/api/v1/captchas
+ {"X-Api-Key": <admin_key>}
{"amount": <integer>, "description": <string>, "memo":
+ <string>, "remembers": <boolean>, "url":
+ <string>}
+ {"amount": <integer>, "description": <string>, "id":
+ <string>, "memo": <string>, "remembers": <boolean>,
+ "time": <int>, "url": <string>, "wallet":
+ <string>}
+ 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
+ {"amount": <integer>}
+ {"payment_hash": <string>, "payment_request":
+ <string>}
+ 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
+ {"payment_hash": <string>}
+ {"paid": false}
{"paid": true, "url": <string>, "remembers":
+ <boolean>}
+ 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>
+ {"X-Api-Key": <admin_key>}
+ curl -X DELETE {{ request.url_root
+ }}captcha/api/v1/captchas/<captcha_id> -H "X-Api-Key: {{
+ g.user.wallets[0].adminkey }}"
+
+ {{ captcha.description }}
+ {% endif %} +
+ Captcha accepted. You are probably human.
+
+
+ {{ 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 }}
+
+