Browse Source

feat(paywall): extension basics

fee_issues
Eneko Illarramendi 5 years ago
parent
commit
768f0427e7
  1. 11
      lnbits/extensions/paywall/README.md
  2. 8
      lnbits/extensions/paywall/__init__.py
  3. 6
      lnbits/extensions/paywall/config.json
  4. 44
      lnbits/extensions/paywall/crud.py
  5. 10
      lnbits/extensions/paywall/models.py
  6. 8
      lnbits/extensions/paywall/schema.sql
  7. 28
      lnbits/extensions/paywall/templates/paywall/_api_docs.html
  8. 222
      lnbits/extensions/paywall/templates/paywall/index.html
  9. 1
      lnbits/extensions/paywall/templates/paywall/wall.html
  10. 21
      lnbits/extensions/paywall/views.py
  11. 51
      lnbits/extensions/paywall/views_api.py

11
lnbits/extensions/paywall/README.md

@ -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

@ -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

@ -0,0 +1,6 @@
{
"name": "Paywall",
"short_description": "BLah blah blah.",
"icon": "vpn_lock",
"contributors": ["eillarra"]
}

44
lnbits/extensions/paywall/crud.py

@ -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

@ -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

@ -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

@ -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

@ -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

@ -0,0 +1 @@
{{ paywall.url }}

21
lnbits/extensions/paywall/views.py

@ -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

@ -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
Loading…
Cancel
Save