feat: paywall extension
This commit is contained in:
parent
fd4dc6c48f
commit
403385c205
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
|
4
lnbits/extensions/paywall/static/vendor/fingerprintjs2@2.1.0/fingerprint2.min.js
vendored
Normal file
4
lnbits/extensions/paywall/static/vendor/fingerprintjs2@2.1.0/fingerprint2.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
|
@ -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>
|
||||
|
||||
|
|
114
lnbits/extensions/paywall/templates/paywall/display.html
Normal file
114
lnbits/extensions/paywall/templates/paywall/display.html
Normal 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 %}
|
|
@ -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'
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
{{ paywall.url }}
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue
Block a user