diff --git a/lnbits/extensions/paywall/README.md b/lnbits/extensions/paywall/README.md new file mode 100644 index 00000000..738485e2 --- /dev/null +++ b/lnbits/extensions/paywall/README.md @@ -0,0 +1,22 @@ +# Paywall + +## Hide content behind a paywall, a user has to pay some amount to access your hidden content + +A Paywall is a way of restricting to content via a purchase or paid subscription. For example to read a determined blog post, or to continue reading further, to access a downloads area, etc... + +## Usage + +1. Create a paywall by clicking "NEW PAYWALL"\ + ![create new paywall](https://i.imgur.com/q0ZIekC.png) +2. Fill the options for your PAYWALL + - select the wallet + - set the link that will be unlocked after a successful payment + - give your paywall a _Title_ + - an optional small description + - and set an amount a user must pay to access the hidden content. Note this is the minimum amount, a user can over pay if they wish + - if _Remember payments_ is checked, a returning paying user won't have to pay again for the same content.\ + ![paywall config](https://i.imgur.com/CBW48F6.png) +3. You can then use your paywall link to secure your awesome content\ + ![paywall link](https://i.imgur.com/hDQmCDf.png) +4. When a user wants to access your hidden content, he can use the minimum amount or increase and click the "_Check icon_" to generate an invoice, user will then be redirected to the content page\ + ![user paywall view](https://i.imgur.com/3pLywkZ.png) diff --git a/lnbits/extensions/paywall/__init__.py b/lnbits/extensions/paywall/__init__.py new file mode 100644 index 00000000..cf9570a1 --- /dev/null +++ b/lnbits/extensions/paywall/__init__.py @@ -0,0 +1,12 @@ +from quart import Blueprint +from lnbits.db import Database + +db = Database("ext_paywall") + +paywall_ext: Blueprint = Blueprint( + "paywall", __name__, static_folder="static", template_folder="templates" +) + + +from .views_api import * # noqa +from .views import * # noqa diff --git a/lnbits/extensions/paywall/config.json b/lnbits/extensions/paywall/config.json new file mode 100644 index 00000000..d08ce7ba --- /dev/null +++ b/lnbits/extensions/paywall/config.json @@ -0,0 +1,6 @@ +{ + "name": "Paywall", + "short_description": "Create paywalls for content", + "icon": "policy", + "contributors": ["eillarra"] +} diff --git a/lnbits/extensions/paywall/crud.py b/lnbits/extensions/paywall/crud.py new file mode 100644 index 00000000..c13aba43 --- /dev/null +++ b/lnbits/extensions/paywall/crud.py @@ -0,0 +1,53 @@ +from typing import List, Optional, Union + +from lnbits.helpers import urlsafe_short_hash + +from . import db +from .models import Paywall + + +async def create_paywall( + *, + wallet_id: str, + url: str, + memo: str, + description: Optional[str] = None, + amount: int = 0, + remembers: bool = True, +) -> Paywall: + paywall_id = urlsafe_short_hash() + await db.execute( + """ + INSERT INTO paywall.paywalls (id, wallet, url, memo, description, amount, remembers) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + (paywall_id, wallet_id, url, memo, description, amount, int(remembers)), + ) + + paywall = await get_paywall(paywall_id) + assert paywall, "Newly created paywall couldn't be retrieved" + return paywall + + +async def get_paywall(paywall_id: str) -> Optional[Paywall]: + row = await db.fetchone( + "SELECT * FROM paywall.paywalls WHERE id = ?", (paywall_id,) + ) + + return Paywall.from_row(row) if row else None + + +async def get_paywalls(wallet_ids: Union[str, List[str]]) -> List[Paywall]: + if isinstance(wallet_ids, str): + wallet_ids = [wallet_ids] + + q = ",".join(["?"] * len(wallet_ids)) + rows = await db.fetchall( + f"SELECT * FROM paywall.paywalls WHERE wallet IN ({q})", (*wallet_ids,) + ) + + return [Paywall.from_row(row) for row in rows] + + +async def delete_paywall(paywall_id: str) -> None: + await db.execute("DELETE FROM paywall.paywalls WHERE id = ?", (paywall_id,)) diff --git a/lnbits/extensions/paywall/migrations.py b/lnbits/extensions/paywall/migrations.py new file mode 100644 index 00000000..8afe58b1 --- /dev/null +++ b/lnbits/extensions/paywall/migrations.py @@ -0,0 +1,66 @@ +from sqlalchemy.exc import OperationalError # type: ignore + + +async def m001_initial(db): + """ + Initial paywalls table. + """ + await db.execute( + """ + CREATE TABLE paywall.paywalls ( + 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 """ + + db.timestamp_now + + """ + ); + """ + ) + + +async def m002_redux(db): + """ + Creates an improved paywalls table and migrates the existing data. + """ + await db.execute("ALTER TABLE paywall.paywalls RENAME TO paywalls_old") + await db.execute( + """ + CREATE TABLE paywall.paywalls ( + 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 """ + + db.timestamp_now + + """, + remembers INTEGER DEFAULT 0, + extras TEXT NULL + ); + """ + ) + + for row in [ + list(row) for row in await db.fetchall("SELECT * FROM paywall.paywalls_old") + ]: + await db.execute( + """ + INSERT INTO paywall.paywalls ( + id, + wallet, + url, + memo, + amount, + time + ) + VALUES (?, ?, ?, ?, ?, ?) + """, + (row[0], row[1], row[3], row[4], row[5], row[6]), + ) + + await db.execute("DROP TABLE paywall.paywalls_old") diff --git a/lnbits/extensions/paywall/models.py b/lnbits/extensions/paywall/models.py new file mode 100644 index 00000000..d7f2451d --- /dev/null +++ b/lnbits/extensions/paywall/models.py @@ -0,0 +1,23 @@ +import json + +from sqlite3 import Row +from typing import NamedTuple, Optional + + +class Paywall(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) -> "Paywall": + 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/paywall/templates/paywall/_api_docs.html b/lnbits/extensions/paywall/templates/paywall/_api_docs.html new file mode 100644 index 00000000..1157fa46 --- /dev/null +++ b/lnbits/extensions/paywall/templates/paywall/_api_docs.html @@ -0,0 +1,147 @@ + + + + + GET /paywall/api/v1/paywalls +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ [<paywall_object>, ...] +
Curl example
+ curl -X GET {{ request.url_root }}api/v1/paywalls -H "X-Api-Key: {{ + g.user.wallets[0].inkey }}" + +
+
+
+ + + + POST /paywall/api/v1/paywalls +
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 }}api/v1/paywalls -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 + /paywall/api/v1/paywalls/<paywall_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 + }}api/v1/paywalls/<paywall_id>/invoice -d '{"amount": + <integer>}' -H "Content-type: application/json" + +
+
+
+ + + + POST + /paywall/api/v1/paywalls/<paywall_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 + }}api/v1/paywalls/<paywall_id>/check_invoice -d + '{"payment_hash": <string>}' -H "Content-type: application/json" + +
+
+
+ + + + DELETE + /paywall/api/v1/paywalls/<paywall_id> +
Headers
+ {"X-Api-Key": <admin_key>}
+
Returns 204 NO CONTENT
+ +
Curl example
+ curl -X DELETE {{ request.url_root + }}api/v1/paywalls/<paywall_id> -H "X-Api-Key: {{ + g.user.wallets[0].adminkey }}" + +
+
+
+
diff --git a/lnbits/extensions/paywall/templates/paywall/display.html b/lnbits/extensions/paywall/templates/paywall/display.html new file mode 100644 index 00000000..7bc7d9b8 --- /dev/null +++ b/lnbits/extensions/paywall/templates/paywall/display.html @@ -0,0 +1,162 @@ +{% extends "public.html" %} {% block page %} +
+
+ + +
{{ paywall.memo }}
+ {% if paywall.description %} +

