paywall initial
This commit is contained in:
parent
92ba2db276
commit
336e3a833a
11 changed files with 946 additions and 0 deletions
22
lnbits/extensions/paywall/README.md
Normal file
22
lnbits/extensions/paywall/README.md
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
# Paywall
|
||||||
|
|
||||||
|
## Hide content behind a paywall, a user has to pay some amount to access your hidden content
|
||||||
|
|
||||||
|
A Paywall is a way of restricting to content via a purchase or paid subscription. For example to read a determined blog post, or to continue reading further, to access a downloads area, etc...
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
1. Create a paywall by clicking "NEW PAYWALL"\
|
||||||
|

|
||||||
|
2. Fill the options for your PAYWALL
|
||||||
|
- select the wallet
|
||||||
|
- set the link that will be unlocked after a successful payment
|
||||||
|
- give your paywall a _Title_
|
||||||
|
- an optional small description
|
||||||
|
- and set an amount a user must pay to access the hidden content. Note this is the minimum amount, a user can over pay if they wish
|
||||||
|
- if _Remember payments_ is checked, a returning paying user won't have to pay again for the same content.\
|
||||||
|

|
||||||
|
3. You can then use your paywall link to secure your awesome content\
|
||||||
|

|
||||||
|
4. When a user wants to access your hidden content, he can use the minimum amount or increase and click the "_Check icon_" to generate an invoice, user will then be redirected to the content page\
|
||||||
|

