mirror of
https://github.com/lnbits/lnbits.git
synced 2025-09-29 05:12:39 +02:00
feat(paywall): extension basics
This commit is contained in:
11
lnbits/extensions/paywall/README.md
Normal file
11
lnbits/extensions/paywall/README.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<h1>Example Extension</h1>
|
||||||
|
<h2>*tagline*</h2>
|
||||||
|
This is an example extension to help you organise and build you own.
|
||||||
|
|
||||||
|
Try to include an image
|
||||||
|
<img src="https://i.imgur.com/9i4xcQB.png">
|
||||||
|
|
||||||
|
|
||||||
|
<h2>If your extension has API endpoints, include useful ones here</h2>
|
||||||
|
|
||||||
|
<code>curl -H "Content-type: application/json" -X POST https://YOUR-LNBITS/YOUR-EXTENSION/api/v1/EXAMPLE -d '{"amount":"100","memo":"example"}' -H "Grpc-Metadata-macaroon: YOUR_WALLET-ADMIN/INVOICE-KEY"</code>
|
8
lnbits/extensions/paywall/__init__.py
Normal file
8
lnbits/extensions/paywall/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
from flask import Blueprint
|
||||||
|
|
||||||
|
|
||||||
|
paywall_ext = Blueprint("paywall", __name__, static_folder="static", template_folder="templates")
|
||||||
|
|
||||||
|
|
||||||
|
from .views_api import * # noqa
|
||||||
|
from .views import * # noqa
|
6
lnbits/extensions/paywall/config.json
Normal file
6
lnbits/extensions/paywall/config.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "Paywall",
|
||||||
|
"short_description": "BLah blah blah.",
|
||||||
|
"icon": "vpn_lock",
|
||||||
|
"contributors": ["eillarra"]
|
||||||
|
}
|
44
lnbits/extensions/paywall/crud.py
Normal file
44
lnbits/extensions/paywall/crud.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
from base64 import urlsafe_b64encode
|
||||||
|
from uuid import uuid4
|
||||||
|
from typing import List, Optional, Union
|
||||||
|
|
||||||
|
from lnbits.db import open_ext_db
|
||||||
|
|
||||||
|
from .models import Paywall
|
||||||
|
|
||||||
|
|
||||||
|
def create_paywall(*, wallet_id: str, url: str, memo: str, amount: int) -> Paywall:
|
||||||
|
with open_ext_db("paywall") as db:
|
||||||
|
paywall_id = urlsafe_b64encode(uuid4().bytes_le).decode('utf-8')
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO paywalls (id, wallet, url, memo, amount)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(paywall_id, wallet_id, url, memo, amount),
|
||||||
|
)
|
||||||
|
|
||||||
|
return get_paywall(paywall_id)
|
||||||
|
|
||||||
|
|
||||||
|
def get_paywall(paywall_id: str) -> Optional[Paywall]:
|
||||||
|
with open_ext_db("paywall") as db:
|
||||||
|
row = db.fetchone("SELECT * FROM paywalls WHERE id = ?", (paywall_id,))
|
||||||
|
|
||||||
|
return Paywall(**row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def get_paywalls(wallet_ids: Union[str, List[str]]) -> List[Paywall]:
|
||||||
|
if isinstance(wallet_ids, str):
|
||||||
|
wallet_ids = [wallet_ids]
|
||||||
|
|
||||||
|
with open_ext_db("paywall") as db:
|
||||||
|
q = ",".join(["?"] * len(wallet_ids))
|
||||||
|
rows = db.fetchall(f"SELECT * FROM paywalls WHERE wallet IN ({q})", (*wallet_ids,))
|
||||||
|
|
||||||
|
return [Paywall(**row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def delete_paywall(paywall_id: str) -> None:
|
||||||
|
with open_ext_db("paywall") as db:
|
||||||
|
db.execute("DELETE FROM paywalls WHERE id = ?", (paywall_id,))
|
10
lnbits/extensions/paywall/models.py
Normal file
10
lnbits/extensions/paywall/models.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
from typing import NamedTuple
|
||||||
|
|
||||||
|
|
||||||
|
class Paywall(NamedTuple):
|
||||||
|
id: str
|
||||||
|
wallet: str
|
||||||
|
url: str
|
||||||
|
memo: str
|
||||||
|
amount: int
|
||||||
|
time: int
|
8
lnbits/extensions/paywall/schema.sql
Normal file
8
lnbits/extensions/paywall/schema.sql
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS paywalls (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
wallet TEXT NOT NULL,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
memo TEXT NOT NULL,
|
||||||
|
amount INTEGER NOT NULL,
|
||||||
|
time TIMESTAMP NOT NULL DEFAULT (strftime('%s', 'now'))
|
||||||
|
);
|
28
lnbits/extensions/paywall/templates/paywall/_api_docs.html
Normal file
28
lnbits/extensions/paywall/templates/paywall/_api_docs.html
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<q-expansion-item
|
||||||
|
group="extras"
|
||||||
|
icon="swap_vertical_circle"
|
||||||
|
label="API info"
|
||||||
|
:content-inset-level="0.5"
|
||||||
|
>
|
||||||
|
<q-expansion-item group="api" dense expand-separator label="Create a paywall">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
<q-expansion-item group="api" dense expand-separator label="List paywalls">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
<q-expansion-item group="api" dense expand-separator label="Delete a paywall">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
</q-expansion-item>
|
222
lnbits/extensions/paywall/templates/paywall/index.html
Normal file
222
lnbits/extensions/paywall/templates/paywall/index.html
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% from "macros.jinja" import window_vars with context %}
|
||||||
|
|
||||||
|
|
||||||
|
{% block page %}
|
||||||
|
<div class="row q-col-gutter-md">
|
||||||
|
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<q-btn unelevated color="deep-purple" @click="paywallDialog.show = true">New Paywall</q-btn>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<div class="row items-center no-wrap q-mb-md">
|
||||||
|
<div class="col">
|
||||||
|
<h5 class="text-subtitle1 q-my-none">Paywalls</h5>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<q-btn flat color="grey" @click="exportCSV">Export to CSV</q-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-table dense flat
|
||||||
|
:data="paywalls"
|
||||||
|
row-key="id"
|
||||||
|
:columns="paywallsTable.columns"
|
||||||
|
:pagination.sync="paywallsTable.pagination">
|
||||||
|
{% raw %}
|
||||||
|
<template v-slot:header="props">
|
||||||
|
<q-tr :props="props">
|
||||||
|
<q-th auto-width></q-th>
|
||||||
|
<q-th
|
||||||
|
v-for="col in props.cols"
|
||||||
|
:key="col.name"
|
||||||
|
:props="props"
|
||||||
|
>
|
||||||
|
{{ col.label }}
|
||||||
|
</q-th>
|
||||||
|
<q-th auto-width></q-th>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
<template v-slot:body="props">
|
||||||
|
<q-tr :props="props">
|
||||||
|
<q-td auto-width>
|
||||||
|
<q-btn unelevated dense size="xs" icon="vpn_lock" :color="($q.dark.isActive) ? 'grey-7' : 'grey-5'" type="a" :href="props.row.wall" target="_blank"></q-btn>
|
||||||
|
<q-btn unelevated dense size="xs" icon="link" :color="($q.dark.isActive) ? 'grey-7' : 'grey-5'" type="a" :href="props.row.url" target="_blank"></q-btn>
|
||||||
|
</q-td>
|
||||||
|
<q-td
|
||||||
|
v-for="col in props.cols"
|
||||||
|
:key="col.name"
|
||||||
|
:props="props"
|
||||||
|
>
|
||||||
|
{{ col.value }}
|
||||||
|
</q-td>
|
||||||
|
<q-td auto-width>
|
||||||
|
<q-btn flat dense size="xs" @click="deletePaywall(props.row.id)" icon="cancel" color="pink"></q-btn>
|
||||||
|
</q-td>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
{% endraw %}
|
||||||
|
</q-table>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-md-4 col-lg-5 q-gutter-y-md">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<h6 class="text-subtitle1 q-my-none">LNbits paywall extension</h6>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section class="q-pa-none">
|
||||||
|
<q-separator></q-separator>
|
||||||
|
<q-list>
|
||||||
|
{% include "paywall/_api_docs.html" %}
|
||||||
|
</q-list>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-dialog v-model="paywallDialog.show" position="top">
|
||||||
|
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
|
||||||
|
<q-form class="q-gutter-md">
|
||||||
|
<q-select filled dense emit-value v-model="paywallDialog.data.wallet" :options="g.user.walletOptions" label="Wallet *">
|
||||||
|
</q-select>
|
||||||
|
<q-input filled dense
|
||||||
|
v-model.trim="paywallDialog.data.url"
|
||||||
|
type="url"
|
||||||
|
label="Target URL *"></q-input>
|
||||||
|
<q-input filled dense
|
||||||
|
v-model.number="paywallDialog.data.amount"
|
||||||
|
type="number"
|
||||||
|
label="Amount *"></q-input>
|
||||||
|
<q-input filled dense
|
||||||
|
v-model.trim="paywallDialog.data.memo"
|
||||||
|
label="Memo"
|
||||||
|
placeholder="LNbits invoice"></q-input>
|
||||||
|
<q-btn unelevated
|
||||||
|
color="deep-purple"
|
||||||
|
:disable="paywallDialog.data.amount == null || paywallDialog.data.amount < 0 || paywallDialog.data.url == null"
|
||||||
|
@click="createPaywall">Create paywall</q-btn>
|
||||||
|
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
|
||||||
|
</q-form>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
{{ window_vars(user) }}
|
||||||
|
<script>
|
||||||
|
var mapPaywall = function (obj) {
|
||||||
|
obj.date = Quasar.utils.date.formatDate(new Date(obj.time * 1000), 'YYYY-MM-DD HH:mm');
|
||||||
|
obj.fsat = new Intl.NumberFormat(LOCALE).format(obj.amount);
|
||||||
|
obj.wall = ['/paywall/', obj.id].join('');
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
new Vue({
|
||||||
|
el: '#vue',
|
||||||
|
mixins: [windowMixin],
|
||||||
|
data: function () {
|
||||||
|
return {
|
||||||
|
paywalls: [],
|
||||||
|
paywallsTable: {
|
||||||
|
columns: [
|
||||||
|
{name: 'id', align: 'left', label: 'ID', field: 'id'},
|
||||||
|
{name: 'memo', align: 'left', label: 'Memo', field: 'memo'},
|
||||||
|
{name: 'date', align: 'left', label: 'Date', field: 'date', sortable: true},
|
||||||
|
{
|
||||||
|
name: 'amount', align: 'right', label: 'Amount (sat)', field: 'fsat', sortable: true,
|
||||||
|
sort: function (a, b, rowA, rowB) {
|
||||||
|
return rowA.amount - rowB.amount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
pagination: {
|
||||||
|
rowsPerPage: 10
|
||||||
|
}
|
||||||
|
},
|
||||||
|
paywallDialog: {
|
||||||
|
show: false,
|
||||||
|
data: {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
getPaywalls: function () {
|
||||||
|
var self = this;
|
||||||
|
|
||||||
|
LNbits.api.request(
|
||||||
|
'GET',
|
||||||
|
'/paywall/api/v1/paywalls?all_wallets',
|
||||||
|
this.g.user.wallets[0].inkey
|
||||||
|
).then(function (response) {
|
||||||
|
self.paywalls = response.data.map(function (obj) {
|
||||||
|
return mapPaywall(obj);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
createPaywall: function () {
|
||||||
|
var data = {
|
||||||
|
url: this.paywallDialog.data.url,
|
||||||
|
memo: this.paywallDialog.data.memo,
|
||||||
|
amount: this.paywallDialog.data.amount
|
||||||
|
};
|
||||||
|
var self = this;
|
||||||
|
|
||||||
|
console.log(this.paywallDialog.data.wallet);
|
||||||
|
|
||||||
|
LNbits.api.request(
|
||||||
|
'POST',
|
||||||
|
'/paywall/api/v1/paywalls',
|
||||||
|
_.findWhere(this.g.user.wallets, {id: this.paywallDialog.data.wallet}).inkey,
|
||||||
|
data
|
||||||
|
).then(function (response) {
|
||||||
|
self.paywalls.push(mapPaywall(response.data));
|
||||||
|
self.paywallDialog.show = false;
|
||||||
|
self.paywallDialog.data = {};
|
||||||
|
}).catch(function (error) {
|
||||||
|
LNbits.utils.notifyApiError(error);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
deletePaywall: function (paywallId) {
|
||||||
|
var self = this;
|
||||||
|
var paywall = _.findWhere(this.paywalls, {id: paywallId});
|
||||||
|
|
||||||
|
this.$q.dialog({
|
||||||
|
message: 'Are you sure you want to delete this Paywall link?',
|
||||||
|
ok: {
|
||||||
|
flat: true,
|
||||||
|
color: 'orange'
|
||||||
|
},
|
||||||
|
cancel: {
|
||||||
|
flat: true,
|
||||||
|
color: 'grey'
|
||||||
|
}
|
||||||
|
}).onOk(function () {
|
||||||
|
LNbits.api.request(
|
||||||
|
'DELETE',
|
||||||
|
'/paywall/api/v1/paywalls/' + paywallId,
|
||||||
|
_.findWhere(self.g.user.wallets, {id: paywall.wallet}).inkey
|
||||||
|
).then(function (response) {
|
||||||
|
self.paywalls = _.reject(self.paywalls, function (obj) { return obj.id == paywallId; });
|
||||||
|
}).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) {
|
||||||
|
this.getPaywalls();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
1
lnbits/extensions/paywall/templates/paywall/wall.html
Normal file
1
lnbits/extensions/paywall/templates/paywall/wall.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{{ paywall.url }}
|
21
lnbits/extensions/paywall/views.py
Normal file
21
lnbits/extensions/paywall/views.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
from flask import g, abort, render_template
|
||||||
|
|
||||||
|
from lnbits.decorators import check_user_exists, validate_uuids
|
||||||
|
from lnbits.extensions.paywall import paywall_ext
|
||||||
|
from lnbits.helpers import Status
|
||||||
|
|
||||||
|
from .crud import get_paywall
|
||||||
|
|
||||||
|
|
||||||
|
@paywall_ext.route("/")
|
||||||
|
@validate_uuids(["usr"], required=True)
|
||||||
|
@check_user_exists()
|
||||||
|
def index():
|
||||||
|
return render_template("paywall/index.html", user=g.user)
|
||||||
|
|
||||||
|
|
||||||
|
@paywall_ext.route("/<paywall_id>")
|
||||||
|
def wall(paywall_id):
|
||||||
|
paywall = get_paywall(paywall_id) or abort(Status.NOT_FOUND, "Paywall does not exist.")
|
||||||
|
|
||||||
|
return render_template("paywall/wall.html", paywall=paywall)
|
51
lnbits/extensions/paywall/views_api.py
Normal file
51
lnbits/extensions/paywall/views_api.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
from flask import g, jsonify, request
|
||||||
|
|
||||||
|
from lnbits.core.crud import get_user
|
||||||
|
from lnbits.decorators import api_check_wallet_macaroon, api_validate_post_request
|
||||||
|
from lnbits.helpers import Status
|
||||||
|
|
||||||
|
from lnbits.extensions.paywall 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_macaroon(key_type="invoice")
|
||||||
|
def api_paywalls():
|
||||||
|
wallet_ids = [g.wallet.id]
|
||||||
|
|
||||||
|
if "all_wallets" in request.args:
|
||||||
|
wallet_ids = get_user(g.wallet.user).wallet_ids
|
||||||
|
|
||||||
|
return jsonify([paywall._asdict() for paywall in get_paywalls(wallet_ids)]), Status.OK
|
||||||
|
|
||||||
|
|
||||||
|
@paywall_ext.route("/api/v1/paywalls", methods=["POST"])
|
||||||
|
@api_check_wallet_macaroon(key_type="invoice")
|
||||||
|
@api_validate_post_request(required_params=["url", "memo", "amount"])
|
||||||
|
def api_paywall_create():
|
||||||
|
if not isinstance(g.data["amount"], int) or g.data["amount"] < 0:
|
||||||
|
return jsonify({"message": "`amount` needs to be a positive integer, or zero."}), Status.BAD_REQUEST
|
||||||
|
|
||||||
|
for var in ["url", "memo"]:
|
||||||
|
if not isinstance(g.data[var], str) or not g.data[var].strip():
|
||||||
|
return jsonify({"message": f"`{var}` needs to be a valid string."}), Status.BAD_REQUEST
|
||||||
|
|
||||||
|
paywall = create_paywall(wallet_id=g.wallet.id, url=g.data["url"], memo=g.data["memo"], amount=g.data["amount"])
|
||||||
|
|
||||||
|
return jsonify(paywall._asdict()), Status.CREATED
|
||||||
|
|
||||||
|
|
||||||
|
@paywall_ext.route("/api/v1/paywalls/<paywall_id>", methods=["DELETE"])
|
||||||
|
@api_check_wallet_macaroon(key_type="invoice")
|
||||||
|
def api_paywall_delete(paywall_id):
|
||||||
|
paywall = get_paywall(paywall_id)
|
||||||
|
|
||||||
|
if not paywall:
|
||||||
|
return jsonify({"message": "Paywall does not exist."}), Status.NOT_FOUND
|
||||||
|
|
||||||
|
if paywall.wallet != g.wallet.id:
|
||||||
|
return jsonify({"message": "Not your paywall."}), Status.FORBIDDEN
|
||||||
|
|
||||||
|
delete_paywall(paywall_id)
|
||||||
|
|
||||||
|
return '', Status.NO_CONTENT
|
Reference in New Issue
Block a user