{{ paywall.description }}

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

+ You can access the URL behind this paywall:
+ {% raw %}{{ redirectUrl }}{% endraw %} +

+
+ Open URL +
+
+
+
+
+
+{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/lnbits/extensions/paywall/templates/paywall/index.html b/lnbits/extensions/paywall/templates/paywall/index.html new file mode 100644 index 00000000..8be3b2fa --- /dev/null +++ b/lnbits/extensions/paywall/templates/paywall/index.html @@ -0,0 +1,312 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + + New paywall + + + + + +
+
+
Paywalls
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
+
+ +
+ + +
+ {{SITE_TITLE}} paywall extension +
+
+ + + {% include "paywall/_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 access the + URL. + + + +
+ Create paywall + Cancel +
+
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/lnbits/extensions/paywall/views.py b/lnbits/extensions/paywall/views.py new file mode 100644 index 00000000..0dcbad2f --- /dev/null +++ b/lnbits/extensions/paywall/views.py @@ -0,0 +1,22 @@ +from quart import g, abort, render_template +from http import HTTPStatus + +from lnbits.decorators import check_user_exists, validate_uuids + +from . import paywall_ext +from .crud import get_paywall + + +@paywall_ext.route("/") +@validate_uuids(["usr"], required=True) +@check_user_exists() +async def index(): + return await render_template("paywall/index.html", user=g.user) + + +@paywall_ext.route("/") +async def display(paywall_id): + paywall = await get_paywall(paywall_id) or abort( + HTTPStatus.NOT_FOUND, "Paywall does not exist." + ) + return await render_template("paywall/display.html", paywall=paywall) diff --git a/lnbits/extensions/paywall/views_api.py b/lnbits/extensions/paywall/views_api.py new file mode 100644 index 00000000..45c80af4 --- /dev/null +++ b/lnbits/extensions/paywall/views_api.py @@ -0,0 +1,121 @@ +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 paywall_ext +from .crud import create_paywall, get_paywall, get_paywalls, delete_paywall + + +@paywall_ext.route("/api/v1/paywalls", methods=["GET"]) +@api_check_wallet_key("invoice") +async def api_paywalls(): + wallet_ids = [g.wallet.id] + + if "all_wallets" in request.args: + wallet_ids = (await get_user(g.wallet.user)).wallet_ids + + return ( + jsonify([paywall._asdict() for paywall in await get_paywalls(wallet_ids)]), + HTTPStatus.OK, + ) + + +@paywall_ext.route("/api/v1/paywalls", 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_paywall_create(): + paywall = await create_paywall(wallet_id=g.wallet.id, **g.data) + return jsonify(paywall._asdict()), HTTPStatus.CREATED + + +@paywall_ext.route("/api/v1/paywalls/", methods=["DELETE"]) +@api_check_wallet_key("invoice") +async def api_paywall_delete(paywall_id): + paywall = await get_paywall(paywall_id) + + if not paywall: + return jsonify({"message": "Paywall does not exist."}), HTTPStatus.NOT_FOUND + + if paywall.wallet != g.wallet.id: + return jsonify({"message": "Not your paywall."}), HTTPStatus.FORBIDDEN + + await delete_paywall(paywall_id) + + return "", HTTPStatus.NO_CONTENT + + +@paywall_ext.route("/api/v1/paywalls//invoice", methods=["POST"]) +@api_validate_post_request( + schema={"amount": {"type": "integer", "min": 1, "required": True}} +) +async def api_paywall_create_invoice(paywall_id): + paywall = await get_paywall(paywall_id) + + if g.data["amount"] < paywall.amount: + return ( + jsonify({"message": f"Minimum amount is {paywall.amount} sat."}), + HTTPStatus.BAD_REQUEST, + ) + + try: + amount = ( + g.data["amount"] if g.data["amount"] > paywall.amount else paywall.amount + ) + payment_hash, payment_request = await create_invoice( + wallet_id=paywall.wallet, + amount=amount, + memo=f"{paywall.memo}", + extra={"tag": "paywall"}, + ) + 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, + ) + + +@paywall_ext.route("/api/v1/paywalls//check_invoice", methods=["POST"]) +@api_validate_post_request( + schema={"payment_hash": {"type": "string", "empty": False, "required": True}} +) +async def api_paywal_check_invoice(paywall_id): + paywall = await get_paywall(paywall_id) + + if not paywall: + return jsonify({"message": "Paywall does not exist."}), HTTPStatus.NOT_FOUND + + try: + status = await check_invoice_status(paywall.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(paywall.wallet) + payment = await wallet.get_payment(g.data["payment_hash"]) + await payment.set_pending(False) + + return ( + jsonify({"paid": True, "url": paywall.url, "remembers": paywall.remembers}), + HTTPStatus.OK, + ) + + return jsonify({"paid": False}), HTTPStatus.OK