|
||||||
12
lnbits/extensions/paywall/__init__.py
Normal file
12
lnbits/extensions/paywall/__init__.py
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
from quart import Blueprint
|
||||||
|
from lnbits.db import Database
|
||||||
|
|
||||||
|
db = Database("ext_paywall")
|
||||||
|
|
||||||
|
paywall_ext: Blueprint = Blueprint(
|
||||||
|
"paywall", __name__, static_folder="static", template_folder="templates"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
from .views_api import * # noqa
|
||||||
|
from .views import * # noqa
|
||||||
6
lnbits/extensions/paywall/config.json
Normal file
6
lnbits/extensions/paywall/config.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"name": "Paywall",
|
||||||
|
"short_description": "Create paywalls for content",
|
||||||
|
"icon": "policy",
|
||||||
|
"contributors": ["eillarra"]
|
||||||
|
}
|
||||||
53
lnbits/extensions/paywall/crud.py
Normal file
53
lnbits/extensions/paywall/crud.py
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
from typing import List, Optional, Union
|
||||||
|
|
||||||
|
from lnbits.helpers import urlsafe_short_hash
|
||||||
|
|
||||||
|
from . import db
|
||||||
|
from .models import Paywall
|
||||||
|
|
||||||
|
|
||||||
|
async def create_paywall(
|
||||||
|
*,
|
||||||
|
wallet_id: str,
|
||||||
|
url: str,
|
||||||
|
memo: str,
|
||||||
|
description: Optional[str] = None,
|
||||||
|
amount: int = 0,
|
||||||
|
remembers: bool = True,
|
||||||
|
) -> Paywall:
|
||||||
|
paywall_id = urlsafe_short_hash()
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO paywall.paywalls (id, wallet, url, memo, description, amount, remembers)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(paywall_id, wallet_id, url, memo, description, amount, int(remembers)),
|
||||||
|
)
|
||||||
|
|
||||||
|
paywall = await get_paywall(paywall_id)
|
||||||
|
assert paywall, "Newly created paywall couldn't be retrieved"
|
||||||
|
return paywall
|
||||||
|
|
||||||
|
|
||||||
|
async def get_paywall(paywall_id: str) -> Optional[Paywall]:
|
||||||
|
row = await db.fetchone(
|
||||||
|
"SELECT * FROM paywall.paywalls WHERE id = ?", (paywall_id,)
|
||||||
|
)
|
||||||
|
|
||||||
|
return Paywall.from_row(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_paywalls(wallet_ids: Union[str, List[str]]) -> List[Paywall]:
|
||||||
|
if isinstance(wallet_ids, str):
|
||||||
|
wallet_ids = [wallet_ids]
|
||||||
|
|
||||||
|
q = ",".join(["?"] * len(wallet_ids))
|
||||||
|
rows = await db.fetchall(
|
||||||
|
f"SELECT * FROM paywall.paywalls WHERE wallet IN ({q})", (*wallet_ids,)
|
||||||
|
)
|
||||||
|
|
||||||
|
return [Paywall.from_row(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_paywall(paywall_id: str) -> None:
|
||||||
|
await db.execute("DELETE FROM paywall.paywalls WHERE id = ?", (paywall_id,))
|
||||||
66
lnbits/extensions/paywall/migrations.py
Normal file
66
lnbits/extensions/paywall/migrations.py
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
from sqlalchemy.exc import OperationalError # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
async def m001_initial(db):
|
||||||
|
"""
|
||||||
|
Initial paywalls table.
|
||||||
|
"""
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE paywall.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,
|
||||||
|
time TIMESTAMP NOT NULL DEFAULT """
|
||||||
|
+ db.timestamp_now
|
||||||
|
+ """
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def m002_redux(db):
|
||||||
|
"""
|
||||||
|
Creates an improved paywalls table and migrates the existing data.
|
||||||
|
"""
|
||||||
|
await db.execute("ALTER TABLE paywall.paywalls RENAME TO paywalls_old")
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE paywall.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 """
|
||||||
|
+ db.timestamp_now
|
||||||
|
+ """,
|
||||||
|
remembers INTEGER DEFAULT 0,
|
||||||
|
extras TEXT NULL
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
for row in [
|
||||||
|
list(row) for row in await db.fetchall("SELECT * FROM paywall.paywalls_old")
|
||||||
|
]:
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO paywall.paywalls (
|
||||||
|
id,
|
||||||
|
wallet,
|
||||||
|
url,
|
||||||
|
memo,
|
||||||
|
amount,
|
||||||
|
time
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(row[0], row[1], row[3], row[4], row[5], row[6]),
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.execute("DROP TABLE paywall.paywalls_old")
|
||||||
23
lnbits/extensions/paywall/models.py
Normal file
23
lnbits/extensions/paywall/models.py
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import json
|
||||||
|
|
||||||
|
from sqlite3 import Row
|
||||||
|
from typing import NamedTuple, Optional
|
||||||
|
|
||||||
|
|
||||||
|
class Paywall(NamedTuple):
|
||||||
|
id: str
|
||||||
|
wallet: 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)
|
||||||
147
lnbits/extensions/paywall/templates/paywall/_api_docs.html
Normal file
147
lnbits/extensions/paywall/templates/paywall/_api_docs.html
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
<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="List paywalls">
|
||||||
|
<q-card>
|
||||||
|
<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": <invoice_key>}</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>[<paywall_object>, ...]</code>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
|
<code
|
||||||
|
>curl -X GET {{ request.url_root }}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>
|
||||||
|
<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": <admin_key>}</code><br />
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||||
|
<code
|
||||||
|
>{"amount": <integer>, "description": <string>, "memo":
|
||||||
|
<string>, "remembers": <boolean>, "url":
|
||||||
|
<string>}</code
|
||||||
|
>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||||
|
Returns 201 CREATED (application/json)
|
||||||
|
</h5>
|
||||||
|
<code
|
||||||
|
>{"amount": <integer>, "description": <string>, "id":
|
||||||
|
<string>, "memo": <string>, "remembers": <boolean>,
|
||||||
|
"time": <int>, "url": <string>, "wallet":
|
||||||
|
<string>}</code
|
||||||
|
>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
|
<code
|
||||||
|
>curl -X POST {{ request.url_root }}api/v1/paywalls -d '{"url":
|
||||||
|
<string>, "memo": <string>, "description": <string>,
|
||||||
|
"amount": <integer>, "remembers": <boolean>}' -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/<paywall_id>/invoice</code
|
||||||
|
>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||||
|
<code>{"amount": <integer>}</code>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||||
|
Returns 201 CREATED (application/json)
|
||||||
|
</h5>
|
||||||
|
<code
|
||||||
|
>{"payment_hash": <string>, "payment_request":
|
||||||
|
<string>}</code
|
||||||
|
>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
|
<code
|
||||||
|
>curl -X POST {{ request.url_root
|
||||||
|
}}api/v1/paywalls/<paywall_id>/invoice -d '{"amount":
|
||||||
|
<integer>}' -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/<paywall_id>/check_invoice</code
|
||||||
|
>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||||
|
<code>{"payment_hash": <string>}</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": <string>, "remembers":
|
||||||
|
<boolean>}</code
|
||||||
|
>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
|
<code
|
||||||
|
>curl -X POST {{ request.url_root
|
||||||
|
}}api/v1/paywalls/<paywall_id>/check_invoice -d
|
||||||
|
'{"payment_hash": <string>}' -H "Content-type: application/json"
|
||||||
|
</code>
|
||||||
|
</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>
|
||||||
|
<code
|
||||||
|
><span class="text-pink">DELETE</span>
|
||||||
|
/paywall/api/v1/paywalls/<paywall_id></code
|
||||||
|
>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||||
|
<code>{"X-Api-Key": <admin_key>}</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
|
||||||
|
}}api/v1/paywalls/<paywall_id> -H "X-Api-Key: {{
|
||||||
|
g.user.wallets[0].adminkey }}"
|
||||||
|
</code>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
</q-expansion-item>
|
||||||
162
lnbits/extensions/paywall/templates/paywall/display.html
Normal file
162
lnbits/extensions/paywall/templates/paywall/display.html
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
{% extends "public.html" %} {% block page %}
|
||||||
|
<div class="row q-col-gutter-md justify-center">
|
||||||
|
<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-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="primary"
|
||||||
|
type="submit"
|
||||||
|
@click="createInvoice"
|
||||||
|
:disabled="userAmount < paywallAmount || paymentReq"
|
||||||
|
></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-else>
|
||||||
|
<q-separator class="q-my-lg"></q-separator>
|
||||||
|
<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>
|
||||||
|
{% endblock %} {% block scripts %}
|
||||||
|
<script>
|
||||||
|
Vue.component(VueQrcode.name, VueQrcode)
|
||||||
|
|
||||||
|
new Vue({
|
||||||
|
el: '#vue',
|
||||||
|
mixins: [windowMixin],
|
||||||
|
data: function () {
|
||||||
|
return {
|
||||||
|
userAmount: {{ paywall.amount }},
|
||||||
|
paywallAmount: {{ paywall.amount }},
|
||||||
|
paymentReq: null,
|
||||||
|
redirectUrl: null,
|
||||||
|
paymentDialog: {
|
||||||
|
dismissMsg: null,
|
||||||
|
checker: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
amount: function () {
|
||||||
|
return (this.paywallAmount > this.userAmount) ? this.paywallAmount : this.userAmount
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
cancelPayment: function () {
|
||||||
|
this.paymentReq = null
|
||||||
|
clearInterval(this.paymentDialog.checker)
|
||||||
|
if (this.paymentDialog.dismissMsg) {
|
||||||
|
this.paymentDialog.dismissMsg()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
createInvoice: function () {
|
||||||
|
var self = this
|
||||||
|
|
||||||
|
axios
|
||||||
|
.post(
|
||||||
|
'/paywall/api/v1/paywalls/{{ paywall.id }}/invoice',
|
||||||
|
{amount: this.amount}
|
||||||
|
)
|
||||||
|
.then(function (response) {
|
||||||
|
self.paymentReq = response.data.payment_request.toUpperCase()
|
||||||
|
|
||||||
|
self.paymentDialog.dismissMsg = self.$q.notify({
|
||||||
|
timeout: 0,
|
||||||
|
message: 'Waiting for payment...'
|
||||||
|
})
|
||||||
|
|
||||||
|
self.paymentDialog.checker = setInterval(function () {
|
||||||
|
axios
|
||||||
|
.post(
|
||||||
|
'/paywall/api/v1/paywalls/{{ paywall.id }}/check_invoice',
|
||||||
|
{payment_hash: response.data.payment_hash}
|
||||||
|
)
|
||||||
|
.then(function (res) {
|
||||||
|
if (res.data.paid) {
|
||||||
|
self.cancelPayment()
|
||||||
|
self.redirectUrl = res.data.url
|
||||||
|
if (res.data.remembers) {
|
||||||
|
self.$q.localStorage.set(
|
||||||
|
'lnbits.paywall.{{ paywall.id }}',
|
||||||
|
res.data.url
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
self.$q.notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'Payment received!',
|
||||||
|
icon: null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
})
|
||||||
|
}, 2000)
|
||||||
|
})
|
||||||
|
.catch(function (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created: function () {
|
||||||
|
var url = this.$q.localStorage.getItem('lnbits.paywall.{{ paywall.id }}')
|
||||||
|
|
||||||
|
if (url) {
|
||||||
|
this.redirectUrl = url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
312
lnbits/extensions/paywall/templates/paywall/index.html
Normal file
312
lnbits/extensions/paywall/templates/paywall/index.html
Normal file
|
|
@ -0,0 +1,312 @@
|
||||||
|
{% 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="primary" @click="formDialog.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="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 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">
|
||||||
|
{{SITE_TITLE}} 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="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="formDialog.data.wallet"
|
||||||
|
:options="g.user.walletOptions"
|
||||||
|
label="Wallet *"
|
||||||
|
>
|
||||||
|
</q-select>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialog.data.url"
|
||||||
|
type="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
|
||||||
|
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="primary"
|
||||||
|
></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="primary"
|
||||||
|
:disable="formDialog.data.amount == null || formDialog.data.amount < 0 || formDialog.data.url == null || formDialog.data.memo == null"
|
||||||
|
type="submit"
|
||||||
|
>Create paywall</q-btn
|
||||||
|
>
|
||||||
|
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||||
|
>Cancel</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</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.displayUrl = ['/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: 'amount',
|
||||||
|
align: 'right',
|
||||||
|
label: 'Amount (sat)',
|
||||||
|
field: 'fsat',
|
||||||
|
sortable: true,
|
||||||
|
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: {
|
||||||
|
rowsPerPage: 10
|
||||||
|
}
|
||||||
|
},
|
||||||
|
formDialog: {
|
||||||
|
show: false,
|
||||||
|
data: {
|
||||||
|
remembers: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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.formDialog.data.url,
|
||||||
|
memo: this.formDialog.data.memo,
|
||||||
|
amount: this.formDialog.data.amount,
|
||||||
|
description: this.formDialog.data.description,
|
||||||
|
remembers: this.formDialog.data.remembers
|
||||||
|
}
|
||||||
|
var self = this
|
||||||
|
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'POST',
|
||||||
|
'/paywall/api/v1/paywalls',
|
||||||
|
_.findWhere(this.g.user.wallets, {id: this.formDialog.data.wallet})
|
||||||
|
.inkey,
|
||||||
|
data
|
||||||
|
)
|
||||||
|
.then(function (response) {
|
||||||
|
self.paywalls.push(mapPaywall(response.data))
|
||||||
|
self.formDialog.show = false
|
||||||
|
self.formDialog.data = {
|
||||||
|
remembers: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
deletePaywall: function (paywallId) {
|
||||||
|
var self = this
|
||||||
|
var paywall = _.findWhere(this.paywalls, {id: paywallId})
|
||||||
|
|
||||||
|
LNbits.utils
|
||||||
|
.confirmDialog('Are you sure you want to delete this paywall link?')
|
||||||
|
.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 %}
|
||||||
22
lnbits/extensions/paywall/views.py
Normal file
22
lnbits/extensions/paywall/views.py
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
from quart import g, abort, render_template
|
||||||
|
from http import HTTPStatus
|
||||||
|
|
||||||
|
from lnbits.decorators import check_user_exists, validate_uuids
|
||||||
|
|
||||||
|
from . import paywall_ext
|
||||||
|
from .crud import get_paywall
|
||||||
|
|
||||||
|
|
||||||
|
@paywall_ext.route("/")
|
||||||
|
@validate_uuids(["usr"], required=True)
|
||||||
|
@check_user_exists()
|
||||||
|
async def index():
|
||||||
|
return await render_template("paywall/index.html", user=g.user)
|
||||||
|
|
||||||
|
|
||||||
|
@paywall_ext.route("/<paywall_id>")
|
||||||
|
async def display(paywall_id):
|
||||||
|
paywall = await get_paywall(paywall_id) or abort(
|
||||||
|
HTTPStatus.NOT_FOUND, "Paywall does not exist."
|
||||||
|
)
|
||||||
|
return await render_template("paywall/display.html", paywall=paywall)
|
||||||
121
lnbits/extensions/paywall/views_api.py
Normal file
121
lnbits/extensions/paywall/views_api.py
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
from quart import g, jsonify, request
|
||||||
|
from http import HTTPStatus
|
||||||
|
|
||||||
|
from lnbits.core.crud import get_user, get_wallet
|
||||||
|
from lnbits.core.services import create_invoice, check_invoice_status
|
||||||
|
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
|
||||||
|
|
||||||
|
from . 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_key("invoice")
|
||||||
|
async def api_paywalls():
|
||||||
|
wallet_ids = [g.wallet.id]
|
||||||
|
|
||||||
|
if "all_wallets" in request.args:
|
||||||
|
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
|
||||||
|
|
||||||
|
return (
|
||||||
|
jsonify([paywall._asdict() for paywall in await get_paywalls(wallet_ids)]),
|
||||||
|
HTTPStatus.OK,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"empty": True,
|
||||||
|
"nullable": True,
|
||||||
|
"required": False,
|
||||||
|
},
|
||||||
|
"amount": {"type": "integer", "min": 0, "required": True},
|
||||||
|
"remembers": {"type": "boolean", "required": True},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
async def api_paywall_create():
|
||||||
|
paywall = await create_paywall(wallet_id=g.wallet.id, **g.data)
|
||||||
|
return jsonify(paywall._asdict()), HTTPStatus.CREATED
|
||||||
|
|
||||||
|
|
||||||
|
@paywall_ext.route("/api/v1/paywalls/<paywall_id>", methods=["DELETE"])
|
||||||
|
@api_check_wallet_key("invoice")
|
||||||
|
async def api_paywall_delete(paywall_id):
|
||||||
|
paywall = await get_paywall(paywall_id)
|
||||||
|
|
||||||
|
if not paywall:
|
||||||
|
return jsonify({"message": "Paywall does not exist."}), HTTPStatus.NOT_FOUND
|
||||||
|
|
||||||
|
if paywall.wallet != g.wallet.id:
|
||||||
|
return jsonify({"message": "Not your paywall."}), HTTPStatus.FORBIDDEN
|
||||||
|
|
||||||
|
await delete_paywall(paywall_id)
|
||||||
|
|
||||||
|
return "", HTTPStatus.NO_CONTENT
|
||||||
|
|
||||||
|
|
||||||
|
@paywall_ext.route("/api/v1/paywalls/<paywall_id>/invoice", methods=["POST"])
|
||||||
|
@api_validate_post_request(
|
||||||
|
schema={"amount": {"type": "integer", "min": 1, "required": True}}
|
||||||
|
)
|
||||||
|
async def api_paywall_create_invoice(paywall_id):
|
||||||
|
paywall = await 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
|
||||||
|
)
|
||||||
|
payment_hash, payment_request = await create_invoice(
|
||||||
|
wallet_id=paywall.wallet,
|
||||||
|
amount=amount,
|
||||||
|
memo=f"{paywall.memo}",
|
||||||
|
extra={"tag": "paywall"},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR
|
||||||
|
|
||||||
|
return (
|
||||||
|
jsonify({"payment_hash": payment_hash, "payment_request": payment_request}),
|
||||||
|
HTTPStatus.CREATED,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@paywall_ext.route("/api/v1/paywalls/<paywall_id>/check_invoice", methods=["POST"])
|
||||||
|
@api_validate_post_request(
|
||||||
|
schema={"payment_hash": {"type": "string", "empty": False, "required": True}}
|
||||||
|
)
|
||||||
|
async def api_paywal_check_invoice(paywall_id):
|
||||||
|
paywall = await get_paywall(paywall_id)
|
||||||
|
|
||||||
|
if not paywall:
|
||||||
|
return jsonify({"message": "Paywall does not exist."}), HTTPStatus.NOT_FOUND
|
||||||
|
|
||||||
|
try:
|
||||||
|
status = await check_invoice_status(paywall.wallet, g.data["payment_hash"])
|
||||||
|
is_paid = not status.pending
|
||||||
|
except Exception:
|
||||||
|
return jsonify({"paid": False}), HTTPStatus.OK
|
||||||
|
|
||||||
|
if is_paid:
|
||||||
|
wallet = await get_wallet(paywall.wallet)
|
||||||
|
payment = await wallet.get_payment(g.data["payment_hash"])
|
||||||
|
await payment.set_pending(False)
|
||||||
|
|
||||||
|
return (
|
||||||
|
jsonify({"paid": True, "url": paywall.url, "remembers": paywall.remembers}),
|
||||||
|
HTTPStatus.OK,
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify({"paid": False}), HTTPStatus.OK
|
||||||
Loading…
Add table
Add a link
Reference in a new issue