feat: paywall extension

This commit is contained in:
Eneko Illarramendi 2020-04-21 23:42:41 +02:00
parent fd4dc6c48f
commit 403385c205
10 changed files with 229 additions and 43 deletions

View File

@ -11,10 +11,10 @@ def create_paywall(*, wallet_id: str, url: str, memo: str, amount: int) -> Paywa
paywall_id = urlsafe_short_hash()
db.execute(
"""
INSERT INTO paywalls (id, wallet, url, memo, amount)
VALUES (?, ?, ?, ?, ?)
INSERT INTO paywalls (id, wallet, secret, url, memo, amount)
VALUES (?, ?, ?, ?, ?, ?)
""",
(paywall_id, wallet_id, url, memo, amount),
(paywall_id, wallet_id, urlsafe_short_hash(), url, memo, amount),
)
return get_paywall(paywall_id)

View File

@ -9,6 +9,7 @@ def m001_initial(db):
CREATE TABLE IF NOT EXISTS 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,
@ -18,5 +19,5 @@ def m001_initial(db):
def migrate():
with open_ext_db("tpos") as db:
with open_ext_db("paywall") as db:
m001_initial(db)

View File

@ -1,10 +1,15 @@
from hashlib import sha256
from typing import NamedTuple
class Paywall(NamedTuple):
id: str
wallet: str
secret: str
url: str
memo: str
amount: int
time: int
def key_for(self, fingerprint: str) -> str:
return sha256(f"{self.secret}{fingerprint}".encode("utf-8")).hexdigest()

File diff suppressed because one or more lines are too long

View File

@ -4,13 +4,6 @@
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>
@ -18,7 +11,14 @@
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Delete a paywall">
<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="Delete a paywall" class="q-pb-md">
<q-card>
<q-card-section>

View File

@ -0,0 +1,114 @@
{% 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">
<q-card class="q-pa-lg">
<q-card-section class="q-pa-none">
<q-responsive v-if="pr" :ratio="1" class="q-mx-xl q-mb-md">
<qrcode v-if="pr" :value="pr" :options="{width: 800}" class="rounded-borders"></qrcode>
</q-responsive>
<div v-if="redirectUrl">
<p>You can access the URL behind this paywall:<br>
<strong>{% raw %}{{ redirectUrl }}{% endraw %}</strong></p>
<div class="row q-mt-lg">
<q-btn outline color="grey" type="a" :href="redirectUrl">Open URL</q-btn>
</div>
</div>
</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('paywall.static', filename='vendor/fingerprintjs2@2.1.0/fingerprint2.min.js') }}"></script>
<script src="{{ url_for('static', filename='vendor/vue-qrcode@1.0.2/vue-qrcode.min.js') }}"></script>
<script>
Vue.component(VueQrcode.name, VueQrcode);
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
pr: null,
fingerprint: {
hash: null,
isValid: false
},
redirectUrl: null
};
},
methods: {
getInvoice: function () {
var self = this;
axios.get(
'/paywall/api/v1/paywalls/{{ paywall.id }}/invoice'
).then(function (response) {
self.pr = response.data.payment_request;
dismissMsg = self.$q.notify({
timeout: 0,
message: 'Waiting for payment...'
});
paymentChecker = setInterval(function () {
axios.post(
'/paywall/api/v1/paywalls/{{ paywall.id }}/check_invoice',
{checking_id: response.data.checking_id, fingerprint: self.fingerprint.hash}
).then(function (res) {
if (res.data.paid) {
clearInterval(paymentChecker);
dismissMsg();
self.redirectUrl = res.data.url;
self.$q.localStorage.set('lnbits.paywall.{{ paywall.id }}', res.data.key);
self.$q.notify({
type: 'positive',
message: 'Payment received!',
icon: null
});
}
});
}, 2000);
});
}
},
created: function () {
var self = this;
Fingerprint2.get(function (components) {
self.fingerprint.hash = Fingerprint2.x64hash128(JSON.stringify(components));
var key = self.$q.localStorage.getItem('lnbits.paywall.{{ paywall.id }}');
if (key) {
axios.post(
'/paywall/api/v1/paywalls/{{ paywall.id }}/check_access',
{key: key, fingerprint: self.fingerprint.hash}
).then(function (response) {
if (response.data.valid) {
self.fingerprint.isValid = true;
self.redirectUrl = response.data.url;
} else {
self.getInvoice();
}
});
} else {
self.getInvoice();
};
});
}
});
</script>
{% endblock %}

View File

