feat(paywall): improved extension

- make remember cookie optional
- improve database
- improve type casting
This commit is contained in:
Eneko Illarramendi 2020-05-08 21:05:32 +02:00
parent 2fac47c05a
commit e73a508011
8 changed files with 319 additions and 78 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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()
}
}
})

View file

@ -114,7 +114,21 @@
dense
v-model.trim="formDialog.data.url"
type="url"
label="Target URL *"
label="Redirect URL *"
></q-input>
<q-input
filled
dense
v-model.trim="formDialog.data.memo"
label="Title *"
placeholder="LNbits paywall"
></q-input>
<q-input
filled
dense
autogrow
v-model.trim="formDialog.data.description"
label="Description"
></q-input>
<q-input
filled
@ -122,19 +136,31 @@
v-model.number="formDialog.data.amount"
type="number"
label="Amount (sat) *"
hint="This is the minimum amount users can pay/donate."
></q-input>
<q-input
filled
dense
v-model.trim="formDialog.data.memo"
label="Memo"
placeholder="LNbits invoice"
></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)

View file

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