Browse Source

feat(paywall): improved extension

- make remember cookie optional
- improve database
- improve type casting
Login
Eneko Illarramendi 5 years ago
parent
commit
e73a508011
  1. 2
      lnbits/extensions/paywall/config.json
  2. 14
      lnbits/extensions/paywall/crud.py
  3. 48
      lnbits/extensions/paywall/migrations.py
  4. 16
      lnbits/extensions/paywall/models.py
  5. 115
      lnbits/extensions/paywall/templates/paywall/_api_docs.html
  6. 114
      lnbits/extensions/paywall/templates/paywall/display.html
  7. 69
      lnbits/extensions/paywall/templates/paywall/index.html
  8. 17
      lnbits/extensions/paywall/views_api.py

2
lnbits/extensions/paywall/config.json

@ -1,6 +1,6 @@
{
"name": "Paywall",
"short_description": "Create paywalls for content",
"icon": "vpn_lock",
"icon": "policy",
"contributors": ["eillarra"]
}

14
lnbits/extensions/paywall/crud.py

@ -6,15 +6,17 @@ from lnbits.helpers import urlsafe_short_hash
from .models import Paywall
def create_paywall(*, wallet_id: str, url: str, memo: str, amount: int) -> Paywall:
def create_paywall(
*, wallet_id: str, url: str, memo: str, description: Optional[str] = None, amount: int = 0, remembers: bool = True
) -> Paywall:
with open_ext_db("paywall") as db:
paywall_id = urlsafe_short_hash()
db.execute(
"""
INSERT INTO paywalls (id, wallet, secret, url, memo, amount)
VALUES (?, ?, ?, ?, ?, ?)
INSERT INTO paywalls (id, wallet, url, memo, description, amount, remembers)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(paywall_id, wallet_id, urlsafe_short_hash(), url, memo, amount),
(paywall_id, wallet_id, url, memo, description, amount, int(remembers)),
)
return get_paywall(paywall_id)
@ -24,7 +26,7 @@ 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
return Paywall.from_row(row) if row else None
def get_paywalls(wallet_ids: Union[str, List[str]]) -> List[Paywall]:
@ -35,7 +37,7 @@ def get_paywalls(wallet_ids: Union[str, List[str]]) -> List[Paywall]:
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]
return [Paywall.from_row(row) for row in rows]
def delete_paywall(paywall_id: str) -> None:

48
lnbits/extensions/paywall/migrations.py

@ -1,3 +1,5 @@
from sqlite3 import OperationalError
from lnbits.db import open_ext_db
@ -20,6 +22,52 @@ def m001_initial(db):
)
def m002_redux(db):
"""
Creates an improved paywalls table and migrates the existing data.
"""
try:
db.execute("SELECT remembers FROM paywalls")
except OperationalError:
db.execute("ALTER TABLE paywalls RENAME TO paywalls_old")
db.execute(
"""
CREATE TABLE IF NOT EXISTS 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 (strftime('%s', 'now')),
remembers INTEGER DEFAULT 0,
extras TEXT NULL
);
"""
)
db.execute("CREATE INDEX IF NOT EXISTS wallet_idx ON paywalls (wallet)")
for row in [list(row) for row in db.fetchall("SELECT * FROM paywalls_old")]:
db.execute(
"""
INSERT INTO paywalls (
id,
wallet,
url,
memo,
amount,
time
)
VALUES (?, ?, ?, ?, ?, ?)
""",
(row[0], row[1], row[3], row[4], row[5], row[6]),
)
db.execute("DROP TABLE paywalls_old")
def migrate():
with open_ext_db("paywall") as db:
m001_initial(db)
m002_redux(db)

16
lnbits/extensions/paywall/models.py

@ -1,11 +1,23 @@
from typing import NamedTuple
import json
from sqlite3 import Row
from typing import NamedTuple, Optional
class Paywall(NamedTuple):
id: str
wallet: str
secret: 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)

115
lnbits/extensions/paywall/templates/paywall/_api_docs.html

@ -6,12 +6,106 @@
>
<q-expansion-item group="api" dense expand-separator label="List paywalls">
<q-card>
<q-card-section> </q-card-section>
<q-card-section>
<code
><span class="text-blue">GET</span> /paywall/api/v1/paywalls</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>[&lt;paywall_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root }}paywall/api/v1/paywalls -H
"X-Api-Key: {{ g.user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Create a paywall">
<q-card>
<q-card-section> </q-card-section>
<q-card-section>
<code
><span class="text-green">POST</span>
/paywall/api/v1/paywalls</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code
>{"amount": &lt;integer&gt;, "description": &lt;string&gt;,
"memo": &lt;string&gt;, "remembers": &lt;boolean&gt;,
"url": &lt;string&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
</h5>
<code>{"amount": &lt;integer&gt;, "description": &lt;string&gt;,
"id": &lt;string&gt;, "memo": &lt;string&gt;,
"remembers": &lt;boolean&gt;, "time": &lt;int&gt;,
"url": &lt;string&gt;, "wallet": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.url_root }}paywall/api/v1/paywalls -d
'{"url": &lt;string&gt;, "memo": &lt;string&gt;,
"description": &lt;string&gt;, "amount": &lt;integer&gt;,
"remembers": &lt;boolean&gt;}' -H
"Content-type: application/json" -H "X-Api-Key: {{
g.user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Create an invoice (public)">
<q-card>
<q-card-section>
<code
><span class="text-green">POST</span>
/paywall/api/v1/paywalls/&lt;paywall_id&gt;/invoice</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code
>{"amount": &lt;integer&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
</h5>
<code>{"checking_id": &lt;string&gt;, "payment_request": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.url_root }}paywall/api/v1/paywalls/&lt;paywall_id&gt;/invoice -d
'{"amount": &lt;integer&gt;}' -H
"Content-type: application/json"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Check invoice status (public)">
<q-card>
<q-card-section>
<code
><span class="text-green">POST</span>
/paywall/api/v1/paywalls/&lt;paywall_id&gt;/check_invoice</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code
>{"checking_id": &lt;string&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>{"paid": false}</code><br>
<code>{"paid": true, "url": &lt;string&gt;, "remembers": &lt;boolean&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.url_root }}paywall/api/v1/paywalls/&lt;paywall_id&gt;/check_invoice -d
'{"checking_id": &lt;string&gt;}' -H
"Content-type: application/json"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
@ -22,7 +116,22 @@
class="q-pb-md"
>
<q-card>
<q-card-section> </q-card-section>
<q-card-section>
<code
><span class="text-pink">DELETE</span>
/paywall/api/v1/paywalls/&lt;paywall_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Returns 204 NO CONTENT</h5>
<code></code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X DELETE {{ request.url_root
}}paywall/api/v1/paywalls/&lt;paywall_id&gt; -H "X-Api-Key: {{
g.user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
</q-expansion-item>

114
lnbits/extensions/paywall/templates/paywall/display.html

@ -1,31 +1,48 @@
{% extends "public.html" %} {% block page %}
<div class="row q-col-gutter-md justify-center">
<div class="col-12 col-sm-6 col-md-5 col-lg-4">
<div class="col-12 col-sm-8 col-md-5 col-lg-4">
<q-card class="q-pa-lg">
<q-card-section class="q-pa-none">
<h5 class="text-subtitle1 q-my-none">{{ paywall.memo }}</h5>
<strong class="text-purple"
>Price:
<lnbits-fsat :amount="{{ paywall.amount }}"></lnbits-fsat> sat</strong
>
<q-separator class="q-my-lg"></q-separator>
<div v-if="paymentReq">
<a :href="'lightning:' + paymentReq">
<q-responsive :ratio="1" class="q-mx-xl q-mb-md">
<qrcode
:value="paymentReq"
:options="{width: 800}"
class="rounded-borders"
></qrcode>
</q-responsive>
</a>
<div class="row q-mt-lg">
<q-btn outline color="grey" @click="copyText(paymentReq)"
>Copy invoice</q-btn
<h5 class="text-subtitle1 q-mt-none q-mb-sm">{{ paywall.memo }}</h5>
{% if paywall.description %}
<p>{{ paywall.description }}</p>
{% endif %}
<div v-if="!this.redirectUrl" class="q-mt-lg">
<q-form v-if="">
<q-input
filled
v-model.number="userAmount"
type="number"
:min="paywallAmount"
suffix="sat"
label="Choose an amount *"
:hint="'Minimum ' + paywallAmount + ' sat'"
>
<template v-slot:after>
<q-btn round dense flat icon="check" color="deep-purple" type="submit" @click="createInvoice" :disabled="userAmount < paywallAmount"></q-btn>
</template>
</q-input>
</q-form>
<div v-if="paymentReq" class="q-mt-lg">
<a :href="'lightning:' + paymentReq">
<q-responsive :ratio="1" class="q-mx-xl q-mb-md">
<qrcode
:value="paymentReq"
:options="{width: 800}"
class="rounded-borders"
></qrcode>
</q-responsive>
</a>
<div class="row q-mt-lg">
<q-btn outline color="grey" @click="copyText(paymentReq)"
>Copy invoice</q-btn
>
<q-btn @click="cancelPayment" flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
</div>
</div>
<div v-if="redirectUrl">
<div v-else>
<q-separator class="q-my-lg"></q-separator>
<p>
You can access the URL behind this paywall:<br />
<strong>{% raw %}{{ redirectUrl }}{% endraw %}</strong>
@ -39,13 +56,6 @@
</q-card-section>
</q-card>
</div>
<div class="col-12 col-sm-6 col-md-5 col-lg-4 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-mb-sm q-mt-none">LNbits paywall</h6>
</q-card-section>
</q-card>
</div>
</div>
{% endblock %} {% block scripts %}
<script src="{{ url_for('static', filename='vendor/vue-qrcode@1.0.2/vue-qrcode.min.js') }}"></script>
@ -57,25 +67,46 @@
mixins: [windowMixin],
data: function () {
return {
userAmount: {{ paywall.amount }},
paywallAmount: {{ paywall.amount }},
paymentReq: null,
redirectUrl: null
redirectUrl: null,
paymentDialog: {
dismissMsg: null,
checker: null
}
}
},
computed: {
amount: function () {
return (this.paywallAmount > this.userAmount) ? this.paywallAmount : this.userAmount
}
},
methods: {
getInvoice: function () {
cancelPayment: function () {
this.paymentReq = null
clearInterval(this.paymentDialog.checker)
if (this.paymentDialog.dismissMsg) {
this.paymentDialog.dismissMsg()
}
},
createInvoice: function () {
var self = this
axios
.get('/paywall/api/v1/paywalls/{{ paywall.id }}/invoice')
.post(
'/paywall/api/v1/paywalls/{{ paywall.id }}/invoice',
{amount: this.amount}
)
.then(function (response) {
self.paymentReq = response.data.payment_request
self.paymentReq = response.data.payment_request.toUpperCase()
dismissMsg = self.$q.notify({
self.paymentDialog.dismissMsg = self.$q.notify({
timeout: 0,
message: 'Waiting for payment...'
})
paymentChecker = setInterval(function () {
self.paymentDialog.checker = setInterval(function () {
axios
.post(
'/paywall/api/v1/paywalls/{{ paywall.id }}/check_invoice',
@ -83,13 +114,14 @@
)
.then(function (res) {
if (res.data.paid) {
clearInterval(paymentChecker)
dismissMsg()
self.cancelPayment()
self.redirectUrl = res.data.url
self.$q.localStorage.set(
'lnbits.paywall.{{ paywall.id }}',
res.data.url
)
if (res.data.remembers) {
self.$q.localStorage.set(
'lnbits.paywall.{{ paywall.id }}',
res.data.url
)
}
self.$q.notify({
type: 'positive',
@ -113,8 +145,6 @@
if (url) {
this.redirectUrl = url
} else {
this.getInvoice()
}
}
})

69
lnbits/extensions/paywall/templates/paywall/index.html

@ -114,27 +114,53 @@
dense
v-model.trim="formDialog.data.url"
type="url"
label="Target URL *"
label="Redirect URL *"
></q-input>
<q-input
filled
dense
v-model.number="formDialog.data.amount"
type="number"
label="Amount (sat) *"
v-model.trim="formDialog.data.memo"
label="Title *"
placeholder="LNbits paywall"
></q-input>
<q-input
filled
dense
v-model.trim="formDialog.data.memo"
label="Memo"
placeholder="LNbits invoice"
autogrow
v-model.trim="formDialog.data.description"
label="Description"
></q-input>
<q-input
filled
dense
v-model.number="formDialog.data.amount"
type="number"
label="Amount (sat) *"
hint="This is the minimum amount users can pay/donate."
></q-input>
<q-list>
<q-item tag="label" class="rounded-borders">
<q-item-section avatar>
<q-checkbox
v-model="formDialog.data.remembers"
color="deep-purple"
></q-checkbox>
</q-item-section>
<q-item-section>
<q-item-label
>Remember payments</q-item-label
>
<q-item-label caption
>A succesful payment will be registered in the browser's storage, so the user doesn't need to pay again to access the URL.</q-item-label
>
</q-item-section>
</q-item>
</q-list>
<div class="row q-mt-lg">
<q-btn
unelevated
color="deep-purple"
:disable="formDialog.data.amount == null || formDialog.data.amount < 0 || formDialog.data.url == null"
:disable="formDialog.data.amount == null || formDialog.data.amount < 0 || formDialog.data.url == null || formDialog.data.memo == null"
type="submit"
>Create paywall</q-btn
>
@ -168,13 +194,6 @@
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',
@ -184,6 +203,14 @@
sort: function (a, b, rowA, rowB) {
return rowA.amount - rowB.amount
}
},
{name: 'remembers', align: 'left', label: 'Remember', field: 'remembers'},
{
name: 'date',
align: 'left',
label: 'Date',
field: 'date',
sortable: true
}
],
pagination: {
@ -192,7 +219,9 @@
},
formDialog: {
show: false,
data: {}
data: {
remembers: false
}
}
}
},
@ -216,7 +245,9 @@
var data = {
url: this.formDialog.data.url,
memo: this.formDialog.data.memo,
amount: this.formDialog.data.amount
amount: this.formDialog.data.amount,
description: this.formDialog.data.description,
remembers: this.formDialog.data.remembers
}
var self = this
@ -231,7 +262,9 @@
.then(function (response) {
self.paywalls.push(mapPaywall(response.data))
self.formDialog.show = false
self.formDialog.data = {}
self.formDialog.data = {
remembers: false
}
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)

17
lnbits/extensions/paywall/views_api.py

@ -27,7 +27,9 @@ def api_paywalls():
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},
}
)
def api_paywall_create():
@ -52,18 +54,23 @@ def api_paywall_delete(paywall_id):
return "", HTTPStatus.NO_CONTENT
@paywall_ext.route("/api/v1/paywalls/<paywall_id>/invoice", methods=["GET"])
def api_paywall_get_invoice(paywall_id):
@paywall_ext.route("/api/v1/paywalls/<paywall_id>/invoice", methods=["POST"])
@api_validate_post_request(schema={"amount": {"type": "integer", "min": 1, "required": True}})
def api_paywall_create_invoice(paywall_id):
paywall = 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
checking_id, payment_request = create_invoice(
wallet_id=paywall.wallet, amount=paywall.amount, memo=f"#paywall {paywall.memo}"
wallet_id=paywall.wallet, amount=amount, memo=f"#paywall {paywall.memo}"
)
except Exception as e:
return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR
return jsonify({"checking_id": checking_id, "payment_request": payment_request}), HTTPStatus.OK
return jsonify({"checking_id": checking_id, "payment_request": payment_request}), HTTPStatus.CREATED
@paywall_ext.route("/api/v1/paywalls/<paywall_id>/check_invoice", methods=["POST"])
@ -84,6 +91,6 @@ def api_paywal_check_invoice(paywall_id):
payment = wallet.get_payment(g.data["checking_id"])
payment.set_pending(False)
return jsonify({"paid": True, "url": paywall.url}), HTTPStatus.OK
return jsonify({"paid": True, "url": paywall.url, "remembers": paywall.remembers}), HTTPStatus.OK
return jsonify({"paid": False}), HTTPStatus.OK

Loading…
Cancel
Save