@ -8,7 +8,7 @@
<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-btn unelevated color="deep-purple" @click="formDialog.show = true">New paywall</q-btn>
</q-card-section>
</q-card>
@ -44,7 +44,7 @@
<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="launch" :color="($q.dark.isActive) ? 'grey-7' : 'grey-5'" type="a" :href="props.row.displayUrl" 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
@ -79,27 +79,27 @@
</q-card>
</div>
<q-dialog v-model="paywallDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<q-dialog v-model="formDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="createPaywall" class="q-gutter-md">
<q-select filled dense emit-value v-model="paywallDialog.data.wallet" :options="g.user.walletOptions" label="Wallet *">
<q-select filled dense emit-value v-model="formDialog.data.wallet" :options="g.user.walletOptions" label="Wallet *">
</q-select>
<q-input filled dense
v-model.trim="paywallDialog.data.url"
v-model.trim="formDialog.data.url"
type="url"
label="Target URL *"></q-input>
<q-input filled dense
v-model.number="paywallDialog.data.amount"
v-model.number="formDialog.data.amount"
type="number"
label="Amount *"></q-input>
label="Amount (sat) *"></q-input>
<q-input filled dense
v-model.trim="paywallDialog.data.memo"
v-model.trim="formDialog.data.memo"
label="Memo"
placeholder="LNbits invoice"></q-input>
<div class="row q-mt-lg">
<q-btn unelevated
color="deep-purple"
:disable="paywallDialog.data.amount == null || paywallDialog.data.amount < 0 || paywallDialog.data.url == null"
:disable="formDialog.data.amount == null || formDialog.data.amount < 0 || formDialog.data.url == null"
type="submit">Create paywall</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
@ -115,7 +115,7 @@
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('');
obj.displayUrl = ['/paywall/', obj.id].join('');
return obj;
}
@ -141,7 +141,7 @@
rowsPerPage: 10
}
},
paywallDialog: {
formDialog: {
show: false,
data: {}
}
@ -163,23 +163,21 @@
},
createPaywall: function () {
var data = {
url: this.paywallDialog.data.url,
memo: this.paywallDialog.data.memo,
amount: this.paywallDialog.data.amount
url: this.formDialog.data.url,
memo: this.formDialog.data.memo,
amount: this.formDialog.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,
_.findWhere(this.g.user.wallets, {id: this.formDialog.data.wallet}).inkey,
data
).then(function (response) {
self.paywalls.push(mapPaywall(response.data));
self.paywallDialog.show = false;
self.paywallDialog.data = {};
self.formDialog.show = false;
self.formDialog.data = {};
}).catch(function (error) {
LNbits.utils.notifyApiError(error);
});
@ -189,7 +187,7 @@
var paywall = _.findWhere(this.paywalls, {id: paywallId});
this.$q.dialog({
message: 'Are you sure you want to delete this Paywall link?',
message: 'Are you sure you want to delete this paywall link?',
ok: {
flat: true,
color: 'orange'

View File

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

View File

@ -1,9 +1,9 @@
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 lnbits.extensions.paywall import paywall_ext
from .crud import get_paywall
@ -15,7 +15,7 @@ def index():
@paywall_ext.route("/<paywall_id>")
def wall(paywall_id):
def display(paywall_id):
paywall = get_paywall(paywall_id) or abort(Status.NOT_FOUND, "Paywall does not exist.")
return render_template("paywall/wall.html", paywall=paywall)
return render_template("paywall/display.html", paywall=paywall)

View File

@ -1,8 +1,10 @@
from flask import g, jsonify, request
from lnbits.core.crud import get_user
from lnbits.core.crud import get_user, get_wallet
from lnbits.core.services import create_invoice
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
from lnbits.helpers import Status
from lnbits.settings import WALLET
from lnbits.extensions.paywall import paywall_ext
from .crud import create_paywall, get_paywall, get_paywalls, delete_paywall
@ -21,11 +23,13 @@ def api_paywalls():
@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},
"amount": {"type": "integer", "min": 0, "required": True},
})
@api_validate_post_request(
schema={
"url": {"type": "string", "empty": False, "required": True},
"memo": {"type": "string", "empty": False, "required": True},
"amount": {"type": "integer", "min": 0, "required": True},
}
)
def api_paywall_create():
paywall = create_paywall(wallet_id=g.wallet.id, **g.data)
@ -46,3 +50,64 @@ def api_paywall_delete(paywall_id):
delete_paywall(paywall_id)
return "", Status.NO_CONTENT
@paywall_ext.route("/api/v1/paywalls/<paywall_id>/invoice", methods=["GET"])
def api_paywall_get_invoice(paywall_id):
paywall = get_paywall(paywall_id)
try:
checking_id, payment_request = create_invoice(
wallet_id=paywall.wallet, amount=paywall.amount, memo=paywall.memo
)
except Exception as e:
return jsonify({"message": str(e)}), Status.INTERNAL_SERVER_ERROR
return jsonify({"checking_id": checking_id, "payment_request": payment_request}), Status.OK
@paywall_ext.route("/api/v1/paywalls/<paywall_id>/check_invoice", methods=["POST"])
@api_validate_post_request(
schema={
"checking_id": {"type": "string", "empty": False, "required": True},
"fingerprint": {"type": "string", "empty": False, "required": True},
}
)
def api_paywal_check_invoice(paywall_id):
paywall = get_paywall(paywall_id)
if not paywall:
return jsonify({"message": "Paywall does not exist."}), Status.NOT_FOUND
try:
is_paid = not WALLET.get_invoice_status(g.data["checking_id"]).pending
except Exception:
return jsonify({"paid": False}), Status.OK
if is_paid:
wallet = get_wallet(paywall.wallet)
payment = wallet.get_payment(g.data["checking_id"])
payment.set_pending(False)
return jsonify({"paid": True, "key": paywall.key_for(g.data["fingerprint"]), "url": paywall.url}), Status.OK
return jsonify({"paid": False}), Status.OK
@paywall_ext.route("/api/v1/paywalls/<paywall_id>/check_access", methods=["POST"])
@api_validate_post_request(
schema={
"key": {"type": "string", "empty": False, "required": True},
"fingerprint": {"type": "string", "empty": False, "required": True},
}
)
def api_fingerprint_check(paywall_id):
paywall = get_paywall(paywall_id)
if not paywall:
return jsonify({"message": "Paywall does not exist."}), Status.NOT_FOUND
if paywall.key_for(g.data["fingerprint"]) != g.data["key"]:
return jsonify({"valid": False}), Status.OK
return jsonify({"valid": True, "url": paywall.url}), Status.OK