remove extensions
@@ -1,11 +0,0 @@
|
||||
<h1>Example Extension</h1>
|
||||
<h2>*tagline*</h2>
|
||||
This is an example extension to help you organise and build you own.
|
||||
|
||||
Try to include an image
|
||||
<img src="https://i.imgur.com/9i4xcQB.png">
|
||||
|
||||
|
||||
<h2>If your extension has API endpoints, include useful ones here</h2>
|
||||
|
||||
<code>curl -H "Content-type: application/json" -X POST https://YOUR-LNBITS/YOUR-EXTENSION/api/v1/EXAMPLE -d '{"amount":"100","memo":"example"}' -H "X-Api-Key: YOUR_WALLET-ADMIN/INVOICE-KEY"</code>
|
@@ -1,12 +0,0 @@
|
||||
from quart import Blueprint
|
||||
from lnbits.db import Database
|
||||
|
||||
db = Database("ext_amilk")
|
||||
|
||||
amilk_ext: Blueprint = Blueprint(
|
||||
"amilk", __name__, static_folder="static", template_folder="templates"
|
||||
)
|
||||
|
||||
|
||||
from .views_api import * # noqa
|
||||
from .views import * # noqa
|
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "AMilk",
|
||||
"short_description": "Assistant Faucet Milker",
|
||||
"icon": "room_service",
|
||||
"contributors": ["arcbtc"]
|
||||
}
|
@@ -1,42 +0,0 @@
|
||||
from base64 import urlsafe_b64encode
|
||||
from uuid import uuid4
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from . import db
|
||||
from .models import AMilk
|
||||
|
||||
|
||||
async def create_amilk(*, wallet_id: str, lnurl: str, atime: int, amount: int) -> AMilk:
|
||||
amilk_id = urlsafe_b64encode(uuid4().bytes_le).decode("utf-8")
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO amilk.amilks (id, wallet, lnurl, atime, amount)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(amilk_id, wallet_id, lnurl, atime, amount),
|
||||
)
|
||||
|
||||
amilk = await get_amilk(amilk_id)
|
||||
assert amilk, "Newly created amilk_id couldn't be retrieved"
|
||||
return amilk
|
||||
|
||||
|
||||
async def get_amilk(amilk_id: str) -> Optional[AMilk]:
|
||||
row = await db.fetchone("SELECT * FROM amilk.amilks WHERE id = ?", (amilk_id,))
|
||||
return AMilk(**row) if row else None
|
||||
|
||||
|
||||
async def get_amilks(wallet_ids: Union[str, List[str]]) -> List[AMilk]:
|
||||
if isinstance(wallet_ids, str):
|
||||
wallet_ids = [wallet_ids]
|
||||
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM amilk.amilks WHERE wallet IN ({q})", (*wallet_ids,)
|
||||
)
|
||||
|
||||
return [AMilk(**row) for row in rows]
|
||||
|
||||
|
||||
async def delete_amilk(amilk_id: str) -> None:
|
||||
await db.execute("DELETE FROM amilk.amilks WHERE id = ?", (amilk_id,))
|
@@ -1,15 +0,0 @@
|
||||
async def m001_initial(db):
|
||||
"""
|
||||
Initial amilks table.
|
||||
"""
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE amilk.amilks (
|
||||
id TEXT PRIMARY KEY,
|
||||
wallet TEXT NOT NULL,
|
||||
lnurl TEXT NOT NULL,
|
||||
atime INTEGER NOT NULL,
|
||||
amount INTEGER NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
@@ -1,9 +0,0 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class AMilk(BaseModel):
|
||||
id: str
|
||||
wallet: str
|
||||
lnurl: str
|
||||
atime: int
|
||||
amount: int
|
@@ -1,24 +0,0 @@
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="swap_vertical_circle"
|
||||
label="Info"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h5 class="text-subtitle1 q-my-none">Assistant Faucet Milker</h5>
|
||||
<p>
|
||||
Milking faucets with software, known as "assmilking", seems at first to
|
||||
be black-hat, although in fact there might be some unexplored use cases.
|
||||
An LNURL withdraw gives someone the right to pull funds, which can be
|
||||
done over time. An LNURL withdraw could be used outside of just faucets,
|
||||
to provide money streaming and repeat payments.<br />Paste or scan an
|
||||
LNURL withdraw, enter the amount for the AMilk to pull and the frequency
|
||||
for it to be pulled.<br />
|
||||
<small>
|
||||
Created by, <a href="https://github.com/benarc">Ben Arc</a></small
|
||||
>
|
||||
</p>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
@@ -1,250 +0,0 @@
|
||||
{% 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="amilkDialog.show = true"
|
||||
>New AMilk</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">AMilks</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="amilks"
|
||||
row-key="id"
|
||||
:columns="amilksTable.columns"
|
||||
:pagination.sync="amilksTable.pagination"
|
||||
>
|
||||
{% raw %}
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.label }}
|
||||
</q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<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="deleteAMilk(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">
|
||||
LNbits Assistant Faucet Milker Extension
|
||||
</h6>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-separator></q-separator>
|
||||
<q-list> {% include "amilk/_api_docs.html" %} </q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<q-dialog v-model="amilkDialog.show" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form @submit="createAMilk" class="q-gutter-md">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model="amilkDialog.data.wallet"
|
||||
:options="g.user.walletOptions"
|
||||
label="Wallet *"
|
||||
>
|
||||
</q-select>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="amilkDialog.data.lnurl"
|
||||
type="url"
|
||||
label="LNURL Withdraw"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="amilkDialog.data.amount"
|
||||
type="number"
|
||||
label="Amount *"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="amilkDialog.data.atime"
|
||||
type="number"
|
||||
label="Hit frequency (secs)"
|
||||
placeholder="Frequency to be hit"
|
||||
></q-input>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="amilkDialog.data.amount == null || amilkDialog.data.amount < 0 || amilkDialog.data.lnurl == null"
|
||||
type="submit"
|
||||
>Create amilk</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||
<script>
|
||||
var mapAMilk = 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 = ['/amilk/', obj.id].join('')
|
||||
return obj
|
||||
}
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
amilks: [],
|
||||
amilksTable: {
|
||||
columns: [
|
||||
{name: 'id', align: 'left', label: 'ID', field: 'id'},
|
||||
{name: 'lnurl', align: 'left', label: 'LNURL', field: 'lnurl'},
|
||||
{name: 'atime', align: 'left', label: 'Freq', field: 'atime'},
|
||||
{name: 'amount', align: 'left', label: 'Amount', field: 'amount'}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10
|
||||
}
|
||||
},
|
||||
amilkDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getAMilks: function () {
|
||||
var self = this
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/amilk/api/v1/amilk?all_wallets',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.amilks = response.data.map(function (obj) {
|
||||
response.data.forEach(MILK)
|
||||
function MILK(item) {
|
||||
window.setInterval(function () {
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/amilk/api/v1/amilk/milk/' + item.id,
|
||||
'Lorem'
|
||||
)
|
||||
.then(function (response) {
|
||||
self.amilks = response.data.map(function (obj) {
|
||||
return mapAMilk(obj)
|
||||
})
|
||||
})
|
||||
}, item.atime * 1000)
|
||||
}
|
||||
return mapAMilk(obj)
|
||||
})
|
||||
})
|
||||
},
|
||||
createAMilk: function () {
|
||||
var data = {
|
||||
lnurl: this.amilkDialog.data.lnurl,
|
||||
atime: parseInt(this.amilkDialog.data.atime),
|
||||
amount: this.amilkDialog.data.amount
|
||||
}
|
||||
var self = this
|
||||
|
||||
console.log(this.amilkDialog.data.wallet)
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'POST',
|
||||
'/amilk/api/v1/amilk',
|
||||
_.findWhere(this.g.user.wallets, {id: this.amilkDialog.data.wallet})
|
||||
.inkey,
|
||||
data
|
||||
)
|
||||
.then(function (response) {
|
||||
self.amilks.push(mapAMilk(response.data))
|
||||
self.amilkDialog.show = false
|
||||
self.amilkDialog.data = {}
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
deleteAMilk: function (amilkId) {
|
||||
var self = this
|
||||
var amilk = _.findWhere(this.amilks, {id: amilkId})
|
||||
|
||||
LNbits.utils
|
||||
.confirmDialog('Are you sure you want to delete this AMilk link?')
|
||||
.onOk(function () {
|
||||
LNbits.api
|
||||
.request(
|
||||
'DELETE',
|
||||
'/amilk/api/v1/amilks/' + amilkId,
|
||||
_.findWhere(self.g.user.wallets, {id: amilk.wallet}).inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.amilks = _.reject(self.amilks, function (obj) {
|
||||
return obj.id == amilkId
|
||||
})
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
})
|
||||
},
|
||||
exportCSV: function () {
|
||||
LNbits.utils.exportCSV(this.amilksTable.columns, this.amilks)
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
if (this.g.user.wallets.length) {
|
||||
this.getAMilks()
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
@@ -1,23 +0,0 @@
|
||||
from quart import g, abort, render_template
|
||||
from http import HTTPStatus
|
||||
|
||||
from lnbits.decorators import check_user_exists, validate_uuids
|
||||
|
||||
from . import amilk_ext
|
||||
from .crud import get_amilk
|
||||
|
||||
|
||||
@amilk_ext.route("/")
|
||||
@validate_uuids(["usr"], required=True)
|
||||
@check_user_exists()
|
||||
async def index():
|
||||
return await render_template("amilk/index.html", user=g.user)
|
||||
|
||||
|
||||
@amilk_ext.route("/<amilk_id>")
|
||||
async def wall(amilk_id):
|
||||
amilk = await get_amilk(amilk_id)
|
||||
if not amilk:
|
||||
abort(HTTPStatus.NOT_FOUND, "AMilk does not exist.")
|
||||
|
||||
return await render_template("amilk/wall.html", amilk=amilk)
|
@@ -1,105 +0,0 @@
|
||||
import httpx
|
||||
from quart import g, jsonify, request, abort
|
||||
from http import HTTPStatus
|
||||
from lnurl import LnurlWithdrawResponse, handle as handle_lnurl # type: ignore
|
||||
from lnurl.exceptions import LnurlException # type: ignore
|
||||
from time import sleep
|
||||
|
||||
from lnbits.core.crud import get_user
|
||||
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
|
||||
from lnbits.core.services import create_invoice, check_invoice_status
|
||||
|
||||
from . import amilk_ext
|
||||
from .crud import create_amilk, get_amilk, get_amilks, delete_amilk
|
||||
|
||||
|
||||
@amilk_ext.route("/api/v1/amilk", methods=["GET"])
|
||||
@api_check_wallet_key("invoice")
|
||||
async def api_amilks():
|
||||
wallet_ids = [g.wallet.id]
|
||||
|
||||
if "all_wallets" in request.args:
|
||||
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
|
||||
|
||||
return (
|
||||
jsonify([amilk._asdict() for amilk in await get_amilks(wallet_ids)]),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
|
||||
@amilk_ext.route("/api/v1/amilk/milk/<amilk_id>", methods=["GET"])
|
||||
async def api_amilkit(amilk_id):
|
||||
milk = await get_amilk(amilk_id)
|
||||
memo = milk.id
|
||||
|
||||
try:
|
||||
withdraw_res = handle_lnurl(milk.lnurl, response_class=LnurlWithdrawResponse)
|
||||
except LnurlException:
|
||||
abort(HTTPStatus.INTERNAL_SERVER_ERROR, "Could not process withdraw LNURL.")
|
||||
|
||||
try:
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
wallet_id=milk.wallet,
|
||||
amount=withdraw_res.max_sats,
|
||||
memo=memo,
|
||||
extra={"tag": "amilk"},
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
|
||||
r = httpx.get(
|
||||
withdraw_res.callback.base,
|
||||
params={
|
||||
**withdraw_res.callback.query_params,
|
||||
**{"k1": withdraw_res.k1, "pr": payment_request},
|
||||
},
|
||||
)
|
||||
|
||||
if r.is_error:
|
||||
abort(HTTPStatus.INTERNAL_SERVER_ERROR, "Could not process withdraw LNURL.")
|
||||
|
||||
for i in range(10):
|
||||
sleep(i)
|
||||
invoice_status = await check_invoice_status(milk.wallet, payment_hash)
|
||||
if invoice_status.paid:
|
||||
return jsonify({"paid": True}), HTTPStatus.OK
|
||||
else:
|
||||
continue
|
||||
|
||||
return jsonify({"paid": False}), HTTPStatus.OK
|
||||
|
||||
|
||||
@amilk_ext.route("/api/v1/amilk", methods=["POST"])
|
||||
@api_check_wallet_key("invoice")
|
||||
@api_validate_post_request(
|
||||
schema={
|
||||
"lnurl": {"type": "string", "empty": False, "required": True},
|
||||
"atime": {"type": "integer", "min": 0, "required": True},
|
||||
"amount": {"type": "integer", "min": 0, "required": True},
|
||||
}
|
||||
)
|
||||
async def api_amilk_create():
|
||||
amilk = await create_amilk(
|
||||
wallet_id=g.wallet.id,
|
||||
lnurl=g.data["lnurl"],
|
||||
atime=g.data["atime"],
|
||||
amount=g.data["amount"],
|
||||
)
|
||||
|
||||
return jsonify(amilk._asdict()), HTTPStatus.CREATED
|
||||
|
||||
|
||||
@amilk_ext.route("/api/v1/amilk/<amilk_id>", methods=["DELETE"])
|
||||
@api_check_wallet_key("invoice")
|
||||
async def api_amilk_delete(amilk_id):
|
||||
amilk = await get_amilk(amilk_id)
|
||||
|
||||
if not amilk:
|
||||
return jsonify({"message": "Paywall does not exist."}), HTTPStatus.NOT_FOUND
|
||||
|
||||
if amilk.wallet != g.wallet.id:
|
||||
return jsonify({"message": "Not your amilk."}), HTTPStatus.FORBIDDEN
|
||||
|
||||
await delete_amilk(amilk_id)
|
||||
|
||||
return "", HTTPStatus.NO_CONTENT
|
@@ -1,21 +0,0 @@
|
||||
# Bleskomat Extension for lnbits
|
||||
|
||||
This extension allows you to connect a Bleskomat ATM to an lnbits wallet. It will work with both the [open-source DIY Bleskomat ATM project](https://github.com/samotari/bleskomat) as well as the [commercial Bleskomat ATM](https://www.bleskomat.com/).
|
||||
|
||||
|
||||
## Connect Your Bleskomat ATM
|
||||
|
||||
* Click the "Add Bleskomat" button on this page to begin.
|
||||
* Choose a wallet. This will be the wallet that is used to pay satoshis to your ATM customers.
|
||||
* Choose the fiat currency. This should match the fiat currency that your ATM accepts.
|
||||
* Pick an exchange rate provider. This is the API that will be used to query the fiat to satoshi exchange rate at the time your customer attempts to withdraw their funds.
|
||||
* Set your ATM's fee percentage.
|
||||
* Click the "Done" button.
|
||||
* Find the new Bleskomat in the list and then click the export icon to download a new configuration file for your ATM.
|
||||
* Copy the configuration file ("bleskomat.conf") to your ATM's SD card.
|
||||
* Restart Your Bleskomat ATM. It should automatically reload the configurations from the SD card.
|
||||
|
||||
|
||||
## How Does It Work?
|
||||
|
||||
Since the Bleskomat ATMs are designed to be offline, a cryptographic signing scheme is used to verify that the URL was generated by an authorized device. When one of your customers inserts fiat money into the device, a signed URL (lnurl-withdraw) is created and displayed as a QR code. Your customer scans the QR code with their lnurl-supporting mobile app, their mobile app communicates with the web API of lnbits to verify the signature, the fiat currency amount is converted to sats, the customer accepts the withdrawal, and finally lnbits will pay the customer from your lnbits wallet.
|
@@ -1,12 +0,0 @@
|
||||
from quart import Blueprint
|
||||
from lnbits.db import Database
|
||||
|
||||
db = Database("ext_bleskomat")
|
||||
|
||||
bleskomat_ext: Blueprint = Blueprint(
|
||||
"bleskomat", __name__, static_folder="static", template_folder="templates"
|
||||
)
|
||||
|
||||
from .lnurl_api import * # noqa
|
||||
from .views_api import * # noqa
|
||||
from .views import * # noqa
|
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "Bleskomat",
|
||||
"short_description": "Connect a Bleskomat ATM to an lnbits",
|
||||
"icon": "money",
|
||||
"contributors": ["chill117"]
|
||||
}
|
@@ -1,119 +0,0 @@
|
||||
import secrets
|
||||
import time
|
||||
from uuid import uuid4
|
||||
from typing import List, Optional, Union
|
||||
from . import db
|
||||
from .models import Bleskomat, BleskomatLnurl
|
||||
from .helpers import generate_bleskomat_lnurl_hash
|
||||
|
||||
|
||||
async def create_bleskomat(
|
||||
*,
|
||||
wallet_id: str,
|
||||
name: str,
|
||||
fiat_currency: str,
|
||||
exchange_rate_provider: str,
|
||||
fee: str,
|
||||
) -> Bleskomat:
|
||||
bleskomat_id = uuid4().hex
|
||||
api_key_id = secrets.token_hex(8)
|
||||
api_key_secret = secrets.token_hex(32)
|
||||
api_key_encoding = "hex"
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO bleskomat.bleskomats (id, wallet, api_key_id, api_key_secret, api_key_encoding, name, fiat_currency, exchange_rate_provider, fee)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
bleskomat_id,
|
||||
wallet_id,
|
||||
api_key_id,
|
||||
api_key_secret,
|
||||
api_key_encoding,
|
||||
name,
|
||||
fiat_currency,
|
||||
exchange_rate_provider,
|
||||
fee,
|
||||
),
|
||||
)
|
||||
bleskomat = await get_bleskomat(bleskomat_id)
|
||||
assert bleskomat, "Newly created bleskomat couldn't be retrieved"
|
||||
return bleskomat
|
||||
|
||||
|
||||
async def get_bleskomat(bleskomat_id: str) -> Optional[Bleskomat]:
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM bleskomat.bleskomats WHERE id = ?", (bleskomat_id,)
|
||||
)
|
||||
return Bleskomat(**row) if row else None
|
||||
|
||||
|
||||
async def get_bleskomat_by_api_key_id(api_key_id: str) -> Optional[Bleskomat]:
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM bleskomat.bleskomats WHERE api_key_id = ?", (api_key_id,)
|
||||
)
|
||||
return Bleskomat(**row) if row else None
|
||||
|
||||
|
||||
async def get_bleskomats(wallet_ids: Union[str, List[str]]) -> List[Bleskomat]:
|
||||
if isinstance(wallet_ids, str):
|
||||
wallet_ids = [wallet_ids]
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM bleskomat.bleskomats WHERE wallet IN ({q})", (*wallet_ids,)
|
||||
)
|
||||
return [Bleskomat(**row) for row in rows]
|
||||
|
||||
|
||||
async def update_bleskomat(bleskomat_id: str, **kwargs) -> Optional[Bleskomat]:
|
||||
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
||||
await db.execute(
|
||||
f"UPDATE bleskomat.bleskomats SET {q} WHERE id = ?",
|
||||
(*kwargs.values(), bleskomat_id),
|
||||
)
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM bleskomat.bleskomats WHERE id = ?", (bleskomat_id,)
|
||||
)
|
||||
return Bleskomat(**row) if row else None
|
||||
|
||||
|
||||
async def delete_bleskomat(bleskomat_id: str) -> None:
|
||||
await db.execute("DELETE FROM bleskomat.bleskomats WHERE id = ?", (bleskomat_id,))
|
||||
|
||||
|
||||
async def create_bleskomat_lnurl(
|
||||
*, bleskomat: Bleskomat, secret: str, tag: str, params: str, uses: int = 1
|
||||
) -> BleskomatLnurl:
|
||||
bleskomat_lnurl_id = uuid4().hex
|
||||
hash = generate_bleskomat_lnurl_hash(secret)
|
||||
now = int(time.time())
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO bleskomat.bleskomat_lnurls (id, bleskomat, wallet, hash, tag, params, api_key_id, initial_uses, remaining_uses, created_time, updated_time)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
bleskomat_lnurl_id,
|
||||
bleskomat.id,
|
||||
bleskomat.wallet,
|
||||
hash,
|
||||
tag,
|
||||
params,
|
||||
bleskomat.api_key_id,
|
||||
uses,
|
||||
uses,
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
bleskomat_lnurl = await get_bleskomat_lnurl(secret)
|
||||
assert bleskomat_lnurl, "Newly created bleskomat LNURL couldn't be retrieved"
|
||||
return bleskomat_lnurl
|
||||
|
||||
|
||||
async def get_bleskomat_lnurl(secret: str) -> Optional[BleskomatLnurl]:
|
||||
hash = generate_bleskomat_lnurl_hash(secret)
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM bleskomat.bleskomat_lnurls WHERE hash = ?", (hash,)
|
||||
)
|
||||
return BleskomatLnurl(**row) if row else None
|
@@ -1,79 +0,0 @@
|
||||
import httpx
|
||||
import json
|
||||
import os
|
||||
|
||||
fiat_currencies = json.load(
|
||||
open(
|
||||
os.path.join(
|
||||
os.path.dirname(os.path.realpath(__file__)), "fiat_currencies.json"
|
||||
),
|
||||
"r",
|
||||
)
|
||||
)
|
||||
|
||||
exchange_rate_providers = {
|
||||
"bitfinex": {
|
||||
"name": "Bitfinex",
|
||||
"domain": "bitfinex.com",
|
||||
"api_url": "https://api.bitfinex.com/v1/pubticker/{from}{to}",
|
||||
"getter": lambda data, replacements: data["last_price"],
|
||||
},
|
||||
"bitstamp": {
|
||||
"name": "Bitstamp",
|
||||
"domain": "bitstamp.net",
|
||||
"api_url": "https://www.bitstamp.net/api/v2/ticker/{from}{to}/",
|
||||
"getter": lambda data, replacements: data["last"],
|
||||
},
|
||||
"coinbase": {
|
||||
"name": "Coinbase",
|
||||
"domain": "coinbase.com",
|
||||
"api_url": "https://api.coinbase.com/v2/exchange-rates?currency={FROM}",
|
||||
"getter": lambda data, replacements: data["data"]["rates"][replacements["TO"]],
|
||||
},
|
||||
"coinmate": {
|
||||
"name": "CoinMate",
|
||||
"domain": "coinmate.io",
|
||||
"api_url": "https://coinmate.io/api/ticker?currencyPair={FROM}_{TO}",
|
||||
"getter": lambda data, replacements: data["data"]["last"],
|
||||
},
|
||||
"kraken": {
|
||||
"name": "Kraken",
|
||||
"domain": "kraken.com",
|
||||
"api_url": "https://api.kraken.com/0/public/Ticker?pair=XBT{TO}",
|
||||
"getter": lambda data, replacements: data["result"][
|
||||
"XXBTZ" + replacements["TO"]
|
||||
]["c"][0],
|
||||
},
|
||||
}
|
||||
|
||||
exchange_rate_providers_serializable = {}
|
||||
for ref, exchange_rate_provider in exchange_rate_providers.items():
|
||||
exchange_rate_provider_serializable = {}
|
||||
for key, value in exchange_rate_provider.items():
|
||||
if not callable(value):
|
||||
exchange_rate_provider_serializable[key] = value
|
||||
exchange_rate_providers_serializable[ref] = exchange_rate_provider_serializable
|
||||
|
||||
|
||||
async def fetch_fiat_exchange_rate(currency: str, provider: str):
|
||||
|
||||
replacements = {
|
||||
"FROM": "BTC",
|
||||
"from": "btc",
|
||||
"TO": currency.upper(),
|
||||
"to": currency.lower(),
|
||||
}
|
||||
|
||||
url = exchange_rate_providers[provider]["api_url"]
|
||||
for key in replacements.keys():
|
||||
url = url.replace("{" + key + "}", replacements[key])
|
||||
|
||||
getter = exchange_rate_providers[provider]["getter"]
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.get(url)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
rate = float(getter(data, replacements))
|
||||
|
||||
return rate
|
@@ -1,166 +0,0 @@
|
||||
{
|
||||
"AED": "United Arab Emirates Dirham",
|
||||
"AFN": "Afghan Afghani",
|
||||
"ALL": "Albanian Lek",
|
||||
"AMD": "Armenian Dram",
|
||||
"ANG": "Netherlands Antillean Gulden",
|
||||
"AOA": "Angolan Kwanza",
|
||||
"ARS": "Argentine Peso",
|
||||
"AUD": "Australian Dollar",
|
||||
"AWG": "Aruban Florin",
|
||||
"AZN": "Azerbaijani Manat",
|
||||
"BAM": "Bosnia and Herzegovina Convertible Mark",
|
||||
"BBD": "Barbadian Dollar",
|
||||
"BDT": "Bangladeshi Taka",
|
||||
"BGN": "Bulgarian Lev",
|
||||
"BHD": "Bahraini Dinar",
|
||||
"BIF": "Burundian Franc",
|
||||
"BMD": "Bermudian Dollar",
|
||||
"BND": "Brunei Dollar",
|
||||
"BOB": "Bolivian Boliviano",
|
||||
"BRL": "Brazilian Real",
|
||||
"BSD": "Bahamian Dollar",
|
||||
"BTN": "Bhutanese Ngultrum",
|
||||
"BWP": "Botswana Pula",
|
||||
"BYN": "Belarusian Ruble",
|
||||
"BYR": "Belarusian Ruble",
|
||||
"BZD": "Belize Dollar",
|
||||
"CAD": "Canadian Dollar",
|
||||
"CDF": "Congolese Franc",
|
||||
"CHF": "Swiss Franc",
|
||||
"CLF": "Unidad de Fomento",
|
||||
"CLP": "Chilean Peso",
|
||||
"CNH": "Chinese Renminbi Yuan Offshore",
|
||||
"CNY": "Chinese Renminbi Yuan",
|
||||
"COP": "Colombian Peso",
|
||||
"CRC": "Costa Rican Colón",
|
||||
"CUC": "Cuban Convertible Peso",
|
||||
"CVE": "Cape Verdean Escudo",
|
||||
"CZK": "Czech Koruna",
|
||||
"DJF": "Djiboutian Franc",
|
||||
"DKK": "Danish Krone",
|
||||
"DOP": "Dominican Peso",
|
||||
"DZD": "Algerian Dinar",
|
||||
"EGP": "Egyptian Pound",
|
||||
"ERN": "Eritrean Nakfa",
|
||||
"ETB": "Ethiopian Birr",
|
||||
"EUR": "Euro",
|
||||
"FJD": "Fijian Dollar",
|
||||
"FKP": "Falkland Pound",
|
||||
"GBP": "British Pound",
|
||||
"GEL": "Georgian Lari",
|
||||
"GGP": "Guernsey Pound",
|
||||
"GHS": "Ghanaian Cedi",
|
||||
"GIP": "Gibraltar Pound",
|
||||
"GMD": "Gambian Dalasi",
|
||||
"GNF": "Guinean Franc",
|
||||
"GTQ": "Guatemalan Quetzal",
|
||||
"GYD": "Guyanese Dollar",
|
||||
"HKD": "Hong Kong Dollar",
|
||||
"HNL": "Honduran Lempira",
|
||||
"HRK": "Croatian Kuna",
|
||||
"HTG": "Haitian Gourde",
|
||||
"HUF": "Hungarian Forint",
|
||||
"IDR": "Indonesian Rupiah",
|
||||
"ILS": "Israeli New Sheqel",
|
||||
"IMP": "Isle of Man Pound",
|
||||
"INR": "Indian Rupee",
|
||||
"IQD": "Iraqi Dinar",
|
||||
"ISK": "Icelandic Króna",
|
||||
"JEP": "Jersey Pound",
|
||||
"JMD": "Jamaican Dollar",
|
||||
"JOD": "Jordanian Dinar",
|
||||
"JPY": "Japanese Yen",
|
||||
"KES": "Kenyan Shilling",
|
||||
"KGS": "Kyrgyzstani Som",
|
||||
"KHR": "Cambodian Riel",
|
||||
"KMF": "Comorian Franc",
|
||||
"KRW": "South Korean Won",
|
||||
"KWD": "Kuwaiti Dinar",
|
||||
"KYD": "Cayman Islands Dollar",
|
||||
"KZT": "Kazakhstani Tenge",
|
||||
"LAK": "Lao Kip",
|
||||
"LBP": "Lebanese Pound",
|
||||
"LKR": "Sri Lankan Rupee",
|
||||
"LRD": "Liberian Dollar",
|
||||
"LSL": "Lesotho Loti",
|
||||
"LYD": "Libyan Dinar",
|
||||
"MAD": "Moroccan Dirham",
|
||||
"MDL": "Moldovan Leu",
|
||||
"MGA": "Malagasy Ariary",
|
||||
"MKD": "Macedonian Denar",
|
||||
"MMK": "Myanmar Kyat",
|
||||
"MNT": "Mongolian Tögrög",
|
||||
"MOP": "Macanese Pataca",
|
||||
"MRO": "Mauritanian Ouguiya",
|
||||
"MUR": "Mauritian Rupee",
|
||||
"MVR": "Maldivian Rufiyaa",
|
||||
"MWK": "Malawian Kwacha",
|
||||
"MXN": "Mexican Peso",
|
||||
"MYR": "Malaysian Ringgit",
|
||||
"MZN": "Mozambican Metical",
|
||||
"NAD": "Namibian Dollar",
|
||||
"NGN": "Nigerian Naira",
|
||||
"NIO": "Nicaraguan Córdoba",
|
||||
"NOK": "Norwegian Krone",
|
||||
"NPR": "Nepalese Rupee",
|
||||
"NZD": "New Zealand Dollar",
|
||||
"OMR": "Omani Rial",
|
||||
"PAB": "Panamanian Balboa",
|
||||
"PEN": "Peruvian Sol",
|
||||
"PGK": "Papua New Guinean Kina",
|
||||
"PHP": "Philippine Peso",
|
||||
"PKR": "Pakistani Rupee",
|
||||
"PLN": "Polish Złoty",
|
||||
"PYG": "Paraguayan Guaraní",
|
||||
"QAR": "Qatari Riyal",
|
||||
"RON": "Romanian Leu",
|
||||
"RSD": "Serbian Dinar",
|
||||
"RUB": "Russian Ruble",
|
||||
"RWF": "Rwandan Franc",
|
||||
"SAR": "Saudi Riyal",
|
||||
"SBD": "Solomon Islands Dollar",
|
||||
"SCR": "Seychellois Rupee",
|
||||
"SEK": "Swedish Krona",
|
||||
"SGD": "Singapore Dollar",
|
||||
"SHP": "Saint Helenian Pound",
|
||||
"SLL": "Sierra Leonean Leone",
|
||||
"SOS": "Somali Shilling",
|
||||
"SRD": "Surinamese Dollar",
|
||||
"SSP": "South Sudanese Pound",
|
||||
"STD": "São Tomé and Príncipe Dobra",
|
||||
"SVC": "Salvadoran Colón",
|
||||
"SZL": "Swazi Lilangeni",
|
||||
"THB": "Thai Baht",
|
||||
"TJS": "Tajikistani Somoni",
|
||||
"TMT": "Turkmenistani Manat",
|
||||
"TND": "Tunisian Dinar",
|
||||
"TOP": "Tongan Paʻanga",
|
||||
"TRY": "Turkish Lira",
|
||||
"TTD": "Trinidad and Tobago Dollar",
|
||||
"TWD": "New Taiwan Dollar",
|
||||
"TZS": "Tanzanian Shilling",
|
||||
"UAH": "Ukrainian Hryvnia",
|
||||
"UGX": "Ugandan Shilling",
|
||||
"USD": "US Dollar",
|
||||
"UYU": "Uruguayan Peso",
|
||||
"UZS": "Uzbekistan Som",
|
||||
"VEF": "Venezuelan Bolívar",
|
||||
"VES": "Venezuelan Bolívar Soberano",
|
||||
"VND": "Vietnamese Đồng",
|
||||
"VUV": "Vanuatu Vatu",
|
||||
"WST": "Samoan Tala",
|
||||
"XAF": "Central African Cfa Franc",
|
||||
"XAG": "Silver (Troy Ounce)",
|
||||
"XAU": "Gold (Troy Ounce)",
|
||||
"XCD": "East Caribbean Dollar",
|
||||
"XDR": "Special Drawing Rights",
|
||||
"XOF": "West African Cfa Franc",
|
||||
"XPD": "Palladium",
|
||||
"XPF": "Cfp Franc",
|
||||
"XPT": "Platinum",
|
||||
"YER": "Yemeni Rial",
|
||||
"ZAR": "South African Rand",
|
||||
"ZMW": "Zambian Kwacha",
|
||||
"ZWL": "Zimbabwean Dollar"
|
||||
}
|
@@ -1,153 +0,0 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
from http import HTTPStatus
|
||||
from binascii import unhexlify
|
||||
from typing import Dict
|
||||
from quart import url_for
|
||||
import urllib
|
||||
|
||||
|
||||
def generate_bleskomat_lnurl_hash(secret: str):
|
||||
m = hashlib.sha256()
|
||||
m.update(f"{secret}".encode())
|
||||
return m.hexdigest()
|
||||
|
||||
|
||||
def generate_bleskomat_lnurl_signature(
|
||||
payload: str, api_key_secret: str, api_key_encoding: str = "hex"
|
||||
):
|
||||
if api_key_encoding == "hex":
|
||||
key = unhexlify(api_key_secret)
|
||||
elif api_key_encoding == "base64":
|
||||
key = base64.b64decode(api_key_secret)
|
||||
else:
|
||||
key = bytes(f"{api_key_secret}")
|
||||
return hmac.new(key=key, msg=payload.encode(), digestmod=hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def generate_bleskomat_lnurl_secret(api_key_id: str, signature: str):
|
||||
# The secret is not randomly generated by the server.
|
||||
# Instead it is the hash of the API key ID and signature concatenated together.
|
||||
m = hashlib.sha256()
|
||||
m.update(f"{api_key_id}-{signature}".encode())
|
||||
return m.hexdigest()
|
||||
|
||||
|
||||
def get_callback_url():
|
||||
return url_for("bleskomat.api_bleskomat_lnurl", _external=True)
|
||||
|
||||
|
||||
def is_supported_lnurl_subprotocol(tag: str) -> bool:
|
||||
return tag == "withdrawRequest"
|
||||
|
||||
|
||||
class LnurlHttpError(Exception):
|
||||
def __init__(
|
||||
self,
|
||||
message: str = "",
|
||||
http_status: HTTPStatus = HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
):
|
||||
self.message = message
|
||||
self.http_status = http_status
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
class LnurlValidationError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def prepare_lnurl_params(tag: str, query: Dict[str, str]):
|
||||
params = {}
|
||||
if not is_supported_lnurl_subprotocol(tag):
|
||||
raise LnurlValidationError(f'Unsupported subprotocol: "{tag}"')
|
||||
if tag == "withdrawRequest":
|
||||
params["minWithdrawable"] = float(query["minWithdrawable"])
|
||||
params["maxWithdrawable"] = float(query["maxWithdrawable"])
|
||||
params["defaultDescription"] = query["defaultDescription"]
|
||||
if not params["minWithdrawable"] > 0:
|
||||
raise LnurlValidationError('"minWithdrawable" must be greater than zero')
|
||||
if not params["maxWithdrawable"] >= params["minWithdrawable"]:
|
||||
raise LnurlValidationError(
|
||||
'"maxWithdrawable" must be greater than or equal to "minWithdrawable"'
|
||||
)
|
||||
return params
|
||||
|
||||
|
||||
encode_uri_component_safe_chars = (
|
||||
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.!~*'()"
|
||||
)
|
||||
|
||||
|
||||
def query_to_signing_payload(query: Dict[str, str]) -> str:
|
||||
# Sort the query by key, then stringify it to create the payload.
|
||||
sorted_keys = sorted(query.keys(), key=str.lower)
|
||||
payload = []
|
||||
for key in sorted_keys:
|
||||
if not key == "signature":
|
||||
encoded_key = urllib.parse.quote(key, safe=encode_uri_component_safe_chars)
|
||||
encoded_value = urllib.parse.quote(
|
||||
query[key], safe=encode_uri_component_safe_chars
|
||||
)
|
||||
payload.append(f"{encoded_key}={encoded_value}")
|
||||
return "&".join(payload)
|
||||
|
||||
|
||||
unshorten_rules = {
|
||||
"query": {"n": "nonce", "s": "signature", "t": "tag"},
|
||||
"tags": {
|
||||
"c": "channelRequest",
|
||||
"l": "login",
|
||||
"p": "payRequest",
|
||||
"w": "withdrawRequest",
|
||||
},
|
||||
"params": {
|
||||
"channelRequest": {"pl": "localAmt", "pp": "pushAmt"},
|
||||
"login": {},
|
||||
"payRequest": {"pn": "minSendable", "px": "maxSendable", "pm": "metadata"},
|
||||
"withdrawRequest": {
|
||||
"pn": "minWithdrawable",
|
||||
"px": "maxWithdrawable",
|
||||
"pd": "defaultDescription",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def unshorten_lnurl_query(query: Dict[str, str]) -> Dict[str, str]:
|
||||
new_query = {}
|
||||
rules = unshorten_rules
|
||||
if "tag" in query:
|
||||
tag = query["tag"]
|
||||
elif "t" in query:
|
||||
tag = query["t"]
|
||||
else:
|
||||
raise LnurlValidationError('Missing required query parameter: "tag"')
|
||||
# Unshorten tag:
|
||||
if tag in rules["tags"]:
|
||||
long_tag = rules["tags"][tag]
|
||||
new_query["tag"] = long_tag
|
||||
tag = long_tag
|
||||
if not tag in rules["params"]:
|
||||
raise LnurlValidationError(f'Unknown tag: "{tag}"')
|
||||
for key in query:
|
||||
if key in rules["params"][tag]:
|
||||
short_param_key = key
|
||||
long_param_key = rules["params"][tag][short_param_key]
|
||||
if short_param_key in query:
|
||||
new_query[long_param_key] = query[short_param_key]
|
||||
else:
|
||||
new_query[long_param_key] = query[long_param_key]
|
||||
elif key in rules["query"]:
|
||||
# Unshorten general keys:
|
||||
short_key = key
|
||||
long_key = rules["query"][short_key]
|
||||
if not long_key in new_query:
|
||||
if short_key in query:
|
||||
new_query[long_key] = query[short_key]
|
||||
else:
|
||||
new_query[long_key] = query[long_key]
|
||||
else:
|
||||
# Keep unknown key/value pairs unchanged:
|
||||
new_query[key] = query[key]
|
||||
return new_query
|
@@ -1,134 +0,0 @@
|
||||
import json
|
||||
import math
|
||||
from quart import jsonify, request
|
||||
from http import HTTPStatus
|
||||
import traceback
|
||||
|
||||
from . import bleskomat_ext
|
||||
from .crud import (
|
||||
create_bleskomat_lnurl,
|
||||
get_bleskomat_by_api_key_id,
|
||||
get_bleskomat_lnurl,
|
||||
)
|
||||
|
||||
from .exchange_rates import (
|
||||
fetch_fiat_exchange_rate,
|
||||
)
|
||||
|
||||
from .helpers import (
|
||||
generate_bleskomat_lnurl_signature,
|
||||
generate_bleskomat_lnurl_secret,
|
||||
LnurlHttpError,
|
||||
LnurlValidationError,
|
||||
prepare_lnurl_params,
|
||||
query_to_signing_payload,
|
||||
unshorten_lnurl_query,
|
||||
)
|
||||
|
||||
|
||||
# Handles signed URL from Bleskomat ATMs and "action" callback of auto-generated LNURLs.
|
||||
@bleskomat_ext.route("/u", methods=["GET"])
|
||||
async def api_bleskomat_lnurl():
|
||||
try:
|
||||
query = request.args.to_dict()
|
||||
|
||||
# Unshorten query if "s" is used instead of "signature".
|
||||
if "s" in query:
|
||||
query = unshorten_lnurl_query(query)
|
||||
|
||||
if "signature" in query:
|
||||
|
||||
# Signature provided.
|
||||
# Use signature to verify that the URL was generated by an authorized device.
|
||||
# Later validate parameters, auto-generate LNURL, reply with LNURL response object.
|
||||
signature = query["signature"]
|
||||
|
||||
# The API key ID, nonce, and tag should be present in the query string.
|
||||
for field in ["id", "nonce", "tag"]:
|
||||
if not field in query:
|
||||
raise LnurlHttpError(
|
||||
f'Failed API key signature check: Missing "{field}"',
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
|
||||
# URL signing scheme is described here:
|
||||
# https://github.com/chill117/lnurl-node#how-to-implement-url-signing-scheme
|
||||
payload = query_to_signing_payload(query)
|
||||
api_key_id = query["id"]
|
||||
bleskomat = await get_bleskomat_by_api_key_id(api_key_id)
|
||||
if not bleskomat:
|
||||
raise LnurlHttpError("Unknown API key", HTTPStatus.BAD_REQUEST)
|
||||
api_key_secret = bleskomat.api_key_secret
|
||||
api_key_encoding = bleskomat.api_key_encoding
|
||||
expected_signature = generate_bleskomat_lnurl_signature(
|
||||
payload, api_key_secret, api_key_encoding
|
||||
)
|
||||
if signature != expected_signature:
|
||||
raise LnurlHttpError("Invalid API key signature", HTTPStatus.FORBIDDEN)
|
||||
|
||||
# Signature is valid.
|
||||
# In the case of signed URLs, the secret is deterministic based on the API key ID and signature.
|
||||
secret = generate_bleskomat_lnurl_secret(api_key_id, signature)
|
||||
lnurl = await get_bleskomat_lnurl(secret)
|
||||
if not lnurl:
|
||||
try:
|
||||
tag = query["tag"]
|
||||
params = prepare_lnurl_params(tag, query)
|
||||
if "f" in query:
|
||||
rate = await fetch_fiat_exchange_rate(
|
||||
currency=query["f"],
|
||||
provider=bleskomat.exchange_rate_provider,
|
||||
)
|
||||
# Convert fee (%) to decimal:
|
||||
fee = float(bleskomat.fee) / 100
|
||||
if tag == "withdrawRequest":
|
||||
for key in ["minWithdrawable", "maxWithdrawable"]:
|
||||
amount_sats = int(
|
||||
math.floor((params[key] / rate) * 1e8)
|
||||
)
|
||||
fee_sats = int(math.floor(amount_sats * fee))
|
||||
amount_sats_less_fee = amount_sats - fee_sats
|
||||
# Convert to msats:
|
||||
params[key] = int(amount_sats_less_fee * 1e3)
|
||||
except LnurlValidationError as e:
|
||||
raise LnurlHttpError(e.message, HTTPStatus.BAD_REQUEST)
|
||||
# Create a new LNURL using the query parameters provided in the signed URL.
|
||||
params = json.JSONEncoder().encode(params)
|
||||
lnurl = await create_bleskomat_lnurl(
|
||||
bleskomat=bleskomat, secret=secret, tag=tag, params=params, uses=1
|
||||
)
|
||||
|
||||
# Reply with LNURL response object.
|
||||
return jsonify(lnurl.get_info_response_object(secret)), HTTPStatus.OK
|
||||
|
||||
# No signature provided.
|
||||
# Treat as "action" callback.
|
||||
|
||||
if not "k1" in query:
|
||||
raise LnurlHttpError("Missing secret", HTTPStatus.BAD_REQUEST)
|
||||
|
||||
secret = query["k1"]
|
||||
lnurl = await get_bleskomat_lnurl(secret)
|
||||
if not lnurl:
|
||||
raise LnurlHttpError("Invalid secret", HTTPStatus.BAD_REQUEST)
|
||||
|
||||
if not lnurl.has_uses_remaining():
|
||||
raise LnurlHttpError(
|
||||
"Maximum number of uses already reached", HTTPStatus.BAD_REQUEST
|
||||
)
|
||||
|
||||
try:
|
||||
await lnurl.execute_action(query)
|
||||
except LnurlValidationError as e:
|
||||
raise LnurlHttpError(str(e), HTTPStatus.BAD_REQUEST)
|
||||
|
||||
except LnurlHttpError as e:
|
||||
return jsonify({"status": "ERROR", "reason": str(e)}), e.http_status
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
return (
|
||||
jsonify({"status": "ERROR", "reason": "Unexpected error"}),
|
||||
HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
return jsonify({"status": "OK"}), HTTPStatus.OK
|
@@ -1,37 +0,0 @@
|
||||
async def m001_initial(db):
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE bleskomat.bleskomats (
|
||||
id TEXT PRIMARY KEY,
|
||||
wallet TEXT NOT NULL,
|
||||
api_key_id TEXT NOT NULL,
|
||||
api_key_secret TEXT NOT NULL,
|
||||
api_key_encoding TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
fiat_currency TEXT NOT NULL,
|
||||
exchange_rate_provider TEXT NOT NULL,
|
||||
fee TEXT NOT NULL,
|
||||
UNIQUE(api_key_id)
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE bleskomat.bleskomat_lnurls (
|
||||
id TEXT PRIMARY KEY,
|
||||
bleskomat TEXT NOT NULL,
|
||||
wallet TEXT NOT NULL,
|
||||
hash TEXT NOT NULL,
|
||||
tag TEXT NOT NULL,
|
||||
params TEXT NOT NULL,
|
||||
api_key_id TEXT NOT NULL,
|
||||
initial_uses INTEGER DEFAULT 1,
|
||||
remaining_uses INTEGER DEFAULT 0,
|
||||
created_time INTEGER,
|
||||
updated_time INTEGER,
|
||||
UNIQUE(hash)
|
||||
);
|
||||
"""
|
||||
)
|
@@ -1,112 +0,0 @@
|
||||
import json
|
||||
import time
|
||||
from typing import NamedTuple, Dict
|
||||
from lnbits import bolt11
|
||||
from lnbits.core.services import pay_invoice
|
||||
from . import db
|
||||
from .helpers import get_callback_url, LnurlValidationError
|
||||
from sqlite3 import Row
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class Bleskomat(BaseModel):
|
||||
id: str
|
||||
wallet: str
|
||||
api_key_id: str
|
||||
api_key_secret: str
|
||||
api_key_encoding: str
|
||||
name: str
|
||||
fiat_currency: str
|
||||
exchange_rate_provider: str
|
||||
fee: str
|
||||
|
||||
|
||||
class BleskomatLnurl(BaseModel):
|
||||
id: str
|
||||
bleskomat: str
|
||||
wallet: str
|
||||
hash: str
|
||||
tag: str
|
||||
params: str
|
||||
api_key_id: str
|
||||
initial_uses: int
|
||||
remaining_uses: int
|
||||
created_time: int
|
||||
updated_time: int
|
||||
|
||||
def has_uses_remaining(self) -> bool:
|
||||
# When initial uses is 0 then the LNURL has unlimited uses.
|
||||
return self.initial_uses == 0 or self.remaining_uses > 0
|
||||
|
||||
def get_info_response_object(self, secret: str) -> Dict[str, str]:
|
||||
tag = self.tag
|
||||
params = json.loads(self.params)
|
||||
response = {"tag": tag}
|
||||
if tag == "withdrawRequest":
|
||||
for key in ["minWithdrawable", "maxWithdrawable", "defaultDescription"]:
|
||||
response[key] = params[key]
|
||||
response["callback"] = get_callback_url()
|
||||
response["k1"] = secret
|
||||
return response
|
||||
|
||||
def validate_action(self, query: Dict[str, str]) -> None:
|
||||
tag = self.tag
|
||||
params = json.loads(self.params)
|
||||
# Perform tag-specific checks.
|
||||
if tag == "withdrawRequest":
|
||||
for field in ["pr"]:
|
||||
if not field in query:
|
||||
raise LnurlValidationError(f'Missing required parameter: "{field}"')
|
||||
# Check the bolt11 invoice(s) provided.
|
||||
pr = query["pr"]
|
||||
if "," in pr:
|
||||
raise LnurlValidationError("Multiple payment requests not supported")
|
||||
try:
|
||||
invoice = bolt11.decode(pr)
|
||||
except ValueError:
|
||||
raise LnurlValidationError(
|
||||
'Invalid parameter ("pr"): Lightning payment request expected'
|
||||
)
|
||||
if invoice.amount_msat < params["minWithdrawable"]:
|
||||
raise LnurlValidationError(
|
||||
'Amount in invoice must be greater than or equal to "minWithdrawable"'
|
||||
)
|
||||
if invoice.amount_msat > params["maxWithdrawable"]:
|
||||
raise LnurlValidationError(
|
||||
'Amount in invoice must be less than or equal to "maxWithdrawable"'
|
||||
)
|
||||
else:
|
||||
raise LnurlValidationError(f'Unknown subprotocol: "{tag}"')
|
||||
|
||||
async def execute_action(self, query: Dict[str, str]):
|
||||
self.validate_action(query)
|
||||
used = False
|
||||
async with db.connect() as conn:
|
||||
if self.initial_uses > 0:
|
||||
used = await self.use(conn)
|
||||
if not used:
|
||||
raise LnurlValidationError("Maximum number of uses already reached")
|
||||
tag = self.tag
|
||||
if tag == "withdrawRequest":
|
||||
try:
|
||||
payment_hash = await pay_invoice(
|
||||
wallet_id=self.wallet,
|
||||
payment_request=query["pr"],
|
||||
)
|
||||
except Exception:
|
||||
raise LnurlValidationError("Failed to pay invoice")
|
||||
if not payment_hash:
|
||||
raise LnurlValidationError("Failed to pay invoice")
|
||||
|
||||
async def use(self, conn) -> bool:
|
||||
now = int(time.time())
|
||||
result = await conn.execute(
|
||||
"""
|
||||
UPDATE bleskomat.bleskomat_lnurls
|
||||
SET remaining_uses = remaining_uses - 1, updated_time = ?
|
||||
WHERE id = ?
|
||||
AND remaining_uses > 0
|
||||
""",
|
||||
(now, self.id),
|
||||
)
|
||||
return result.rowcount > 0
|
@@ -1,216 +0,0 @@
|
||||
/* global Vue, VueQrcode, _, Quasar, LOCALE, windowMixin, LNbits */
|
||||
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
|
||||
var mapBleskomat = function (obj) {
|
||||
obj._data = _.clone(obj)
|
||||
return obj
|
||||
}
|
||||
|
||||
var defaultValues = {
|
||||
name: 'My Bleskomat',
|
||||
fiat_currency: 'EUR',
|
||||
exchange_rate_provider: 'coinbase',
|
||||
fee: '0.00'
|
||||
}
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
checker: null,
|
||||
bleskomats: [],
|
||||
bleskomatsTable: {
|
||||
columns: [
|
||||
{
|
||||
name: 'api_key_id',
|
||||
align: 'left',
|
||||
label: 'API Key ID',
|
||||
field: 'api_key_id'
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
align: 'left',
|
||||
label: 'Name',
|
||||
field: 'name'
|
||||
},
|
||||
{
|
||||
name: 'fiat_currency',
|
||||
align: 'left',
|
||||
label: 'Fiat Currency',
|
||||
field: 'fiat_currency'
|
||||
},
|
||||
{
|
||||
name: 'exchange_rate_provider',
|
||||
align: 'left',
|
||||
label: 'Exchange Rate Provider',
|
||||
field: 'exchange_rate_provider'
|
||||
},
|
||||
{
|
||||
name: 'fee',
|
||||
align: 'left',
|
||||
label: 'Fee (%)',
|
||||
field: 'fee'
|
||||
}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10
|
||||
}
|
||||
},
|
||||
formDialog: {
|
||||
show: false,
|
||||
fiatCurrencies: _.keys(window.bleskomat_vars.fiat_currencies),
|
||||
exchangeRateProviders: _.keys(
|
||||
window.bleskomat_vars.exchange_rate_providers
|
||||
),
|
||||
data: _.clone(defaultValues)
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
sortedBleskomats: function () {
|
||||
return this.bleskomats.sort(function (a, b) {
|
||||
// Sort by API Key ID alphabetically.
|
||||
var apiKeyId_A = a.api_key_id.toLowerCase()
|
||||
var apiKeyId_B = b.api_key_id.toLowerCase()
|
||||
return apiKeyId_A < apiKeyId_B ? -1 : apiKeyId_A > apiKeyId_B ? 1 : 0
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getBleskomats: function () {
|
||||
var self = this
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/bleskomat/api/v1/bleskomats?all_wallets',
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.bleskomats = response.data.map(function (obj) {
|
||||
return mapBleskomat(obj)
|
||||
})
|
||||
})
|
||||
.catch(function (error) {
|
||||
clearInterval(self.checker)
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
closeFormDialog: function () {
|
||||
this.formDialog.data = _.clone(defaultValues)
|
||||
},
|
||||
exportConfigFile: function (bleskomatId) {
|
||||
var bleskomat = _.findWhere(this.bleskomats, {id: bleskomatId})
|
||||
var fieldToKey = {
|
||||
api_key_id: 'apiKey.id',
|
||||
api_key_secret: 'apiKey.key',
|
||||
api_key_encoding: 'apiKey.encoding',
|
||||
fiat_currency: 'fiatCurrency'
|
||||
}
|
||||
var lines = _.chain(bleskomat)
|
||||
.map(function (value, field) {
|
||||
var key = fieldToKey[field] || null
|
||||
return key ? [key, value].join('=') : null
|
||||
})
|
||||
.compact()
|
||||
.value()
|
||||
lines.push('callbackUrl=' + window.bleskomat_vars.callback_url)
|
||||
lines.push('shorten=true')
|
||||
var content = lines.join('\n')
|
||||
var status = Quasar.utils.exportFile(
|
||||
'bleskomat.conf',
|
||||
content,
|
||||
'text/plain'
|
||||
)
|
||||
if (status !== true) {
|
||||
Quasar.plugins.Notify.create({
|
||||
message: 'Browser denied file download...',
|
||||
color: 'negative',
|
||||
icon: null
|
||||
})
|
||||
}
|
||||
},
|
||||
openUpdateDialog: function (bleskomatId) {
|
||||
var bleskomat = _.findWhere(this.bleskomats, {id: bleskomatId})
|
||||
this.formDialog.data = _.clone(bleskomat._data)
|
||||
this.formDialog.show = true
|
||||
},
|
||||
sendFormData: function () {
|
||||
var wallet = _.findWhere(this.g.user.wallets, {
|
||||
id: this.formDialog.data.wallet
|
||||
})
|
||||
var data = _.omit(this.formDialog.data, 'wallet')
|
||||
if (data.id) {
|
||||
this.updateBleskomat(wallet, data)
|
||||
} else {
|
||||
this.createBleskomat(wallet, data)
|
||||
}
|
||||
},
|
||||
updateBleskomat: function (wallet, data) {
|
||||
var self = this
|
||||
LNbits.api
|
||||
.request(
|
||||
'PUT',
|
||||
'/bleskomat/api/v1/bleskomat/' + data.id,
|
||||
wallet.adminkey,
|
||||
_.pick(data, 'name', 'fiat_currency', 'exchange_rate_provider', 'fee')
|
||||
)
|
||||
.then(function (response) {
|
||||
self.bleskomats = _.reject(self.bleskomats, function (obj) {
|
||||
return obj.id === data.id
|
||||
})
|
||||
self.bleskomats.push(mapBleskomat(response.data))
|
||||
self.formDialog.show = false
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
createBleskomat: function (wallet, data) {
|
||||
var self = this
|
||||
LNbits.api
|
||||
.request('POST', '/bleskomat/api/v1/bleskomat', wallet.adminkey, data)
|
||||
.then(function (response) {
|
||||
self.bleskomats.push(mapBleskomat(response.data))
|
||||
self.formDialog.show = false
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
deleteBleskomat: function (bleskomatId) {
|
||||
var self = this
|
||||
var bleskomat = _.findWhere(this.bleskomats, {id: bleskomatId})
|
||||
LNbits.utils
|
||||
.confirmDialog(
|
||||
'Are you sure you want to delete "' + bleskomat.name + '"?'
|
||||
)
|
||||
.onOk(function () {
|
||||
LNbits.api
|
||||
.request(
|
||||
'DELETE',
|
||||
'/bleskomat/api/v1/bleskomat/' + bleskomatId,
|
||||
_.findWhere(self.g.user.wallets, {id: bleskomat.wallet}).adminkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.bleskomats = _.reject(self.bleskomats, function (obj) {
|
||||
return obj.id === bleskomatId
|
||||
})
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
if (this.g.user.wallets.length) {
|
||||
var getBleskomats = this.getBleskomats
|
||||
getBleskomats()
|
||||
this.checker = setInterval(function () {
|
||||
getBleskomats()
|
||||
}, 20000)
|
||||
}
|
||||
}
|
||||
})
|
@@ -1,65 +0,0 @@
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="swap_vertical_circle"
|
||||
label="Setup guide"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<p>
|
||||
This extension allows you to connect a Bleskomat ATM to an lnbits
|
||||
wallet. It will work with both the
|
||||
<a href="https://github.com/samotari/bleskomat"
|
||||
>open-source DIY Bleskomat ATM project</a
|
||||
>
|
||||
as well as the
|
||||
<a href="https://www.bleskomat.com/">commercial Bleskomat ATM</a>.
|
||||
</p>
|
||||
<h5 class="text-subtitle1 q-my-none">Connect Your Bleskomat ATM</h5>
|
||||
<div>
|
||||
<ol>
|
||||
<li>Click the "Add Bleskomat" button on this page to begin.</li>
|
||||
<li>
|
||||
Choose a wallet. This will be the wallet that is used to pay
|
||||
satoshis to your ATM customers.
|
||||
</li>
|
||||
<li>
|
||||
Choose the fiat currency. This should match the fiat currency that
|
||||
your ATM accepts.
|
||||
</li>
|
||||
<li>
|
||||
Pick an exchange rate provider. This is the API that will be used to
|
||||
query the fiat to satoshi exchange rate at the time your customer
|
||||
attempts to withdraw their funds.
|
||||
</li>
|
||||
<li>Set your ATM's fee percentage.</li>
|
||||
<li>Click the "Done" button.</li>
|
||||
<li>
|
||||
Find the new Bleskomat in the list and then click the export icon to
|
||||
download a new configuration file for your ATM.
|
||||
</li>
|
||||
<li>
|
||||
Copy the configuration file ("bleskomat.conf") to your ATM's SD
|
||||
card.
|
||||
</li>
|
||||
<li>
|
||||
Restart Your Bleskomat ATM. It should automatically reload the
|
||||
configurations from the SD card.
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
<h5 class="text-subtitle1 q-my-none">How does it work?</h5>
|
||||
<p>
|
||||
Since the Bleskomat ATMs are designed to be offline, a cryptographic
|
||||
signing scheme is used to verify that the URL was generated by an
|
||||
authorized device. When one of your customers inserts fiat money into
|
||||
the device, a signed URL (lnurl-withdraw) is created and displayed as a
|
||||
QR code. Your customer scans the QR code with their lnurl-supporting
|
||||
mobile app, their mobile app communicates with the web API of lnbits to
|
||||
verify the signature, the fiat currency amount is converted to sats, the
|
||||
customer accepts the withdrawal, and finally lnbits will pay the
|
||||
customer from your lnbits wallet.
|
||||
</p>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
@@ -1,180 +0,0 @@
|
||||
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||
%} {% block scripts %} {{ window_vars(user) }}
|
||||
<script>
|
||||
{% if bleskomat_vars %}
|
||||
window.bleskomat_vars = {{ bleskomat_vars | tojson | safe }}
|
||||
{% endif %}
|
||||
</script>
|
||||
<script src="/bleskomat/static/js/index.js"></script>
|
||||
{% endblock %} {% block page %}
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-7 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<q-btn unelevated color="primary" @click="formDialog.show = true"
|
||||
>Add Bleskomat</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">Bleskomats</h5>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="sortedBleskomats"
|
||||
row-key="id"
|
||||
:columns="bleskomatsTable.columns"
|
||||
:pagination.sync="bleskomatsTable.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
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
icon="file_download"
|
||||
color="orange"
|
||||
@click="exportConfigFile(props.row.id)"
|
||||
>
|
||||
<q-tooltip content-class="bg-accent"
|
||||
>Export Configuration</q-tooltip
|
||||
>
|
||||
</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="openUpdateDialog(props.row.id)"
|
||||
icon="edit"
|
||||
color="light-blue"
|
||||
>
|
||||
<q-tooltip content-class="bg-accent">Edit</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="deleteBleskomat(props.row.id)"
|
||||
icon="cancel"
|
||||
color="pink"
|
||||
>
|
||||
<q-tooltip content-class="bg-accent">Delete</q-tooltip>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
{% endraw %}
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-5 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h6 class="text-subtitle1 q-my-none">
|
||||
{{SITE_TITLE}} Bleskomat extension
|
||||
</h6>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-separator></q-separator>
|
||||
<q-list> {% include "bleskomat/_api_docs.html" %} </q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<q-dialog v-model="formDialog.show" position="top" @hide="closeFormDialog">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form @submit="sendFormData" 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.name"
|
||||
type="text"
|
||||
label="Name *"
|
||||
></q-input>
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
v-model="formDialog.data.fiat_currency"
|
||||
:options="formDialog.fiatCurrencies"
|
||||
label="Fiat Currency *"
|
||||
>
|
||||
</q-select>
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
v-model="formDialog.data.exchange_rate_provider"
|
||||
:options="formDialog.exchangeRateProviders"
|
||||
label="Exchange Rate Provider *"
|
||||
>
|
||||
</q-select>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="formDialog.data.fee"
|
||||
type="string"
|
||||
:default="0.00"
|
||||
label="Fee (%) *"
|
||||
></q-input>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
v-if="formDialog.data.id"
|
||||
unelevated
|
||||
color="primary"
|
||||
type="submit"
|
||||
>Update Bleskomat</q-btn
|
||||
>
|
||||
<q-btn
|
||||
v-else
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="
|
||||
formDialog.data.wallet == null ||
|
||||
formDialog.data.name == null ||
|
||||
formDialog.data.fiat_currency == null ||
|
||||
formDialog.data.exchange_rate_provider == null ||
|
||||
formDialog.data.fee == null"
|
||||
type="submit"
|
||||
>Add Bleskomat</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 %}
|
@@ -1,24 +0,0 @@
|
||||
from quart import g, render_template
|
||||
|
||||
from lnbits.decorators import check_user_exists, validate_uuids
|
||||
|
||||
from . import bleskomat_ext
|
||||
|
||||
from .exchange_rates import exchange_rate_providers_serializable, fiat_currencies
|
||||
from .helpers import get_callback_url
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi import Request
|
||||
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
@bleskomat_ext.get("/")
|
||||
@validate_uuids(["usr"], required=True)
|
||||
@check_user_exists()
|
||||
async def index(request: Request):
|
||||
bleskomat_vars = {
|
||||
"callback_url": get_callback_url(),
|
||||
"exchange_rate_providers": exchange_rate_providers_serializable,
|
||||
"fiat_currencies": fiat_currencies,
|
||||
}
|
||||
return await templates.TemplateResponse("bleskomat/index.html", {"request": request, "user":g.user, "bleskomat_vars":bleskomat_vars})
|
||||
|
@@ -1,109 +0,0 @@
|
||||
from typing import Union
|
||||
from fastapi.param_functions import Query
|
||||
from pydantic import BaseModel
|
||||
from quart import g, jsonify, request
|
||||
from http import HTTPStatus
|
||||
|
||||
from lnbits.core.crud import get_user
|
||||
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
|
||||
|
||||
from . import bleskomat_ext
|
||||
from .crud import (
|
||||
create_bleskomat,
|
||||
get_bleskomat,
|
||||
get_bleskomats,
|
||||
update_bleskomat,
|
||||
delete_bleskomat,
|
||||
)
|
||||
|
||||
from .exchange_rates import (
|
||||
exchange_rate_providers,
|
||||
fetch_fiat_exchange_rate,
|
||||
fiat_currencies,
|
||||
)
|
||||
|
||||
|
||||
@bleskomat_ext.get("/api/v1/bleskomats")
|
||||
@api_check_wallet_key("admin")
|
||||
async def api_bleskomats():
|
||||
wallet_ids = [g.wallet.id]
|
||||
|
||||
if "all_wallets" in request.args:
|
||||
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
|
||||
|
||||
return (
|
||||
[bleskomat._asdict() for bleskomat in await get_bleskomats(wallet_ids)],
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
|
||||
@bleskomat_ext.get("/api/v1/bleskomat/{bleskomat_id}")
|
||||
@api_check_wallet_key("admin")
|
||||
async def api_bleskomat_retrieve(bleskomat_id):
|
||||
bleskomat = await get_bleskomat(bleskomat_id)
|
||||
|
||||
if not bleskomat or bleskomat.wallet != g.wallet.id:
|
||||
return (
|
||||
jsonify({"message": "Bleskomat configuration not found."}),
|
||||
HTTPStatus.NOT_FOUND,
|
||||
)
|
||||
|
||||
return jsonify(bleskomat._asdict()), HTTPStatus.OK
|
||||
|
||||
|
||||
class CreateData(BaseModel):
|
||||
name: str
|
||||
fiat_currency: str = "EUR" # TODO: fix this
|
||||
exchange_rate_provider: str = "bitfinex"
|
||||
fee: Union[str, int, float] = Query(...)
|
||||
|
||||
@bleskomat_ext.post("/api/v1/bleskomat")
|
||||
@bleskomat_ext.put("/api/v1/bleskomat/{bleskomat_id}")
|
||||
@api_check_wallet_key("admin")
|
||||
async def api_bleskomat_create_or_update(data: CreateData, bleskomat_id=None):
|
||||
try:
|
||||
fiat_currency = data.fiat_currency
|
||||
exchange_rate_provider = data.exchange_rate_provider
|
||||
await fetch_fiat_exchange_rate(
|
||||
currency=fiat_currency, provider=exchange_rate_provider
|
||||
)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
return (
|
||||
{
|
||||
"message": f'Failed to fetch BTC/{fiat_currency} currency pair from "{exchange_rate_provider}"'
|
||||
},
|
||||
HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
if bleskomat_id:
|
||||
bleskomat = await get_bleskomat(bleskomat_id)
|
||||
if not bleskomat or bleskomat.wallet != g.wallet.id:
|
||||
return (
|
||||
jsonify({"message": "Bleskomat configuration not found."}),
|
||||
HTTPStatus.NOT_FOUND,
|
||||
)
|
||||
bleskomat = await update_bleskomat(bleskomat_id, **data)
|
||||
else:
|
||||
bleskomat = await create_bleskomat(wallet_id=g.wallet.id, **data)
|
||||
|
||||
return (
|
||||
bleskomat._asdict(),
|
||||
HTTPStatus.OK if bleskomat_id else HTTPStatus.CREATED,
|
||||
)
|
||||
|
||||
|
||||
@bleskomat_ext.delete("/api/v1/bleskomat/{bleskomat_id}")
|
||||
@api_check_wallet_key("admin")
|
||||
async def api_bleskomat_delete(bleskomat_id):
|
||||
bleskomat = await get_bleskomat(bleskomat_id)
|
||||
|
||||
if not bleskomat or bleskomat.wallet != g.wallet.id:
|
||||
return (
|
||||
jsonify({"message": "Bleskomat configuration not found."}),
|
||||
HTTPStatus.NOT_FOUND,
|
||||
)
|
||||
|
||||
await delete_bleskomat(bleskomat_id)
|
||||
|
||||
return "", HTTPStatus.NO_CONTENT
|
@@ -1,11 +0,0 @@
|
||||
<h1>Example Extension</h1>
|
||||
<h2>*tagline*</h2>
|
||||
This is an example extension to help you organise and build you own.
|
||||
|
||||
Try to include an image
|
||||
<img src="https://i.imgur.com/9i4xcQB.png">
|
||||
|
||||
|
||||
<h2>If your extension has API endpoints, include useful ones here</h2>
|
||||
|
||||
<code>curl -H "Content-type: application/json" -X POST https://YOUR-LNBITS/YOUR-EXTENSION/api/v1/EXAMPLE -d '{"amount":"100","memo":"example"}' -H "X-Api-Key: YOUR_WALLET-ADMIN/INVOICE-KEY"</code>
|
@@ -1,12 +0,0 @@
|
||||
from quart import Blueprint
|
||||
from lnbits.db import Database
|
||||
|
||||
db = Database("ext_captcha")
|
||||
|
||||
captcha_ext: Blueprint = Blueprint(
|
||||
"captcha", __name__, static_folder="static", template_folder="templates"
|
||||
)
|
||||
|
||||
|
||||
from .views_api import * # noqa
|
||||
from .views import * # noqa
|
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "Captcha",
|
||||
"short_description": "Create captcha to stop spam",
|
||||
"icon": "block",
|
||||
"contributors": ["pseudozach"]
|
||||
}
|
@@ -1,53 +0,0 @@
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
|
||||
from . import db
|
||||
from .models import Captcha
|
||||
|
||||
|
||||
async def create_captcha(
|
||||
*,
|
||||
wallet_id: str,
|
||||
url: str,
|
||||
memo: str,
|
||||
description: Optional[str] = None,
|
||||
amount: int = 0,
|
||||
remembers: bool = True,
|
||||
) -> Captcha:
|
||||
captcha_id = urlsafe_short_hash()
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO captcha.captchas (id, wallet, url, memo, description, amount, remembers)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(captcha_id, wallet_id, url, memo, description, amount, int(remembers)),
|
||||
)
|
||||
|
||||
captcha = await get_captcha(captcha_id)
|
||||
assert captcha, "Newly created captcha couldn't be retrieved"
|
||||
return captcha
|
||||
|
||||
|
||||
async def get_captcha(captcha_id: str) -> Optional[Captcha]:
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM captcha.captchas WHERE id = ?", (captcha_id,)
|
||||
)
|
||||
|
||||
return Captcha.from_row(row) if row else None
|
||||
|
||||
|
||||
async def get_captchas(wallet_ids: Union[str, List[str]]) -> List[Captcha]:
|
||||
if isinstance(wallet_ids, str):
|
||||
wallet_ids = [wallet_ids]
|
||||
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM captcha.captchas WHERE wallet IN ({q})", (*wallet_ids,)
|
||||
)
|
||||
|
||||
return [Captcha.from_row(row) for row in rows]
|
||||
|
||||
|
||||
async def delete_captcha(captcha_id: str) -> None:
|
||||
await db.execute("DELETE FROM captcha.captchas WHERE id = ?", (captcha_id,))
|
@@ -1,63 +0,0 @@
|
||||
async def m001_initial(db):
|
||||
"""
|
||||
Initial captchas table.
|
||||
"""
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE captcha.captchas (
|
||||
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 captchas table and migrates the existing data.
|
||||
"""
|
||||
await db.execute("ALTER TABLE captcha.captchas RENAME TO captchas_old")
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE captcha.captchas (
|
||||
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 captcha.captchas_old")
|
||||
]:
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO captcha.captchas (
|
||||
id,
|
||||
wallet,
|
||||
url,
|
||||
memo,
|
||||
amount,
|
||||
time
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(row[0], row[1], row[3], row[4], row[5], row[6]),
|
||||
)
|
||||
|
||||
await db.execute("DROP TABLE captcha.captchas_old")
|
@@ -1,24 +0,0 @@
|
||||
import json
|
||||
|
||||
from sqlite3 import Row
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class Captcha(BaseModel):
|
||||
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) -> "Captcha":
|
||||
data = dict(row)
|
||||
data["remembers"] = bool(data["remembers"])
|
||||
data["extras"] = json.loads(data["extras"]) if data["extras"] else None
|
||||
return cls(**data)
|
@@ -1,82 +0,0 @@
|
||||
var ciframeLoaded = !1,
|
||||
captchaStyleAdded = !1
|
||||
|
||||
function ccreateIframeElement(t = {}) {
|
||||
const e = document.createElement('iframe')
|
||||
// e.style.marginLeft = "25px",
|
||||
;(e.style.border = 'none'),
|
||||
(e.style.width = '100%'),
|
||||
(e.style.height = '100%'),
|
||||
(e.scrolling = 'no'),
|
||||
(e.id = 'captcha-iframe')
|
||||
t.dest, t.amount, t.currency, t.label, t.opReturn
|
||||
var captchaid = document
|
||||
.getElementById('captchascript')
|
||||
.getAttribute('data-captchaid')
|
||||
var lnbhostsrc = document.getElementById('captchascript').getAttribute('src')
|
||||
var lnbhost = lnbhostsrc.split('/captcha/static/js/captcha.js')[0]
|
||||
return (e.src = lnbhost + '/captcha/' + captchaid), e
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
if (captchaStyleAdded) console.log('Captcha already added!')
|
||||
else {
|
||||
console.log('Adding captcha'), (captchaStyleAdded = !0)
|
||||
var t = document.createElement('style')
|
||||
t.innerHTML =
|
||||
"\t/*Button*/\t\t.button-captcha-filled\t\t\t{\t\t\tdisplay: flex;\t\t\talign-items: center;\t\t\tjustify-content: center;\t\t\twidth: 120px;\t\t\tmin-width: 30px;\t\t\theight: 40px;\t\t\tline-height: 2.5;\t\t\ttext-align: center;\t\t\tcursor: pointer;\t\t\t/* Rectangle 2: */\t\t\tbackground: #FF7979;\t\t\tbox-shadow: 0 2px 4px 0 rgba(0,0,0,0.20);\t\t\tborder-radius: 20px;\t\t\t/* Sign up: */\t\t\tfont-family: 'Avenir-Heavy', Futura, Helvetica, Arial;\t\t\tfont-size: 16px;\t\t\tcolor: #FFFFFF;\t\t}\t\t.button-captcha-filled:hover\t\t{\t\t\tbackground:#FFFFFF;\t\t\tcolor: #FF7979;\t\t\tbox-shadow: 0 0 4px 0 rgba(0,0,0,0.20);\t\t}\t\t.button-captcha-filled:active\t\t{\t\t\tbackground:#FFFFFF;\t\t\tcolor: #FF7979;\t\t\t/*Move it down a little bit*/\t\t\tposition: relative;\t\t\ttop: 1px;\t\t}\t\t.button-captcha-filled-dark\t\t\t{\t\t\tdisplay: flex;\t\t\talign-items: center;\t\t\tjustify-content: center;\t\t\twidth: 120px;\t\t\tmin-width: 30px;\t\t\theight: 40px;\t\t\tline-height: 2.5;\t\t\ttext-align: center;\t\t\tcursor: pointer;\t\t\t/* Rectangle 2: */\t\t\tbackground: #161C38;\t\t\tbox-shadow: 0 0px 4px 0 rgba(0,0,0,0.20);\t\t\tborder-radius: 20px;\t\t\t/* Sign up: */\t\t\tfont-family: 'Avenir-Heavy', Futura, Helvetica, Arial;\t\t\tfont-size: 16px;\t\t\tcolor: #FFFFFF;\t\t}\t\t.button-captcha-filled-dark:hover\t\t{\t\t\tbackground:#FFFFFF;\t\t\tcolor: #161C38;\t\t\tbox-shadow: 0 0px 4px 0 rgba(0,0,0,0.20);\t\t}\t\t.button-captcha-filled-dark:active\t\t{\t\t\tbackground:#FFFFFF;\t\t\tcolor: #161C38;\t\t\t/*Move it down a little bit*/\t\t\tposition: relative;\t\t\ttop: 1px;\t\t}\t\t.modal-captcha-container {\t\t position: fixed;\t\t z-index: 1000;\t\t text-align: left;/*Si no añado esto, a veces hereda el text-align:center del body, y entonces el popup queda movido a la derecha, por center + margin left que aplico*/\t\t left: 0;\t\t top: 0;\t\t width: 100%;\t\t height: 100%;\t\t background-color: rgba(0, 0, 0, 0.5);\t\t opacity: 0;\t\t visibility: hidden;\t\t transform: scale(1.1);\t\t transition: visibility 0s linear 0.25s, opacity 0.25s 0s, transform 0.25s;\t\t}\t\t.modal-captcha-content {\t\t position: absolute;\t\t top: 50%;\t\t left: 50%;\t\t transform: translate(-50%, -50%);\t\t background-color: white;\t\t width: 100%;\t\t height: 100%;\t\t border-radius: 0.5rem;\t\t /*Rounded shadowed borders*/\t\t\tbox-shadow: 2px 2px 4px 0 rgba(0,0,0,0.15);\t\t\tborder-radius: 5px;\t\t}\t\t.close-button-captcha {\t\t float: right;\t\t width: 1.5rem;\t\t line-height: 1.5rem;\t\t text-align: center;\t\t cursor: pointer;\t\t margin-right:20px;\t\t margin-top:10px;\t\t border-radius: 0.25rem;\t\t background-color: lightgray;\t\t}\t\t.close-button-captcha:hover {\t\t background-color: darkgray;\t\t}\t\t.show-modal-captcha {\t\t opacity: 1;\t\t visibility: visible;\t\t transform: scale(1.0);\t\t transition: visibility 0s linear 0s, opacity 0.25s 0s, transform 0.25s;\t\t}\t\t/* Mobile */\t\t@media screen and (min-device-width: 160px) and ( max-width: 1077px ) /*No tendria ni por que poner un min-device, porq abarca todo lo humano...*/\t\t{\t\t}"
|
||||
var e = document.querySelector('script')
|
||||
e.parentNode.insertBefore(t, e)
|
||||
var i = document.getElementById('captchacheckbox'),
|
||||
n = i.dataset,
|
||||
o = 'true' === n.dark
|
||||
var a = document.createElement('div')
|
||||
;(a.className += ' modal-captcha-container'),
|
||||
(a.innerHTML =
|
||||
'\t\t<div class="modal-captcha-content"> \t<span class="close-button-captcha" style="display: none;">×</span>\t\t</div>\t'),
|
||||
document.getElementsByTagName('body')[0].appendChild(a)
|
||||
var r = document.getElementsByClassName('modal-captcha-content').item(0)
|
||||
document
|
||||
.getElementsByClassName('close-button-captcha')
|
||||
.item(0)
|
||||
.addEventListener('click', d),
|
||||
window.addEventListener('click', function (t) {
|
||||
t.target === a && d()
|
||||
}),
|
||||
i.addEventListener('change', function () {
|
||||
if (this.checked) {
|
||||
// console.log("checkbox checked");
|
||||
if (0 == ciframeLoaded) {
|
||||
// console.log("n: ", n);
|
||||
var t = ccreateIframeElement(n)
|
||||
r.appendChild(t), (ciframeLoaded = !0)
|
||||
}
|
||||
d()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function d() {
|
||||
a.classList.toggle('show-modal-captcha')
|
||||
}
|
||||
})
|
||||
|
||||
function receiveMessage(event) {
|
||||
if (event.data.includes('paymenthash')) {
|
||||
// console.log("paymenthash received: ", event.data);
|
||||
document.getElementById('captchapayhash').value = event.data.split('_')[1]
|
||||
}
|
||||
if (event.data.includes('removetheiframe')) {
|
||||
if (event.data.includes('nok')) {
|
||||
//invoice was NOT paid
|
||||
// console.log("receiveMessage not paid")
|
||||
document.getElementById('captchacheckbox').checked = false
|
||||
}
|
||||
ciframeLoaded = !1
|
||||
var element = document.getElementById('captcha-iframe')
|
||||
document
|
||||
.getElementsByClassName('modal-captcha-container')[0]
|
||||
.classList.toggle('show-modal-captcha')
|
||||
element.parentNode.removeChild(element)
|
||||
}
|
||||
}
|
||||
window.addEventListener('message', receiveMessage, false)
|
@@ -1,147 +0,0 @@
|
||||
<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 captchas">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code><span class="text-blue">GET</span> /captcha/api/v1/captchas</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>[<captcha_object>, ...]</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.url_root }}captcha/api/v1/captchas -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 captcha">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-green">POST</span> /captcha/api/v1/captchas</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 }}captcha/api/v1/captchas -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>
|
||||
/captcha/api/v1/captchas/<captcha_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
|
||||
}}captcha/api/v1/captchas/<captcha_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>
|
||||
/captcha/api/v1/captchas/<captcha_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
|
||||
}}captcha/api/v1/captchas/<captcha_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 captcha"
|
||||
class="q-pb-md"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-pink">DELETE</span>
|
||||
/captcha/api/v1/captchas/<captcha_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
|
||||
}}captcha/api/v1/captchas/<captcha_id> -H "X-Api-Key: {{
|
||||
g.user.wallets[0].adminkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
</q-expansion-item>
|
@@ -1,178 +0,0 @@
|
||||
{% 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">{{ captcha.memo }}</h5>
|
||||
{% if captcha.description %}
|
||||
<p>{{ captcha.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="captchaAmount"
|
||||
suffix="sat"
|
||||
label="Choose an amount *"
|
||||
:hint="'Minimum ' + captchaAmount + ' sat'"
|
||||
>
|
||||
<template v-slot:after>
|
||||
<q-btn
|
||||
round
|
||||
dense
|
||||
flat
|
||||
icon="check"
|
||||
color="primary"
|
||||
type="submit"
|
||||
@click="createInvoice"
|
||||
:disabled="userAmount < captchaAmount || 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(false)"
|
||||
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>
|
||||
Captcha accepted. You are probably human.<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: {{ captcha.amount }},
|
||||
captchaAmount: {{ captcha.amount }},
|
||||
paymentReq: null,
|
||||
redirectUrl: null,
|
||||
paymentDialog: {
|
||||
dismissMsg: null,
|
||||
checker: null
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
amount: function () {
|
||||
return (this.captchaAmount > this.userAmount) ? this.captchaAmount : this.userAmount
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
cancelPayment: function (paid) {
|
||||
this.paymentReq = null
|
||||
clearInterval(this.paymentDialog.checker)
|
||||
if (this.paymentDialog.dismissMsg) {
|
||||
this.paymentDialog.dismissMsg()
|
||||
}
|
||||
var removeiframestring = "removetheiframe_nok";
|
||||
var timeout = 500;
|
||||
if(paid){
|
||||
console.log("paid, dismissing iframe");
|
||||
removeiframestring = "removetheiframe_ok";
|
||||
timeout = 2000;
|
||||
}
|
||||
setTimeout(function () {
|
||||
// parent.closeIFrame()
|
||||
parent.window.postMessage(removeiframestring, "*");
|
||||
}, timeout)
|
||||
},
|
||||
createInvoice: function () {
|
||||
var self = this
|
||||
|
||||
axios
|
||||
.post(
|
||||
'/captcha/api/v1/captchas/{{ captcha.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(
|
||||
'/captcha/api/v1/captchas/{{ captcha.id }}/check_invoice',
|
||||
{payment_hash: response.data.payment_hash}
|
||||
)
|
||||
.then(function (res) {
|
||||
if (res.data.paid) {
|
||||
self.cancelPayment(true)
|
||||
self.redirectUrl = res.data.url
|
||||
if (res.data.remembers) {
|
||||
self.$q.localStorage.set(
|
||||
'lnbits.captcha.{{ captcha.id }}',
|
||||
res.data.url
|
||||
)
|
||||
}
|
||||
|
||||
parent.window.postMessage("paymenthash_"+response.data.payment_hash, "*");
|
||||
|
||||
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.captcha.{{ captcha.id }}')
|
||||
|
||||
if (url) {
|
||||
this.redirectUrl = url
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
@@ -1,427 +0,0 @@
|
||||
{% 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 captcha</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">Captchas</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="captchas"
|
||||
row-key="id"
|
||||
:columns="captchasTable.columns"
|
||||
:pagination.sync="captchasTable.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="visibility"
|
||||
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||
:href="buildCaptchaSnippet(props.row.id)"
|
||||
@click="openQrCodeDialog(props.row.id)"
|
||||
></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="deleteCaptcha(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}} captcha extension
|
||||
</h6>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-separator></q-separator>
|
||||
<q-list> {% include "captcha/_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="createCaptcha" 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="hidden"
|
||||
label="Redirect URL *"
|
||||
:value="https://dummy.com"
|
||||
></q-input> -->
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.memo"
|
||||
label="Title *"
|
||||
placeholder="LNbits captcha"
|
||||
></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 prove they are
|
||||
human.</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.memo == null"
|
||||
type="submit"
|
||||
>Create captcha</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="qrCodeDialog.show" position="top">
|
||||
<q-card v-if="qrCodeDialog.data" class="q-pa-lg lnbits__dialog-card">
|
||||
{% raw %}
|
||||
<q-responsive :ratio="1" class="q-mx-xl q-mb-md">
|
||||
<!-- <qrcode
|
||||
:value="qrCodeDialog.data.lnurl"
|
||||
:options="{width: 800}"
|
||||
class="rounded-borders"
|
||||
></qrcode> -->
|
||||
<code style="word-break: break-all">
|
||||
{{ qrCodeDialog.data.snippet }}
|
||||
</code>
|
||||
<p style="margin-top: 20px">
|
||||
Copy the snippet above and paste into your website/form. The checkbox
|
||||
can be in checked state only after user pays.
|
||||
</p>
|
||||
</q-responsive>
|
||||
<p style="word-break: break-all">
|
||||
<strong>ID:</strong> {{ qrCodeDialog.data.id }}<br />
|
||||
<strong>Amount:</strong> {{ qrCodeDialog.data.amount }}<br />
|
||||
<!-- <span v-if="qrCodeDialog.data.currency"
|
||||
><strong>{{ qrCodeDialog.data.currency }} price:</strong> {{
|
||||
fiatRates[qrCodeDialog.data.currency] ?
|
||||
fiatRates[qrCodeDialog.data.currency] + ' sat' : 'Loading...' }}<br
|
||||
/></span>
|
||||
<strong>Accepts comments:</strong> {{ qrCodeDialog.data.comments }}<br />
|
||||
<strong>Dispatches webhook to:</strong> {{ qrCodeDialog.data.webhook
|
||||
}}<br />
|
||||
<strong>On success:</strong> {{ qrCodeDialog.data.success }}<br /> -->
|
||||
</p>
|
||||
{% endraw %}
|
||||
<div class="row q-mt-lg q-gutter-sm">
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText(qrCodeDialog.data.snippet, 'Snippet copied to clipboard!')"
|
||||
class="q-ml-sm"
|
||||
>Copy Snippet</q-btn
|
||||
>
|
||||
<!-- <q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText(qrCodeDialog.data.pay_url, 'Link copied to clipboard!')"
|
||||
>Shareable link</q-btn
|
||||
>
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
icon="print"
|
||||
type="a"
|
||||
:href="qrCodeDialog.data.print_url"
|
||||
target="_blank"
|
||||
></q-btn> -->
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||
<script>
|
||||
var mapCaptcha = 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 = ['/captcha/', obj.id].join('')
|
||||
return obj
|
||||
}
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
captchas: [],
|
||||
captchasTable: {
|
||||
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
|
||||
}
|
||||
},
|
||||
qrCodeDialog: {
|
||||
show: false,
|
||||
data: null
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getCaptchas: function () {
|
||||
var self = this
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/captcha/api/v1/captchas?all_wallets',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.captchas = response.data.map(function (obj) {
|
||||
return mapCaptcha(obj)
|
||||
})
|
||||
})
|
||||
},
|
||||
createCaptcha: function () {
|
||||
var data = {
|
||||
// url: this.formDialog.data.url,
|
||||
url: 'http://dummy.com',
|
||||
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',
|
||||
'/captcha/api/v1/captchas',
|
||||
_.findWhere(this.g.user.wallets, {id: this.formDialog.data.wallet})
|
||||
.inkey,
|
||||
data
|
||||
)
|
||||
.then(function (response) {
|
||||
self.captchas.push(mapCaptcha(response.data))
|
||||
self.formDialog.show = false
|
||||
self.formDialog.data = {
|
||||
remembers: false
|
||||
}
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
deleteCaptcha: function (captchaId) {
|
||||
var self = this
|
||||
var captcha = _.findWhere(this.captchas, {id: captchaId})
|
||||
|
||||
LNbits.utils
|
||||
.confirmDialog('Are you sure you want to delete this captcha link?')
|
||||
.onOk(function () {
|
||||
LNbits.api
|
||||
.request(
|
||||
'DELETE',
|
||||
'/captcha/api/v1/captchas/' + captchaId,
|
||||
_.findWhere(self.g.user.wallets, {id: captcha.wallet}).inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.captchas = _.reject(self.captchas, function (obj) {
|
||||
return obj.id == captchaId
|
||||
})
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
})
|
||||
},
|
||||
buildCaptchaSnippet: function (captchaId) {
|
||||
var locationPath = [
|
||||
window.location.protocol,
|
||||
'//',
|
||||
window.location.host,
|
||||
window.location.pathname
|
||||
].join('')
|
||||
|
||||
var captchasnippet =
|
||||
'<!-- Captcha Checkbox Start -->\n' +
|
||||
'<input type="checkbox" id="captchacheckbox">\n' +
|
||||
'<label for="captchacheckbox">I\'m not a robot</label><br/>\n' +
|
||||
'<input type="text" id="captchapayhash" style="display: none;"/>\n' +
|
||||
'<script type="text/javascript" src="' +
|
||||
locationPath +
|
||||
'static/js/captcha.js" id="captchascript" data-captchaid="' +
|
||||
captchaId +
|
||||
'">\n' +
|
||||
'<\/script>\n' +
|
||||
'<!-- Captcha Checkbox End -->'
|
||||
return captchasnippet
|
||||
},
|
||||
openQrCodeDialog(captchaId) {
|
||||
// var link = _.findWhere(this.payLinks, {id: linkId})
|
||||
var captcha = _.findWhere(this.captchas, {id: captchaId})
|
||||
// if (link.currency) this.updateFiatRate(link.currency)
|
||||
|
||||
this.qrCodeDialog.data = {
|
||||
id: captcha.id,
|
||||
amount: captcha.amount,
|
||||
// (link.min === link.max ? link.min : `${link.min} - ${link.max}`) +
|
||||
// ' ' +
|
||||
// (link.currency || 'sat'),
|
||||
snippet: this.buildCaptchaSnippet(captcha.id)
|
||||
// currency: link.currency,
|
||||
// comments: link.comment_chars
|
||||
// ? `${link.comment_chars} characters`
|
||||
// : 'no',
|
||||
// webhook: link.webhook_url || 'nowhere',
|
||||
// success:
|
||||
// link.success_text || link.success_url
|
||||
// ? 'Display message "' +
|
||||
// link.success_text +
|
||||
// '"' +
|
||||
// (link.success_url ? ' and URL "' + link.success_url + '"' : '')
|
||||
// : 'do nothing',
|
||||
// lnurl: link.lnurl,
|
||||
// pay_url: link.pay_url,
|
||||
// print_url: link.print_url
|
||||
}
|
||||
this.qrCodeDialog.show = true
|
||||
},
|
||||
exportCSV: function () {
|
||||
LNbits.utils.exportCSV(this.captchasTable.columns, this.captchas)
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
if (this.g.user.wallets.length) {
|
||||
this.getCaptchas()
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
@@ -1,24 +0,0 @@
|
||||
from quart import g, abort, render_template
|
||||
from http import HTTPStatus
|
||||
|
||||
from lnbits.decorators import check_user_exists, validate_uuids
|
||||
|
||||
from . import captcha_ext
|
||||
from .crud import get_captcha
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi import Request
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
@captcha_ext.route("/")
|
||||
@validate_uuids(["usr"], required=True)
|
||||
@check_user_exists()
|
||||
async def index():
|
||||
return await render_template("captcha/index.html", user=g.user)
|
||||
|
||||
|
||||
@captcha_ext.route("/{captcha_id}")
|
||||
async def display(request: Request, captcha_id):
|
||||
captcha = await get_captcha(captcha_id) or abort(
|
||||
HTTPStatus.NOT_FOUND, "captcha does not exist."
|
||||
)
|
||||
return await templates.TemplateResponse("captcha/display.html", {"request": request, "captcha": captcha})
|
@@ -1,121 +0,0 @@
|
||||
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 captcha_ext
|
||||
from .crud import create_captcha, get_captcha, get_captchas, delete_captcha
|
||||
|
||||
|
||||
@captcha_ext.get("/api/v1/captchas")
|
||||
@api_check_wallet_key("invoice")
|
||||
async def api_captchas():
|
||||
wallet_ids = [g.wallet.id]
|
||||
|
||||
if "all_wallets" in request.args:
|
||||
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
|
||||
|
||||
return (
|
||||
jsonify([captcha._asdict() for captcha in await get_captchas(wallet_ids)]),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
|
||||
@captcha_ext.post("/api/v1/captchas")
|
||||
@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_captcha_create():
|
||||
captcha = await create_captcha(wallet_id=g.wallet.id, **g.data)
|
||||
return jsonify(captcha._asdict()), HTTPStatus.CREATED
|
||||
|
||||
|
||||
@captcha_ext.delete("/api/v1/captchas/{captcha_id}")
|
||||
@api_check_wallet_key("invoice")
|
||||
async def api_captcha_delete(captcha_id):
|
||||
captcha = await get_captcha(captcha_id)
|
||||
|
||||
if not captcha:
|
||||
return jsonify({"message": "captcha does not exist."}), HTTPStatus.NOT_FOUND
|
||||
|
||||
if captcha.wallet != g.wallet.id:
|
||||
return jsonify({"message": "Not your captcha."}), HTTPStatus.FORBIDDEN
|
||||
|
||||
await delete_captcha(captcha_id)
|
||||
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
|
||||
|
||||
@captcha_ext.post("/api/v1/captchas/{captcha_id}/invoice")
|
||||
@api_validate_post_request(
|
||||
schema={"amount": {"type": "integer", "min": 1, "required": True}}
|
||||
)
|
||||
async def api_captcha_create_invoice(captcha_id):
|
||||
captcha = await get_captcha(captcha_id)
|
||||
|
||||
if g.data["amount"] < captcha.amount:
|
||||
return (
|
||||
jsonify({"message": f"Minimum amount is {captcha.amount} sat."}),
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
amount = (
|
||||
g.data["amount"] if g.data["amount"] > captcha.amount else captcha.amount
|
||||
)
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
wallet_id=captcha.wallet,
|
||||
amount=amount,
|
||||
memo=f"{captcha.memo}",
|
||||
extra={"tag": "captcha"},
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@captcha_ext.post("/api/v1/captchas/{captcha_id}/check_invoice")
|
||||
@api_validate_post_request(
|
||||
schema={"payment_hash": {"type": "string", "empty": False, "required": True}}
|
||||
)
|
||||
async def api_paywal_check_invoice(captcha_id):
|
||||
captcha = await get_captcha(captcha_id)
|
||||
|
||||
if not captcha:
|
||||
return {"message": "captcha does not exist."}, HTTPStatus.NOT_FOUND
|
||||
|
||||
try:
|
||||
status = await check_invoice_status(captcha.wallet, g.data["payment_hash"])
|
||||
is_paid = not status.pending
|
||||
except Exception:
|
||||
return {"paid": False}, HTTPStatus.OK
|
||||
|
||||
if is_paid:
|
||||
wallet = await get_wallet(captcha.wallet)
|
||||
payment = await wallet.get_payment(g.data["payment_hash"])
|
||||
await payment.set_pending(False)
|
||||
|
||||
return (
|
||||
{"paid": True, "url": captcha.url, "remembers": captcha.remembers},
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
return {"paid": False}, HTTPStatus.OK
|
@@ -1,3 +0,0 @@
|
||||
# StreamerCopilot
|
||||
|
||||
Tool to help streamers accept sats for tips
|
@@ -1,17 +0,0 @@
|
||||
from quart import Blueprint
|
||||
from lnbits.db import Database
|
||||
|
||||
db = Database("ext_copilot")
|
||||
|
||||
copilot_ext: Blueprint = Blueprint(
|
||||
"copilot", __name__, static_folder="static", template_folder="templates"
|
||||
)
|
||||
|
||||
from .views_api import * # noqa
|
||||
from .views import * # noqa
|
||||
from .lnurl import * # noqa
|
||||
from .tasks import register_listeners
|
||||
|
||||
from lnbits.tasks import record_async
|
||||
|
||||
copilot_ext.record(record_async(register_listeners))
|
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"name": "StreamerCopilot",
|
||||
"short_description": "Video tips/animations/webhooks",
|
||||
"icon": "face",
|
||||
"contributors": [
|
||||
"arcbtc"
|
||||
]
|
||||
}
|
@@ -1,107 +0,0 @@
|
||||
from typing import List, Optional, Union
|
||||
|
||||
# from lnbits.db import open_ext_db
|
||||
from . import db
|
||||
from .models import Copilots
|
||||
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
|
||||
from quart import jsonify
|
||||
|
||||
|
||||
###############COPILOTS##########################
|
||||
|
||||
|
||||
async def create_copilot(
|
||||
title: str,
|
||||
user: str,
|
||||
lnurl_toggle: Optional[int] = 0,
|
||||
wallet: Optional[str] = None,
|
||||
animation1: Optional[str] = None,
|
||||
animation2: Optional[str] = None,
|
||||
animation3: Optional[str] = None,
|
||||
animation1threshold: Optional[int] = None,
|
||||
animation2threshold: Optional[int] = None,
|
||||
animation3threshold: Optional[int] = None,
|
||||
animation1webhook: Optional[str] = None,
|
||||
animation2webhook: Optional[str] = None,
|
||||
animation3webhook: Optional[str] = None,
|
||||
lnurl_title: Optional[str] = None,
|
||||
show_message: Optional[int] = 0,
|
||||
show_ack: Optional[int] = 0,
|
||||
show_price: Optional[str] = None,
|
||||
amount_made: Optional[int] = None,
|
||||
) -> Copilots:
|
||||
copilot_id = urlsafe_short_hash()
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO copilot.copilots (
|
||||
id,
|
||||
"user",
|
||||
lnurl_toggle,
|
||||
wallet,
|
||||
title,
|
||||
animation1,
|
||||
animation2,
|
||||
animation3,
|
||||
animation1threshold,
|
||||
animation2threshold,
|
||||
animation3threshold,
|
||||
animation1webhook,
|
||||
animation2webhook,
|
||||
animation3webhook,
|
||||
lnurl_title,
|
||||
show_message,
|
||||
show_ack,
|
||||
show_price,
|
||||
amount_made
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
copilot_id,
|
||||
user,
|
||||
int(lnurl_toggle),
|
||||
wallet,
|
||||
title,
|
||||
animation1,
|
||||
animation2,
|
||||
animation3,
|
||||
animation1threshold,
|
||||
animation2threshold,
|
||||
animation3threshold,
|
||||
animation1webhook,
|
||||
animation2webhook,
|
||||
animation3webhook,
|
||||
lnurl_title,
|
||||
int(show_message),
|
||||
int(show_ack),
|
||||
show_price,
|
||||
0,
|
||||
),
|
||||
)
|
||||
return await get_copilot(copilot_id)
|
||||
|
||||
|
||||
async def update_copilot(copilot_id: str, **kwargs) -> Optional[Copilots]:
|
||||
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
||||
await db.execute(
|
||||
f"UPDATE copilot.copilots SET {q} WHERE id = ?", (*kwargs.values(), copilot_id)
|
||||
)
|
||||
row = await db.fetchone("SELECT * FROM copilot.copilots WHERE id = ?", (copilot_id,))
|
||||
return Copilots.from_row(row) if row else None
|
||||
|
||||
|
||||
async def get_copilot(copilot_id: str) -> Copilots:
|
||||
row = await db.fetchone("SELECT * FROM copilot.copilots WHERE id = ?", (copilot_id,))
|
||||
return Copilots.from_row(row) if row else None
|
||||
|
||||
|
||||
async def get_copilots(user: str) -> List[Copilots]:
|
||||
rows = await db.fetchall("""SELECT * FROM copilot.copilots WHERE "user" = ?""", (user,))
|
||||
return [Copilots.from_row(row) for row in rows]
|
||||
|
||||
|
||||
async def delete_copilot(copilot_id: str) -> None:
|
||||
await db.execute("DELETE FROM copilot.copilots WHERE id = ?", (copilot_id,))
|
@@ -1,86 +0,0 @@
|
||||
import json
|
||||
import hashlib
|
||||
import math
|
||||
from quart import jsonify, url_for, request
|
||||
from lnurl import LnurlPayResponse, LnurlPayActionResponse, LnurlErrorResponse # type: ignore
|
||||
from lnurl.types import LnurlPayMetadata
|
||||
from lnbits.core.services import create_invoice
|
||||
|
||||
from . import copilot_ext
|
||||
from .crud import get_copilot
|
||||
|
||||
|
||||
@copilot_ext.route("/lnurl/<cp_id>", methods=["GET"])
|
||||
async def lnurl_response(cp_id):
|
||||
cp = await get_copilot(cp_id)
|
||||
if not cp:
|
||||
return jsonify({"status": "ERROR", "reason": "Copilot not found."})
|
||||
|
||||
resp = LnurlPayResponse(
|
||||
callback=url_for("copilot.lnurl_callback", cp_id=cp_id, _external=True),
|
||||
min_sendable=10000,
|
||||
max_sendable=50000000,
|
||||
metadata=LnurlPayMetadata(json.dumps([["text/plain", str(cp.lnurl_title)]])),
|
||||
)
|
||||
|
||||
params = resp.dict()
|
||||
if cp.show_message:
|
||||
params["commentAllowed"] = 300
|
||||
|
||||
return jsonify(params)
|
||||
|
||||
|
||||
@copilot_ext.route("/lnurl/cb/<cp_id>", methods=["GET"])
|
||||
async def lnurl_callback(cp_id):
|
||||
cp = await get_copilot(cp_id)
|
||||
if not cp:
|
||||
return jsonify({"status": "ERROR", "reason": "Copilot not found."})
|
||||
|
||||
amount_received = int(request.args.get("amount"))
|
||||
|
||||
if amount_received < 10000:
|
||||
return (
|
||||
jsonify(
|
||||
LnurlErrorResponse(
|
||||
reason=f"Amount {round(amount_received / 1000)} is smaller than minimum 10 sats."
|
||||
).dict()
|
||||
),
|
||||
)
|
||||
elif amount_received / 1000 > 10000000:
|
||||
return (
|
||||
jsonify(
|
||||
LnurlErrorResponse(
|
||||
reason=f"Amount {round(amount_received / 1000)} is greater than maximum 50000."
|
||||
).dict()
|
||||
),
|
||||
)
|
||||
comment = ""
|
||||
if request.args.get("comment"):
|
||||
comment = request.args.get("comment")
|
||||
if len(comment or "") > 300:
|
||||
return jsonify(
|
||||
LnurlErrorResponse(
|
||||
reason=f"Got a comment with {len(comment)} characters, but can only accept 300"
|
||||
).dict()
|
||||
)
|
||||
if len(comment) < 1:
|
||||
comment = "none"
|
||||
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
wallet_id=cp.wallet,
|
||||
amount=int(amount_received / 1000),
|
||||
memo=cp.lnurl_title,
|
||||
description_hash=hashlib.sha256(
|
||||
(
|
||||
LnurlPayMetadata(json.dumps([["text/plain", str(cp.lnurl_title)]]))
|
||||
).encode("utf-8")
|
||||
).digest(),
|
||||
extra={"tag": "copilot", "copilot": cp.id, "comment": comment},
|
||||
)
|
||||
resp = LnurlPayActionResponse(
|
||||
pr=payment_request,
|
||||
success_action=None,
|
||||
disposable=False,
|
||||
routes=[],
|
||||
)
|
||||
return jsonify(resp.dict())
|
@@ -1,76 +0,0 @@
|
||||
async def m001_initial(db):
|
||||
"""
|
||||
Initial copilot table.
|
||||
"""
|
||||
|
||||
await db.execute(
|
||||
f"""
|
||||
CREATE TABLE copilot.copilots (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
"user" TEXT,
|
||||
title TEXT,
|
||||
lnurl_toggle INTEGER,
|
||||
wallet TEXT,
|
||||
animation1 TEXT,
|
||||
animation2 TEXT,
|
||||
animation3 TEXT,
|
||||
animation1threshold INTEGER,
|
||||
animation2threshold INTEGER,
|
||||
animation3threshold INTEGER,
|
||||
animation1webhook TEXT,
|
||||
animation2webhook TEXT,
|
||||
animation3webhook TEXT,
|
||||
lnurl_title TEXT,
|
||||
show_message INTEGER,
|
||||
show_ack INTEGER,
|
||||
show_price INTEGER,
|
||||
amount_made INTEGER,
|
||||
fullscreen_cam INTEGER,
|
||||
iframe_url TEXT,
|
||||
timestamp TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
async def m002_fix_data_types(db):
|
||||
"""
|
||||
Fix data types.
|
||||
"""
|
||||
|
||||
if(db.type != "SQLITE"):
|
||||
await db.execute("ALTER TABLE copilot.copilots ALTER COLUMN show_price TYPE TEXT;")
|
||||
|
||||
# If needed, migration for SQLite (RENAME not working properly)
|
||||
#
|
||||
# await db.execute(
|
||||
# f"""
|
||||
# CREATE TABLE copilot.new_copilots (
|
||||
# id TEXT NOT NULL PRIMARY KEY,
|
||||
# "user" TEXT,
|
||||
# title TEXT,
|
||||
# lnurl_toggle INTEGER,
|
||||
# wallet TEXT,
|
||||
# animation1 TEXT,
|
||||
# animation2 TEXT,
|
||||
# animation3 TEXT,
|
||||
# animation1threshold INTEGER,
|
||||
# animation2threshold INTEGER,
|
||||
# animation3threshold INTEGER,
|
||||
# animation1webhook TEXT,
|
||||
# animation2webhook TEXT,
|
||||
# animation3webhook TEXT,
|
||||
# lnurl_title TEXT,
|
||||
# show_message INTEGER,
|
||||
# show_ack INTEGER,
|
||||
# show_price TEXT,
|
||||
# amount_made INTEGER,
|
||||
# fullscreen_cam INTEGER,
|
||||
# iframe_url TEXT,
|
||||
# timestamp TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
|
||||
# );
|
||||
# """
|
||||
# )
|
||||
#
|
||||
# await db.execute("INSERT INTO copilot.new_copilots SELECT * FROM copilot.copilots;")
|
||||
# await db.execute("DROP TABLE IF EXISTS copilot.copilots;")
|
||||
# await db.execute("ALTER TABLE copilot.new_copilots RENAME TO copilot.copilots;")
|
@@ -1,42 +0,0 @@
|
||||
from sqlite3 import Row
|
||||
from typing import NamedTuple
|
||||
import time
|
||||
from quart import url_for
|
||||
from lnurl import Lnurl, encode as lnurl_encode # type: ignore
|
||||
from lnurl.types import LnurlPayMetadata # type: ignore
|
||||
from lnurl.models import LnurlPaySuccessAction, UrlAction # type: ignore
|
||||
from sqlite3 import Row
|
||||
from pydantic import BaseModel
|
||||
|
||||
class Copilots(BaseModel):
|
||||
id: str
|
||||
user: str
|
||||
title: str
|
||||
lnurl_toggle: int
|
||||
wallet: str
|
||||
animation1: str
|
||||
animation2: str
|
||||
animation3: str
|
||||
animation1threshold: int
|
||||
animation2threshold: int
|
||||
animation3threshold: int
|
||||
animation1webhook: str
|
||||
animation2webhook: str
|
||||
animation3webhook: str
|
||||
lnurl_title: str
|
||||
show_message: int
|
||||
show_ack: int
|
||||
show_price: int
|
||||
amount_made: int
|
||||
timestamp: int
|
||||
fullscreen_cam: int
|
||||
iframe_url: str
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: Row) -> "Copilots":
|
||||
return cls(**dict(row))
|
||||
|
||||
@property
|
||||
def lnurl(self) -> Lnurl:
|
||||
url = url_for("copilot.lnurl_response", cp_id=self.id, _external=True)
|
||||
return lnurl_encode(url)
|
Before Width: | Height: | Size: 308 KiB |
Before Width: | Height: | Size: 333 KiB |
Before Width: | Height: | Size: 536 KiB |
Before Width: | Height: | Size: 35 KiB |
Before Width: | Height: | Size: 504 KiB |
Before Width: | Height: | Size: 2.3 MiB |
Before Width: | Height: | Size: 577 KiB |
@@ -1,88 +0,0 @@
|
||||
import trio # type: ignore
|
||||
import json
|
||||
import httpx
|
||||
from quart import g, jsonify, url_for, websocket
|
||||
from http import HTTPStatus
|
||||
|
||||
from lnbits.core import db as core_db
|
||||
from lnbits.core.models import Payment
|
||||
from lnbits.tasks import register_invoice_listener
|
||||
|
||||
from .crud import get_copilot
|
||||
from .views import updater
|
||||
import shortuuid
|
||||
|
||||
|
||||
async def register_listeners():
|
||||
invoice_paid_chan_send, invoice_paid_chan_recv = trio.open_memory_channel(2)
|
||||
register_invoice_listener(invoice_paid_chan_send)
|
||||
await wait_for_paid_invoices(invoice_paid_chan_recv)
|
||||
|
||||
|
||||
async def wait_for_paid_invoices(invoice_paid_chan: trio.MemoryReceiveChannel):
|
||||
async for payment in invoice_paid_chan:
|
||||
await on_invoice_paid(payment)
|
||||
|
||||
|
||||
async def on_invoice_paid(payment: Payment) -> None:
|
||||
webhook = None
|
||||
data = None
|
||||
if "copilot" != payment.extra.get("tag"):
|
||||
# not an copilot invoice
|
||||
return
|
||||
|
||||
if payment.extra.get("wh_status"):
|
||||
# this webhook has already been sent
|
||||
return
|
||||
|
||||
copilot = await get_copilot(payment.extra.get("copilot", -1))
|
||||
|
||||
if not copilot:
|
||||
return (
|
||||
jsonify({"message": "Copilot link link does not exist."}),
|
||||
HTTPStatus.NOT_FOUND,
|
||||
)
|
||||
if copilot.animation1threshold:
|
||||
if int(payment.amount / 1000) >= copilot.animation1threshold:
|
||||
data = copilot.animation1
|
||||
webhook = copilot.animation1webhook
|
||||
if copilot.animation2threshold:
|
||||
if int(payment.amount / 1000) >= copilot.animation2threshold:
|
||||
data = copilot.animation2
|
||||
webhook = copilot.animation1webhook
|
||||
if copilot.animation3threshold:
|
||||
if int(payment.amount / 1000) >= copilot.animation3threshold:
|
||||
data = copilot.animation3
|
||||
webhook = copilot.animation1webhook
|
||||
if webhook:
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
r = await client.post(
|
||||
webhook,
|
||||
json={
|
||||
"payment_hash": payment.payment_hash,
|
||||
"payment_request": payment.bolt11,
|
||||
"amount": payment.amount,
|
||||
"comment": payment.extra.get("comment"),
|
||||
},
|
||||
timeout=40,
|
||||
)
|
||||
await mark_webhook_sent(payment, r.status_code)
|
||||
except (httpx.ConnectError, httpx.RequestError):
|
||||
await mark_webhook_sent(payment, -1)
|
||||
if payment.extra.get("comment"):
|
||||
await updater(copilot.id, data, payment.extra.get("comment"))
|
||||
else:
|
||||
await updater(copilot.id, data, "none")
|
||||
|
||||
|
||||
async def mark_webhook_sent(payment: Payment, status: int) -> None:
|
||||
payment.extra["wh_status"] = status
|
||||
|
||||
await core_db.execute(
|
||||
"""
|
||||
UPDATE apipayments SET extra = ?
|
||||
WHERE hash = ?
|
||||
""",
|
||||
(json.dumps(payment.extra), payment.payment_hash),
|
||||
)
|
@@ -1,172 +0,0 @@
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<p>
|
||||
StreamerCopilot: get tips via static QR (lnurl-pay) and show an
|
||||
animation<br />
|
||||
<small>
|
||||
Created by, <a href="https://github.com/benarc">Ben Arc</a></small
|
||||
>
|
||||
</p>
|
||||
</q-card-section>
|
||||
<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="Create copilot">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">POST</span> /copilot/api/v1/copilot</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>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>[<copilot_object>, ...]</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X POST {{ request.url_root }}api/v1/copilot -d '{"title":
|
||||
<string>, "animation": <string>,
|
||||
"show_message":<string>, "amount": <integer>,
|
||||
"lnurl_title": <string>}' -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="Update copilot">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">PUT</span>
|
||||
/copilot/api/v1/copilot/<copilot_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">
|
||||
Body (application/json)
|
||||
</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>[<copilot_object>, ...]</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X POST {{ request.url_root
|
||||
}}api/v1/copilot/<copilot_id> -d '{"title": <string>,
|
||||
"animation": <string>, "show_message":<string>,
|
||||
"amount": <integer>, "lnurl_title": <string>}' -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="Get copilot">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">GET</span>
|
||||
/copilot/api/v1/copilot/<copilot_id></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>[<copilot_object>, ...]</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.url_root }}api/v1/copilot/<copilot_id>
|
||||
-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="Get copilots">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">GET</span> /copilot/api/v1/copilots</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>[<copilot_object>, ...]</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.url_root }}api/v1/copilots -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="Delete a pay link"
|
||||
class="q-pb-md"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-pink">DELETE</span>
|
||||
/copilot/api/v1/copilot/<copilot_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/copilot/<copilot_id> -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="Trigger an animation"
|
||||
class="q-pb-md"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">GET</span>
|
||||
/api/v1/copilot/ws/<copilot_id>/<comment>/<data></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 200</h5>
|
||||
<code></code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.url_root }}/api/v1/copilot/ws/<string,
|
||||
copilot_id>/<string, comment>/<string, gif name> -H
|
||||
"X-Api-Key: {{ g.user.wallets[0].adminkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
</q-expansion-item>
|
||||
</q-card>
|
@@ -1,289 +0,0 @@
|
||||
{% extends "public.html" %} {% block page %}<q-page>
|
||||
<video
|
||||
autoplay="true"
|
||||
id="videoScreen"
|
||||
style="width: 100%"
|
||||
class="fixed-bottom-right"
|
||||
></video>
|
||||
<video
|
||||
autoplay="true"
|
||||
id="videoCamera"
|
||||
style="width: 100%"
|
||||
class="fixed-bottom-right"
|
||||
></video>
|
||||
<img src="" style="width: 100%" id="animations" class="fixed-bottom-left" />
|
||||
|
||||
<div
|
||||
v-if="copilot.lnurl_toggle == 1"
|
||||
class="rounded-borders column fixed-right"
|
||||
style="
|
||||
width: 250px;
|
||||
background-color: white;
|
||||
height: 300px;
|
||||
margin-top: 10%;
|
||||
"
|
||||
>
|
||||
<div class="col">
|
||||
<qrcode
|
||||
:value="copilot.lnurl"
|
||||
:options="{width:250}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
<center class="absolute-bottom" style="color: black; font-size: 20px">
|
||||
{% raw %}{{ copilot.lnurl_title }}{% endraw %}
|
||||
</center>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2
|
||||
v-if="copilot.show_price != 0"
|
||||
class="text-bold fixed-bottom-left"
|
||||
style="
|
||||
margin: 60px 60px;
|
||||
font-size: 110px;
|
||||
text-shadow: 4px 8px 4px black;
|
||||
color: white;
|
||||
"
|
||||
>
|
||||
{% raw %}{{ price }}{% endraw %}
|
||||
</h2>
|
||||
<p
|
||||
v-if="copilot.show_ack != 0"
|
||||
class="fixed-top"
|
||||
style="
|
||||
font-size: 22px;
|
||||
text-shadow: 2px 4px 1px black;
|
||||
color: white;
|
||||
padding-left: 40%;
|
||||
"
|
||||
>
|
||||
Powered by LNbits/StreamerCopilot
|
||||
</p>
|
||||
</q-page>
|
||||
{% endblock %} {% block scripts %}
|
||||
<script src="{{ url_for('static', filename='vendor/vue-qrcode@1.0.2/vue-qrcode.min.js') }}"></script>
|
||||
<style>
|
||||
body.body--dark .q-drawer,
|
||||
body.body--dark .q-footer,
|
||||
body.body--dark .q-header,
|
||||
.q-drawer,
|
||||
.q-footer,
|
||||
.q-header {
|
||||
display: none;
|
||||
}
|
||||
.q-page {
|
||||
padding: 0px;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data() {
|
||||
return {
|
||||
price: '',
|
||||
counter: 1,
|
||||
colours: ['teal', 'purple', 'indigo', 'pink', 'green'],
|
||||
copilot: {},
|
||||
animQueue: [],
|
||||
queue: false,
|
||||
lnurl: ''
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
showNotif: function (userMessage) {
|
||||
var colour = this.colours[
|
||||
Math.floor(Math.random() * this.colours.length)
|
||||
]
|
||||
this.$q.notify({
|
||||
color: colour,
|
||||
icon: 'chat_bubble_outline',
|
||||
html: true,
|
||||
message: '<h4 style="color: white;">' + userMessage + '</h4>',
|
||||
position: 'top-left',
|
||||
timeout: 5000
|
||||
})
|
||||
},
|
||||
openURL: function (url) {
|
||||
return Quasar.utils.openURL(url)
|
||||
},
|
||||
initCamera() {
|
||||
var video = document.querySelector('#videoCamera')
|
||||
|
||||
if (navigator.mediaDevices.getUserMedia) {
|
||||
navigator.mediaDevices
|
||||
.getUserMedia({video: true})
|
||||
.then(function (stream) {
|
||||
video.srcObject = stream
|
||||
})
|
||||
.catch(function (err0r) {
|
||||
console.log('Something went wrong!')
|
||||
})
|
||||
}
|
||||
},
|
||||
initScreenShare() {
|
||||
var video = document.querySelector('#videoScreen')
|
||||
navigator.mediaDevices
|
||||
.getDisplayMedia({video: true})
|
||||
.then(function (stream) {
|
||||
video.srcObject = stream
|
||||
})
|
||||
.catch(function (err0r) {
|
||||
console.log('Something went wrong!')
|
||||
})
|
||||
},
|
||||
pushAnim(content) {
|
||||
document.getElementById('animations').style.width = content[0]
|
||||
document.getElementById('animations').src = content[1]
|
||||
if (content[2] != 'none') {
|
||||
self.showNotif(content[2])
|
||||
}
|
||||
setTimeout(function () {
|
||||
document.getElementById('animations').src = ''
|
||||
}, 5000)
|
||||
},
|
||||
launch() {
|
||||
self = this
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/copilot/api/v1/copilot/ws/' +
|
||||
self.copilot.id +
|
||||
'/launching/rocket'
|
||||
)
|
||||
.then(function (response1) {
|
||||
self.$q.notify({
|
||||
color: 'green',
|
||||
message: 'Sent!'
|
||||
})
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.initCamera()
|
||||
},
|
||||
created: function () {
|
||||
self = this
|
||||
self.copilot = JSON.parse(localStorage.getItem('copilot'))
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/copilot/api/v1/copilot/' + self.copilot.id,
|
||||
localStorage.getItem('inkey')
|
||||
)
|
||||
.then(function (response) {
|
||||
self.copilot = response.data
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
|
||||
this.connectionBitStamp = new WebSocket('wss://ws.bitstamp.net')
|
||||
|
||||
const obj = JSON.stringify({
|
||||
event: 'bts:subscribe',
|
||||
data: {channel: 'live_trades_' + self.copilot.show_price}
|
||||
})
|
||||
|
||||
this.connectionBitStamp.onmessage = function (e) {
|
||||
if (self.copilot.show_price) {
|
||||
if (self.copilot.show_price == 'btcusd') {
|
||||
self.price = String(
|
||||
new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD'
|
||||
}).format(JSON.parse(e.data).data.price)
|
||||
)
|
||||
} else if (self.copilot.show_price == 'btceur') {
|
||||
self.price = String(
|
||||
new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'EUR'
|
||||
}).format(JSON.parse(e.data).data.price)
|
||||
)
|
||||
} else if (self.copilot.show_price == 'btcgbp') {
|
||||
self.price = String(
|
||||
new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'GBP'
|
||||
}).format(JSON.parse(e.data).data.price)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
this.connectionBitStamp.onopen = () => this.connectionBitStamp.send(obj)
|
||||
|
||||
const fetch = data =>
|
||||
new Promise(resolve => setTimeout(resolve, 5000, this.pushAnim(data)))
|
||||
|
||||
const addTask = (() => {
|
||||
let pending = Promise.resolve()
|
||||
const run = async data => {
|
||||
try {
|
||||
await pending
|
||||
} finally {
|
||||
return fetch(data)
|
||||
}
|
||||
}
|
||||
return data => (pending = run(data))
|
||||
})()
|
||||
|
||||
if (location.protocol !== 'http:') {
|
||||
localUrl =
|
||||
'wss://' +
|
||||
document.domain +
|
||||
':' +
|
||||
location.port +
|
||||
'/copilot/ws/' +
|
||||
self.copilot.id +
|
||||
'/'
|
||||
} else {
|
||||
localUrl =
|
||||
'ws://' +
|
||||
document.domain +
|
||||
':' +
|
||||
location.port +
|
||||
'/copilot/ws/' +
|
||||
self.copilot.id +
|
||||
'/'
|
||||
}
|
||||
this.connection = new WebSocket(localUrl)
|
||||
this.connection.onmessage = function (e) {
|
||||
res = e.data.split('-')
|
||||
if (res[0] == 'rocket') {
|
||||
addTask(['40%', '/copilot/static/rocket.gif', res[1]])
|
||||
}
|
||||
if (res[0] == 'face') {
|
||||
addTask(['35%', '/copilot/static/face.gif', res[1]])
|
||||
}
|
||||
if (res[0] == 'bitcoin') {
|
||||
addTask(['30%', '/copilot/static/bitcoin.gif', res[1]])
|
||||
}
|
||||
if (res[0] == 'confetti') {
|
||||
addTask(['100%', '/copilot/static/confetti.gif', res[1]])
|
||||
}
|
||||
if (res[0] == 'martijn') {
|
||||
addTask(['40%', '/copilot/static/martijn.gif', res[1]])
|
||||
}
|
||||
if (res[0] == 'rick') {
|
||||
addTask(['40%', '/copilot/static/rick.gif', res[1]])
|
||||
}
|
||||
if (res[0] == 'true') {
|
||||
document.getElementById('videoCamera').style.width = '20%'
|
||||
self.initScreenShare()
|
||||
}
|
||||
if (res[0] == 'false') {
|
||||
document.getElementById('videoCamera').style.width = '100%'
|
||||
document.getElementById('videoScreen').src = null
|
||||
}
|
||||
}
|
||||
this.connection.onopen = () => this.launch
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
@@ -1,658 +0,0 @@
|
||||
{% 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-7 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
{% raw %}
|
||||
<q-btn unelevated color="primary" @click="formDialogCopilot.show = true"
|
||||
>New copilot instance
|
||||
</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">Copilots</h5>
|
||||
</div>
|
||||
|
||||
<div class="col-auto">
|
||||
<q-input
|
||||
borderless
|
||||
dense
|
||||
debounce="300"
|
||||
v-model="filter"
|
||||
placeholder="Search"
|
||||
>
|
||||
<template v-slot:append>
|
||||
<q-icon name="search"></q-icon>
|
||||
</template>
|
||||
</q-input>
|
||||
<q-btn flat color="grey" @click="exportcopilotCSV"
|
||||
>Export to CSV</q-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
flat
|
||||
dense
|
||||
:data="CopilotLinks"
|
||||
row-key="id"
|
||||
:columns="CopilotsTable.columns"
|
||||
:pagination.sync="CopilotsTable.pagination"
|
||||
:filter="filter"
|
||||
>
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th style="width: 5%"></q-th>
|
||||
<q-th style="width: 5%"></q-th>
|
||||
<q-th style="width: 5%"></q-th>
|
||||
<q-th style="width: 5%"></q-th>
|
||||
|
||||
<q-th
|
||||
v-for="col in props.cols"
|
||||
:key="col.name"
|
||||
:props="props"
|
||||
auto-width
|
||||
>
|
||||
<div v-if="col.name == 'id'"></div>
|
||||
<div v-else>{{ col.label }}</div>
|
||||
</q-th>
|
||||
<!-- <q-th auto-width></q-th> -->
|
||||
</q-tr>
|
||||
</template>
|
||||
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td>
|
||||
<q-btn
|
||||
unelevated
|
||||
dense
|
||||
size="xs"
|
||||
icon="apps"
|
||||
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||
@click="openCopilotPanel(props.row.id)"
|
||||
>
|
||||
<q-tooltip> Panel </q-tooltip>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
<q-td>
|
||||
<q-btn
|
||||
unelevated
|
||||
dense
|
||||
size="xs"
|
||||
icon="face"
|
||||
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||
@click="openCopilotCompose(props.row.id)"
|
||||
>
|
||||
<q-tooltip> Compose window </q-tooltip>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
<q-td>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="deleteCopilotLink(props.row.id)"
|
||||
icon="cancel"
|
||||
color="pink"
|
||||
>
|
||||
<q-tooltip> Delete copilot </q-tooltip>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
<q-td>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="openUpdateCopilotLink(props.row.id)"
|
||||
icon="edit"
|
||||
color="light-blue"
|
||||
>
|
||||
<q-tooltip> Edit copilot </q-tooltip>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
<q-td
|
||||
v-for="col in props.cols"
|
||||
:key="col.name"
|
||||
:props="props"
|
||||
auto-width
|
||||
>
|
||||
<div v-if="col.name == 'id'"></div>
|
||||
<div v-else>{{ col.value }}</div>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
{% endraw %}
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-5 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h6 class="text-subtitle1 q-my-none">
|
||||
{{SITE_TITLE}} StreamCopilot Extension
|
||||
</h6>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-separator></q-separator>
|
||||
<q-list> {% include "copilot/_api_docs.html" %} </q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
<q-dialog
|
||||
v-model="formDialogCopilot.show"
|
||||
position="top"
|
||||
@hide="closeFormDialog"
|
||||
>
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form @submit="sendFormDataCopilot" class="q-gutter-md">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialogCopilot.data.title"
|
||||
type="text"
|
||||
label="Title"
|
||||
></q-input>
|
||||
<div class="row">
|
||||
<q-checkbox
|
||||
v-model="formDialogCopilot.data.lnurl_toggle"
|
||||
label="Include lnurl payment QR? (requires https)"
|
||||
left-label
|
||||
></q-checkbox>
|
||||
</div>
|
||||
|
||||
<div v-if="formDialogCopilot.data.lnurl_toggle">
|
||||
<q-checkbox
|
||||
v-model="formDialogCopilot.data.show_message"
|
||||
left-label
|
||||
label="Show lnurl-pay messages? (supported by few wallets)"
|
||||
></q-checkbox>
|
||||
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model="formDialogCopilot.data.wallet"
|
||||
:options="g.user.walletOptions"
|
||||
label="Wallet *"
|
||||
></q-select>
|
||||
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Payment threshold 1"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialogCopilot.data.animation1"
|
||||
:options="options"
|
||||
label="Animation"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col q-pl-xs">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialogCopilot.data.animation1threshold"
|
||||
type="number"
|
||||
step="1"
|
||||
label="From *sats (min. 10)"
|
||||
:rules="[ val => val >= 10 || 'Please use minimum 10' ]"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
<div class="col q-pl-xs">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialogCopilot.data.animation1webhook"
|
||||
type="text"
|
||||
label="Webhook"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Payment threshold 2 (Must be higher than last)"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div
|
||||
class="row"
|
||||
v-if="formDialogCopilot.data.animation1threshold > 0"
|
||||
>
|
||||
<div class="col">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialogCopilot.data.animation2"
|
||||
:options="options"
|
||||
label="Animation"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col q-pl-xs">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model="formDialogCopilot.data.animation2threshold"
|
||||
type="number"
|
||||
step="1"
|
||||
label="From *sats"
|
||||
:min="formDialogCopilot.data.animation1threshold"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
<div class="col q-pl-xs">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialogCopilot.data.animation2webhook"
|
||||
type="text"
|
||||
label="Webhook"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Payment threshold 3 (Must be higher than last)"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div
|
||||
class="row"
|
||||
v-if="formDialogCopilot.data.animation2threshold > formDialogCopilot.data.animation1threshold"
|
||||
>
|
||||
<div class="col">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialogCopilot.data.animation3"
|
||||
:options="options"
|
||||
label="Animation"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col q-pl-xs">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model="formDialogCopilot.data.animation3threshold"
|
||||
type="number"
|
||||
step="1"
|
||||
label="From *sats"
|
||||
:min="formDialogCopilot.data.animation2threshold"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
<div class="col q-pl-xs">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialogCopilot.data.animation3webhook"
|
||||
type="text"
|
||||
label="Webhook"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialogCopilot.data.lnurl_title"
|
||||
type="text"
|
||||
max="1440"
|
||||
label="Lnurl title (message with QR code)"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
|
||||
<div class="q-gutter-sm">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
style="width: 50%"
|
||||
v-model.trim="formDialogCopilot.data.show_price"
|
||||
:options="currencyOptions"
|
||||
label="Show price"
|
||||
/>
|
||||
</div>
|
||||
<div class="q-gutter-sm">
|
||||
<div class="row">
|
||||
<q-checkbox
|
||||
v-model="formDialogCopilot.data.show_ack"
|
||||
left-label
|
||||
label="Show 'powered by LNbits'"
|
||||
></q-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
v-if="formDialogCopilot.data.id"
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="
|
||||
formDialogCopilot.data.title == ''"
|
||||
type="submit"
|
||||
>Update Copilot</q-btn
|
||||
>
|
||||
<q-btn
|
||||
v-else
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="
|
||||
formDialogCopilot.data.title == ''"
|
||||
type="submit"
|
||||
>Create Copilot</q-btn
|
||||
>
|
||||
<q-btn @click="cancelCopilot" 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 src="{{ url_for('static', filename='vendor/vue-qrcode@1.0.2/vue-qrcode.min.js') }}"></script>
|
||||
<style></style>
|
||||
<script>
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
|
||||
var mapCopilot = obj => {
|
||||
obj._data = _.clone(obj)
|
||||
obj.theTime = obj.time * 60 - (Date.now() / 1000 - obj.timestamp)
|
||||
obj.time = obj.time + 'mins'
|
||||
|
||||
if (obj.time_elapsed) {
|
||||
obj.date = 'Time elapsed'
|
||||
} else {
|
||||
obj.date = Quasar.utils.date.formatDate(
|
||||
new Date((obj.theTime - 3600) * 1000),
|
||||
'HH:mm:ss'
|
||||
)
|
||||
}
|
||||
obj.displayComposeUrl = ['/copilot/cp/', obj.id].join('')
|
||||
obj.displayPanelUrl = ['/copilot/', obj.id].join('')
|
||||
return obj
|
||||
}
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
filter: '',
|
||||
CopilotLinks: [],
|
||||
CopilotLinksObj: [],
|
||||
CopilotsTable: {
|
||||
columns: [
|
||||
{
|
||||
name: 'theId',
|
||||
align: 'left',
|
||||
label: 'id',
|
||||
field: 'id'
|
||||
},
|
||||
{
|
||||
name: 'lnurl_toggle',
|
||||
align: 'left',
|
||||
label: 'Show lnurl pay link',
|
||||
field: 'lnurl_toggle'
|
||||
},
|
||||
{
|
||||
name: 'title',
|
||||
align: 'left',
|
||||
label: 'title',
|
||||
field: 'title'
|
||||
},
|
||||
{
|
||||
name: 'amount_made',
|
||||
align: 'left',
|
||||
label: 'amount made',
|
||||
field: 'amount_made'
|
||||
}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10
|
||||
}
|
||||
},
|
||||
passedCopilot: {},
|
||||
formDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
},
|
||||
formDialogCopilot: {
|
||||
show: false,
|
||||
data: {
|
||||
lnurl_toggle: false,
|
||||
show_message: false,
|
||||
show_ack: false,
|
||||
show_price: 'None',
|
||||
title: ''
|
||||
}
|
||||
},
|
||||
qrCodeDialog: {
|
||||
show: false,
|
||||
data: null
|
||||
},
|
||||
options: ['bitcoin', 'confetti', 'rocket', 'face', 'martijn', 'rick'],
|
||||
currencyOptions: ['None', 'btcusd', 'btceur', 'btcgbp']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
cancelCopilot: function (data) {
|
||||
var self = this
|
||||
self.formDialogCopilot.show = false
|
||||
self.clearFormDialogCopilot()
|
||||
},
|
||||
closeFormDialog: function () {
|
||||
this.clearFormDialogCopilot()
|
||||
this.formDialog.data = {
|
||||
is_unique: false
|
||||
}
|
||||
},
|
||||
sendFormDataCopilot: function () {
|
||||
var self = this
|
||||
if (self.formDialogCopilot.data.id) {
|
||||
this.updateCopilot(
|
||||
self.g.user.wallets[0].adminkey,
|
||||
self.formDialogCopilot.data
|
||||
)
|
||||
} else {
|
||||
this.createCopilot(
|
||||
self.g.user.wallets[0].adminkey,
|
||||
self.formDialogCopilot.data
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
createCopilot: function (wallet, data) {
|
||||
var self = this
|
||||
var updatedData = {}
|
||||
for (const property in data) {
|
||||
if (data[property]) {
|
||||
updatedData[property] = data[property]
|
||||
}
|
||||
if (property == 'animation1threshold' && data[property]) {
|
||||
updatedData[property] = parseInt(data[property])
|
||||
}
|
||||
if (property == 'animation2threshold' && data[property]) {
|
||||
updatedData[property] = parseInt(data[property])
|
||||
}
|
||||
if (property == 'animation3threshold' && data[property]) {
|
||||
updatedData[property] = parseInt(data[property])
|
||||
}
|
||||
}
|
||||
LNbits.api
|
||||
.request('POST', '/copilot/api/v1/copilot', wallet, updatedData)
|
||||
.then(function (response) {
|
||||
self.CopilotLinks.push(mapCopilot(response.data))
|
||||
self.formDialogCopilot.show = false
|
||||
self.clearFormDialogCopilot()
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
getCopilots: function () {
|
||||
var self = this
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/copilot/api/v1/copilot',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
if(response.data){
|
||||
self.CopilotLinks = response.data.map(mapCopilot)
|
||||
}
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
getCopilot: function (copilot_id) {
|
||||
var self = this
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/copilot/api/v1/copilot/' + copilot_id,
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
localStorage.setItem('copilot', JSON.stringify(response.data))
|
||||
localStorage.setItem('inkey', self.g.user.wallets[0].inkey)
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
openCopilotCompose: function (copilot_id) {
|
||||
this.getCopilot(copilot_id)
|
||||
let params =
|
||||
'scrollbars=no, resizable=no,status=no,location=no,toolbar=no,menubar=no,width=1200,height=644,left=410,top=100'
|
||||
open('../copilot/cp/', '_blank', params)
|
||||
},
|
||||
openCopilotPanel: function (copilot_id) {
|
||||
this.getCopilot(copilot_id)
|
||||
let params =
|
||||
'scrollbars=no, resizable=no,status=no,location=no,toolbar=no,menubar=no,width=300,height=450,left=10,top=400'
|
||||
open('../copilot/pn/', '_blank', params)
|
||||
},
|
||||
deleteCopilotLink: function (copilotId) {
|
||||
var self = this
|
||||
var link = _.findWhere(this.CopilotLinks, {id: copilotId})
|
||||
LNbits.utils
|
||||
.confirmDialog('Are you sure you want to delete this pay link?')
|
||||
.onOk(function () {
|
||||
LNbits.api
|
||||
.request(
|
||||
'DELETE',
|
||||
'/copilot/api/v1/copilot/' + copilotId,
|
||||
self.g.user.wallets[0].adminkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.CopilotLinks = _.reject(self.CopilotLinks, function (obj) {
|
||||
return obj.id === copilotId
|
||||
})
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
})
|
||||
},
|
||||
openUpdateCopilotLink: function (copilotId) {
|
||||
var self = this
|
||||
var copilot = _.findWhere(this.CopilotLinks, {id: copilotId})
|
||||
self.formDialogCopilot.data = _.clone(copilot._data)
|
||||
self.formDialogCopilot.show = true
|
||||
},
|
||||
updateCopilot: function (wallet, data) {
|
||||
var self = this
|
||||
var updatedData = {}
|
||||
for (const property in data) {
|
||||
if (data[property]) {
|
||||
updatedData[property] = data[property]
|
||||
}
|
||||
if (property == 'animation1threshold' && data[property]) {
|
||||
updatedData[property] = parseInt(data[property])
|
||||
}
|
||||
if (property == 'animation2threshold' && data[property]) {
|
||||
updatedData[property] = parseInt(data[property])
|
||||
}
|
||||
if (property == 'animation3threshold' && data[property]) {
|
||||
updatedData[property] = parseInt(data[property])
|
||||
}
|
||||
}
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'PUT',
|
||||
'/copilot/api/v1/copilot/' + updatedData.id,
|
||||
wallet,
|
||||
updatedData
|
||||
)
|
||||
.then(function (response) {
|
||||
self.CopilotLinks = _.reject(self.CopilotLinks, function (obj) {
|
||||
return obj.id === updatedData.id
|
||||
})
|
||||
self.CopilotLinks.push(mapCopilot(response.data))
|
||||
self.formDialogCopilot.show = false
|
||||
self.clearFormDialogCopilot()
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
clearFormDialogCopilot(){
|
||||
this.formDialogCopilot.data = {
|
||||
lnurl_toggle: false,
|
||||
show_message: false,
|
||||
show_ack: false,
|
||||
show_price: 'None',
|
||||
title: ''
|
||||
}
|
||||
},
|
||||
exportcopilotCSV: function () {
|
||||
var self = this
|
||||
LNbits.utils.exportCSV(self.CopilotsTable.columns, this.CopilotLinks)
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
var self = this
|
||||
var getCopilots = this.getCopilots
|
||||
getCopilots()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
@@ -1,157 +0,0 @@
|
||||
{% extends "public.html" %} {% block page %}
|
||||
<div class="q-pa-sm" style="width: 240px; margin: 10px auto">
|
||||
<q-card class="my-card">
|
||||
<div class="column">
|
||||
<div class="col">
|
||||
<center>
|
||||
<q-btn
|
||||
flat
|
||||
round
|
||||
dense
|
||||
@click="openCompose"
|
||||
icon="face"
|
||||
style="font-size: 60px"
|
||||
></q-btn>
|
||||
</center>
|
||||
</div>
|
||||
<center>
|
||||
<div class="col" style="margin: 15px; font-size: 22px">
|
||||
Title: {% raw %} {{ copilot.title }} {% endraw %}
|
||||
</div>
|
||||
</center>
|
||||
<q-separator></q-separator>
|
||||
<div class="col">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<q-btn
|
||||
class="q-mt-sm q-ml-sm"
|
||||
color="primary"
|
||||
@click="fullscreenToggle"
|
||||
label="Screen share"
|
||||
size="sm"
|
||||
>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-pa-sm">
|
||||
<div class="col">
|
||||
<q-btn
|
||||
style="width: 95%"
|
||||
color="primary"
|
||||
@click="animationBTN('rocket')"
|
||||
label="rocket"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="col">
|
||||
<q-btn
|
||||
style="width: 95%"
|
||||
color="primary"
|
||||
@click="animationBTN('confetti')"
|
||||
label="confetti"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="col">
|
||||
<q-btn
|
||||
style="width: 95%"
|
||||
color="primary"
|
||||
@click="animationBTN('face')"
|
||||
label="face"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-pa-sm">
|
||||
<div class="col">
|
||||
<q-btn
|
||||
style="width: 95%"
|
||||
color="primary"
|
||||
@click="animationBTN('rick')"
|
||||
label="rick"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="col">
|
||||
<q-btn
|
||||
style="width: 95%"
|
||||
color="primary"
|
||||
@click="animationBTN('martijn')"
|
||||
label="martijn"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="col">
|
||||
<q-btn
|
||||
style="width: 95%"
|
||||
color="primary"
|
||||
@click="animationBTN('bitcoin')"
|
||||
label="bitcoin"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
{% endblock %} {% block scripts %}
|
||||
<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() {
|
||||
return {
|
||||
fullscreen_cam: true,
|
||||
textareaModel: '',
|
||||
iframe: '',
|
||||
copilot: {}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
iframeChange: function (url) {
|
||||
this.connection.send(String(url))
|
||||
},
|
||||
fullscreenToggle: function () {
|
||||
self = this
|
||||
self.animationBTN(String(this.fullscreen_cam))
|
||||
if (this.fullscreen_cam) {
|
||||
this.fullscreen_cam = false
|
||||
} else {
|
||||
this.fullscreen_cam = true
|
||||
}
|
||||
},
|
||||
openCompose: function () {
|
||||
let params =
|
||||
'scrollbars=no, resizable=no,status=no,location=no,toolbar=no,menubar=no,width=1200,height=644,left=410,top=100'
|
||||
open('../cp/', 'test', params)
|
||||
},
|
||||
animationBTN: function (name) {
|
||||
self = this
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/copilot/api/v1/copilot/ws/' + self.copilot.id + '/none/' + name
|
||||
)
|
||||
.then(function (response1) {
|
||||
self.$q.notify({
|
||||
color: 'green',
|
||||
message: 'Sent!'
|
||||
})
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
self = this
|
||||
self.copilot = JSON.parse(localStorage.getItem('copilot'))
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
@@ -1,62 +0,0 @@
|
||||
from quart import g, abort, render_template, jsonify, websocket
|
||||
from http import HTTPStatus
|
||||
import httpx
|
||||
from collections import defaultdict
|
||||
from lnbits.decorators import check_user_exists, validate_uuids
|
||||
from . import copilot_ext
|
||||
from .crud import get_copilot
|
||||
from quart import g, abort, render_template, jsonify, websocket
|
||||
from functools import wraps
|
||||
import trio
|
||||
import shortuuid
|
||||
from . import copilot_ext
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
@copilot_ext.route("/")
|
||||
@validate_uuids(["usr"], required=True)
|
||||
@check_user_exists()
|
||||
async def index(request: Request):
|
||||
return await templates.TemplateResponse("copilot/index.html", {"request": request, "user":g.user})
|
||||
|
||||
@copilot_ext.route("/cp/")
|
||||
async def compose(request: Request):
|
||||
return await templates.TemplateResponse("copilot/compose.html", {"request": request})
|
||||
|
||||
@copilot_ext.route("/pn/")
|
||||
async def panel(request: Request):
|
||||
return await templates.TemplateResponse("copilot/panel.html", {"request": request})
|
||||
|
||||
|
||||
##################WEBSOCKET ROUTES########################
|
||||
|
||||
# socket_relay is a list where the control panel or
|
||||
# lnurl endpoints can leave a message for the compose window
|
||||
|
||||
connected_websockets = defaultdict(set)
|
||||
|
||||
|
||||
@copilot_ext.websocket("/ws/{id}/")
|
||||
async def wss(id):
|
||||
copilot = await get_copilot(id)
|
||||
if not copilot:
|
||||
return "", HTTPStatus.FORBIDDEN
|
||||
global connected_websockets
|
||||
send_channel, receive_channel = trio.open_memory_channel(0)
|
||||
connected_websockets[id].add(send_channel)
|
||||
try:
|
||||
while True:
|
||||
data = await receive_channel.receive()
|
||||
await websocket.send(data)
|
||||
finally:
|
||||
connected_websockets[id].remove(send_channel)
|
||||
|
||||
|
||||
async def updater(copilot_id, data, comment):
|
||||
copilot = await get_copilot(copilot_id)
|
||||
if not copilot:
|
||||
return
|
||||
for queue in connected_websockets[copilot_id]:
|
||||
await queue.send(f"{data + '-' + comment}")
|
@@ -1,104 +0,0 @@
|
||||
import hashlib
|
||||
from quart import g, jsonify, url_for, websocket
|
||||
from http import HTTPStatus
|
||||
import httpx
|
||||
|
||||
from lnbits.core.crud import get_user
|
||||
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
|
||||
from .views import updater
|
||||
|
||||
from . import copilot_ext
|
||||
|
||||
from lnbits.extensions.copilot import copilot_ext
|
||||
from .crud import (
|
||||
create_copilot,
|
||||
update_copilot,
|
||||
get_copilot,
|
||||
get_copilots,
|
||||
delete_copilot,
|
||||
)
|
||||
|
||||
#######################COPILOT##########################
|
||||
|
||||
class CreateData(BaseModel):
|
||||
title: str
|
||||
lnurl_toggle: Optional[int]
|
||||
wallet: Optional[str]
|
||||
animation1: Optional[str]
|
||||
animation2: Optional[str]
|
||||
animation3: Optional[str]
|
||||
animation1threshold: Optional[int]
|
||||
animation2threshold: Optional[int]
|
||||
animation2threshold: Optional[int]
|
||||
animation1webhook: Optional[str]
|
||||
animation2webhook: Optional[str]
|
||||
animation3webhook: Optional[str]
|
||||
lnurl_title: Optional[str]
|
||||
show_message: Optional[int]
|
||||
show_ack: Optional[int]
|
||||
show_price: Optional[str]
|
||||
|
||||
@copilot_ext.post("/api/v1/copilot")
|
||||
@copilot_ext.put("/api/v1/copilot/{copilot_id}")
|
||||
@api_check_wallet_key("admin")
|
||||
async def api_copilot_create_or_update(data: CreateData,copilot_id=None):
|
||||
if not copilot_id:
|
||||
copilot = await create_copilot(user=g.wallet.user, **data)
|
||||
return jsonify(copilot._asdict()), HTTPStatus.CREATED
|
||||
else:
|
||||
copilot = await update_copilot(copilot_id=copilot_id, **data)
|
||||
return jsonify(copilot._asdict()), HTTPStatus.OK
|
||||
|
||||
|
||||
@copilot_ext.get("/api/v1/copilot")
|
||||
@api_check_wallet_key("invoice")
|
||||
async def api_copilots_retrieve():
|
||||
try:
|
||||
return (
|
||||
[{**copilot._asdict()} for copilot in await get_copilots(g.wallet.user)],
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
except:
|
||||
return ""
|
||||
|
||||
|
||||
@copilot_ext.get("/api/v1/copilot/{copilot_id}")
|
||||
@api_check_wallet_key("invoice")
|
||||
async def api_copilot_retrieve(copilot_id):
|
||||
copilot = await get_copilot(copilot_id)
|
||||
if not copilot:
|
||||
return {"message": "copilot does not exist"}, HTTPStatus.NOT_FOUND
|
||||
if not copilot.lnurl_toggle:
|
||||
return (
|
||||
{**copilot._asdict()},
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
return (
|
||||
{**copilot._asdict(), **{"lnurl": copilot.lnurl}},
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
|
||||
@copilot_ext.delete("/api/v1/copilot/{copilot_id}")
|
||||
@api_check_wallet_key("admin")
|
||||
async def api_copilot_delete(copilot_id):
|
||||
copilot = await get_copilot(copilot_id)
|
||||
|
||||
if not copilot:
|
||||
return {"message": "Wallet link does not exist."}, HTTPStatus.NOT_FOUND
|
||||
|
||||
await delete_copilot(copilot_id)
|
||||
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
|
||||
|
||||
@copilot_ext.get("/api/v1/copilot/ws/{copilot_id}/{comment}/{data}")
|
||||
async def api_copilot_ws_relay(copilot_id, comment, data):
|
||||
copilot = await get_copilot(copilot_id)
|
||||
if not copilot:
|
||||
return {"message": "copilot does not exist"}, HTTPStatus.NOT_FOUND
|
||||
try:
|
||||
await updater(copilot_id, data, comment)
|
||||
except:
|
||||
return "", HTTPStatus.FORBIDDEN
|
||||
return "", HTTPStatus.OK
|
@@ -1,10 +0,0 @@
|
||||
<h1>Diagon Alley</h1>
|
||||
<h2>A movable market stand</h2>
|
||||
Make a list of products to sell, point the list to an indexer (or many), stack sats.
|
||||
Diagon Alley is a movable market stand, for anon transactions. You then give permission for an indexer to list those products. Delivery addresses are sent through the Lightning Network.
|
||||
<img src="https://i.imgur.com/P1tvBSG.png">
|
||||
|
||||
|
||||
<h2>API endpoints</h2>
|
||||
|
||||
<code>curl -X GET http://YOUR-TOR-ADDRESS</code>
|
@@ -1,10 +0,0 @@
|
||||
from quart import Blueprint
|
||||
|
||||
|
||||
diagonalley_ext: Blueprint = Blueprint(
|
||||
"diagonalley", __name__, static_folder="static", template_folder="templates"
|
||||
)
|
||||
|
||||
|
||||
from .views_api import * # noqa
|
||||
from .views import * # noqa
|
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "Diagon Alley",
|
||||
"short_description": "Movable anonymous market stand",
|
||||
"icon": "add_shopping_cart",
|
||||
"contributors": ["benarc"]
|
||||
}
|
@@ -1,308 +0,0 @@
|
||||
from base64 import urlsafe_b64encode
|
||||
from uuid import uuid4
|
||||
from typing import List, Optional, Union
|
||||
import httpx
|
||||
from lnbits.db import open_ext_db
|
||||
from lnbits.settings import WALLET
|
||||
from .models import Products, Orders, Indexers
|
||||
import re
|
||||
|
||||
regex = re.compile(
|
||||
r"^(?:http|ftp)s?://" # http:// or https://
|
||||
r"(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|"
|
||||
r"localhost|"
|
||||
r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})"
|
||||
r"(?::\d+)?"
|
||||
r"(?:/?|[/?]\S+)$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
###Products
|
||||
|
||||
|
||||
def create_diagonalleys_product(
|
||||
*,
|
||||
wallet_id: str,
|
||||
product: str,
|
||||
categories: str,
|
||||
description: str,
|
||||
image: str,
|
||||
price: int,
|
||||
quantity: int,
|
||||
) -> Products:
|
||||
with open_ext_db("diagonalley") as db:
|
||||
product_id = urlsafe_b64encode(uuid4().bytes_le).decode("utf-8")
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO diagonalley.products (id, wallet, product, categories, description, image, price, quantity)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
product_id,
|
||||
wallet_id,
|
||||
product,
|
||||
categories,
|
||||
description,
|
||||
image,
|
||||
price,
|
||||
quantity,
|
||||
),
|
||||
)
|
||||
|
||||
return get_diagonalleys_product(product_id)
|
||||
|
||||
|
||||
def update_diagonalleys_product(product_id: str, **kwargs) -> Optional[Indexers]:
|
||||
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
||||
|
||||
with open_ext_db("diagonalley") as db:
|
||||
db.execute(
|
||||
f"UPDATE diagonalley.products SET {q} WHERE id = ?",
|
||||
(*kwargs.values(), product_id),
|
||||
)
|
||||
row = db.fetchone(
|
||||
"SELECT * FROM diagonalley.products WHERE id = ?", (product_id,)
|
||||
)
|
||||
|
||||
return get_diagonalleys_indexer(product_id)
|
||||
|
||||
|
||||
def get_diagonalleys_product(product_id: str) -> Optional[Products]:
|
||||
with open_ext_db("diagonalley") as db:
|
||||
row = db.fetchone(
|
||||
"SELECT * FROM diagonalley.products WHERE id = ?", (product_id,)
|
||||
)
|
||||
|
||||
return Products(**row) if row else None
|
||||
|
||||
|
||||
def get_diagonalleys_products(wallet_ids: Union[str, List[str]]) -> List[Products]:
|
||||
if isinstance(wallet_ids, str):
|
||||
wallet_ids = [wallet_ids]
|
||||
|
||||
with open_ext_db("diagonalley") as db:
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = db.fetchall(
|
||||
f"SELECT * FROM diagonalley.products WHERE wallet IN ({q})", (*wallet_ids,)
|
||||
)
|
||||
|
||||
return [Products(**row) for row in rows]
|
||||
|
||||
|
||||
def delete_diagonalleys_product(product_id: str) -> None:
|
||||
with open_ext_db("diagonalley") as db:
|
||||
db.execute("DELETE FROM diagonalley.products WHERE id = ?", (product_id,))
|
||||
|
||||
|
||||
###Indexers
|
||||
|
||||
|
||||
def create_diagonalleys_indexer(
|
||||
wallet_id: str,
|
||||
shopname: str,
|
||||
indexeraddress: str,
|
||||
shippingzone1: str,
|
||||
shippingzone2: str,
|
||||
zone1cost: int,
|
||||
zone2cost: int,
|
||||
email: str,
|
||||
) -> Indexers:
|
||||
with open_ext_db("diagonalley") as db:
|
||||
indexer_id = urlsafe_b64encode(uuid4().bytes_le).decode("utf-8")
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO diagonalley.indexers (id, wallet, shopname, indexeraddress, online, rating, shippingzone1, shippingzone2, zone1cost, zone2cost, email)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
indexer_id,
|
||||
wallet_id,
|
||||
shopname,
|
||||
indexeraddress,
|
||||
False,
|
||||
0,
|
||||
shippingzone1,
|
||||
shippingzone2,
|
||||
zone1cost,
|
||||
zone2cost,
|
||||
email,
|
||||
),
|
||||
)
|
||||
return get_diagonalleys_indexer(indexer_id)
|
||||
|
||||
|
||||
def update_diagonalleys_indexer(indexer_id: str, **kwargs) -> Optional[Indexers]:
|
||||
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
||||
|
||||
with open_ext_db("diagonalley") as db:
|
||||
db.execute(
|
||||
f"UPDATE diagonalley.indexers SET {q} WHERE id = ?",
|
||||
(*kwargs.values(), indexer_id),
|
||||
)
|
||||
row = db.fetchone(
|
||||
"SELECT * FROM diagonalley.indexers WHERE id = ?", (indexer_id,)
|
||||
)
|
||||
|
||||
return get_diagonalleys_indexer(indexer_id)
|
||||
|
||||
|
||||
def get_diagonalleys_indexer(indexer_id: str) -> Optional[Indexers]:
|
||||
with open_ext_db("diagonalley") as db:
|
||||
roww = db.fetchone(
|
||||
"SELECT * FROM diagonalley.indexers WHERE id = ?", (indexer_id,)
|
||||
)
|
||||
try:
|
||||
x = httpx.get(roww["indexeraddress"] + "/" + roww["ratingkey"])
|
||||
if x.status_code == 200:
|
||||
print(x)
|
||||
print("poo")
|
||||
with open_ext_db("diagonalley") as db:
|
||||
db.execute(
|
||||
"UPDATE diagonalley.indexers SET online = ? WHERE id = ?",
|
||||
(
|
||||
True,
|
||||
indexer_id,
|
||||
),
|
||||
)
|
||||
else:
|
||||
with open_ext_db("diagonalley") as db:
|
||||
db.execute(
|
||||
"UPDATE diagonalley.indexers SET online = ? WHERE id = ?",
|
||||
(
|
||||
False,
|
||||
indexer_id,
|
||||
),
|
||||
)
|
||||
except:
|
||||
print("An exception occurred")
|
||||
with open_ext_db("diagonalley") as db:
|
||||
row = db.fetchone(
|
||||
"SELECT * FROM diagonalley.indexers WHERE id = ?", (indexer_id,)
|
||||
)
|
||||
return Indexers(**row) if row else None
|
||||
|
||||
|
||||
def get_diagonalleys_indexers(wallet_ids: Union[str, List[str]]) -> List[Indexers]:
|
||||
if isinstance(wallet_ids, str):
|
||||
wallet_ids = [wallet_ids]
|
||||
|
||||
with open_ext_db("diagonalley") as db:
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = db.fetchall(
|
||||
f"SELECT * FROM diagonalley.indexers WHERE wallet IN ({q})", (*wallet_ids,)
|
||||
)
|
||||
|
||||
for r in rows:
|
||||
try:
|
||||
x = httpx.get(r["indexeraddress"] + "/" + r["ratingkey"])
|
||||
if x.status_code == 200:
|
||||
with open_ext_db("diagonalley") as db:
|
||||
db.execute(
|
||||
"UPDATE diagonalley.indexers SET online = ? WHERE id = ?",
|
||||
(
|
||||
True,
|
||||
r["id"],
|
||||
),
|
||||
)
|
||||
else:
|
||||
with open_ext_db("diagonalley") as db:
|
||||
db.execute(
|
||||
"UPDATE diagonalley.indexers SET online = ? WHERE id = ?",
|
||||
(
|
||||
False,
|
||||
r["id"],
|
||||
),
|
||||
)
|
||||
except:
|
||||
print("An exception occurred")
|
||||
with open_ext_db("diagonalley") as db:
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = db.fetchall(
|
||||
f"SELECT * FROM diagonalley.indexers WHERE wallet IN ({q})", (*wallet_ids,)
|
||||
)
|
||||
return [Indexers(**row) for row in rows]
|
||||
|
||||
|
||||
def delete_diagonalleys_indexer(indexer_id: str) -> None:
|
||||
with open_ext_db("diagonalley") as db:
|
||||
db.execute("DELETE FROM diagonalley.indexers WHERE id = ?", (indexer_id,))
|
||||
|
||||
|
||||
###Orders
|
||||
|
||||
|
||||
def create_diagonalleys_order(
|
||||
*,
|
||||
productid: str,
|
||||
wallet: str,
|
||||
product: str,
|
||||
quantity: int,
|
||||
shippingzone: str,
|
||||
address: str,
|
||||
email: str,
|
||||
invoiceid: str,
|
||||
paid: bool,
|
||||
shipped: bool,
|
||||
) -> Indexers:
|
||||
with open_ext_db("diagonalley") as db:
|
||||
order_id = urlsafe_b64encode(uuid4().bytes_le).decode("utf-8")
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO diagonalley.orders (id, productid, wallet, product, quantity, shippingzone, address, email, invoiceid, paid, shipped)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
order_id,
|
||||
productid,
|
||||
wallet,
|
||||
product,
|
||||
quantity,
|
||||
shippingzone,
|
||||
address,
|
||||
email,
|
||||
invoiceid,
|
||||
False,
|
||||
False,
|
||||
),
|
||||
)
|
||||
|
||||
return get_diagonalleys_order(order_id)
|
||||
|
||||
|
||||
def get_diagonalleys_order(order_id: str) -> Optional[Orders]:
|
||||
with open_ext_db("diagonalley") as db:
|
||||
row = db.fetchone("SELECT * FROM diagonalley.orders WHERE id = ?", (order_id,))
|
||||
|
||||
return Orders(**row) if row else None
|
||||
|
||||
|
||||
def get_diagonalleys_orders(wallet_ids: Union[str, List[str]]) -> List[Orders]:
|
||||
if isinstance(wallet_ids, str):
|
||||
wallet_ids = [wallet_ids]
|
||||
|
||||
with open_ext_db("diagonalley") as db:
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = db.fetchall(
|
||||
f"SELECT * FROM diagonalley.orders WHERE wallet IN ({q})", (*wallet_ids,)
|
||||
)
|
||||
for r in rows:
|
||||
PAID = (await WALLET.get_invoice_status(r["invoiceid"])).paid
|
||||
if PAID:
|
||||
with open_ext_db("diagonalley") as db:
|
||||
db.execute(
|
||||
"UPDATE diagonalley.orders SET paid = ? WHERE id = ?",
|
||||
(
|
||||
True,
|
||||
r["id"],
|
||||
),
|
||||
)
|
||||
rows = db.fetchall(
|
||||
f"SELECT * FROM diagonalley.orders WHERE wallet IN ({q})",
|
||||
(*wallet_ids,),
|
||||
)
|
||||
return [Orders(**row) for row in rows]
|
||||
|
||||
|
||||
def delete_diagonalleys_order(order_id: str) -> None:
|
||||
with open_ext_db("diagonalley") as db:
|
||||
db.execute("DELETE FROM diagonalley.orders WHERE id = ?", (order_id,))
|
@@ -1,60 +0,0 @@
|
||||
async def m001_initial(db):
|
||||
"""
|
||||
Initial products table.
|
||||
"""
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE diagonalley.products (
|
||||
id TEXT PRIMARY KEY,
|
||||
wallet TEXT NOT NULL,
|
||||
product TEXT NOT NULL,
|
||||
categories TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
image TEXT NOT NULL,
|
||||
price INTEGER NOT NULL,
|
||||
quantity INTEGER NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
"""
|
||||
Initial indexers table.
|
||||
"""
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE diagonalley.indexers (
|
||||
id TEXT PRIMARY KEY,
|
||||
wallet TEXT NOT NULL,
|
||||
shopname TEXT NOT NULL,
|
||||
indexeraddress TEXT NOT NULL,
|
||||
online BOOLEAN NOT NULL,
|
||||
rating INTEGER NOT NULL,
|
||||
shippingzone1 TEXT NOT NULL,
|
||||
shippingzone2 TEXT NOT NULL,
|
||||
zone1cost INTEGER NOT NULL,
|
||||
zone2cost INTEGER NOT NULL,
|
||||
email TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
"""
|
||||
Initial orders table.
|
||||
"""
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE diagonalley.orders (
|
||||
id TEXT PRIMARY KEY,
|
||||
productid TEXT NOT NULL,
|
||||
wallet TEXT NOT NULL,
|
||||
product TEXT NOT NULL,
|
||||
quantity INTEGER NOT NULL,
|
||||
shippingzone INTEGER NOT NULL,
|
||||
address TEXT NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
invoiceid TEXT NOT NULL,
|
||||
paid BOOLEAN NOT NULL,
|
||||
shipped BOOLEAN NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
@@ -1,41 +0,0 @@
|
||||
from typing import NamedTuple
|
||||
from sqlite3 import Row
|
||||
from pydantic import BaseModel
|
||||
|
||||
class Indexers(BaseModel):
|
||||
id: str
|
||||
wallet: str
|
||||
shopname: str
|
||||
indexeraddress: str
|
||||
online: bool
|
||||
rating: str
|
||||
shippingzone1: str
|
||||
shippingzone2: str
|
||||
zone1cost: int
|
||||
zone2cost: int
|
||||
email: str
|
||||
|
||||
|
||||
class Products(BaseModel):
|
||||
id: str
|
||||
wallet: str
|
||||
product: str
|
||||
categories: str
|
||||
description: str
|
||||
image: str
|
||||
price: int
|
||||
quantity: int
|
||||
|
||||
|
||||
class Orders(BaseModel):
|
||||
id: str
|
||||
productid: str
|
||||
wallet: str
|
||||
product: str
|
||||
quantity: int
|
||||
shippingzone: int
|
||||
address: str
|
||||
email: str
|
||||
invoiceid: str
|
||||
paid: bool
|
||||
shipped: bool
|
@@ -1,122 +0,0 @@
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="swap_vertical_circle"
|
||||
label="Info"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h5 class="text-subtitle1 q-my-none">
|
||||
Diagon Alley: Decentralised Market-Stalls
|
||||
</h5>
|
||||
<p>
|
||||
Make a list of products to sell, point your list of products at a public
|
||||
indexer. Buyers browse your products on the indexer, and pay you
|
||||
directly. Ratings are managed by the indexer. Your stall can be listed
|
||||
in multiple indexers, even over TOR, if you wish to be anonymous.<br />
|
||||
More information on the
|
||||
<a href="https://github.com/lnbits/Diagon-Alley"
|
||||
>Diagon Alley Protocol</a
|
||||
><br />
|
||||
<small>
|
||||
Created by, <a href="https://github.com/benarc">Ben Arc</a></small
|
||||
>
|
||||
</p>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<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="Get prodcuts, categorised by wallet"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-light-blue">GET</span>
|
||||
/api/v1/diagonalley/stall/products/<indexer_id></code
|
||||
>
|
||||
<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 201 CREATED (application/json)
|
||||
</h5>
|
||||
<code>Product JSON list</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.url_root
|
||||
}}diagonalley/api/v1/diagonalley/stall/products/<indexer_id></code
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Get invoice for product"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-light-green">POST</span>
|
||||
/api/v1/diagonalley/stall/order/<indexer_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<code
|
||||
>{"id": <string>, "address": <string>, "shippingzone":
|
||||
<integer>, "email": <string>, "quantity":
|
||||
<integer>}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 201 CREATED (application/json)
|
||||
</h5>
|
||||
<code
|
||||
>{"checking_id": <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
|
||||
}}diagonalley/api/v1/diagonalley/stall/order/<indexer_id> -d
|
||||
'{"id": <product_id&>, "email": <customer_email>,
|
||||
"address": <customer_address>, "quantity": 2, "shippingzone":
|
||||
1}' -H "Content-type: application/json"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Check a product has been shipped"
|
||||
class="q-mb-md"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-light-blue">GET</span>
|
||||
/diagonalley/api/v1/diagonalley/stall/checkshipped/<checking_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>{"shipped": <boolean>}</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.url_root
|
||||
}}diagonalley/api/v1/diagonalley/stall/checkshipped/<checking_id>
|
||||
-H "Content-type: application/json"</code
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
</q-expansion-item>
|
@@ -1,906 +0,0 @@
|
||||
{% 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="productDialog.show = true"
|
||||
>New Product</q-btn
|
||||
>
|
||||
<q-btn unelevated color="primary" @click="indexerDialog.show = true"
|
||||
>New Indexer
|
||||
<q-tooltip>
|
||||
Frontend shop your stall will list its products in
|
||||
</q-tooltip></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">Products</h5>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn flat color="grey" @click="exportProductsCSV"
|
||||
>Export to CSV</q-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="products"
|
||||
row-key="id"
|
||||
:columns="productsTable.columns"
|
||||
:pagination.sync="productsTable.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="add_shopping_cart"
|
||||
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||
type="a"
|
||||
:href="props.row.wallet"
|
||||
target="_blank"
|
||||
></q-btn>
|
||||
<q-tooltip> Link to pass to stall indexer </q-tooltip>
|
||||
</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="openProductUpdateDialog(props.row.id)"
|
||||
icon="edit"
|
||||
color="light-blue"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="deleteProduct(props.row.id)"
|
||||
icon="cancel"
|
||||
color="pink"
|
||||
></q-btn>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
{% endraw %}
|
||||
</q-table>
|
||||
</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">Indexers</h5>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn flat color="grey" @click="exportIndexersCSV"
|
||||
>Export to CSV</q-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="indexers"
|
||||
row-key="id"
|
||||
:columns="indexersTable.columns"
|
||||
:pagination.sync="indexersTable.pagination"
|
||||
>
|
||||
{% raw %}
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<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 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="openIndexerUpdateDialog(props.row.id)"
|
||||
icon="edit"
|
||||
color="light-blue"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="deleteIndexer(props.row.id)"
|
||||
icon="cancel"
|
||||
color="pink"
|
||||
></q-btn>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
{% endraw %}
|
||||
</q-table>
|
||||
</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">Orders</h5>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn flat color="grey" @click="exportOrdersCSV"
|
||||
>Export to CSV</q-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="orders"
|
||||
row-key="id"
|
||||
:columns="ordersTable.columns"
|
||||
:pagination.sync="ordersTable.pagination"
|
||||
>
|
||||
{% raw %}
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<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 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="shipOrder(props.row.id)"
|
||||
icon="add_shopping_cart"
|
||||
color="green"
|
||||
>
|
||||
<q-tooltip> Product shipped? </q-tooltip>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="deleteOrder(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">LNbits Diagon Alley Extension</h6>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-separator></q-separator>
|
||||
<q-list> {% include "diagonalley/_api_docs.html" %} </q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<q-dialog v-model="productDialog.show" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
|
||||
<q-form @submit="sendProductFormData" class="q-gutter-md">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model="productDialog.data.wallet"
|
||||
:options="g.user.walletOptions"
|
||||
label="Wallet *"
|
||||
>
|
||||
</q-select>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="productDialog.data.product"
|
||||
label="Product"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="productDialog.data.categories"
|
||||
placeholder="cakes, guns, wool, drugs"
|
||||
label="Categories seperated by comma"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="productDialog.data.description"
|
||||
label="Description"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="productDialog.data.image"
|
||||
label="Image"
|
||||
placeholder="Imagur link (max 500/500px)"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="productDialog.data.price"
|
||||
type="number"
|
||||
label="Price"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="productDialog.data.quantity"
|
||||
type="number"
|
||||
label="Quantity"
|
||||
></q-input>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
v-if="productDialog.data.id"
|
||||
unelevated
|
||||
color="primary"
|
||||
type="submit"
|
||||
>Update Product</q-btn
|
||||
>
|
||||
|
||||
<q-btn
|
||||
v-else
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="productDialog.data.image == null
|
||||
|| productDialog.data.product == null
|
||||
|| productDialog.data.description == null
|
||||
|| productDialog.data.quantity == null"
|
||||
type="submit"
|
||||
>Create Product</q-btn
|
||||
>
|
||||
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="indexerDialog.show" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
|
||||
<q-form @submit="sendIndexerFormData" class="q-gutter-md">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model="indexerDialog.data.wallet"
|
||||
:options="g.user.walletOptions"
|
||||
label="Wallet *"
|
||||
>
|
||||
</q-select>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="indexerDialog.data.shopname"
|
||||
label="Shop Name"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="indexerDialog.data.indexeraddress"
|
||||
label="Shop link (LNbits will point to)"
|
||||
></q-input>
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
v-model.trim="indexerDialog.data.shippingzone1"
|
||||
multiple
|
||||
:options="shippingoptions"
|
||||
label="Shipping Zone 1"
|
||||
></q-select>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="indexerDialog.data.zone1cost"
|
||||
type="number"
|
||||
label="Zone 1 Cost"
|
||||
></q-input>
|
||||
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
v-model.trim="indexerDialog.data.shippingzone2"
|
||||
multiple
|
||||
:options="shippingoptions"
|
||||
label="Shipping Zone 2"
|
||||
></q-select>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="indexerDialog.data.zone2cost"
|
||||
type="number"
|
||||
label="Zone 2 Cost"
|
||||
></q-input>
|
||||
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="indexerDialog.data.email"
|
||||
label="Email to share with customers"
|
||||
></q-input>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
v-if="indexerDialog.data.id"
|
||||
unelevated
|
||||
color="primary"
|
||||
type="submit"
|
||||
>Update Indexer</q-btn
|
||||
>
|
||||
|
||||
<q-btn
|
||||
v-else
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="indexerDialog.data.shopname == null
|
||||
|| indexerDialog.data.shippingzone1 == null
|
||||
|| indexerDialog.data.indexeraddress == null
|
||||
|| indexerDialog.data.zone1cost == null
|
||||
|| indexerDialog.data.shippingzone2 == null
|
||||
|| indexerDialog.data.zone2cost == null
|
||||
|| indexerDialog.data.email == null"
|
||||
type="submit"
|
||||
>Create Indexer</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 mapDiagonAlley = 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 = ['/diagonalley/', obj.id].join('')
|
||||
obj._data = _.clone(obj)
|
||||
return obj
|
||||
}
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
products: [],
|
||||
orders: [],
|
||||
indexers: [],
|
||||
shippedModel: false,
|
||||
shippingoptions: [
|
||||
'Australia',
|
||||
'Austria',
|
||||
'Belgium',
|
||||
'Brazil',
|
||||
'Canada',
|
||||
'Denmark',
|
||||
'Finland',
|
||||
'France*',
|
||||
'Germany',
|
||||
'Greece',
|
||||
'Hong Kong',
|
||||
'Hungary',
|
||||
'Ireland',
|
||||
'Indonesia',
|
||||
'Israel',
|
||||
'Italy',
|
||||
'Japan',
|
||||
'Kazakhstan',
|
||||
'Korea',
|
||||
'Luxembourg',
|
||||
'Malaysia',
|
||||
'Mexico',
|
||||
'Netherlands',
|
||||
'New Zealand',
|
||||
'Norway',
|
||||
'Poland',
|
||||
'Portugal',
|
||||
'Russia',
|
||||
'Saudi Arabia',
|
||||
'Singapore',
|
||||
'Spain',
|
||||
'Sweden',
|
||||
'Switzerland',
|
||||
'Thailand',
|
||||
'Turkey',
|
||||
'Ukraine',
|
||||
'United Kingdom**',
|
||||
'United States***',
|
||||
'Vietnam',
|
||||
'China'
|
||||
],
|
||||
label: '',
|
||||
indexersTable: {
|
||||
columns: [
|
||||
{name: 'shopname', align: 'left', label: 'Shop', field: 'shopname'},
|
||||
{
|
||||
name: 'indexeraddress',
|
||||
align: 'left',
|
||||
label: 'Address',
|
||||
field: 'indexeraddress'
|
||||
},
|
||||
{name: 'id', align: 'left', label: 'ID', field: 'id'},
|
||||
{
|
||||
name: 'rating',
|
||||
align: 'left',
|
||||
label: 'Your Rating',
|
||||
field: 'rating'
|
||||
},
|
||||
{name: 'email', align: 'left', label: 'Your email', field: 'email'},
|
||||
{name: 'online', align: 'left', label: 'Online', field: 'online'}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10
|
||||
}
|
||||
},
|
||||
ordersTable: {
|
||||
columns: [
|
||||
{
|
||||
name: 'product',
|
||||
align: 'left',
|
||||
label: 'Product',
|
||||
field: 'product'
|
||||
},
|
||||
{
|
||||
name: 'quantity',
|
||||
align: 'left',
|
||||
label: 'Quantity',
|
||||
field: 'quantity'
|
||||
},
|
||||
{
|
||||
name: 'address',
|
||||
align: 'left',
|
||||
label: 'Address',
|
||||
field: 'address'
|
||||
},
|
||||
{
|
||||
name: 'invoiceid',
|
||||
align: 'left',
|
||||
label: 'InvoiceID',
|
||||
field: 'invoiceid'
|
||||
},
|
||||
{name: 'paid', align: 'left', label: 'Paid', field: 'paid'},
|
||||
{name: 'shipped', align: 'left', label: 'Shipped', field: 'shipped'}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10
|
||||
}
|
||||
},
|
||||
productsTable: {
|
||||
columns: [
|
||||
{
|
||||
name: 'product',
|
||||
align: 'left',
|
||||
label: 'Product',
|
||||
field: 'product'
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
align: 'left',
|
||||
label: 'Description',
|
||||
field: 'description'
|
||||
},
|
||||
{
|
||||
name: 'categories',
|
||||
align: 'left',
|
||||
label: 'Categories',
|
||||
field: 'categories'
|
||||
},
|
||||
{name: 'image', align: 'left', label: 'Image', field: 'image'},
|
||||
{name: 'price', align: 'left', label: 'Price', field: 'price'},
|
||||
{
|
||||
name: 'quantity',
|
||||
align: 'left',
|
||||
label: 'Quantity',
|
||||
field: 'quantity'
|
||||
},
|
||||
{name: 'id', align: 'left', label: 'ID', field: 'id'},
|
||||
{name: 'wallet', align: 'left', label: 'Wallet', field: 'wallet'}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10
|
||||
}
|
||||
},
|
||||
productDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
},
|
||||
orderDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
},
|
||||
indexerDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getIndexers: function () {
|
||||
var self = this
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/diagonalley/api/v1/diagonalley/indexers?all_wallets',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.indexers = response.data.map(function (obj) {
|
||||
return mapDiagonAlley(obj)
|
||||
})
|
||||
})
|
||||
},
|
||||
openIndexerUpdateDialog: function (linkId) {
|
||||
var link = _.findWhere(this.indexers, {id: linkId})
|
||||
|
||||
this.indexerDialog.data = _.clone(link._data)
|
||||
this.indexerDialog.show = true
|
||||
},
|
||||
sendIndexerFormData: function () {
|
||||
if (this.indexerDialog.data.id) {
|
||||
} else {
|
||||
var data = {
|
||||
shopname: this.indexerDialog.data.shopname,
|
||||
indexeraddress: this.indexerDialog.data.indexeraddress,
|
||||
shippingzone1: this.indexerDialog.data.shippingzone1.join(', '),
|
||||
zone1cost: this.indexerDialog.data.zone1cost,
|
||||
shippingzone2: this.indexerDialog.data.shippingzone2.join(', '),
|
||||
zone2cost: this.indexerDialog.data.zone2cost,
|
||||
email: this.indexerDialog.data.email
|
||||
}
|
||||
}
|
||||
|
||||
if (this.indexerDialog.data.id) {
|
||||
this.updateIndexer(this.indexerDialog.data)
|
||||
} else {
|
||||
this.createIndexer(data)
|
||||
}
|
||||
},
|
||||
updateIndexer: function (data) {
|
||||
var self = this
|
||||
LNbits.api
|
||||
.request(
|
||||
'PUT',
|
||||
'/diagonalley/api/v1/diagonalley/indexers' + data.id,
|
||||
_.findWhere(this.g.user.wallets, {
|
||||
id: this.indexerDialog.data.wallet
|
||||
}).inkey,
|
||||
_.pick(
|
||||
data,
|
||||
'shopname',
|
||||
'indexeraddress',
|
||||
'shippingzone1',
|
||||
'zone1cost',
|
||||
'shippingzone2',
|
||||
'zone2cost',
|
||||
'email'
|
||||
)
|
||||
)
|
||||
.then(function (response) {
|
||||
self.indexers = _.reject(self.indexers, function (obj) {
|
||||
return obj.id == data.id
|
||||
})
|
||||
self.indexers.push(mapDiagonAlley(response.data))
|
||||
self.indexerDialog.show = false
|
||||
self.indexerDialog.data = {}
|
||||
data = {}
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
createIndexer: function (data) {
|
||||
var self = this
|
||||
LNbits.api
|
||||
.request(
|
||||
'POST',
|
||||
'/diagonalley/api/v1/diagonalley/indexers',
|
||||
_.findWhere(this.g.user.wallets, {
|
||||
id: this.indexerDialog.data.wallet
|
||||
}).inkey,
|
||||
data
|
||||
)
|
||||
.then(function (response) {
|
||||
self.indexers.push(mapDiagonAlley(response.data))
|
||||
self.indexerDialog.show = false
|
||||
self.indexerDialog.data = {}
|
||||
data = {}
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
deleteIndexer: function (indexerId) {
|
||||
var self = this
|
||||
var indexer = _.findWhere(this.indexers, {id: indexerId})
|
||||
|
||||
LNbits.utils
|
||||
.confirmDialog('Are you sure you want to delete this Indexer link?')
|
||||
.onOk(function () {
|
||||
LNbits.api
|
||||
.request(
|
||||
'DELETE',
|
||||
'/diagonalley/api/v1/diagonalley/indexers/' + indexerId,
|
||||
_.findWhere(self.g.user.wallets, {id: indexer.wallet}).inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.indexers = _.reject(self.indexers, function (obj) {
|
||||
return obj.id == indexerId
|
||||
})
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
})
|
||||
},
|
||||
exportIndexersCSV: function () {
|
||||
LNbits.utils.exportCSV(this.indexersTable.columns, this.indexers)
|
||||
},
|
||||
getOrders: function () {
|
||||
var self = this
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/diagonalley/api/v1/diagonalley/orders?all_wallets',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.orders = response.data.map(function (obj) {
|
||||
return mapDiagonAlley(obj)
|
||||
})
|
||||
})
|
||||
},
|
||||
createOrder: function () {
|
||||
var data = {
|
||||
address: this.orderDialog.data.address,
|
||||
email: this.orderDialog.data.email,
|
||||
quantity: this.orderDialog.data.quantity,
|
||||
shippingzone: this.orderDialog.data.shippingzone
|
||||
}
|
||||
var self = this
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'POST',
|
||||
'/diagonalley/api/v1/diagonalley/orders',
|
||||
_.findWhere(this.g.user.wallets, {id: this.orderDialog.data.wallet})
|
||||
.inkey,
|
||||
data
|
||||
)
|
||||
.then(function (response) {
|
||||
self.orders.push(mapDiagonAlley(response.data))
|
||||
self.orderDialog.show = false
|
||||
self.orderDialog.data = {}
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
deleteOrder: function (orderId) {
|
||||
var self = this
|
||||
var order = _.findWhere(this.orders, {id: orderId})
|
||||
|
||||
LNbits.utils
|
||||
.confirmDialog('Are you sure you want to delete this order link?')
|
||||
.onOk(function () {
|
||||
LNbits.api
|
||||
.request(
|
||||
'DELETE',
|
||||
'/diagonalley/api/v1/diagonalley/orders/' + orderId,
|
||||
_.findWhere(self.g.user.wallets, {id: order.wallet}).inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.orders = _.reject(self.orders, function (obj) {
|
||||
return obj.id == orderId
|
||||
})
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
})
|
||||
},
|
||||
shipOrder: function (order_id) {
|
||||
var self = this
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/diagonalley/api/v1/diagonalley/orders/shipped/' + order_id,
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.orders = response.data.map(function (obj) {
|
||||
return mapDiagonAlley(obj)
|
||||
})
|
||||
})
|
||||
},
|
||||
exportOrdersCSV: function () {
|
||||
LNbits.utils.exportCSV(this.ordersTable.columns, this.orders)
|
||||
},
|
||||
getProducts: function () {
|
||||
var self = this
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/diagonalley/api/v1/diagonalley/products?all_wallets',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.products = response.data.map(function (obj) {
|
||||
return mapDiagonAlley(obj)
|
||||
})
|
||||
})
|
||||
},
|
||||
openProductUpdateDialog: function (linkId) {
|
||||
var link = _.findWhere(this.products, {id: linkId})
|
||||
|
||||
this.productDialog.data = _.clone(link._data)
|
||||
this.productDialog.show = true
|
||||
},
|
||||
sendProductFormData: function () {
|
||||
if (this.productDialog.data.id) {
|
||||
} else {
|
||||
var data = {
|
||||
product: this.productDialog.data.product,
|
||||
categories: this.productDialog.data.categories,
|
||||
description: this.productDialog.data.description,
|
||||
image: this.productDialog.data.image,
|
||||
price: this.productDialog.data.price,
|
||||
quantity: this.productDialog.data.quantity
|
||||
}
|
||||
}
|
||||
if (this.productDialog.data.id) {
|
||||
this.updateProduct(this.productDialog.data)
|
||||
} else {
|
||||
this.createProduct(data)
|
||||
}
|
||||
},
|
||||
updateProduct: function (data) {
|
||||
var self = this
|
||||
LNbits.api
|
||||
.request(
|
||||
'PUT',
|
||||
'/diagonalley/api/v1/diagonalley/products' + data.id,
|
||||
_.findWhere(this.g.user.wallets, {
|
||||
id: this.productDialog.data.wallet
|
||||
}).inkey,
|
||||
_.pick(
|
||||
data,
|
||||
'shopname',
|
||||
'indexeraddress',
|
||||
'shippingzone1',
|
||||
'zone1cost',
|
||||
'shippingzone2',
|
||||
'zone2cost',
|
||||
'email'
|
||||
)
|
||||
)
|
||||
.then(function (response) {
|
||||
self.products = _.reject(self.products, function (obj) {
|
||||
return obj.id == data.id
|
||||
})
|
||||
self.products.push(mapDiagonAlley(response.data))
|
||||
self.productDialog.show = false
|
||||
self.productDialog.data = {}
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
createProduct: function (data) {
|
||||
var self = this
|
||||
LNbits.api
|
||||
.request(
|
||||
'POST',
|
||||
'/diagonalley/api/v1/diagonalley/products',
|
||||
_.findWhere(this.g.user.wallets, {
|
||||
id: this.productDialog.data.wallet
|
||||
}).inkey,
|
||||
data
|
||||
)
|
||||
.then(function (response) {
|
||||
self.products.push(mapDiagonAlley(response.data))
|
||||
self.productDialog.show = false
|
||||
self.productDialog.data = {}
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
deleteProduct: function (productId) {
|
||||
var self = this
|
||||
var product = _.findWhere(this.products, {id: productId})
|
||||
|
||||
LNbits.utils
|
||||
.confirmDialog('Are you sure you want to delete this products link?')
|
||||
.onOk(function () {
|
||||
LNbits.api
|
||||
.request(
|
||||
'DELETE',
|
||||
'/diagonalley/api/v1/diagonalley/products/' + productId,
|
||||
_.findWhere(self.g.user.wallets, {id: product.wallet}).inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.products = _.reject(self.products, function (obj) {
|
||||
return obj.id == productId
|
||||
})
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
})
|
||||
},
|
||||
exportProductsCSV: function () {
|
||||
LNbits.utils.exportCSV(this.productsTable.columns, this.products)
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
if (this.g.user.wallets.length) {
|
||||
this.getProducts()
|
||||
this.getOrders()
|
||||
this.getIndexers()
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
@@ -1,3 +0,0 @@
|
||||
<script>
|
||||
console.log('{{ stall }}')
|
||||
</script>
|
@@ -1,14 +0,0 @@
|
||||
from quart import g, render_template
|
||||
|
||||
from lnbits.decorators import check_user_exists, validate_uuids
|
||||
from lnbits.extensions.diagonalley import diagonalley_ext
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
@diagonalley_ext.route("/")
|
||||
@validate_uuids(["usr"], required=True)
|
||||
@check_user_exists()
|
||||
async def index(request: Request):
|
||||
return await templates.TemplateResponse("diagonalley/index.html", {"request": request, "user": g.user})
|
@@ -1,350 +0,0 @@
|
||||
from quart import g, jsonify, request
|
||||
from http import HTTPStatus
|
||||
|
||||
from lnbits.core.crud import get_user
|
||||
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
|
||||
|
||||
from lnbits.extensions.diagonalley import diagonalley_ext
|
||||
from .crud import (
|
||||
create_diagonalleys_product,
|
||||
get_diagonalleys_product,
|
||||
get_diagonalleys_products,
|
||||
delete_diagonalleys_product,
|
||||
create_diagonalleys_indexer,
|
||||
update_diagonalleys_indexer,
|
||||
get_diagonalleys_indexer,
|
||||
get_diagonalleys_indexers,
|
||||
delete_diagonalleys_indexer,
|
||||
create_diagonalleys_order,
|
||||
get_diagonalleys_order,
|
||||
get_diagonalleys_orders,
|
||||
update_diagonalleys_product,
|
||||
)
|
||||
from lnbits.core.services import create_invoice
|
||||
from base64 import urlsafe_b64encode
|
||||
from uuid import uuid4
|
||||
from lnbits.db import open_ext_db
|
||||
|
||||
### Products
|
||||
|
||||
|
||||
@diagonalley_ext.get("/api/v1/diagonalley/products")
|
||||
@api_check_wallet_key(key_type="invoice")
|
||||
async def api_diagonalley_products():
|
||||
wallet_ids = [g.wallet.id]
|
||||
|
||||
if "all_wallets" in request.args:
|
||||
wallet_ids = get_user(g.wallet.user).wallet_ids
|
||||
|
||||
return (
|
||||
jsonify(
|
||||
[product._asdict() for product in get_diagonalleys_products(wallet_ids)]
|
||||
),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
class CreateData(BaseModel):
|
||||
product: str
|
||||
categories: str
|
||||
description: str
|
||||
image: str
|
||||
price: int = Query(ge=0)
|
||||
quantity: int = Query(ge=0)
|
||||
|
||||
@diagonalley_ext.post("/api/v1/diagonalley/products")
|
||||
@diagonalley_ext.put("/api/v1/diagonalley/products{product_id}")
|
||||
@api_check_wallet_key(key_type="invoice")
|
||||
async def api_diagonalley_product_create(product_id=None):
|
||||
|
||||
if product_id:
|
||||
product = get_diagonalleys_indexer(product_id)
|
||||
|
||||
if not product:
|
||||
return (
|
||||
jsonify({"message": "Withdraw product does not exist."}),
|
||||
HTTPStatus.NOT_FOUND,
|
||||
)
|
||||
|
||||
if product.wallet != g.wallet.id:
|
||||
return (
|
||||
jsonify({"message": "Not your withdraw product."}),
|
||||
HTTPStatus.FORBIDDEN,
|
||||
)
|
||||
|
||||
product = update_diagonalleys_product(product_id, **g.data)
|
||||
else:
|
||||
product = create_diagonalleys_product(wallet_id=g.wallet.id, **g.data)
|
||||
|
||||
return (
|
||||
jsonify(product._asdict()),
|
||||
HTTPStatus.OK if product_id else HTTPStatus.CREATED,
|
||||
)
|
||||
|
||||
|
||||
@diagonalley_ext.delete("/api/v1/diagonalley/products/{product_id}")
|
||||
@api_check_wallet_key(key_type="invoice")
|
||||
async def api_diagonalley_products_delete(product_id):
|
||||
product = get_diagonalleys_product(product_id)
|
||||
|
||||
if not product:
|
||||
return jsonify({"message": "Product does not exist."}), HTTPStatus.NOT_FOUND
|
||||
|
||||
if product.wallet != g.wallet.id:
|
||||
return jsonify({"message": "Not your Diagon Alley."}), HTTPStatus.FORBIDDEN
|
||||
|
||||
delete_diagonalleys_product(product_id)
|
||||
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
|
||||
|
||||
###Indexers
|
||||
|
||||
|
||||
@diagonalley_ext.get("/api/v1/diagonalley/indexers")
|
||||
@api_check_wallet_key(key_type="invoice")
|
||||
async def api_diagonalley_indexers():
|
||||
wallet_ids = [g.wallet.id]
|
||||
|
||||
if "all_wallets" in request.args:
|
||||
wallet_ids = get_user(g.wallet.user).wallet_ids
|
||||
|
||||
return (
|
||||
jsonify(
|
||||
[indexer._asdict() for indexer in get_diagonalleys_indexers(wallet_ids)]
|
||||
),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
class CreateData(BaseModel):
|
||||
shopname: str
|
||||
indexeraddress: str
|
||||
shippingzone1: str
|
||||
shippingzone2: str
|
||||
email: str
|
||||
zone1cost: int = Query(ge=0)
|
||||
zone2cost: int = Query(ge=0)
|
||||
|
||||
@diagonalley_ext.post("/api/v1/diagonalley/indexers")
|
||||
@diagonalley_ext.put("/api/v1/diagonalley/indexers{indexer_id}")
|
||||
@api_check_wallet_key(key_type="invoice")
|
||||
async def api_diagonalley_indexer_create(data: CreateData, indexer_id=None):
|
||||
|
||||
if indexer_id:
|
||||
indexer = get_diagonalleys_indexer(indexer_id)
|
||||
|
||||
if not indexer:
|
||||
return (
|
||||
jsonify({"message": "Withdraw indexer does not exist."}),
|
||||
HTTPStatus.NOT_FOUND,
|
||||
)
|
||||
|
||||
if indexer.wallet != g.wallet.id:
|
||||
return (
|
||||
jsonify({"message": "Not your withdraw indexer."}),
|
||||
HTTPStatus.FORBIDDEN,
|
||||
)
|
||||
|
||||
indexer = update_diagonalleys_indexer(indexer_id, **data)
|
||||
else:
|
||||
indexer = create_diagonalleys_indexer(wallet_id=g.wallet.id, **data)
|
||||
|
||||
return (
|
||||
indexer._asdict(),
|
||||
HTTPStatus.OK if indexer_id else HTTPStatus.CREATED,
|
||||
)
|
||||
|
||||
|
||||
@diagonalley_ext.delete("/api/v1/diagonalley/indexers/{indexer_id}")
|
||||
@api_check_wallet_key(key_type="invoice")
|
||||
async def api_diagonalley_indexer_delete(indexer_id):
|
||||
indexer = get_diagonalleys_indexer(indexer_id)
|
||||
|
||||
if not indexer:
|
||||
return {"message": "Indexer does not exist."}, HTTPStatus.NOT_FOUND
|
||||
|
||||
if indexer.wallet != g.wallet.id:
|
||||
return {"message": "Not your Indexer."}, HTTPStatus.FORBIDDEN
|
||||
|
||||
delete_diagonalleys_indexer(indexer_id)
|
||||
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
|
||||
|
||||
###Orders
|
||||
|
||||
|
||||
@diagonalley_ext.get("/api/v1/diagonalley/orders")
|
||||
@api_check_wallet_key(key_type="invoice")
|
||||
async def api_diagonalley_orders():
|
||||
wallet_ids = [g.wallet.id]
|
||||
|
||||
if "all_wallets" in request.args:
|
||||
wallet_ids = get_user(g.wallet.user).wallet_ids
|
||||
|
||||
return (
|
||||
[order._asdict() for order in get_diagonalleys_orders(wallet_ids)],
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
class CreateData(BaseModel):
|
||||
id: str
|
||||
address: str
|
||||
email: str
|
||||
quantity: int
|
||||
shippingzone: int
|
||||
|
||||
@diagonalley_ext.post("/api/v1/diagonalley/orders")
|
||||
@api_check_wallet_key(key_type="invoice")
|
||||
|
||||
async def api_diagonalley_order_create(data: CreateData):
|
||||
order = create_diagonalleys_order(wallet_id=g.wallet.id, **data)
|
||||
return order._asdict(), HTTPStatus.CREATED
|
||||
|
||||
|
||||
@diagonalley_ext.delete("/api/v1/diagonalley/orders/{order_id}")
|
||||
@api_check_wallet_key(key_type="invoice")
|
||||
async def api_diagonalley_order_delete(order_id):
|
||||
order = get_diagonalleys_order(order_id)
|
||||
|
||||
if not order:
|
||||
return {"message": "Indexer does not exist."}, HTTPStatus.NOT_FOUND
|
||||
|
||||
if order.wallet != g.wallet.id:
|
||||
return {"message": "Not your Indexer."}, HTTPStatus.FORBIDDEN
|
||||
|
||||
delete_diagonalleys_indexer(order_id)
|
||||
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
|
||||
|
||||
@diagonalley_ext.get("/api/v1/diagonalley/orders/paid/{order_id}")
|
||||
@api_check_wallet_key(key_type="invoice")
|
||||
async def api_diagonalleys_order_paid(order_id):
|
||||
with open_ext_db("diagonalley") as db:
|
||||
db.execute(
|
||||
"UPDATE diagonalley.orders SET paid = ? WHERE id = ?",
|
||||
(
|
||||
True,
|
||||
order_id,
|
||||
),
|
||||
)
|
||||
return "", HTTPStatus.OK
|
||||
|
||||
|
||||
@diagonalley_ext.get("/api/v1/diagonalley/orders/shipped/{order_id}")
|
||||
@api_check_wallet_key(key_type="invoice")
|
||||
async def api_diagonalleys_order_shipped(order_id):
|
||||
with open_ext_db("diagonalley") as db:
|
||||
db.execute(
|
||||
"UPDATE diagonalley.orders SET shipped = ? WHERE id = ?",
|
||||
(
|
||||
True,
|
||||
order_id,
|
||||
),
|
||||
)
|
||||
order = db.fetchone(
|
||||
"SELECT * FROM diagonalley.orders WHERE id = ?", (order_id,)
|
||||
)
|
||||
|
||||
return (
|
||||
jsonify(
|
||||
[order._asdict() for order in get_diagonalleys_orders(order["wallet"])]
|
||||
),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
|
||||
###List products based on indexer id
|
||||
|
||||
|
||||
@diagonalley_ext.get(
|
||||
"/api/v1/diagonalley/stall/products/{indexer_id}"
|
||||
)
|
||||
async def api_diagonalleys_stall_products(indexer_id):
|
||||
with open_ext_db("diagonalley") as db:
|
||||
rows = db.fetchone(
|
||||
"SELECT * FROM diagonalley.indexers WHERE id = ?", (indexer_id,)
|
||||
)
|
||||
print(rows[1])
|
||||
if not rows:
|
||||
return {"message": "Indexer does not exist."}, HTTPStatus.NOT_FOUND
|
||||
|
||||
products = db.fetchone(
|
||||
"SELECT * FROM diagonalley.products WHERE wallet = ?", (rows[1],)
|
||||
)
|
||||
if not products:
|
||||
return {"message": "No products"}, HTTPStatus.NOT_FOUND
|
||||
|
||||
return (
|
||||
[products._asdict() for products in get_diagonalleys_products(rows[1])],
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
|
||||
###Check a product has been shipped
|
||||
|
||||
|
||||
@diagonalley_ext.get(
|
||||
"/api/v1/diagonalley/stall/checkshipped/{checking_id}"
|
||||
)
|
||||
async def api_diagonalleys_stall_checkshipped(checking_id):
|
||||
with open_ext_db("diagonalley") as db:
|
||||
rows = db.fetchone(
|
||||
"SELECT * FROM diagonalley.orders WHERE invoiceid = ?", (checking_id,)
|
||||
)
|
||||
|
||||
return {"shipped": rows["shipped"]}, HTTPStatus.OK
|
||||
|
||||
|
||||
###Place order
|
||||
|
||||
|
||||
@diagonalley_ext.post("/api/v1/diagonalley/stall/order/{indexer_id}")
|
||||
@api_validate_post_request(
|
||||
schema={
|
||||
"id": {"type": "string", "empty": False, "required": True},
|
||||
"email": {"type": "string", "empty": False, "required": True},
|
||||
"address": {"type": "string", "empty": False, "required": True},
|
||||
"quantity": {"type": "integer", "empty": False, "required": True},
|
||||
"shippingzone": {"type": "integer", "empty": False, "required": True},
|
||||
}
|
||||
)
|
||||
async def api_diagonalley_stall_order(indexer_id):
|
||||
product = get_diagonalleys_product(g.data["id"])
|
||||
shipping = get_diagonalleys_indexer(indexer_id)
|
||||
|
||||
if g.data["shippingzone"] == 1:
|
||||
shippingcost = shipping.zone1cost
|
||||
else:
|
||||
shippingcost = shipping.zone2cost
|
||||
|
||||
checking_id, payment_request = create_invoice(
|
||||
wallet_id=product.wallet,
|
||||
amount=shippingcost + (g.data["quantity"] * product.price),
|
||||
memo=g.data["id"],
|
||||
)
|
||||
selling_id = urlsafe_b64encode(uuid4().bytes_le).decode("utf-8")
|
||||
with open_ext_db("diagonalley") as db:
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO diagonalley.orders (id, productid, wallet, product, quantity, shippingzone, address, email, invoiceid, paid, shipped)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
selling_id,
|
||||
g.data["id"],
|
||||
product.wallet,
|
||||
product.product,
|
||||
g.data["quantity"],
|
||||
g.data["shippingzone"],
|
||||
g.data["address"],
|
||||
g.data["email"],
|
||||
checking_id,
|
||||
False,
|
||||
False,
|
||||
),
|
||||
)
|
||||
return (
|
||||
{"checking_id": checking_id, "payment_request": payment_request},
|
||||
HTTPStatus.OK,
|
||||
)
|
@@ -1,33 +0,0 @@
|
||||
# Events
|
||||
|
||||
## Sell tickets for events and use the built-in scanner for registering attendants
|
||||
|
||||
Events alows you to make tickets for an event. Each ticket is in the form of a uniqque QR code. After registering, and paying for ticket, the user gets a QR code to present at registration/entrance.
|
||||
|
||||
Events includes a shareable ticket scanner, which can be used to register attendees.
|
||||
|
||||
## Usage
|
||||
|
||||
1. Create an event\
|
||||

|
||||
2. Fill out the event information:
|
||||
|
||||
- event name
|
||||
- wallet (normally there's only one)
|
||||
- event information
|
||||
- closing date for event registration
|
||||
- begin and end date of the event
|
||||
|
||||

|
||||
|
||||
3. Share the event registration link\
|
||||

|
||||
|
||||
- ticket example\
|
||||

|
||||
|
||||
- QR code ticket, presented after invoice paid, to present at registration\
|
||||

|
||||
|
||||
4. Use the built-in ticket scanner to validate registered, and paid, attendees\
|
||||

|
@@ -1,13 +0,0 @@
|
||||
from quart import Blueprint
|
||||
from lnbits.db import Database
|
||||
|
||||
db = Database("ext_events")
|
||||
|
||||
|
||||
events_ext: Blueprint = Blueprint(
|
||||
"events", __name__, static_folder="static", template_folder="templates"
|
||||
)
|
||||
|
||||
|
||||
from .views_api import * # noqa
|
||||
from .views import * # noqa
|
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "Events",
|
||||
"short_description": "Sell and register event tickets",
|
||||
"icon": "local_activity",
|
||||
"contributors": ["benarc"]
|
||||
}
|
@@ -1,168 +0,0 @@
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
|
||||
from . import db
|
||||
from .models import Tickets, Events
|
||||
|
||||
|
||||
# TICKETS
|
||||
|
||||
|
||||
async def create_ticket(
|
||||
payment_hash: str, wallet: str, event: str, name: str, email: str
|
||||
) -> Tickets:
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO events.ticket (id, wallet, event, name, email, registered, paid)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(payment_hash, wallet, event, name, email, False, False),
|
||||
)
|
||||
|
||||
ticket = await get_ticket(payment_hash)
|
||||
assert ticket, "Newly created ticket couldn't be retrieved"
|
||||
return ticket
|
||||
|
||||
|
||||
async def set_ticket_paid(payment_hash: str) -> Tickets:
|
||||
row = await db.fetchone("SELECT * FROM events.ticket WHERE id = ?", (payment_hash,))
|
||||
if row[6] != True:
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE events.ticket
|
||||
SET paid = true
|
||||
WHERE id = ?
|
||||
""",
|
||||
(payment_hash,),
|
||||
)
|
||||
|
||||
eventdata = await get_event(row[2])
|
||||
assert eventdata, "Couldn't get event from ticket being paid"
|
||||
|
||||
sold = eventdata.sold + 1
|
||||
amount_tickets = eventdata.amount_tickets - 1
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE events.events
|
||||
SET sold = ?, amount_tickets = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(sold, amount_tickets, row[2]),
|
||||
)
|
||||
|
||||
ticket = await get_ticket(payment_hash)
|
||||
assert ticket, "Newly updated ticket couldn't be retrieved"
|
||||
return ticket
|
||||
|
||||
|
||||
async def get_ticket(payment_hash: str) -> Optional[Tickets]:
|
||||
row = await db.fetchone("SELECT * FROM events.ticket WHERE id = ?", (payment_hash,))
|
||||
return Tickets(**row) if row else None
|
||||
|
||||
|
||||
async def get_tickets(wallet_ids: Union[str, List[str]]) -> List[Tickets]:
|
||||
if isinstance(wallet_ids, str):
|
||||
wallet_ids = [wallet_ids]
|
||||
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM events.ticket WHERE wallet IN ({q})", (*wallet_ids,)
|
||||
)
|
||||
return [Tickets(**row) for row in rows]
|
||||
|
||||
|
||||
async def delete_ticket(payment_hash: str) -> None:
|
||||
await db.execute("DELETE FROM events.ticket WHERE id = ?", (payment_hash,))
|
||||
|
||||
|
||||
# EVENTS
|
||||
|
||||
|
||||
async def create_event(
|
||||
*,
|
||||
wallet: str,
|
||||
name: str,
|
||||
info: str,
|
||||
closing_date: str,
|
||||
event_start_date: str,
|
||||
event_end_date: str,
|
||||
amount_tickets: int,
|
||||
price_per_ticket: int,
|
||||
) -> Events:
|
||||
event_id = urlsafe_short_hash()
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO events.events (id, wallet, name, info, closing_date, event_start_date, event_end_date, amount_tickets, price_per_ticket, sold)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
event_id,
|
||||
wallet,
|
||||
name,
|
||||
info,
|
||||
closing_date,
|
||||
event_start_date,
|
||||
event_end_date,
|
||||
amount_tickets,
|
||||
price_per_ticket,
|
||||
0,
|
||||
),
|
||||
)
|
||||
|
||||
event = await get_event(event_id)
|
||||
assert event, "Newly created event couldn't be retrieved"
|
||||
return event
|
||||
|
||||
|
||||
async def update_event(event_id: str, **kwargs) -> Events:
|
||||
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
||||
await db.execute(
|
||||
f"UPDATE events.events SET {q} WHERE id = ?", (*kwargs.values(), event_id)
|
||||
)
|
||||
event = await get_event(event_id)
|
||||
assert event, "Newly updated event couldn't be retrieved"
|
||||
return event
|
||||
|
||||
|
||||
async def get_event(event_id: str) -> Optional[Events]:
|
||||
row = await db.fetchone("SELECT * FROM events.events WHERE id = ?", (event_id,))
|
||||
return Events(**row) if row else None
|
||||
|
||||
|
||||
async def get_events(wallet_ids: Union[str, List[str]]) -> List[Events]:
|
||||
if isinstance(wallet_ids, str):
|
||||
wallet_ids = [wallet_ids]
|
||||
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM events.events WHERE wallet IN ({q})", (*wallet_ids,)
|
||||
)
|
||||
|
||||
return [Events(**row) for row in rows]
|
||||
|
||||
|
||||
async def delete_event(event_id: str) -> None:
|
||||
await db.execute("DELETE FROM events.events WHERE id = ?", (event_id,))
|
||||
|
||||
|
||||
# EVENTTICKETS
|
||||
|
||||
|
||||
async def get_event_tickets(event_id: str, wallet_id: str) -> List[Tickets]:
|
||||
rows = await db.fetchall(
|
||||
"SELECT * FROM events.ticket WHERE wallet = ? AND event = ?",
|
||||
(wallet_id, event_id),
|
||||
)
|
||||
return [Tickets(**row) for row in rows]
|
||||
|
||||
|
||||
async def reg_ticket(ticket_id: str) -> List[Tickets]:
|
||||
await db.execute(
|
||||
"UPDATE events.ticket SET registered = ? WHERE id = ?", (True, ticket_id)
|
||||
)
|
||||
ticket = await db.fetchone("SELECT * FROM events.ticket WHERE id = ?", (ticket_id,))
|
||||
rows = await db.fetchall(
|
||||
"SELECT * FROM events.ticket WHERE event = ?", (ticket[1],)
|
||||
)
|
||||
return [Tickets(**row) for row in rows]
|
@@ -1,91 +0,0 @@
|
||||
async def m001_initial(db):
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE events.events (
|
||||
id TEXT PRIMARY KEY,
|
||||
wallet TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
info TEXT NOT NULL,
|
||||
closing_date TEXT NOT NULL,
|
||||
event_start_date TEXT NOT NULL,
|
||||
event_end_date TEXT NOT NULL,
|
||||
amount_tickets INTEGER NOT NULL,
|
||||
price_per_ticket INTEGER NOT NULL,
|
||||
sold INTEGER NOT NULL,
|
||||
time TIMESTAMP NOT NULL DEFAULT """
|
||||
+ db.timestamp_now
|
||||
+ """
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE events.tickets (
|
||||
id TEXT PRIMARY KEY,
|
||||
wallet TEXT NOT NULL,
|
||||
event TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
registered BOOLEAN NOT NULL,
|
||||
time TIMESTAMP NOT NULL DEFAULT """
|
||||
+ db.timestamp_now
|
||||
+ """
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m002_changed(db):
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE events.ticket (
|
||||
id TEXT PRIMARY KEY,
|
||||
wallet TEXT NOT NULL,
|
||||
event TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
registered BOOLEAN NOT NULL,
|
||||
paid BOOLEAN NOT NULL,
|
||||
time TIMESTAMP NOT NULL DEFAULT """
|
||||
+ db.timestamp_now
|
||||
+ """
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
for row in [list(row) for row in await db.fetchall("SELECT * FROM events.tickets")]:
|
||||
usescsv = ""
|
||||
|
||||
for i in range(row[5]):
|
||||
if row[7]:
|
||||
usescsv += "," + str(i + 1)
|
||||
else:
|
||||
usescsv += "," + str(1)
|
||||
usescsv = usescsv[1:]
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO events.ticket (
|
||||
id,
|
||||
wallet,
|
||||
event,
|
||||
name,
|
||||
email,
|
||||
registered,
|
||||
paid
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
row[0],
|
||||
row[1],
|
||||
row[2],
|
||||
row[3],
|
||||
row[4],
|
||||
row[5],
|
||||
True,
|
||||
),
|
||||
)
|
||||
await db.execute("DROP TABLE events.tickets")
|
@@ -1,28 +0,0 @@
|
||||
from sqlite3 import Row
|
||||
from pydantic import BaseModel
|
||||
#from typing import NamedTuple
|
||||
|
||||
|
||||
class Events(BaseModel):
|
||||
id: str
|
||||
wallet: str
|
||||
name: str
|
||||
info: str
|
||||
closing_date: str
|
||||
event_start_date: str
|
||||
event_end_date: str
|
||||
amount_tickets: int
|
||||
price_per_ticket: int
|
||||
sold: int
|
||||
time: int
|
||||
|
||||
|
||||
class Tickets(BaseModel):
|
||||
id: str
|
||||
wallet: str
|
||||
event: str
|
||||
name: str
|
||||
email: str
|
||||
registered: bool
|
||||
paid: bool
|
||||
time: int
|
@@ -1,23 +0,0 @@
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="swap_vertical_circle"
|
||||
label="Info"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h5 class="text-subtitle1 q-my-none">
|
||||
Events: Sell and register ticket waves for an event
|
||||
</h5>
|
||||
<p>
|
||||
Events alows you to make a wave of tickets for an event, each ticket is
|
||||
in the form of a unqiue QRcode, which the user presents at registration.
|
||||
Events comes with a shareable ticket scanner, which can be used to
|
||||
register attendees.<br />
|
||||
<small>
|
||||
Created by, <a href="https://github.com/benarc">Ben Arc</a>
|
||||
</small>
|
||||
</p>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
@@ -1,207 +0,0 @@
|
||||
{% extends "public.html" %} {% block page %}
|
||||
<div class="row q-col-gutter-md justify-center">
|
||||
<div class="col-12 col-md-7 col-lg-6 q-gutter-y-md">
|
||||
<q-card class="q-pa-lg">
|
||||
<q-card-section class="q-pa-none">
|
||||
<h3 class="q-my-none">{{ event_name }}</h3>
|
||||
<br />
|
||||
<h5 class="q-my-none">{{ event_info }}</h5>
|
||||
<br />
|
||||
<q-form @submit="Invoice()" class="q-gutter-md">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.name"
|
||||
type="name"
|
||||
label="Your name "
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.email"
|
||||
type="email"
|
||||
label="Your email "
|
||||
></q-input>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="formDialog.data.name == '' || formDialog.data.email == '' || paymentReq"
|
||||
type="submit"
|
||||
>Submit</q-btn
|
||||
>
|
||||
<q-btn @click="resetForm" flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<q-card v-show="ticketLink.show" class="q-pa-lg">
|
||||
<div class="text-center q-mb-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
size="xl"
|
||||
:href="ticketLink.data.link"
|
||||
target="_blank"
|
||||
color="primary"
|
||||
type="a"
|
||||
>Link to your ticket!</q-btn
|
||||
>
|
||||
<br /><br />
|
||||
<p>You'll be redirected in a few moments...</p>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<q-dialog v-model="receive.show" position="top" @hide="closeReceiveDialog">
|
||||
<q-card
|
||||
v-if="!receive.paymentReq"
|
||||
class="q-pa-lg q-pt-xl lnbits__dialog-card"
|
||||
>
|
||||
</q-card>
|
||||
<q-card v-else class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<div class="text-center q-mb-lg">
|
||||
<a :href="'lightning:' + receive.paymentReq">
|
||||
<q-responsive :ratio="1" class="q-mx-xl">
|
||||
<qrcode
|
||||
:value="paymentReq"
|
||||
:options="{width: 340}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
</q-responsive>
|
||||
</a>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn outline color="grey" @click="copyText(receive.paymentReq)"
|
||||
>Copy invoice</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
|
||||
{% endblock %} {% block scripts %}
|
||||
<script>
|
||||
console.log('{{ form_costpword }}')
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
paymentReq: null,
|
||||
redirectUrl: null,
|
||||
formDialog: {
|
||||
show: false,
|
||||
data: {
|
||||
name: '',
|
||||
email: ''
|
||||
}
|
||||
},
|
||||
ticketLink: {
|
||||
show: false,
|
||||
data: {
|
||||
link: ''
|
||||
}
|
||||
},
|
||||
receive: {
|
||||
show: false,
|
||||
status: 'pending',
|
||||
paymentReq: null
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
resetForm: function (e) {
|
||||
e.preventDefault()
|
||||
this.formDialog.data.name = ''
|
||||
this.formDialog.data.email = ''
|
||||
},
|
||||
|
||||
closeReceiveDialog: function () {
|
||||
var checker = this.receive.paymentChecker
|
||||
dismissMsg()
|
||||
|
||||
clearInterval(paymentChecker)
|
||||
setTimeout(function () {}, 10000)
|
||||
},
|
||||
Invoice: function () {
|
||||
var self = this
|
||||
axios
|
||||
|
||||
.post(
|
||||
'/events/api/v1/tickets/' + '{{ event_id }}/{{ event_price }}',
|
||||
{
|
||||
event: '{{ event_id }}',
|
||||
event_name: '{{ event_name }}',
|
||||
name: self.formDialog.data.name,
|
||||
email: self.formDialog.data.email
|
||||
}
|
||||
)
|
||||
.then(function (response) {
|
||||
self.paymentReq = response.data.payment_request
|
||||
self.paymentCheck = response.data.payment_hash
|
||||
|
||||
dismissMsg = self.$q.notify({
|
||||
timeout: 0,
|
||||
message: 'Waiting for payment...'
|
||||
})
|
||||
|
||||
self.receive = {
|
||||
show: true,
|
||||
status: 'pending',
|
||||
paymentReq: self.paymentReq
|
||||
}
|
||||
|
||||
paymentChecker = setInterval(function () {
|
||||
axios
|
||||
.get('/events/api/v1/tickets/' + self.paymentCheck)
|
||||
.then(function (res) {
|
||||
if (res.data.paid) {
|
||||
clearInterval(paymentChecker)
|
||||
dismissMsg()
|
||||
self.formDialog.data.name = ''
|
||||
self.formDialog.data.email = ''
|
||||
|
||||
self.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'Sent, thank you!',
|
||||
icon: null
|
||||
})
|
||||
self.receive = {
|
||||
show: false,
|
||||
status: 'complete',
|
||||
paymentReq: null
|
||||
}
|
||||
|
||||
self.ticketLink = {
|
||||
show: true,
|
||||
data: {
|
||||
link: '/events/ticket/' + res.data.ticket_id
|
||||
}
|
||||
}
|
||||
setTimeout(function () {
|
||||
window.location.href =
|
||||
'/events/ticket/' + res.data.ticket_id
|
||||
}, 5000)
|
||||
}
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
}, 2000)
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
@@ -1,35 +0,0 @@
|
||||
{% extends "public.html" %} {% block page %}
|
||||
<div class="row q-col-gutter-md justify-center">
|
||||
<div class="col-12 col-md-7 col-lg-6 q-gutter-y-md">
|
||||
<q-card class="q-pa-lg">
|
||||
<q-card-section class="q-pa-none">
|
||||
<center>
|
||||
<h3 class="q-my-none">{{ event_name }} error</h3>
|
||||
<br />
|
||||
<q-icon
|
||||
name="warning"
|
||||
class="text-grey"
|
||||
style="font-size: 20rem"
|
||||
></q-icon>
|
||||
|
||||
<h5 class="q-my-none">{{ event_error }}</h5>
|
||||
<br />
|
||||
</center>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
{% endblock %} {% block scripts %}
|
||||
|
||||
<script>
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
</div>
|
@@ -1,538 +0,0 @@
|
||||
{% 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 Event</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">Events</h5>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn flat color="grey" @click="exporteventsCSV"
|
||||
>Export to CSV</q-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="events"
|
||||
row-key="id"
|
||||
:columns="eventsTable.columns"
|
||||
:pagination.sync="eventsTable.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="link"
|
||||
: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="how_to_reg"
|
||||
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||
type="a"
|
||||
:href="'/events/register/' + props.row.id"
|
||||
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="updateformDialog(props.row.id)"
|
||||
icon="edit"
|
||||
color="light-blue"
|
||||
></q-btn>
|
||||
</q-td>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="deleteEvent(props.row.id)"
|
||||
icon="cancel"
|
||||
color="pink"
|
||||
></q-btn>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
{% endraw %}
|
||||
</q-table>
|
||||
</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">Tickets</h5>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn flat color="grey" @click="exportticketsCSV"
|
||||
>Export to CSV</q-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="tickets"
|
||||
row-key="id"
|
||||
:columns="ticketsTable.columns"
|
||||
:pagination.sync="ticketsTable.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-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
unelevated
|
||||
dense
|
||||
size="xs"
|
||||
icon="local_activity"
|
||||
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||
type="a"
|
||||
:href="'/events/ticket/' + props.row.id"
|
||||
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="deleteTicket(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}} Events extension
|
||||
</h6>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-separator></q-separator>
|
||||
<q-list> {% include "events/_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="sendEventData" class="q-gutter-md">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.name"
|
||||
type="name"
|
||||
label="Title of event "
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col q-pl-sm">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model="formDialog.data.wallet"
|
||||
:options="g.user.walletOptions"
|
||||
label="Wallet *"
|
||||
>
|
||||
</q-select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.info"
|
||||
type="textarea"
|
||||
label="Info about the event "
|
||||
></q-input>
|
||||
<div class="row">
|
||||
<div class="col-4">Ticket closing date</div>
|
||||
<div class="col-8">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.closing_date"
|
||||
type="date"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-4">Event begins</div>
|
||||
<div class="col-8">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.event_start_date"
|
||||
type="date"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-4">Event ends</div>
|
||||
<div class="col-8">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.event_end_date"
|
||||
type="date"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="formDialog.data.amount_tickets"
|
||||
type="number"
|
||||
label="Amount of tickets "
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col q-pl-sm">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="formDialog.data.price_per_ticket"
|
||||
type="number"
|
||||
label="Price per ticket "
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
v-if="formDialog.data.id"
|
||||
unelevated
|
||||
color="primary"
|
||||
type="submit"
|
||||
>Update Event</q-btn
|
||||
>
|
||||
<q-btn
|
||||
v-else
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="formDialog.data.wallet == null || formDialog.data.name == null || formDialog.data.info == null || formDialog.data.closing_date == null || formDialog.data.event_start_date == null || formDialog.data.event_end_date == null || formDialog.data.amount_tickets == null || formDialog.data.price_per_ticket == null"
|
||||
type="submit"
|
||||
>Create Event</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 mapEvents = 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 = ['/events/', obj.id].join('')
|
||||
return obj
|
||||
}
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
events: [],
|
||||
tickets: [],
|
||||
eventsTable: {
|
||||
columns: [
|
||||
{name: 'id', align: 'left', label: 'ID', field: 'id'},
|
||||
{name: 'name', align: 'left', label: 'Name', field: 'name'},
|
||||
{name: 'info', align: 'left', label: 'Info', field: 'info'},
|
||||
{
|
||||
name: 'event_start_date',
|
||||
align: 'left',
|
||||
label: 'Start date',
|
||||
field: 'event_start_date'
|
||||
},
|
||||
{
|
||||
name: 'event_end_date',
|
||||
align: 'left',
|
||||
label: 'End date',
|
||||
field: 'event_end_date'
|
||||
},
|
||||
{
|
||||
name: 'closing_date',
|
||||
align: 'left',
|
||||
label: 'Ticket close',
|
||||
field: 'closing_date'
|
||||
},
|
||||
{
|
||||
name: 'price_per_ticket',
|
||||
align: 'left',
|
||||
label: 'Price',
|
||||
field: 'price_per_ticket'
|
||||
},
|
||||
{
|
||||
name: 'amount_tickets',
|
||||
align: 'left',
|
||||
label: 'No tickets',
|
||||
field: 'amount_tickets'
|
||||
},
|
||||
{
|
||||
name: 'sold',
|
||||
align: 'left',
|
||||
label: 'Sold',
|
||||
field: 'sold'
|
||||
}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10
|
||||
}
|
||||
},
|
||||
ticketsTable: {
|
||||
columns: [
|
||||
{name: 'id', align: 'left', label: 'ID', field: 'id'},
|
||||
{name: 'event', align: 'left', label: 'Event', field: 'event'},
|
||||
{name: 'name', align: 'left', label: 'Name', field: 'name'},
|
||||
{name: 'email', align: 'left', label: 'Email', field: 'email'},
|
||||
{
|
||||
name: 'registered',
|
||||
align: 'left',
|
||||
label: 'Registered',
|
||||
field: 'registered'
|
||||
}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10
|
||||
}
|
||||
},
|
||||
formDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getTickets: function () {
|
||||
var self = this
|
||||
console.log('obj')
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/events/api/v1/tickets?all_wallets',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.tickets = response.data.map(function (obj) {
|
||||
console.log(obj)
|
||||
return mapEvents(obj)
|
||||
})
|
||||
})
|
||||
},
|
||||
deleteTicket: function (ticketId) {
|
||||
var self = this
|
||||
var tickets = _.findWhere(this.tickets, {id: ticketId})
|
||||
|
||||
LNbits.utils
|
||||
.confirmDialog('Are you sure you want to delete this ticket')
|
||||
.onOk(function () {
|
||||
LNbits.api
|
||||
.request(
|
||||
'DELETE',
|
||||
'/events/api/v1/tickets/' + ticketId,
|
||||
_.findWhere(self.g.user.wallets, {id: tickets.wallet}).inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.tickets = _.reject(self.tickets, function (obj) {
|
||||
return obj.id == ticketId
|
||||
})
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
})
|
||||
},
|
||||
exportticketsCSV: function () {
|
||||
LNbits.utils.exportCSV(this.ticketsTable.columns, this.tickets)
|
||||
},
|
||||
|
||||
getEvents: function () {
|
||||
var self = this
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/events/api/v1/events?all_wallets',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.events = response.data.map(function (obj) {
|
||||
return mapEvents(obj)
|
||||
})
|
||||
})
|
||||
},
|
||||
sendEventData: function () {
|
||||
var wallet = _.findWhere(this.g.user.wallets, {
|
||||
id: this.formDialog.data.wallet
|
||||
})
|
||||
var data = this.formDialog.data
|
||||
|
||||
if (data.id) {
|
||||
this.updateEvent(wallet, data)
|
||||
} else {
|
||||
this.createEvent(wallet, data)
|
||||
}
|
||||
},
|
||||
|
||||
createEvent: function (wallet, data) {
|
||||
var self = this
|
||||
LNbits.api
|
||||
.request('POST', '/events/api/v1/events', wallet.inkey, data)
|
||||
.then(function (response) {
|
||||
self.events.push(mapEvents(response.data))
|
||||
self.formDialog.show = false
|
||||
self.formDialog.data = {}
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
updateformDialog: function (formId) {
|
||||
var link = _.findWhere(this.events, {id: formId})
|
||||
console.log(link.id)
|
||||
this.formDialog.data.id = link.id
|
||||
this.formDialog.data.wallet = link.wallet
|
||||
this.formDialog.data.name = link.name
|
||||
this.formDialog.data.info = link.info
|
||||
this.formDialog.data.closing_date = link.closing_date
|
||||
this.formDialog.data.event_start_date = link.event_start_date
|
||||
this.formDialog.data.event_end_date = link.event_end_date
|
||||
this.formDialog.data.amount_tickets = link.amount_tickets
|
||||
this.formDialog.data.price_per_ticket = link.price_per_ticket
|
||||
this.formDialog.show = true
|
||||
},
|
||||
updateEvent: function (wallet, data) {
|
||||
var self = this
|
||||
console.log(data)
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'PUT',
|
||||
'/events/api/v1/events/' + data.id,
|
||||
wallet.inkey,
|
||||
data
|
||||
)
|
||||
.then(function (response) {
|
||||
self.events = _.reject(self.events, function (obj) {
|
||||
return obj.id == data.id
|
||||
})
|
||||
self.events.push(mapEvents(response.data))
|
||||
self.formDialog.show = false
|
||||
self.formDialog.data = {}
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
deleteEvent: function (eventsId) {
|
||||
var self = this
|
||||
var events = _.findWhere(this.events, {id: eventsId})
|
||||
|
||||
LNbits.utils
|
||||
.confirmDialog('Are you sure you want to delete this form link?')
|
||||
.onOk(function () {
|
||||
LNbits.api
|
||||
.request(
|
||||
'DELETE',
|
||||
'/events/api/v1/events/' + eventsId,
|
||||
_.findWhere(self.g.user.wallets, {id: events.wallet}).inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.events = _.reject(self.events, function (obj) {
|
||||
return obj.id == eventsId
|
||||
})
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
})
|
||||
},
|
||||
exporteventsCSV: function () {
|
||||
LNbits.utils.exportCSV(this.eventsTable.columns, this.events)
|
||||
}
|
||||
},
|
||||
|
||||
created: function () {
|
||||
if (this.g.user.wallets.length) {
|
||||
this.getTickets()
|
||||
this.getEvents()
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
@@ -1,173 +0,0 @@
|
||||
{% extends "public.html" %} {% block page %}
|
||||
|
||||
<div class="row q-col-gutter-md justify-center">
|
||||
<div class="col-12 col-md-7 col-lg-6 q-gutter-y-md">
|
||||
<q-card class="q-pa-lg">
|
||||
<q-card-section class="q-pa-none">
|
||||
<center>
|
||||
<h3 class="q-my-none">{{ event_name }} Registration</h3>
|
||||
<br />
|
||||
|
||||
<br />
|
||||
|
||||
<q-btn unelevated color="primary" @click="showCamera" size="xl"
|
||||
>Scan ticket</q-btn
|
||||
>
|
||||
</center>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="tickets"
|
||||
row-key="id"
|
||||
:columns="ticketsTable.columns"
|
||||
:pagination.sync="ticketsTable.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-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
unelevated
|
||||
dense
|
||||
size="xs"
|
||||
icon="local_activity"
|
||||
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||
type="a"
|
||||
:href="'/events/ticket/' + props.row.id"
|
||||
target="_blank"
|
||||
></q-btn>
|
||||
</q-td>
|
||||
|
||||
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.value }}
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
{% endraw %}
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<q-dialog v-model="sendCamera.show" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl">
|
||||
<div class="text-center q-mb-lg">
|
||||
<qrcode-stream
|
||||
@decode="decodeQR"
|
||||
class="rounded-borders"
|
||||
></qrcode-stream>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn @click="closeCamera" flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %}
|
||||
<script>
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
Vue.use(VueQrcodeReader)
|
||||
var mapEvents = 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 = ['/events/', obj.id].join('')
|
||||
return obj
|
||||
}
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
tickets: [],
|
||||
ticketsTable: {
|
||||
columns: [
|
||||
{name: 'id', align: 'left', label: 'ID', field: 'id'},
|
||||
{name: 'name', align: 'left', label: 'Name', field: 'name'},
|
||||
{
|
||||
name: 'registered',
|
||||
align: 'left',
|
||||
label: 'Registered',
|
||||
field: 'registered'
|
||||
}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10
|
||||
}
|
||||
},
|
||||
sendCamera: {
|
||||
show: false,
|
||||
camera: 'auto'
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
hoverEmail: function (tmp) {
|
||||
this.tickets.data.emailtemp = tmp
|
||||
},
|
||||
closeCamera: function () {
|
||||
this.sendCamera.show = false
|
||||
},
|
||||
showCamera: function () {
|
||||
this.sendCamera.show = true
|
||||
},
|
||||
decodeQR: function (res) {
|
||||
this.sendCamera.show = false
|
||||
var self = this
|
||||
|
||||
LNbits.api
|
||||
.request('GET', '/events/api/v1/register/ticket/' + res)
|
||||
.then(function (response) {
|
||||
self.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'Registered!'
|
||||
})
|
||||
setTimeout(function () {
|
||||
window.location.reload()
|
||||
}, 2000)
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
getEventTickets: function () {
|
||||
var self = this
|
||||
console.log('obj')
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/events/api/v1/eventtickets/{{ wallet_id }}/{{ event_id }}'
|
||||
)
|
||||
.then(function (response) {
|
||||
self.tickets = response.data.map(function (obj) {
|
||||
return mapEvents(obj)
|
||||
})
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
this.getEventTickets()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
@@ -1,45 +0,0 @@
|
||||
{% extends "public.html" %} {% block page %}
|
||||
<div class="row q-col-gutter-md justify-center">
|
||||
<div class="col-12 col-md-7 col-lg-6 q-gutter-y-md">
|
||||
<q-card class="q-pa-lg">
|
||||
<q-card-section class="q-pa-none">
|
||||
<center>
|
||||
<h3 class="q-my-none">{{ ticket_name }} Ticket</h3>
|
||||
<br />
|
||||
<h5 class="q-my-none">
|
||||
Bookmark, print or screenshot this page,<br />
|
||||
and present it for registration!
|
||||
</h5>
|
||||
<br />
|
||||
|
||||
<qrcode
|
||||
:value="'{{ ticket_id }}'"
|
||||
:options="{width: 340}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
<br />
|
||||
<q-btn @click="printWindow" color="grey" class="q-ml-auto">
|
||||
<q-icon left size="3em" name="print"></q-icon> Print</q-btn
|
||||
>
|
||||
</center>
|
||||
</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 {}
|
||||
},
|
||||
methods: {
|
||||
printWindow: function () {
|
||||
window.print()
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
@@ -1,83 +0,0 @@
|
||||
from quart import g, abort, render_template
|
||||
from datetime import date, datetime
|
||||
from http import HTTPStatus
|
||||
|
||||
from lnbits.decorators import check_user_exists, validate_uuids
|
||||
|
||||
from . import events_ext
|
||||
from .crud import get_ticket, get_event
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
@events_ext.route("/")
|
||||
@validate_uuids(["usr"], required=True)
|
||||
@check_user_exists()
|
||||
async def index(request: Request):
|
||||
return templates.TemplateResponse("events/index.html", {"user":g.user})
|
||||
|
||||
@events_ext.route("/<event_id>")
|
||||
async def display(request: Request, event_id):
|
||||
event = await get_event(event_id)
|
||||
if not event:
|
||||
abort(HTTPStatus.NOT_FOUND, "Event does not exist.")
|
||||
|
||||
if event.amount_tickets < 1:
|
||||
return await templates.TemplateResponse(
|
||||
"events/error.html",
|
||||
{"request":request,
|
||||
"event_name":event.name,
|
||||
"event_error":"Sorry, tickets are sold out :("}
|
||||
)
|
||||
datetime_object = datetime.strptime(event.closing_date, "%Y-%m-%d").date()
|
||||
if date.today() > datetime_object:
|
||||
return await templates.TemplateResponse(
|
||||
"events/error.html",
|
||||
{"request":request,
|
||||
"event_name":event.name,
|
||||
"event_error":"Sorry, ticket closing date has passed :("}
|
||||
)
|
||||
|
||||
return await templates.TemplateResponse(
|
||||
"events/display.html",
|
||||
{"request":request,
|
||||
"event_id":event_id,
|
||||
"event_name":event.name,
|
||||
"event_info":event.info,
|
||||
"event_price":event.price_per_ticket}
|
||||
)
|
||||
|
||||
|
||||
@events_ext.route("/ticket/<ticket_id>")
|
||||
async def ticket(request: Request, ticket_id):
|
||||
ticket = await get_ticket(ticket_id)
|
||||
if not ticket:
|
||||
abort(HTTPStatus.NOT_FOUND, "Ticket does not exist.")
|
||||
|
||||
event = await get_event(ticket.event)
|
||||
if not event:
|
||||
abort(HTTPStatus.NOT_FOUND, "Event does not exist.")
|
||||
|
||||
return await templates.TemplateResponse(
|
||||
"events/ticket.html",
|
||||
{"request":request,
|
||||
"ticket_id":ticket_id,
|
||||
"ticket_name":event.name,
|
||||
"ticket_info":event.info}
|
||||
)
|
||||
|
||||
|
||||
@events_ext.route("/register/<event_id>")
|
||||
async def register(request: Request, event_id):
|
||||
event = await get_event(event_id)
|
||||
if not event:
|
||||
abort(HTTPStatus.NOT_FOUND, "Event does not exist.")
|
||||
|
||||
return await templates.TemplateResponse(
|
||||
"events/register.html",
|
||||
{"request":request,
|
||||
"event_id":event_id,
|
||||
"event_name":event.name,
|
||||
"wallet_id":event.wallet}
|
||||
)
|
@@ -1,191 +0,0 @@
|
||||
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 fastapi.encoders import jsonable_encoder
|
||||
from fastapi import Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
from . import events_ext
|
||||
from .crud import (
|
||||
create_ticket,
|
||||
set_ticket_paid,
|
||||
get_ticket,
|
||||
get_tickets,
|
||||
delete_ticket,
|
||||
create_event,
|
||||
update_event,
|
||||
get_event,
|
||||
get_events,
|
||||
delete_event,
|
||||
get_event_tickets,
|
||||
reg_ticket,
|
||||
)
|
||||
|
||||
|
||||
# Events
|
||||
|
||||
|
||||
|
||||
@events_ext.get("/api/v1/events")
|
||||
@api_check_wallet_key("invoice")
|
||||
async def api_events():
|
||||
wallet_ids = [g.wallet.id]
|
||||
|
||||
if "all_wallets" in request.args:
|
||||
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
|
||||
|
||||
return (
|
||||
[event._asdict() for event in await get_events(wallet_ids)],
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
class CreateData(BaseModel):
|
||||
wallet: str = Query(...)
|
||||
name: str = Query(...)
|
||||
info: str = Query(...)
|
||||
closing_date: str = Query(...)
|
||||
event_start_date: str = Query(...)
|
||||
event_end_date: str = Query(...)
|
||||
amount_tickets: int = Query(..., ge=0)
|
||||
price_per_ticket: int = Query(..., ge=0)
|
||||
|
||||
@events_ext.post("/api/v1/events")
|
||||
@events_ext.put("/api/v1/events/{event_id}")
|
||||
@api_check_wallet_key("invoice")
|
||||
async def api_event_create(data: CreateData, event_id=None):
|
||||
if event_id:
|
||||
event = await get_event(event_id)
|
||||
if not event:
|
||||
return {"message": "Form does not exist."}, HTTPStatus.NOT_FOUND
|
||||
|
||||
if event.wallet != g.wallet.id:
|
||||
return {"message": "Not your event."}, HTTPStatus.FORBIDDEN
|
||||
|
||||
event = await update_event(event_id, **data)
|
||||
else:
|
||||
event = await create_event(**data)
|
||||
|
||||
return event._asdict(), HTTPStatus.CREATED
|
||||
|
||||
|
||||
@events_ext.delete("/api/v1/events/{event_id}")
|
||||
@api_check_wallet_key("invoice")
|
||||
async def api_form_delete(event_id):
|
||||
event = await get_event(event_id)
|
||||
if not event:
|
||||
return {"message": "Event does not exist."}, HTTPStatus.NOT_FOUND
|
||||
|
||||
if event.wallet != g.wallet.id:
|
||||
return {"message": "Not your event."}, HTTPStatus.FORBIDDEN
|
||||
|
||||
await delete_event(event_id)
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
|
||||
|
||||
#########Tickets##########
|
||||
|
||||
|
||||
@events_ext.get("/api/v1/tickets")
|
||||
@api_check_wallet_key("invoice")
|
||||
async def api_tickets():
|
||||
wallet_ids = [g.wallet.id]
|
||||
|
||||
if "all_wallets" in request.args:
|
||||
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
|
||||
|
||||
return (
|
||||
[ticket._asdict() for ticket in await get_tickets(wallet_ids)],
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
class CreateTicketData(BaseModel):
|
||||
name: str = Query(...)
|
||||
email: str
|
||||
|
||||
@events_ext.post("/api/v1/tickets/{event_id}/{sats}")
|
||||
async def api_ticket_make_ticket(data: CreateTicketData, event_id, sats):
|
||||
event = await get_event(event_id)
|
||||
if not event:
|
||||
return {"message": "Event does not exist."}, HTTPStatus.NOT_FOUND
|
||||
try:
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
wallet_id=event.wallet,
|
||||
amount=int(sats),
|
||||
memo=f"{event_id}",
|
||||
extra={"tag": "events"},
|
||||
)
|
||||
except Exception as e:
|
||||
return {"message": str(e)}, HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
|
||||
ticket = await create_ticket(
|
||||
payment_hash=payment_hash, wallet=event.wallet, event=event_id, **data
|
||||
)
|
||||
|
||||
if not ticket:
|
||||
return {"message": "Event could not be fetched."}, HTTPStatus.NOT_FOUND
|
||||
|
||||
return {"payment_hash": payment_hash, "payment_request": payment_request}, HTTPStatus.OK
|
||||
|
||||
|
||||
@events_ext.get("/api/v1/tickets/{payment_hash}")
|
||||
async def api_ticket_send_ticket(payment_hash):
|
||||
ticket = await get_ticket(payment_hash)
|
||||
|
||||
try:
|
||||
status = await check_invoice_status(ticket.wallet, payment_hash)
|
||||
is_paid = not status.pending
|
||||
except Exception:
|
||||
return {"message": "Not paid."}, HTTPStatus.NOT_FOUND
|
||||
|
||||
if is_paid:
|
||||
wallet = await get_wallet(ticket.wallet)
|
||||
payment = await wallet.get_payment(payment_hash)
|
||||
await payment.set_pending(False)
|
||||
ticket = await set_ticket_paid(payment_hash=payment_hash)
|
||||
|
||||
return {"paid": True, "ticket_id": ticket.id}, HTTPStatus.OK
|
||||
|
||||
return {"paid": False}, HTTPStatus.OK
|
||||
|
||||
|
||||
@events_ext.delete("/api/v1/tickets/{ticket_id}")
|
||||
@api_check_wallet_key("invoice")
|
||||
async def api_ticket_delete(ticket_id):
|
||||
ticket = await get_ticket(ticket_id)
|
||||
|
||||
if not ticket:
|
||||
return {"message": "Ticket does not exist."}, HTTPStatus.NOT_FOUND
|
||||
|
||||
if ticket.wallet != g.wallet.id:
|
||||
return {"message": "Not your ticket."}, HTTPStatus.FORBIDDEN
|
||||
|
||||
await delete_ticket(ticket_id)
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
|
||||
|
||||
# Event Tickets
|
||||
|
||||
|
||||
@events_ext.get("/api/v1/eventtickets/{wallet_id}/{event_id]")
|
||||
async def api_event_tickets(wallet_id, event_id):
|
||||
return ([ticket._asdict() for ticket in await get_event_tickets(wallet_id=wallet_id, event_id=event_id)],
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
|
||||
@events_ext.get("/api/v1/register/ticket/{ticket_id}")
|
||||
async def api_event_register_ticket(ticket_id):
|
||||
ticket = await get_ticket(ticket_id)
|
||||
if not ticket:
|
||||
return {"message": "Ticket does not exist."}, HTTPStatus.FORBIDDEN
|
||||
|
||||
if not ticket.paid:
|
||||
return {"message": "Ticket not paid for."}, HTTPStatus.FORBIDDEN
|
||||
|
||||
if ticket.registered == True:
|
||||
return {"message": "Ticket already registered"}, HTTPStatus.FORBIDDEN
|
||||
|
||||
return [ticket._asdict() for ticket in await reg_ticket(ticket_id)], HTTPStatus.OK
|
@@ -1,11 +0,0 @@
|
||||
<h1>Example Extension</h1>
|
||||
<h2>*tagline*</h2>
|
||||
This is an example extension to help you organise and build you own.
|
||||
|
||||
Try to include an image
|
||||
<img src="https://i.imgur.com/9i4xcQB.png">
|
||||
|
||||
|
||||
<h2>If your extension has API endpoints, include useful ones here</h2>
|
||||
|
||||
<code>curl -H "Content-type: application/json" -X POST https://YOUR-LNBITS/YOUR-EXTENSION/api/v1/EXAMPLE -d '{"amount":"100","memo":"example"}' -H "X-Api-Key: YOUR_WALLET-ADMIN/INVOICE-KEY"</code>
|
@@ -1,12 +0,0 @@
|
||||
from quart import Blueprint
|
||||
from lnbits.db import Database
|
||||
|
||||
db = Database("ext_example")
|
||||
|
||||
example_ext: Blueprint = Blueprint(
|
||||
"example", __name__, static_folder="static", template_folder="templates"
|
||||
)
|
||||
|
||||
|
||||
from .views_api import * # noqa
|
||||
from .views import * # noqa
|
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "Build your own!",
|
||||
"short_description": "Join us, make an extension",
|
||||
"icon": "info",
|
||||
"contributors": ["github_username"]
|
||||
}
|
@@ -1,10 +0,0 @@
|
||||
# async def m001_initial(db):
|
||||
# await db.execute(
|
||||
# f"""
|
||||
# CREATE TABLE example.example (
|
||||
# id TEXT PRIMARY KEY,
|
||||
# wallet TEXT NOT NULL,
|
||||
# time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
|
||||
# );
|
||||
# """
|
||||
# )
|
@@ -1,11 +0,0 @@
|
||||
# from sqlite3 import Row
|
||||
# from typing import NamedTuple
|
||||
|
||||
|
||||
# class Example(NamedTuple):
|
||||
# id: str
|
||||
# wallet: str
|
||||
#
|
||||
# @classmethod
|
||||
# def from_row(cls, row: Row) -> "Example":
|
||||
# return cls(**dict(row))
|
@@ -1,59 +0,0 @@
|
||||
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||
%} {% block page %}
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h5 class="text-subtitle1 q-mt-none q-mb-md">
|
||||
Frameworks used by {{SITE_TITLE}}
|
||||
</h5>
|
||||
<q-list>
|
||||
<q-item
|
||||
v-for="tool in tools"
|
||||
:key="tool.name"
|
||||
tag="a"
|
||||
:href="tool.url"
|
||||
target="_blank"
|
||||
>
|
||||
{% raw %}
|
||||
<!-- with raw Flask won't try to interpret the Vue moustaches -->
|
||||
<q-item-section>
|
||||
<q-item-label>{{ tool.name }}</q-item-label>
|
||||
<q-item-label caption>{{ tool.language }}</q-item-label>
|
||||
</q-item-section>
|
||||
{% endraw %}
|
||||
</q-item>
|
||||
</q-list>
|
||||
<q-separator class="q-my-lg"></q-separator>
|
||||
<p>
|
||||
A magical "g" is always available, with info about the user, wallets and
|
||||
extensions:
|
||||
</p>
|
||||
<code class="text-caption">{% raw %}{{ g }}{% endraw %}</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||
<script>
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
tools: []
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
var self = this
|
||||
|
||||
// axios is available for making requests
|
||||
axios({
|
||||
method: 'GET',
|
||||
url: '/example/api/v1/tools',
|
||||
headers: {
|
||||
'X-example-header': 'not-used'
|
||||
}
|
||||
}).then(function (response) {
|
||||
self.tools = response.data
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
@@ -1,16 +0,0 @@
|
||||
from quart import g, render_template
|
||||
|
||||
from lnbits.decorators import check_user_exists, validate_uuids
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from . import example_ext
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
@example_ext.route("/")
|
||||
@validate_uuids(["usr"], required=True)
|
||||
@check_user_exists()
|
||||
async def index(request: Request):
|
||||
return await templates.TemplateResponse("example/index.html", {"request": request, "user":g.user})
|
@@ -1,40 +0,0 @@
|
||||
# views_api.py is for you API endpoints that could be hit by another service
|
||||
|
||||
# add your dependencies here
|
||||
|
||||
# import json
|
||||
# import httpx
|
||||
# (use httpx just like requests, except instead of response.ok there's only the
|
||||
# response.is_error that is its inverse)
|
||||
|
||||
from quart import jsonify
|
||||
from http import HTTPStatus
|
||||
|
||||
from . import example_ext
|
||||
|
||||
|
||||
# add your endpoints here
|
||||
|
||||
|
||||
@example_ext.route("/api/v1/tools", methods=["GET"])
|
||||
async def api_example():
|
||||
"""Try to add descriptions for others."""
|
||||
tools = [
|
||||
{
|
||||
"name": "Quart",
|
||||
"url": "https://pgjones.gitlab.io/quart/",
|
||||
"language": "Python",
|
||||
},
|
||||
{
|
||||
"name": "Vue.js",
|
||||
"url": "https://vuejs.org/",
|
||||
"language": "JavaScript",
|
||||
},
|
||||
{
|
||||
"name": "Quasar Framework",
|
||||
"url": "https://quasar.dev/",
|
||||
"language": "JavaScript",
|
||||
},
|
||||
]
|
||||
|
||||
return jsonify(tools), HTTPStatus.OK
|
@@ -1,3 +0,0 @@
|
||||
<h1>Hivemind</h1>
|
||||
|
||||
Placeholder for a future <a href="https://bitcoinhivemind.com/">Bitcoin Hivemind</a> extension.
|
@@ -1,11 +0,0 @@
|
||||
from quart import Blueprint
|
||||
from lnbits.db import Database
|
||||
|
||||
db = Database("ext_hivemind")
|
||||
|
||||
hivemind_ext: Blueprint = Blueprint(
|
||||
"hivemind", __name__, static_folder="static", template_folder="templates"
|
||||
)
|
||||
|
||||
|
||||
from .views import * # noqa
|
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "Hivemind",
|
||||
"short_description": "Make cheap talk expensive!",
|
||||
"icon": "batch_prediction",
|
||||
"contributors": ["fiatjaf"]
|
||||
}
|
@@ -1,10 +0,0 @@
|
||||
# async def m001_initial(db):
|
||||
# await db.execute(
|
||||
# f"""
|
||||
# CREATE TABLE hivemind.hivemind (
|
||||
# id TEXT PRIMARY KEY,
|
||||
# wallet TEXT NOT NULL,
|
||||
# time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
|
||||
# );
|
||||
# """
|
||||
# )
|
@@ -1,11 +0,0 @@
|
||||
# from sqlite3 import Row
|
||||
# from typing import NamedTuple
|
||||
|
||||
|
||||
# class Example(NamedTuple):
|
||||
# id: str
|
||||
# wallet: str
|
||||
#
|
||||
# @classmethod
|
||||
# def from_row(cls, row: Row) -> "Example":
|
||||
# return cls(**dict(row))
|
@@ -1,35 +0,0 @@
|
||||
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||
%} {% block page %}
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h5 class="text-subtitle1 q-mt-none q-mb-md">
|
||||
This extension is just a placeholder for now.
|
||||
</h5>
|
||||
<p>
|
||||
<a href="https://bitcoinhivemind.com/">Hivemind</a> is a Bitcoin sidechain
|
||||
project for a peer-to-peer oracle protocol that absorbs accurate data into
|
||||
a blockchain so that Bitcoin users can speculate in prediction markets.
|
||||
</p>
|
||||
<p>
|
||||
These markets have the potential to revolutionize the emergence of
|
||||
diffusion of knowledge in society and fix all sorts of problems in the
|
||||
world.
|
||||
</p>
|
||||
<p>
|
||||
This extension will become fully operative when the
|
||||
<a href="https://drivechain.xyz/">BIP300</a> soft-fork gets activated and
|
||||
Bitcoin Hivemind is launched.
|
||||
</p>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||
<script>
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
@@ -1,15 +0,0 @@
|
||||
from quart import g, render_template
|
||||
|
||||
from lnbits.decorators import check_user_exists, validate_uuids
|
||||
|
||||
from . import hivemind_ext
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
@hivemind_ext.route("/")
|
||||
@validate_uuids(["usr"], required=True)
|
||||
@check_user_exists()
|
||||
async def index(request: Request):
|
||||
return await templates.TemplateResponse("hivemind/index.html", {"request": request, "user":g.user})
|
@@ -1,36 +0,0 @@
|
||||
# Jukebox
|
||||
|
||||
## An actual Jukebox where users pay sats to play their favourite music from your playlists
|
||||
|
||||
**Note:** To use this extension you need a Premium Spotify subscription.
|
||||
|
||||
## Usage
|
||||
|
||||
1. Click on "ADD SPOTIFY JUKEBOX"\
|
||||

|
||||
2. Follow the steps required on the form\
|
||||
|
||||
- give your jukebox a name
|
||||
- select a wallet to receive payment
|
||||
- define the price a user must pay to select a song\
|
||||

|
||||
- follow the steps to get your Spotify App and get the client ID and secret key\
|
||||

|
||||
- paste the codes in the form\
|
||||

|
||||
- copy the _Redirect URL_ presented on the form\
|
||||

|
||||
- on Spotify click the "EDIT SETTINGS" button and paste the copied link in the _Redirect URI's_ prompt
|
||||

|
||||
- back on LNBits, click "AUTORIZE ACCESS" and "Agree" on the page that will open
|
||||
- choose on which device the LNBits Jukebox extensions will stream to, you may have to be logged in in order to select the device (browser, smartphone app, etc...)
|
||||
- and select what playlist will be available for users to choose songs (you need to have already playlist on Spotify)\
|
||||

|
||||
|
||||
3. After Jukebox is created, click the icon to open the dialog with the shareable QR, open the Jukebox page, etc...\
|
||||

|
||||
4. The users will see the Jukebox page and choose a song from the selected playlist\
|
||||

|
||||
5. After selecting a song they'd like to hear next a dialog will show presenting the music\
|
||||

|
||||
6. After payment, the song will automatically start playing on the device selected or enter the queue if some other music is already playing
|
@@ -1,17 +0,0 @@
|
||||
from quart import Blueprint
|
||||
|
||||
from lnbits.db import Database
|
||||
|
||||
db = Database("ext_jukebox")
|
||||
|
||||
jukebox_ext: Blueprint = Blueprint(
|
||||
"jukebox", __name__, static_folder="static", template_folder="templates"
|
||||
)
|
||||
|
||||
from .views_api import * # noqa
|
||||
from .views import * # noqa
|
||||
from .tasks import register_listeners
|
||||
|
||||
from lnbits.tasks import record_async
|
||||
|
||||
jukebox_ext.record(record_async(register_listeners))
|