Put extensions back so we can start converting

This commit is contained in:
Ben Arc 2021-08-20 12:44:03 +01:00
parent bfd9ca68e4
commit fe123d6d31
273 changed files with 26031 additions and 0 deletions

View File

@ -0,0 +1,11 @@
<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>

View File

@ -0,0 +1,12 @@
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

View File

@ -0,0 +1,6 @@
{
"name": "AMilk",
"short_description": "Assistant Faucet Milker",
"icon": "room_service",
"contributors": ["arcbtc"]
}

View File

@ -0,0 +1,42 @@
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,))

View File

@ -0,0 +1,15 @@
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
);
"""
)

View File

@ -0,0 +1,9 @@
from typing import NamedTuple
class AMilk(NamedTuple):
id: str
wallet: str
lnurl: str
atime: int
amount: int

View File

@ -0,0 +1,24 @@
<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>

View File

@ -0,0 +1,250 @@
{% 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 %}

View File

@ -0,0 +1,23 @@
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)

View File

@ -0,0 +1,105 @@
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

View File

@ -0,0 +1,21 @@
# 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.

View File

@ -0,0 +1,12 @@
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

View File

@ -0,0 +1,6 @@
{
"name": "Bleskomat",
"short_description": "Connect a Bleskomat ATM to an lnbits",
"icon": "money",
"contributors": ["chill117"]
}

View File

@ -0,0 +1,119 @@
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

View File

@ -0,0 +1,79 @@
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

View File

@ -0,0 +1,166 @@
{
"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"
}

View File

@ -0,0 +1,153 @@
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

View File

@ -0,0 +1,134 @@
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

View File

@ -0,0 +1,37 @@
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)
);
"""
)

View File

@ -0,0 +1,110 @@
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
class Bleskomat(NamedTuple):
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(NamedTuple):
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

View File

@ -0,0 +1,216 @@
/* 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)
}
}
})

View File

@ -0,0 +1,65 @@
<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>

View File

@ -0,0 +1,180 @@
{% 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 %}

View File

@ -0,0 +1,22 @@
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
@bleskomat_ext.route("/")
@validate_uuids(["usr"], required=True)
@check_user_exists()
async def index():
bleskomat_vars = {
"callback_url": get_callback_url(),
"exchange_rate_providers": exchange_rate_providers_serializable,
"fiat_currencies": fiat_currencies,
}
return await render_template(
"bleskomat/index.html", user=g.user, bleskomat_vars=bleskomat_vars
)

View File

@ -0,0 +1,120 @@
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.route("/api/v1/bleskomats", methods=["GET"])
@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 (
jsonify(
[bleskomat._asdict() for bleskomat in await get_bleskomats(wallet_ids)]
),
HTTPStatus.OK,
)
@bleskomat_ext.route("/api/v1/bleskomat/<bleskomat_id>", methods=["GET"])
@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
@bleskomat_ext.route("/api/v1/bleskomat", methods=["POST"])
@bleskomat_ext.route("/api/v1/bleskomat/<bleskomat_id>", methods=["PUT"])
@api_check_wallet_key("admin")
@api_validate_post_request(
schema={
"name": {"type": "string", "empty": False, "required": True},
"fiat_currency": {
"type": "string",
"allowed": fiat_currencies.keys(),
"required": True,
},
"exchange_rate_provider": {
"type": "string",
"allowed": exchange_rate_providers.keys(),
"required": True,
},
"fee": {"type": ["string", "float", "number", "integer"], "required": True},
}
)
async def api_bleskomat_create_or_update(bleskomat_id=None):
try:
fiat_currency = g.data["fiat_currency"]
exchange_rate_provider = g.data["exchange_rate_provider"]
await fetch_fiat_exchange_rate(
currency=fiat_currency, provider=exchange_rate_provider
)
except Exception as e:
print(e)
return (
jsonify(
{
"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, **g.data)
else:
bleskomat = await create_bleskomat(wallet_id=g.wallet.id, **g.data)
return (
jsonify(bleskomat._asdict()),
HTTPStatus.OK if bleskomat_id else HTTPStatus.CREATED,
)
@bleskomat_ext.route("/api/v1/bleskomat/<bleskomat_id>", methods=["DELETE"])
@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

View File

@ -0,0 +1,11 @@
<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>

View File

@ -0,0 +1,12 @@
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

View File

@ -0,0 +1,6 @@
{
"name": "Captcha",
"short_description": "Create captcha to stop spam",
"icon": "block",
"contributors": ["pseudozach"]
}

View File

@ -0,0 +1,53 @@
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,))

View File

@ -0,0 +1,63 @@
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")

View File

@ -0,0 +1,23 @@
import json
from sqlite3 import Row
from typing import NamedTuple, Optional
class Captcha(NamedTuple):
id: str
wallet: str
url: str
memo: str
description: str
amount: int
time: int
remembers: bool
extras: Optional[dict]
@classmethod
def from_row(cls, row: Row) -> "Captcha":
data = dict(row)
data["remembers"] = bool(data["remembers"])
data["extras"] = json.loads(data["extras"]) if data["extras"] else None
return cls(**data)

View File

@ -0,0 +1,82 @@
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;">&times;</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)

View File

@ -0,0 +1,147 @@
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="API info"
:content-inset-level="0.5"
>
<q-expansion-item group="api" dense expand-separator label="List 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": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>[&lt;captcha_object&gt;, ...]</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": &lt;admin_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code
>{"amount": &lt;integer&gt;, "description": &lt;string&gt;, "memo":
&lt;string&gt;, "remembers": &lt;boolean&gt;, "url":
&lt;string&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
</h5>
<code
>{"amount": &lt;integer&gt;, "description": &lt;string&gt;, "id":
&lt;string&gt;, "memo": &lt;string&gt;, "remembers": &lt;boolean&gt;,
"time": &lt;int&gt;, "url": &lt;string&gt;, "wallet":
&lt;string&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.url_root }}captcha/api/v1/captchas -d
'{"url": &lt;string&gt;, "memo": &lt;string&gt;, "description":
&lt;string&gt;, "amount": &lt;integer&gt;, "remembers":
&lt;boolean&gt;}' -H "Content-type: application/json" -H "X-Api-Key:
{{ g.user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Create an invoice (public)"
>
<q-card>
<q-card-section>
<code
><span class="text-green">POST</span>
/captcha/api/v1/captchas/&lt;captcha_id&gt;/invoice</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code>{"amount": &lt;integer&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
</h5>
<code
>{"payment_hash": &lt;string&gt;, "payment_request":
&lt;string&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.url_root
}}captcha/api/v1/captchas/&lt;captcha_id&gt;/invoice -d '{"amount":
&lt;integer&gt;}' -H "Content-type: application/json"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Check invoice status (public)"
>
<q-card>
<q-card-section>
<code
><span class="text-green">POST</span>
/captcha/api/v1/captchas/&lt;captcha_id&gt;/check_invoice</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code>{"payment_hash": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>{"paid": false}</code><br />
<code
>{"paid": true, "url": &lt;string&gt;, "remembers":
&lt;boolean&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.url_root
}}captcha/api/v1/captchas/&lt;captcha_id&gt;/check_invoice -d
'{"payment_hash": &lt;string&gt;}' -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/&lt;captcha_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Returns 204 NO CONTENT</h5>
<code></code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X DELETE {{ request.url_root
}}captcha/api/v1/captchas/&lt;captcha_id&gt; -H "X-Api-Key: {{
g.user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
</q-expansion-item>

View File

@ -0,0 +1,178 @@
{% 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 %}

View File

@ -0,0 +1,427 @@
{% 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 %}

View File

@ -0,0 +1,22 @@
from quart import g, abort, render_template
from http import HTTPStatus
from lnbits.decorators import check_user_exists, validate_uuids
from . import captcha_ext
from .crud import get_captcha
@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(captcha_id):
captcha = await get_captcha(captcha_id) or abort(
HTTPStatus.NOT_FOUND, "captcha does not exist."
)
return await render_template("captcha/display.html", captcha=captcha)

View File

@ -0,0 +1,121 @@
from quart import g, jsonify, request
from http import HTTPStatus
from lnbits.core.crud import get_user, get_wallet
from lnbits.core.services import create_invoice, check_invoice_status
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
from . import captcha_ext
from .crud import create_captcha, get_captcha, get_captchas, delete_captcha
@captcha_ext.route("/api/v1/captchas", methods=["GET"])
@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.route("/api/v1/captchas", methods=["POST"])
@api_check_wallet_key("invoice")
@api_validate_post_request(
schema={
"url": {"type": "string", "empty": False, "required": True},
"memo": {"type": "string", "empty": False, "required": True},
"description": {
"type": "string",
"empty": True,
"nullable": True,
"required": False,
},
"amount": {"type": "integer", "min": 0, "required": True},
"remembers": {"type": "boolean", "required": True},
}
)
async def api_captcha_create():
captcha = await create_captcha(wallet_id=g.wallet.id, **g.data)
return jsonify(captcha._asdict()), HTTPStatus.CREATED
@captcha_ext.route("/api/v1/captchas/<captcha_id>", methods=["DELETE"])
@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.route("/api/v1/captchas/<captcha_id>/invoice", methods=["POST"])
@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.route("/api/v1/captchas/<captcha_id>/check_invoice", methods=["POST"])
@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 jsonify({"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 jsonify({"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 (
jsonify({"paid": True, "url": captcha.url, "remembers": captcha.remembers}),
HTTPStatus.OK,
)
return jsonify({"paid": False}), HTTPStatus.OK

View File

@ -0,0 +1,10 @@
<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>

View File

@ -0,0 +1,10 @@
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

View File

@ -0,0 +1,6 @@
{
"name": "Diagon Alley",
"short_description": "Movable anonymous market stand",
"icon": "add_shopping_cart",
"contributors": ["benarc"]
}

View File

@ -0,0 +1,308 @@
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,))

View File

@ -0,0 +1,60 @@
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
);
"""
)

View File

@ -0,0 +1,40 @@
from typing import NamedTuple
class Indexers(NamedTuple):
id: str
wallet: str
shopname: str
indexeraddress: str
online: bool
rating: str
shippingzone1: str
shippingzone2: str
zone1cost: int
zone2cost: int
email: str
class Products(NamedTuple):
id: str
wallet: str
product: str
categories: str
description: str
image: str
price: int
quantity: int
class Orders(NamedTuple):
id: str
productid: str
wallet: str
product: str
quantity: int
shippingzone: int
address: str
email: str
invoiceid: str
paid: bool
shipped: bool

View File

@ -0,0 +1,122 @@
<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/&lt;indexer_id&gt;</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/&lt;indexer_id&gt;</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/&lt;indexer_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code
>{"id": &lt;string&gt;, "address": &lt;string&gt;, "shippingzone":
&lt;integer&gt;, "email": &lt;string&gt;, "quantity":
&lt;integer&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
</h5>
<code
>{"checking_id": &lt;string&gt;,"payment_request":
&lt;string&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.url_root
}}diagonalley/api/v1/diagonalley/stall/order/&lt;indexer_id&gt; -d
'{"id": &lt;product_id&&gt;, "email": &lt;customer_email&gt;,
"address": &lt;customer_address&gt;, "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/&lt;checking_id&gt;</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": &lt;boolean&gt;}</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/&lt;checking_id&gt;
-H "Content-type: application/json"</code
>
</q-card-section>
</q-card>
</q-expansion-item>
</q-expansion-item>

View File

@ -0,0 +1,906 @@
{% 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 %}

View File

@ -0,0 +1,3 @@
<script>
console.log('{{ stall }}')
</script>

View File

@ -0,0 +1,11 @@
from quart import g, render_template
from lnbits.decorators import check_user_exists, validate_uuids
from lnbits.extensions.diagonalley import diagonalley_ext
@diagonalley_ext.route("/")
@validate_uuids(["usr"], required=True)
@check_user_exists()
async def index():
return await render_template("diagonalley/index.html", user=g.user)

View File

@ -0,0 +1,360 @@
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.route("/api/v1/diagonalley/products", methods=["GET"])
@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,
)
@diagonalley_ext.route("/api/v1/diagonalley/products", methods=["POST"])
@diagonalley_ext.route("/api/v1/diagonalley/products<product_id>", methods=["PUT"])
@api_check_wallet_key(key_type="invoice")
@api_validate_post_request(
schema={
"product": {"type": "string", "empty": False, "required": True},
"categories": {"type": "string", "empty": False, "required": True},
"description": {"type": "string", "empty": False, "required": True},
"image": {"type": "string", "empty": False, "required": True},
"price": {"type": "integer", "min": 0, "required": True},
"quantity": {"type": "integer", "min": 0, "required": True},
}
)
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.route("/api/v1/diagonalley/products/<product_id>", methods=["DELETE"])
@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.route("/api/v1/diagonalley/indexers", methods=["GET"])
@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,
)
@diagonalley_ext.route("/api/v1/diagonalley/indexers", methods=["POST"])
@diagonalley_ext.route("/api/v1/diagonalley/indexers<indexer_id>", methods=["PUT"])
@api_check_wallet_key(key_type="invoice")
@api_validate_post_request(
schema={
"shopname": {"type": "string", "empty": False, "required": True},
"indexeraddress": {"type": "string", "empty": False, "required": True},
"shippingzone1": {"type": "string", "empty": False, "required": True},
"shippingzone2": {"type": "string", "empty": False, "required": True},
"email": {"type": "string", "empty": False, "required": True},
"zone1cost": {"type": "integer", "min": 0, "required": True},
"zone2cost": {"type": "integer", "min": 0, "required": True},
}
)
async def api_diagonalley_indexer_create(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, **g.data)
else:
indexer = create_diagonalleys_indexer(wallet_id=g.wallet.id, **g.data)
return (
jsonify(indexer._asdict()),
HTTPStatus.OK if indexer_id else HTTPStatus.CREATED,
)
@diagonalley_ext.route("/api/v1/diagonalley/indexers/<indexer_id>", methods=["DELETE"])
@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 jsonify({"message": "Indexer does not exist."}), HTTPStatus.NOT_FOUND
if indexer.wallet != g.wallet.id:
return jsonify({"message": "Not your Indexer."}), HTTPStatus.FORBIDDEN
delete_diagonalleys_indexer(indexer_id)
return "", HTTPStatus.NO_CONTENT
###Orders
@diagonalley_ext.route("/api/v1/diagonalley/orders", methods=["GET"])
@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 (
jsonify([order._asdict() for order in get_diagonalleys_orders(wallet_ids)]),
HTTPStatus.OK,
)
@diagonalley_ext.route("/api/v1/diagonalley/orders", methods=["POST"])
@api_check_wallet_key(key_type="invoice")
@api_validate_post_request(
schema={
"id": {"type": "string", "empty": False, "required": True},
"address": {"type": "string", "empty": False, "required": True},
"email": {"type": "string", "empty": False, "required": True},
"quantity": {"type": "integer", "empty": False, "required": True},
"shippingzone": {"type": "integer", "empty": False, "required": True},
}
)
async def api_diagonalley_order_create():
order = create_diagonalleys_order(wallet_id=g.wallet.id, **g.data)
return jsonify(order._asdict()), HTTPStatus.CREATED
@diagonalley_ext.route("/api/v1/diagonalley/orders/<order_id>", methods=["DELETE"])
@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 jsonify({"message": "Indexer does not exist."}), HTTPStatus.NOT_FOUND
if order.wallet != g.wallet.id:
return jsonify({"message": "Not your Indexer."}), HTTPStatus.FORBIDDEN
delete_diagonalleys_indexer(order_id)
return "", HTTPStatus.NO_CONTENT
@diagonalley_ext.route("/api/v1/diagonalley/orders/paid/<order_id>", methods=["GET"])
@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.route("/api/v1/diagonalley/orders/shipped/<order_id>", methods=["GET"])
@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.route(
"/api/v1/diagonalley/stall/products/<indexer_id>", methods=["GET"]
)
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 jsonify({"message": "Indexer does not exist."}), HTTPStatus.NOT_FOUND
products = db.fetchone(
"SELECT * FROM diagonalley.products WHERE wallet = ?", (rows[1],)
)
if not products:
return jsonify({"message": "No products"}), HTTPStatus.NOT_FOUND
return (
jsonify(
[products._asdict() for products in get_diagonalleys_products(rows[1])]
),
HTTPStatus.OK,
)
###Check a product has been shipped
@diagonalley_ext.route(
"/api/v1/diagonalley/stall/checkshipped/<checking_id>", methods=["GET"]
)
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 jsonify({"shipped": rows["shipped"]}), HTTPStatus.OK
###Place order
@diagonalley_ext.route("/api/v1/diagonalley/stall/order/<indexer_id>", methods=["POST"])
@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 (
jsonify({"checking_id": checking_id, "payment_request": payment_request}),
HTTPStatus.OK,
)

View File

@ -0,0 +1,33 @@
# 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\
![create event](https://i.imgur.com/dadK1dp.jpg)
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
![event info](https://imgur.com/KAv68Yr.jpg)
3. Share the event registration link\
![event ticket](https://imgur.com/AQWUOBY.jpg)
- ticket example\
![ticket example](https://i.imgur.com/trAVSLd.jpg)
- QR code ticket, presented after invoice paid, to present at registration\
![event ticket](https://i.imgur.com/M0ROM82.jpg)
4. Use the built-in ticket scanner to validate registered, and paid, attendees\
![ticket scanner](https://i.imgur.com/zrm9202.jpg)

View File

@ -0,0 +1,13 @@
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

View File

@ -0,0 +1,6 @@
{
"name": "Events",
"short_description": "Sell and register event tickets",
"icon": "local_activity",
"contributors": ["benarc"]
}

View File

@ -0,0 +1,168 @@
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]

View File

@ -0,0 +1,91 @@
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")

View File

@ -0,0 +1,26 @@
from typing import NamedTuple
class Events(NamedTuple):
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(NamedTuple):
id: str
wallet: str
event: str
name: str
email: str
registered: bool
paid: bool
time: int

View File

@ -0,0 +1,23 @@
<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>

View File

@ -0,0 +1,207 @@
{% 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 %}

View File

@ -0,0 +1,35 @@
{% 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>

View File

@ -0,0 +1,538 @@
{% 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 %}

View File

@ -0,0 +1,173 @@
{% 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 %}

View File

@ -0,0 +1,45 @@
{% 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 %}

View File

@ -0,0 +1,76 @@
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
@events_ext.route("/")
@validate_uuids(["usr"], required=True)
@check_user_exists()
async def index():
return await render_template("events/index.html", user=g.user)
@events_ext.route("/<event_id>")
async def display(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 render_template(
"events/error.html",
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 render_template(
"events/error.html",
event_name=event.name,
event_error="Sorry, ticket closing date has passed :(",
)
return await render_template(
"events/display.html",
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(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 render_template(
"events/ticket.html",
ticket_id=ticket_id,
ticket_name=event.name,
ticket_info=event.info,
)
@events_ext.route("/register/<event_id>")
async def register(event_id):
event = await get_event(event_id)
if not event:
abort(HTTPStatus.NOT_FOUND, "Event does not exist.")
return await render_template(
"events/register.html",
event_id=event_id,
event_name=event.name,
wallet_id=event.wallet,
)

View File

@ -0,0 +1,207 @@
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 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.route("/api/v1/events", methods=["GET"])
@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 (
jsonify([event._asdict() for event in await get_events(wallet_ids)]),
HTTPStatus.OK,
)
@events_ext.route("/api/v1/events", methods=["POST"])
@events_ext.route("/api/v1/events/<event_id>", methods=["PUT"])
@api_check_wallet_key("invoice")
@api_validate_post_request(
schema={
"wallet": {"type": "string", "empty": False, "required": True},
"name": {"type": "string", "empty": False, "required": True},
"info": {"type": "string", "min": 0, "required": True},
"closing_date": {"type": "string", "empty": False, "required": True},
"event_start_date": {"type": "string", "empty": False, "required": True},
"event_end_date": {"type": "string", "empty": False, "required": True},
"amount_tickets": {"type": "integer", "min": 0, "required": True},
"price_per_ticket": {"type": "integer", "min": 0, "required": True},
}
)
async def api_event_create(event_id=None):
if event_id:
event = await get_event(event_id)
if not event:
return jsonify({"message": "Form does not exist."}), HTTPStatus.NOT_FOUND
if event.wallet != g.wallet.id:
return jsonify({"message": "Not your event."}), HTTPStatus.FORBIDDEN
event = await update_event(event_id, **g.data)
else:
event = await create_event(**g.data)
return jsonify(event._asdict()), HTTPStatus.CREATED
@events_ext.route("/api/v1/events/<event_id>", methods=["DELETE"])
@api_check_wallet_key("invoice")
async def api_form_delete(event_id):
event = await get_event(event_id)
if not event:
return jsonify({"message": "Event does not exist."}), HTTPStatus.NOT_FOUND
if event.wallet != g.wallet.id:
return jsonify({"message": "Not your event."}), HTTPStatus.FORBIDDEN
await delete_event(event_id)
return "", HTTPStatus.NO_CONTENT
#########Tickets##########
@events_ext.route("/api/v1/tickets", methods=["GET"])
@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 (
jsonify([ticket._asdict() for ticket in await get_tickets(wallet_ids)]),
HTTPStatus.OK,
)
@events_ext.route("/api/v1/tickets/<event_id>/<sats>", methods=["POST"])
@api_validate_post_request(
schema={
"name": {"type": "string", "empty": False, "required": True},
"email": {"type": "string", "empty": False, "required": True},
}
)
async def api_ticket_make_ticket(event_id, sats):
event = await get_event(event_id)
if not event:
return jsonify({"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 jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR
ticket = await create_ticket(
payment_hash=payment_hash, wallet=event.wallet, event=event_id, **g.data
)
if not ticket:
return jsonify({"message": "Event could not be fetched."}), HTTPStatus.NOT_FOUND
return (
jsonify({"payment_hash": payment_hash, "payment_request": payment_request}),
HTTPStatus.OK,
)
@events_ext.route("/api/v1/tickets/<payment_hash>", methods=["GET"])
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 jsonify({"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 jsonify({"paid": True, "ticket_id": ticket.id}), HTTPStatus.OK
return jsonify({"paid": False}), HTTPStatus.OK
@events_ext.route("/api/v1/tickets/<ticket_id>", methods=["DELETE"])
@api_check_wallet_key("invoice")
async def api_ticket_delete(ticket_id):
ticket = await get_ticket(ticket_id)
if not ticket:
return jsonify({"message": "Ticket does not exist."}), HTTPStatus.NOT_FOUND
if ticket.wallet != g.wallet.id:
return jsonify({"message": "Not your ticket."}), HTTPStatus.FORBIDDEN
await delete_ticket(ticket_id)
return "", HTTPStatus.NO_CONTENT
# Event Tickets
@events_ext.route("/api/v1/eventtickets/<wallet_id>/<event_id>", methods=["GET"])
async def api_event_tickets(wallet_id, event_id):
return (
jsonify(
[
ticket._asdict()
for ticket in await get_event_tickets(
wallet_id=wallet_id, event_id=event_id
)
]
),
HTTPStatus.OK,
)
@events_ext.route("/api/v1/register/ticket/<ticket_id>", methods=["GET"])
async def api_event_register_ticket(ticket_id):
ticket = await get_ticket(ticket_id)
if not ticket:
return jsonify({"message": "Ticket does not exist."}), HTTPStatus.FORBIDDEN
if not ticket.paid:
return jsonify({"message": "Ticket not paid for."}), HTTPStatus.FORBIDDEN
if ticket.registered == True:
return jsonify({"message": "Ticket already registered"}), HTTPStatus.FORBIDDEN
return (
jsonify([ticket._asdict() for ticket in await reg_ticket(ticket_id)]),
HTTPStatus.OK,
)

View File

@ -0,0 +1,11 @@
<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>

View File

@ -0,0 +1,12 @@
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

View File

@ -0,0 +1,6 @@
{
"name": "Build your own!",
"short_description": "Join us, make an extension",
"icon": "info",
"contributors": ["github_username"]
}

View File

@ -0,0 +1,10 @@
# 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}
# );
# """
# )

View File

@ -0,0 +1,11 @@
# 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))

View File

@ -0,0 +1,59 @@
{% 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 %}

View File

@ -0,0 +1,12 @@
from quart import g, render_template
from lnbits.decorators import check_user_exists, validate_uuids
from . import example_ext
@example_ext.route("/")
@validate_uuids(["usr"], required=True)
@check_user_exists()
async def index():
return await render_template("example/index.html", user=g.user)

View File

@ -0,0 +1,40 @@
# 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

View File

@ -0,0 +1,3 @@
<h1>Hivemind</h1>
Placeholder for a future <a href="https://bitcoinhivemind.com/">Bitcoin Hivemind</a> extension.

View File

@ -0,0 +1,11 @@
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

View File

@ -0,0 +1,6 @@
{
"name": "Hivemind",
"short_description": "Make cheap talk expensive!",
"icon": "batch_prediction",
"contributors": ["fiatjaf"]
}

View File

@ -0,0 +1,10 @@
# 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}
# );
# """
# )

View File

@ -0,0 +1,11 @@
# 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))

View File

@ -0,0 +1,35 @@
{% 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 %}

View File

@ -0,0 +1,12 @@
from quart import g, render_template
from lnbits.decorators import check_user_exists, validate_uuids
from . import hivemind_ext
@hivemind_ext.route("/")
@validate_uuids(["usr"], required=True)
@check_user_exists()
async def index():
return await render_template("hivemind/index.html", user=g.user)

View File

@ -0,0 +1,36 @@
# 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"\
![add jukebox](https://i.imgur.com/NdVoKXd.png)
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\
![pick wallet price](https://i.imgur.com/4bJ8mb9.png)
- follow the steps to get your Spotify App and get the client ID and secret key\
![spotify keys](https://i.imgur.com/w2EzFtB.png)
- paste the codes in the form\
![api keys](https://i.imgur.com/6b9xauo.png)
- copy the _Redirect URL_ presented on the form\
![redirect url](https://i.imgur.com/GMzl0lG.png)
- on Spotify click the "EDIT SETTINGS" button and paste the copied link in the _Redirect URI's_ prompt
![spotify app setting](https://i.imgur.com/vb0x4Tl.png)
- 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)\
![select playlists](https://i.imgur.com/g4dbtED.png)
3. After Jukebox is created, click the icon to open the dialog with the shareable QR, open the Jukebox page, etc...\
![shareable jukebox](https://i.imgur.com/EAh9PI0.png)
4. The users will see the Jukebox page and choose a song from the selected playlist\
![select song](https://i.imgur.com/YYjeQAs.png)
5. After selecting a song they'd like to hear next a dialog will show presenting the music\
![play for sats](https://i.imgur.com/eEHl3o8.png)
6. After payment, the song will automatically start playing on the device selected or enter the queue if some other music is already playing

View File

@ -0,0 +1,17 @@
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))

View File

@ -0,0 +1,6 @@
{
"name": "SpotifyJukebox",
"short_description": "Spotify jukebox middleware",
"icon": "radio",
"contributors": ["benarc"]
}

View File

@ -0,0 +1,122 @@
from typing import List, Optional
from . import db
from .models import Jukebox, JukeboxPayment
from lnbits.helpers import urlsafe_short_hash
async def create_jukebox(
inkey: str,
user: str,
wallet: str,
title: str,
price: int,
sp_user: str,
sp_secret: str,
sp_access_token: Optional[str] = "",
sp_refresh_token: Optional[str] = "",
sp_device: Optional[str] = "",
sp_playlists: Optional[str] = "",
) -> Jukebox:
juke_id = urlsafe_short_hash()
result = await db.execute(
"""
INSERT INTO jukebox.jukebox (id, user, title, wallet, sp_user, sp_secret, sp_access_token, sp_refresh_token, sp_device, sp_playlists, price, profit)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
juke_id,
user,
title,
wallet,
sp_user,
sp_secret,
sp_access_token,
sp_refresh_token,
sp_device,
sp_playlists,
int(price),
0,
),
)
jukebox = await get_jukebox(juke_id)
assert jukebox, "Newly created Jukebox couldn't be retrieved"
return jukebox
async def update_jukebox(juke_id: str, **kwargs) -> Optional[Jukebox]:
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
await db.execute(
f"UPDATE jukebox.jukebox SET {q} WHERE id = ?", (*kwargs.values(), juke_id)
)
row = await db.fetchone("SELECT * FROM jukebox.jukebox WHERE id = ?", (juke_id,))
return Jukebox(**row) if row else None
async def get_jukebox(juke_id: str) -> Optional[Jukebox]:
row = await db.fetchone("SELECT * FROM jukebox.jukebox WHERE id = ?", (juke_id,))
return Jukebox(**row) if row else None
async def get_jukebox_by_user(user: str) -> Optional[Jukebox]:
row = await db.fetchone("SELECT * FROM jukebox.jukebox WHERE sp_user = ?", (user,))
return Jukebox(**row) if row else None
async def get_jukeboxs(user: str) -> List[Jukebox]:
rows = await db.fetchall("SELECT * FROM jukebox.jukebox WHERE user = ?", (user,))
for row in rows:
if row.sp_playlists == "":
await delete_jukebox(row.id)
rows = await db.fetchall("SELECT * FROM jukebox.jukebox WHERE user = ?", (user,))
return [Jukebox.from_row(row) for row in rows]
async def delete_jukebox(juke_id: str):
await db.execute(
"""
DELETE FROM jukebox.jukebox WHERE id = ?
""",
(juke_id),
)
#####################################PAYMENTS
async def create_jukebox_payment(
song_id: str, payment_hash: str, juke_id: str
) -> JukeboxPayment:
result = await db.execute(
"""
INSERT INTO jukebox.jukebox_payment (payment_hash, juke_id, song_id, paid)
VALUES (?, ?, ?, ?)
""",
(
payment_hash,
juke_id,
song_id,
False,
),
)
jukebox_payment = await get_jukebox_payment(payment_hash)
assert jukebox_payment, "Newly created Jukebox Payment couldn't be retrieved"
return jukebox_payment
async def update_jukebox_payment(
payment_hash: str, **kwargs
) -> Optional[JukeboxPayment]:
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
await db.execute(
f"UPDATE jukebox.jukebox_payment SET {q} WHERE payment_hash = ?",
(*kwargs.values(), payment_hash),
)
return await get_jukebox_payment(payment_hash)
async def get_jukebox_payment(payment_hash: str) -> Optional[JukeboxPayment]:
row = await db.fetchone(
"SELECT * FROM jukebox.jukebox_payment WHERE payment_hash = ?", (payment_hash,)
)
return JukeboxPayment(**row) if row else None

View File

@ -0,0 +1,39 @@
async def m001_initial(db):
"""
Initial jukebox table.
"""
await db.execute(
"""
CREATE TABLE jukebox.jukebox (
id TEXT PRIMARY KEY,
"user" TEXT,
title TEXT,
wallet TEXT,
inkey TEXT,
sp_user TEXT NOT NULL,
sp_secret TEXT NOT NULL,
sp_access_token TEXT,
sp_refresh_token TEXT,
sp_device TEXT,
sp_playlists TEXT,
price INTEGER,
profit INTEGER
);
"""
)
async def m002_initial(db):
"""
Initial jukebox_payment table.
"""
await db.execute(
"""
CREATE TABLE jukebox.jukebox_payment (
payment_hash TEXT PRIMARY KEY,
juke_id TEXT,
song_id TEXT,
paid BOOL
);
"""
)

View File

@ -0,0 +1,33 @@
from typing import NamedTuple
from sqlite3 import Row
class Jukebox(NamedTuple):
id: str
user: str
title: str
wallet: str
inkey: str
sp_user: str
sp_secret: str
sp_access_token: str
sp_refresh_token: str
sp_device: str
sp_playlists: str
price: int
profit: int
@classmethod
def from_row(cls, row: Row) -> "Jukebox":
return cls(**dict(row))
class JukeboxPayment(NamedTuple):
payment_hash: str
juke_id: str
song_id: str
paid: bool
@classmethod
def from_row(cls, row: Row) -> "JukeboxPayment":
return cls(**dict(row))

View File

@ -0,0 +1,420 @@
/* globals Quasar, Vue, _, VueQrcode, windowMixin, LNbits, LOCALE */
Vue.component(VueQrcode.name, VueQrcode)
var mapJukebox = obj => {
obj._data = _.clone(obj)
obj.sp_id = obj.id
obj.device = obj.sp_device.split('-')[0]
playlists = obj.sp_playlists.split(',')
var i
playlistsar = []
for (i = 0; i < playlists.length; i++) {
playlistsar.push(playlists[i].split('-')[0])
}
obj.playlist = playlistsar.join()
return obj
}
new Vue({
el: '#vue',
mixins: [windowMixin],
data() {
return {
JukeboxTable: {
columns: [
{
name: 'title',
align: 'left',
label: 'Title',
field: 'title'
},
{
name: 'device',
align: 'left',
label: 'Device',
field: 'device'
},
{
name: 'playlist',
align: 'left',
label: 'Playlist',
field: 'playlist'
},
{
name: 'price',
align: 'left',
label: 'Price',
field: 'price'
}
],
pagination: {
rowsPerPage: 10
}
},
isPwd: true,
tokenFetched: true,
devices: [],
filter: '',
jukebox: {},
playlists: [],
JukeboxLinks: [],
step: 1,
locationcbPath: '',
locationcb: '',
jukeboxDialog: {
show: false,
data: {}
},
spotifyDialog: false,
qrCodeDialog: {
show: false,
data: null
}
}
},
computed: {},
methods: {
openQrCodeDialog: function (linkId) {
var link = _.findWhere(this.JukeboxLinks, {id: linkId})
this.qrCodeDialog.data = _.clone(link)
console.log(this.qrCodeDialog.data)
this.qrCodeDialog.data.url =
window.location.protocol + '//' + window.location.host
this.qrCodeDialog.show = true
},
getJukeboxes() {
self = this
LNbits.api
.request(
'GET',
'/jukebox/api/v1/jukebox',
self.g.user.wallets[0].adminkey
)
.then(function (response) {
self.JukeboxLinks = response.data.map(mapJukebox)
})
.catch(err => {
LNbits.utils.notifyApiError(err)
})
},
deleteJukebox(juke_id) {
self = this
LNbits.utils
.confirmDialog('Are you sure you want to delete this Jukebox?')
.onOk(function () {
LNbits.api
.request(
'DELETE',
'/jukebox/api/v1/jukebox/' + juke_id,
self.g.user.wallets[0].adminkey
)
.then(function (response) {
self.JukeboxLinks = _.reject(self.JukeboxLinks, function (obj) {
return obj.id === juke_id
})
})
.catch(err => {
LNbits.utils.notifyApiError(err)
})
})
},
updateJukebox: function (linkId) {
self = this
var link = _.findWhere(self.JukeboxLinks, {id: linkId})
self.jukeboxDialog.data = _.clone(link._data)
console.log(this.jukeboxDialog.data.sp_access_token)
self.refreshDevices()
self.refreshPlaylists()
self.step = 4
self.jukeboxDialog.data.sp_device = []
self.jukeboxDialog.data.sp_playlists = []
self.jukeboxDialog.data.sp_id = self.jukeboxDialog.data.id
self.jukeboxDialog.data.price = String(self.jukeboxDialog.data.price)
self.jukeboxDialog.show = true
},
closeFormDialog() {
this.jukeboxDialog.data = {}
this.jukeboxDialog.show = false
this.step = 1
},
submitSpotifyKeys() {
self = this
self.jukeboxDialog.data.user = self.g.user.id
LNbits.api
.request(
'POST',
'/jukebox/api/v1/jukebox/',
self.g.user.wallets[0].adminkey,
self.jukeboxDialog.data
)
.then(response => {
if (response.data) {
self.jukeboxDialog.data.sp_id = response.data.id
self.step = 3
}
})
.catch(err => {
LNbits.utils.notifyApiError(err)
})
},
authAccess() {
self = this
self.requestAuthorization()
self.getSpotifyTokens()
self.$q.notify({
spinner: true,
message: 'Processing',
timeout: 10000
})
},
getSpotifyTokens() {
self = this
var counter = 0
var timerId = setInterval(function () {
counter++
if (!self.jukeboxDialog.data.sp_user) {
clearInterval(timerId)
}
LNbits.api
.request(
'GET',
'/jukebox/api/v1/jukebox/' + self.jukeboxDialog.data.sp_id,
self.g.user.wallets[0].adminkey
)
.then(response => {
if (response.data.sp_access_token) {
self.fetchAccessToken(response.data.sp_access_token)
if (self.jukeboxDialog.data.sp_access_token) {
self.refreshPlaylists()
self.refreshDevices()
console.log('this.devices')
console.log(self.devices)
console.log('this.devices')
setTimeout(function () {
if (self.devices.length < 1 || self.playlists.length < 1) {
self.$q.notify({
spinner: true,
color: 'red',
message:
'Error! Make sure Spotify is open on the device you wish to use, has playlists, and is playing something',
timeout: 10000
})
LNbits.api
.request(
'DELETE',
'/jukebox/api/v1/jukebox/' + response.data.id,
self.g.user.wallets[0].adminkey
)
.then(function (response) {
self.getJukeboxes()
})
.catch(err => {
LNbits.utils.notifyApiError(err)
})
clearInterval(timerId)
self.closeFormDialog()
} else {
self.step = 4
clearInterval(timerId)
}
}, 2000)
}
}
})
.catch(err => {
LNbits.utils.notifyApiError(err)
})
}, 3000)
},
requestAuthorization() {
self = this
var url = 'https://accounts.spotify.com/authorize'
url += '?client_id=' + self.jukeboxDialog.data.sp_user
url += '&response_type=code'
url +=
'&redirect_uri=' +
encodeURI(self.locationcbPath + self.jukeboxDialog.data.sp_id)
url += '&show_dialog=true'
url +=
'&scope=user-read-private user-read-email user-modify-playback-state user-read-playback-position user-library-read streaming user-read-playback-state user-read-recently-played playlist-read-private'
window.open(url)
},
openNewDialog() {
this.jukeboxDialog.show = true
this.jukeboxDialog.data = {}
},
createJukebox() {
self = this
self.jukeboxDialog.data.sp_playlists = self.jukeboxDialog.data.sp_playlists.join()
self.updateDB()
self.jukeboxDialog.show = false
self.getJukeboxes()
},
updateDB() {
self = this
console.log(self.jukeboxDialog.data)
LNbits.api
.request(
'PUT',
'/jukebox/api/v1/jukebox/' + this.jukeboxDialog.data.sp_id,
self.g.user.wallets[0].adminkey,
self.jukeboxDialog.data
)
.then(function (response) {
console.log(response.data)
if (
self.jukeboxDialog.data.sp_playlists &&
self.jukeboxDialog.data.sp_devices
) {
self.getJukeboxes()
// self.JukeboxLinks.push(mapJukebox(response.data))
}
})
},
playlistApi(method, url, body) {
self = this
let xhr = new XMLHttpRequest()
xhr.open(method, url, true)
xhr.setRequestHeader('Content-Type', 'application/json')
xhr.setRequestHeader(
'Authorization',
'Bearer ' + this.jukeboxDialog.data.sp_access_token
)
xhr.send(body)
xhr.onload = function () {
if (xhr.status == 401) {
self.refreshAccessToken()
self.playlistApi(
'GET',
'https://api.spotify.com/v1/me/playlists',
null
)
}
let responseObj = JSON.parse(xhr.response)
self.jukeboxDialog.data.playlists = null
self.playlists = []
self.jukeboxDialog.data.playlists = []
var i
for (i = 0; i < responseObj.items.length; i++) {
self.playlists.push(
responseObj.items[i].name + '-' + responseObj.items[i].id
)
}
console.log(self.playlists)
}
},
refreshPlaylists() {
self = this
self.playlistApi('GET', 'https://api.spotify.com/v1/me/playlists', null)
},
deviceApi(method, url, body) {
self = this
let xhr = new XMLHttpRequest()
xhr.open(method, url, true)
xhr.setRequestHeader('Content-Type', 'application/json')
xhr.setRequestHeader(
'Authorization',
'Bearer ' + this.jukeboxDialog.data.sp_access_token
)
xhr.send(body)
xhr.onload = function () {
if (xhr.status == 401) {
self.refreshAccessToken()
self.deviceApi(
'GET',
'https://api.spotify.com/v1/me/player/devices',
null
)
}
let responseObj = JSON.parse(xhr.response)
self.jukeboxDialog.data.devices = []
self.devices = []
var i
for (i = 0; i < responseObj.devices.length; i++) {
self.devices.push(
responseObj.devices[i].name + '-' + responseObj.devices[i].id
)
}
}
},
refreshDevices() {
self = this
self.deviceApi(
'GET',
'https://api.spotify.com/v1/me/player/devices',
null
)
},
fetchAccessToken(code) {
self = this
let body = 'grant_type=authorization_code'
body += '&code=' + code
body +=
'&redirect_uri=' +
encodeURI(self.locationcbPath + self.jukeboxDialog.data.sp_id)
self.callAuthorizationApi(body)
},
refreshAccessToken() {
self = this
let body = 'grant_type=refresh_token'
body += '&refresh_token=' + self.jukeboxDialog.data.sp_refresh_token
body += '&client_id=' + self.jukeboxDialog.data.sp_user
self.callAuthorizationApi(body)
},
callAuthorizationApi(body) {
self = this
console.log(
btoa(
self.jukeboxDialog.data.sp_user +
':' +
self.jukeboxDialog.data.sp_secret
)
)
let xhr = new XMLHttpRequest()
xhr.open('POST', 'https://accounts.spotify.com/api/token', true)
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded')
xhr.setRequestHeader(
'Authorization',
'Basic ' +
btoa(
self.jukeboxDialog.data.sp_user +
':' +
self.jukeboxDialog.data.sp_secret
)
)
xhr.send(body)
xhr.onload = function () {
let responseObj = JSON.parse(xhr.response)
if (responseObj.access_token) {
self.jukeboxDialog.data.sp_access_token = responseObj.access_token
self.jukeboxDialog.data.sp_refresh_token = responseObj.refresh_token
self.updateDB()
}
}
}
},
created() {
console.log(this.g.user.wallets[0])
var getJukeboxes = this.getJukeboxes
getJukeboxes()
this.selectedWallet = this.g.user.wallets[0]
this.locationcbPath = String(
[
window.location.protocol,
'//',
window.location.host,
'/jukebox/api/v1/jukebox/spotify/cb/'
].join('')
)
this.locationcb = this.locationcbPath
}
})

View File

@ -0,0 +1,14 @@
/* globals Quasar, Vue, _, VueQrcode, windowMixin, LNbits, LOCALE */
Vue.component(VueQrcode.name, VueQrcode)
new Vue({
el: '#vue',
mixins: [windowMixin],
data() {
return {}
},
computed: {},
methods: {},
created() {}
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

View File

@ -0,0 +1,28 @@
import json
import trio # type: ignore
from lnbits.core.models import Payment
from lnbits.core.crud import create_payment
from lnbits.core import db as core_db
from lnbits.tasks import register_invoice_listener, internal_invoice_paid
from lnbits.helpers import urlsafe_short_hash
from .crud import get_jukebox, update_jukebox_payment
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:
if "jukebox" != payment.extra.get("tag"):
# not a jukebox invoice
return
await update_jukebox_payment(payment.payment_hash, paid=True)

View File

@ -0,0 +1,125 @@
<q-card-section>
To use this extension you need a Spotify client ID and client secret. You get
these by creating an app in the Spotify developers dashboard
<a
style="color: #43a047"
href="https://developer.spotify.com/dashboard/applications"
>here
</a>
<br /><br />Select the playlists you want people to be able to pay for, share
the frontend page, profit :) <br /><br />
Made by,
<a style="color: #43a047" href="https://twitter.com/arcbtc">benarc</a>.
Inspired by,
<a
style="color: #43a047"
href="https://twitter.com/pirosb3/status/1056263089128161280"
>pirosb3</a
>.
</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="List jukeboxes">
<q-card>
<q-card-section>
<code><span class="text-blue">GET</span> /jukebox/api/v1/jukebox</code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>[&lt;jukebox_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root }}api/v1/jukebox -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 jukebox">
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span>
/jukebox/api/v1/jukebox/&lt;juke_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>&lt;jukebox_object&gt;</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root }}api/v1/jukebox/&lt;juke_id&gt; -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/update track"
>
<q-card>
<q-card-section>
<code
><span class="text-green">POST/PUT</span>
/jukebox/api/v1/jukebox/</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>&lt;jukbox_object&gt;</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.url_root }}api/v1/jukebox/ -d '{"user":
&lt;string, user_id&gt;, "title": &lt;string&gt;,
"wallet":&lt;string&gt;, "sp_user": &lt;string,
spotify_user_account&gt;, "sp_secret": &lt;string,
spotify_user_secret&gt;, "sp_access_token": &lt;string,
not_required&gt;, "sp_refresh_token": &lt;string, not_required&gt;,
"sp_device": &lt;string, spotify_user_secret&gt;, "sp_playlists":
&lt;string, not_required&gt;, "price": &lt;integer, not_required&gt;}'
-H "Content-type: application/json" -H "X-Api-Key:
{{g.user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Delete jukebox">
<q-card>
<q-card-section>
<code
><span class="text-red">DELETE</span>
/jukebox/api/v1/jukebox/&lt;juke_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>&lt;jukebox_object&gt;</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X DELETE {{ request.url_root }}api/v1/jukebox/&lt;juke_id&gt;
-H "X-Api-Key: {{ g.user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item></q-expansion-item
>

View File

@ -0,0 +1,37 @@
{% 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">Jukebox error</h3>
<br />
<q-icon
name="warning"
class="text-grey"
style="font-size: 20rem"
></q-icon>
<h5 class="q-my-none">
Ask the host to turn on the device and launch spotify
</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>

View File

@ -0,0 +1,368 @@
{% 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>
<q-btn
unelevated
color="primary"
class="q-ma-lg"
@click="openNewDialog()"
>Add Spotify Jukebox</q-btn
>
{% raw %}
<q-table
flat
dense
:data="JukeboxLinks"
row-key="id"
:columns="JukeboxTable.columns"
:pagination.sync="JukeboxTable.pagination"
:filter="filter"
>
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th auto-width></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 auto-width>
<q-btn
unelevated
dense
size="xs"
icon="launch"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
@click="openQrCodeDialog(props.row.sp_id)"
>
<q-tooltip> Jukebox QR </q-tooltip>
</q-btn>
</q-td>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="updateJukebox(props.row.id)"
icon="edit"
color="light-blue"
></q-btn>
<q-btn
flat
dense
size="xs"
@click="deleteJukebox(props.row.id)"
icon="cancel"
color="pink"
>
<q-tooltip> Delete Jukebox </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>
</q-table>
{% endraw %}
</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}} jukebox extension
</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list> {% include "jukebox/_api_docs.html" %} </q-list>
</q-card-section>
</q-card>
</div>
<q-dialog v-model="jukeboxDialog.show" position="top" @hide="closeFormDialog">
<q-card class="q-pa-md q-pt-lg q-mt-md" style="width: 100%">
<q-stepper
v-model="step"
active-color="primary"
inactive-color="secondary"
vertical
animated
>
<q-step
:name="1"
title="Pick wallet, price"
icon="account_balance_wallet"
:done="step > 1"
>
<q-input
filled
class="q-pt-md"
dense
v-model.trim="jukeboxDialog.data.title"
label="Jukebox name"
></q-input>
<q-select
class="q-pb-md q-pt-md"
filled
dense
emit-value
v-model="jukeboxDialog.data.wallet"
:options="g.user.walletOptions"
label="Wallet to use"
></q-select>
<q-input
filled
dense
v-model.trim="jukeboxDialog.data.price"
type="number"
max="1440"
label="Price per track"
class="q-pb-lg"
>
</q-input>
<div class="row">
<div class="col-4">
<q-btn
v-if="jukeboxDialog.data.title != null && jukeboxDialog.data.price != null && jukeboxDialog.data.wallet != null"
color="primary"
@click="step = 2"
>Continue</q-btn
>
<q-btn v-else color="primary" disable>Continue</q-btn>
</div>
<div class="col-8">
<q-btn
color="primary"
class="float-right"
@click="closeFormDialog"
>Cancel</q-btn
>
</div>
</div>
<br />
</q-step>
<q-step :name="2" title="Add api keys" icon="vpn_key" :done="step > 2">
<img src="/jukebox/static/spotapi.gif" />
To use this extension you need a Spotify client ID and client secret.
You get these by creating an app in the Spotify developers dashboard
<a
target="_blank"
style="color: #43a047"
href="https://developer.spotify.com/dashboard/applications"
>here</a
>.
<q-input
filled
class="q-pb-md q-pt-md"
dense
v-model.trim="jukeboxDialog.data.sp_user"
label="Client ID"
>
</q-input>
<q-input
dense
v-model="jukeboxDialog.data.sp_secret"
filled
:type="isPwd ? 'password' : 'text'"
label="Client secret"
>
<template #append>
<q-icon
:name="isPwd ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="isPwd = !isPwd"
>
</q-icon>
</template>
</q-input>
<div class="row q-mt-md">
<div class="col-4">
<q-btn
v-if="jukeboxDialog.data.sp_secret != null && jukeboxDialog.data.sp_user != null && tokenFetched"
color="primary"
@click="submitSpotifyKeys"
>Submit keys</q-btn
>
<q-btn v-else color="primary" disable color="primary"
>Submit keys</q-btn
>
</div>
<div class="col-8">
<q-btn
color="primary"
class="float-right"
@click="closeFormDialog"
>Cancel</q-btn
>
</div>
</div>
<br />
</q-step>
<q-step :name="3" title="Add Redirect URI" icon="link" :done="step > 3">
<img src="/jukebox/static/spotapi1.gif" />
In the app go to edit-settings, set the redirect URI to this link
<br />
<q-btn
dense
outline
unelevated
color="primary"
size="xs"
@click="copyText(locationcb + jukeboxDialog.data.sp_id, 'Link copied to clipboard!')"
>{% raw %}{{ locationcb }}{{ jukeboxDialog.data.sp_id }}{% endraw
%}<q-tooltip> Click to copy URL </q-tooltip>
</q-btn>
<br />
Settings can be found
<a
target="_blank"
style="color: #43a047"
href="https://developer.spotify.com/dashboard/applications"
>here</a
>.
<div class="row q-mt-md">
<div class="col-4">
<q-btn
v-if="jukeboxDialog.data.sp_secret != null && jukeboxDialog.data.sp_user != null && tokenFetched"
color="primary"
@click="authAccess"
>Authorise access</q-btn
>
<q-btn v-else color="primary" disable color="primary"
>Authorise access</q-btn
>
</div>
<div class="col-8">
<q-btn
color="primary"
class="float-right"
@click="closeFormDialog"
>Cancel</q-btn
>
</div>
</div>
<br />
</q-step>
<q-step
:name="4"
title="Select playlists"
icon="queue_music"
active-color="primary"
:done="step > 4"
>
<q-select
class="q-pb-md q-pt-md"
filled
dense
emit-value
v-model="jukeboxDialog.data.sp_device"
:options="devices"
label="Device jukebox will play to"
></q-select>
<q-select
class="q-pb-md"
filled
dense
multiple
emit-value
v-model="jukeboxDialog.data.sp_playlists"
:options="playlists"
label="Playlists available to the jukebox"
></q-select>
<div class="row q-mt-md">
<div class="col-5">
<q-btn
v-if="jukeboxDialog.data.sp_device != null && jukeboxDialog.data.sp_playlists != null"
color="primary"
@click="createJukebox"
>Create Jukebox</q-btn
>
<q-btn v-else color="primary" disable>Create Jukebox</q-btn>
</div>
<div class="col-7">
<q-btn
color="primary"
class="float-right"
@click="closeFormDialog"
>Cancel</q-btn
>
</div>
</div>
</q-step>
</q-stepper>
</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">
<center>
<h5 class="q-my-none">Shareable Jukebox QR</h5>
</center>
<q-responsive :ratio="1" class="q-mx-xl q-mb-md">
<qrcode
:value="qrCodeDialog.data.url + '/jukebox/' + qrCodeDialog.data.id"
:options="{width: 800}"
class="rounded-borders"
></qrcode>
</q-responsive>
<div class="row q-mt-lg q-gutter-sm">
<q-btn
outline
color="grey"
@click="copyText(qrCodeDialog.data.url + '/jukebox/' + qrCodeDialog.data.id, 'Link copied to clipboard!')"
>
Copy jukebox link</q-btn
>
<q-btn
outline
color="grey"
type="a"
:href="qrCodeDialog.data.url + '/jukebox/' + qrCodeDialog.data.id"
target="_blank"
>Open jukebox</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 src="https://cdn.jsdelivr.net/npm/pica@6.1.1/dist/pica.min.js"></script>
<script src="/jukebox/static/js/index.js"></script>
{% endblock %}

View File

@ -0,0 +1,277 @@
{% extends "public.html" %} {% block page %} {% raw %}
<div class="row q-col-gutter-md justify-center">
<div class="col-12 col-sm-6 col-md-5 col-lg-4">
<q-card class="q-pa-lg">
<q-card-section class="q-pa-none">
<p style="font-size: 22px">Currently playing</p>
<div class="row">
<div class="col-4">
<img style="width: 100px" :src="currentPlay.image" />
</div>
<div class="col-8">
<strong style="font-size: 20px">{{ currentPlay.name }}</strong
><br />
<strong style="font-size: 15px">{{ currentPlay.artist }}</strong>
</div>
</div>
</q-card-section>
</q-card>
<q-card class="q-mt-lg">
<q-card-section>
<p style="font-size: 22px">Pick a song</p>
<q-select
outlined
v-model="playlist"
:options="playlists"
label="playlists"
@input="selectPlaylist()"
>
</q-select>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-virtual-scroll
style="max-height: 300px"
:items="currentPlaylist"
separator
>
<template v-slot="{ item, index }">
<q-item
:key="index"
dense
clickable
v-ripple
@click="payForSong(item.id, item.name, item.artist, item.image)"
>
<q-item-section>
<q-item-label>
{{ item.name }} - ({{ item.artist }})
</q-item-label>
</q-item-section>
</q-item>
</template>
</q-virtual-scroll>
</q-card-section>
</q-card>
</div>
<q-dialog v-model="receive.dialogues.first" position="top">
<q-card class="q-pa-lg lnbits__dialog-card">
<q-card-section class="q-pa-none">
<div class="row">
<div class="col-4">
<img style="width: 100px" :src="receive.image" />
</div>
<div class="col-8">
<strong style="font-size: 20px">{{ receive.name }}</strong><br />
<strong style="font-size: 15px">{{ receive.artist }}</strong>
</div>
</div>
</q-card-section>
<br />
<div class="row q-mt-lg q-gutter-sm">
<q-btn outline color="grey" @click="getInvoice(receive.id)"
>Play for {% endraw %}{{ price }}{% raw %} sats
</q-btn>
</div>
</q-card>
</q-dialog>
<q-dialog v-model="receive.dialogues.second" position="top">
<q-card class="q-pa-lg lnbits__dialog-card">
<q-responsive :ratio="1" class="q-mx-xl q-mb-md">
<qrcode
:value="'lightning:' + receive.paymentReq"
:options="{width: 800}"
class="rounded-borders"
></qrcode>
</q-responsive>
<div class="row q-mt-lg q-gutter-sm">
<q-btn outline color="grey" @click="copyText(receive.paymentReq)"
>Copy invoice</q-btn
>
</div>
</q-card>
</q-dialog>
</div>
{% endraw %} {% endblock %} {% block scripts %}
<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)
new Vue({
el: '#vue',
mixins: [windowMixin],
data() {
return {
currentPlaylist: [],
currentlyPlaying: {},
playlists: {},
playlist: '',
heavyList: [],
selectedWallet: {},
paid: false,
receive: {
dialogues: {
first: false,
second: false
},
paymentReq: '',
paymentHash: '',
name: '',
artist: '',
image: '',
id: '',
showQR: false,
data: null,
dismissMsg: null,
paymentChecker: null
}
}
},
computed: {
currentPlay() {
return this.currentlyPlaying
}
},
methods: {
payForSong(song_id, name, artist, image) {
self = this
self.receive.name = name
self.receive.artist = artist
self.receive.image = image
self.receive.id = song_id
self.receive.dialogues.first = true
},
getInvoice(song_id) {
self = this
var dialog = this.receive
LNbits.api
.request(
'GET',
'/jukebox/api/v1/jukebox/jb/invoice/' +
'{{ juke_id }}' +
'/' +
song_id
)
.then(function (response) {
self.receive.paymentReq = response.data[0][1]
self.receive.paymentHash = response.data[0][0]
self.receive.dialogues.second = true
dialog.data = response.data
dialog.dismissMsg = self.$q.notify({
timeout: 0,
message: 'Waiting for payment...'
})
dialog.paymentChecker = setInterval(function () {
LNbits.api
.request(
'GET',
'/jukebox/api/v1/jukebox/jb/checkinvoice/' +
self.receive.paymentHash +
'/{{ juke_id }}'
)
.then(function (res) {
console.log(res)
if (res.data.paid == true) {
clearInterval(dialog.paymentChecker)
LNbits.api
.request(
'GET',
'/jukebox/api/v1/jukebox/jb/invoicep/' +
self.receive.id +
'/{{ juke_id }}/' +
self.receive.paymentHash
)
.then(function (ress) {
console.log(ress)
if (ress.data[2] == self.receive.id) {
clearInterval(dialog.paymentChecker)
dialog.dismissMsg()
self.receive.dialogues.second = false
self.$q.notify({
type: 'positive',
message:
'Success! "' +
self.receive.name +
'" will be played soon',
timeout: 3000
})
self.receive.dialogues.first = false
}
})
}
})
}, 3000)
setTimeout(() => {
self.getCurrent()
}, 500)
})
.catch(err => {
self.$q.notify({
color: 'warning',
html: true,
message:
'<center>Device is not connected! <br/> Ask the host to turn on their device and have Spotify open</center>',
timeout: 5000
})
})
},
getCurrent() {
LNbits.api
.request('GET', '/jukebox/api/v1/jukebox/jb/currently/{{juke_id}}')
.then(function (res) {
if (res.data.id) {
self.currentlyPlaying = res.data
}
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
selectPlaylist() {
self = this
LNbits.api
.request(
'GET',
'/jukebox/api/v1/jukebox/jb/playlist/' +
'{{ juke_id }}' +
'/' +
self.playlist.split(',')[0].split('-')[1]
)
.then(function (response) {
self.currentPlaylist = response.data
})
.catch(err => {
LNbits.utils.notifyApiError(err)
})
},
currentSong() {}
},
created() {
this.getCurrent()
this.playlists = JSON.parse('{{ playlists | tojson }}')
this.selectedWallet.inkey = '{{ inkey }}'
self = this
LNbits.api
.request(
'GET',
'/jukebox/api/v1/jukebox/jb/playlist/' +
'{{ juke_id }}' +
'/' +
self.playlists[0].split(',')[0].split('-')[1]
)
.then(function (response) {
self.currentPlaylist = response.data
})
.catch(err => {
LNbits.utils.notifyApiError(err)
})
}
})
</script>
{% endblock %}

View File

@ -0,0 +1,42 @@
import time
from datetime import datetime
from quart import g, render_template, request, jsonify, websocket
from http import HTTPStatus
import trio
from lnbits.decorators import check_user_exists, validate_uuids
from lnbits.core.models import Payment
import json
from . import jukebox_ext
from .crud import get_jukebox
from .views_api import api_get_jukebox_device_check
@jukebox_ext.route("/")
@validate_uuids(["usr"], required=True)
@check_user_exists()
async def index():
return await render_template("jukebox/index.html", user=g.user)
@jukebox_ext.route("/<juke_id>")
async def connect_to_jukebox(juke_id):
jukebox = await get_jukebox(juke_id)
if not jukebox:
return "error"
deviceCheck = await api_get_jukebox_device_check(juke_id)
devices = json.loads(deviceCheck[0].text)
deviceConnected = False
for device in devices["devices"]:
if device["id"] == jukebox.sp_device.split("-")[1]:
deviceConnected = True
if deviceConnected:
return await render_template(
"jukebox/jukebox.html",
playlists=jukebox.sp_playlists.split(","),
juke_id=juke_id,
price=jukebox.price,
inkey=jukebox.inkey,
)
else:
return await render_template("jukebox/error.html")

View File

@ -0,0 +1,491 @@
from quart import g, jsonify, request
from http import HTTPStatus
import base64
from lnbits.core.crud import get_wallet
from lnbits.core.services import create_invoice, check_invoice_status
import json
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
import httpx
from . import jukebox_ext
from .crud import (
create_jukebox,
update_jukebox,
get_jukebox,
get_jukeboxs,
delete_jukebox,
create_jukebox_payment,
get_jukebox_payment,
update_jukebox_payment,
)
from lnbits.core.services import create_invoice, check_invoice_status
@jukebox_ext.route("/api/v1/jukebox", methods=["GET"])
@api_check_wallet_key("admin")
async def api_get_jukeboxs():
try:
return (
jsonify(
[{**jukebox._asdict()} for jukebox in await get_jukeboxs(g.wallet.user)]
),
HTTPStatus.OK,
)
except:
return "", HTTPStatus.NO_CONTENT
##################SPOTIFY AUTH#####################
@jukebox_ext.route("/api/v1/jukebox/spotify/cb/<juke_id>", methods=["GET"])
async def api_check_credentials_callbac(juke_id):
sp_code = ""
sp_access_token = ""
sp_refresh_token = ""
try:
jukebox = await get_jukebox(juke_id)
except:
return (
jsonify({"error": "No Jukebox"}),
HTTPStatus.FORBIDDEN,
)
if request.args.get("code"):
sp_code = request.args.get("code")
jukebox = await update_jukebox(
juke_id=juke_id, sp_secret=jukebox.sp_secret, sp_access_token=sp_code
)
if request.args.get("access_token"):
sp_access_token = request.args.get("access_token")
sp_refresh_token = request.args.get("refresh_token")
jukebox = await update_jukebox(
juke_id=juke_id,
sp_secret=jukebox.sp_secret,
sp_access_token=sp_access_token,
sp_refresh_token=sp_refresh_token,
)
return "<h1>Success!</h1><h2>You can close this window</h2>"
@jukebox_ext.route("/api/v1/jukebox/<juke_id>", methods=["GET"])
@api_check_wallet_key("admin")
async def api_check_credentials_check(juke_id):
jukebox = await get_jukebox(juke_id)
return jsonify(jukebox._asdict()), HTTPStatus.CREATED
@jukebox_ext.route("/api/v1/jukebox/", methods=["POST"])
@jukebox_ext.route("/api/v1/jukebox/<juke_id>", methods=["PUT"])
@api_check_wallet_key("admin")
@api_validate_post_request(
schema={
"user": {"type": "string", "empty": False, "required": True},
"title": {"type": "string", "empty": False, "required": True},
"wallet": {"type": "string", "empty": False, "required": True},
"sp_user": {"type": "string", "empty": False, "required": True},
"sp_secret": {"type": "string", "required": True},
"sp_access_token": {"type": "string", "required": False},
"sp_refresh_token": {"type": "string", "required": False},
"sp_device": {"type": "string", "required": False},
"sp_playlists": {"type": "string", "required": False},
"price": {"type": "string", "required": False},
}
)
async def api_create_update_jukebox(juke_id=None):
if juke_id:
jukebox = await update_jukebox(juke_id=juke_id, inkey=g.wallet.inkey, **g.data)
else:
jukebox = await create_jukebox(inkey=g.wallet.inkey, **g.data)
return jsonify(jukebox._asdict()), HTTPStatus.CREATED
@jukebox_ext.route("/api/v1/jukebox/<juke_id>", methods=["DELETE"])
@api_check_wallet_key("admin")
async def api_delete_item(juke_id):
await delete_jukebox(juke_id)
try:
return (
jsonify(
[{**jukebox._asdict()} for jukebox in await get_jukeboxs(g.wallet.user)]
),
HTTPStatus.OK,
)
except:
return "", HTTPStatus.NO_CONTENT
################JUKEBOX ENDPOINTS##################
######GET ACCESS TOKEN######
@jukebox_ext.route(
"/api/v1/jukebox/jb/playlist/<juke_id>/<sp_playlist>", methods=["GET"]
)
async def api_get_jukebox_song(juke_id, sp_playlist, retry=False):
try:
jukebox = await get_jukebox(juke_id)
except:
return (
jsonify({"error": "No Jukebox"}),
HTTPStatus.FORBIDDEN,
)
tracks = []
async with httpx.AsyncClient() as client:
try:
r = await client.get(
"https://api.spotify.com/v1/playlists/" + sp_playlist + "/tracks",
timeout=40,
headers={"Authorization": "Bearer " + jukebox.sp_access_token},
)
if "items" not in r.json():
if r.status_code == 401:
token = await api_get_token(juke_id)
if token == False:
return False
elif retry:
return (
jsonify({"error": "Failed to get auth"}),
HTTPStatus.FORBIDDEN,
)
else:
return await api_get_jukebox_song(
juke_id, sp_playlist, retry=True
)
return r, HTTPStatus.OK
for item in r.json()["items"]:
tracks.append(
{
"id": item["track"]["id"],
"name": item["track"]["name"],
"album": item["track"]["album"]["name"],
"artist": item["track"]["artists"][0]["name"],
"image": item["track"]["album"]["images"][0]["url"],
}
)
except AssertionError:
something = None
return jsonify([track for track in tracks])
async def api_get_token(juke_id):
try:
jukebox = await get_jukebox(juke_id)
except:
return (
jsonify({"error": "No Jukebox"}),
HTTPStatus.FORBIDDEN,
)
async with httpx.AsyncClient() as client:
try:
r = await client.post(
"https://accounts.spotify.com/api/token",
timeout=40,
params={
"grant_type": "refresh_token",
"refresh_token": jukebox.sp_refresh_token,
"client_id": jukebox.sp_user,
},
headers={
"Content-Type": "application/x-www-form-urlencoded",
"Authorization": "Basic "
+ base64.b64encode(
str(jukebox.sp_user + ":" + jukebox.sp_secret).encode("ascii")
).decode("ascii"),
"Content-Type": "application/x-www-form-urlencoded",
},
)
if "access_token" not in r.json():
return False
else:
await update_jukebox(
juke_id=juke_id, sp_access_token=r.json()["access_token"]
)
except AssertionError:
something = None
return True
######CHECK DEVICE
@jukebox_ext.route("/api/v1/jukebox/jb/<juke_id>", methods=["GET"])
async def api_get_jukebox_device_check(juke_id, retry=False):
try:
jukebox = await get_jukebox(juke_id)
except:
return (
jsonify({"error": "No Jukebox"}),
HTTPStatus.FORBIDDEN,
)
async with httpx.AsyncClient() as client:
rDevice = await client.get(
"https://api.spotify.com/v1/me/player/devices",
timeout=40,
headers={"Authorization": "Bearer " + jukebox.sp_access_token},
)
if rDevice.status_code == 204 or rDevice.status_code == 200:
return (
rDevice,
HTTPStatus.OK,
)
elif rDevice.status_code == 401 or rDevice.status_code == 403:
token = await api_get_token(juke_id)
if token == False:
return (
jsonify({"error": "No device connected"}),
HTTPStatus.FORBIDDEN,
)
elif retry:
return (
jsonify({"error": "Failed to get auth"}),
HTTPStatus.FORBIDDEN,
)
else:
return api_get_jukebox_device_check(juke_id, retry=True)
else:
return (
jsonify({"error": "No device connected"}),
HTTPStatus.FORBIDDEN,
)
######GET INVOICE STUFF
@jukebox_ext.route("/api/v1/jukebox/jb/invoice/<juke_id>/<song_id>", methods=["GET"])
async def api_get_jukebox_invoice(juke_id, song_id):
try:
jukebox = await get_jukebox(juke_id)
except:
return (
jsonify({"error": "No Jukebox"}),
HTTPStatus.FORBIDDEN,
)
try:
deviceCheck = await api_get_jukebox_device_check(juke_id)
devices = json.loads(deviceCheck[0].text)
deviceConnected = False
for device in devices["devices"]:
if device["id"] == jukebox.sp_device.split("-")[1]:
deviceConnected = True
if not deviceConnected:
return (
jsonify({"error": "No device connected"}),
HTTPStatus.NOT_FOUND,
)
except:
return (
jsonify({"error": "No device connected"}),
HTTPStatus.NOT_FOUND,
)
invoice = await create_invoice(
wallet_id=jukebox.wallet,
amount=jukebox.price,
memo=jukebox.title,
extra={"tag": "jukebox"},
)
jukebox_payment = await create_jukebox_payment(song_id, invoice[0], juke_id)
return jsonify(invoice, jukebox_payment)
@jukebox_ext.route(
"/api/v1/jukebox/jb/checkinvoice/<pay_hash>/<juke_id>", methods=["GET"]
)
async def api_get_jukebox_invoice_check(pay_hash, juke_id):
try:
jukebox = await get_jukebox(juke_id)
except:
return (
jsonify({"error": "No Jukebox"}),
HTTPStatus.FORBIDDEN,
)
try:
status = await check_invoice_status(jukebox.wallet, pay_hash)
is_paid = not status.pending
except Exception as exc:
return jsonify({"paid": False}), HTTPStatus.OK
if is_paid:
wallet = await get_wallet(jukebox.wallet)
payment = await wallet.get_payment(pay_hash)
await payment.set_pending(False)
await update_jukebox_payment(pay_hash, paid=True)
return jsonify({"paid": True}), HTTPStatus.OK
return jsonify({"paid": False}), HTTPStatus.OK
@jukebox_ext.route(
"/api/v1/jukebox/jb/invoicep/<song_id>/<juke_id>/<pay_hash>", methods=["GET"]
)
async def api_get_jukebox_invoice_paid(song_id, juke_id, pay_hash, retry=False):
try:
jukebox = await get_jukebox(juke_id)
except:
return (
jsonify({"error": "No Jukebox"}),
HTTPStatus.FORBIDDEN,
)
await api_get_jukebox_invoice_check(pay_hash, juke_id)
jukebox_payment = await get_jukebox_payment(pay_hash)
if jukebox_payment.paid:
async with httpx.AsyncClient() as client:
r = await client.get(
"https://api.spotify.com/v1/me/player/currently-playing?market=ES",
timeout=40,
headers={"Authorization": "Bearer " + jukebox.sp_access_token},
)
rDevice = await client.get(
"https://api.spotify.com/v1/me/player",
timeout=40,
headers={"Authorization": "Bearer " + jukebox.sp_access_token},
)
isPlaying = False
if rDevice.status_code == 200:
isPlaying = rDevice.json()["is_playing"]
if r.status_code == 204 or isPlaying == False:
async with httpx.AsyncClient() as client:
uri = ["spotify:track:" + song_id]
r = await client.put(
"https://api.spotify.com/v1/me/player/play?device_id="
+ jukebox.sp_device.split("-")[1],
json={"uris": uri},
timeout=40,
headers={"Authorization": "Bearer " + jukebox.sp_access_token},
)
if r.status_code == 204:
return jsonify(jukebox_payment), HTTPStatus.OK
elif r.status_code == 401 or r.status_code == 403:
token = await api_get_token(juke_id)
if token == False:
return (
jsonify({"error": "Invoice not paid"}),
HTTPStatus.FORBIDDEN,
)
elif retry:
return (
jsonify({"error": "Failed to get auth"}),
HTTPStatus.FORBIDDEN,
)
else:
return api_get_jukebox_invoice_paid(
song_id, juke_id, pay_hash, retry=True
)
else:
return (
jsonify({"error": "Invoice not paid"}),
HTTPStatus.FORBIDDEN,
)
elif r.status_code == 200:
async with httpx.AsyncClient() as client:
r = await client.post(
"https://api.spotify.com/v1/me/player/queue?uri=spotify%3Atrack%3A"
+ song_id
+ "&device_id="
+ jukebox.sp_device.split("-")[1],
timeout=40,
headers={"Authorization": "Bearer " + jukebox.sp_access_token},
)
if r.status_code == 204:
return jsonify(jukebox_payment), HTTPStatus.OK
elif r.status_code == 401 or r.status_code == 403:
token = await api_get_token(juke_id)
if token == False:
return (
jsonify({"error": "Invoice not paid"}),
HTTPStatus.OK,
)
elif retry:
return (
jsonify({"error": "Failed to get auth"}),
HTTPStatus.FORBIDDEN,
)
else:
return await api_get_jukebox_invoice_paid(
song_id, juke_id, pay_hash
)
else:
return (
jsonify({"error": "Invoice not paid"}),
HTTPStatus.OK,
)
elif r.status_code == 401 or r.status_code == 403:
token = await api_get_token(juke_id)
if token == False:
return (
jsonify({"error": "Invoice not paid"}),
HTTPStatus.OK,
)
elif retry:
return (
jsonify({"error": "Failed to get auth"}),
HTTPStatus.FORBIDDEN,
)
else:
return await api_get_jukebox_invoice_paid(
song_id, juke_id, pay_hash
)
return jsonify({"error": "Invoice not paid"}), HTTPStatus.OK
############################GET TRACKS
@jukebox_ext.route("/api/v1/jukebox/jb/currently/<juke_id>", methods=["GET"])
async def api_get_jukebox_currently(juke_id, retry=False):
try:
jukebox = await get_jukebox(juke_id)
except:
return (
jsonify({"error": "No Jukebox"}),
HTTPStatus.FORBIDDEN,
)
async with httpx.AsyncClient() as client:
try:
r = await client.get(
"https://api.spotify.com/v1/me/player/currently-playing?market=ES",
timeout=40,
headers={"Authorization": "Bearer " + jukebox.sp_access_token},
)
if r.status_code == 204:
return jsonify({"error": "Nothing"}), HTTPStatus.OK
elif r.status_code == 200:
try:
response = r.json()
track = {
"id": response["item"]["id"],
"name": response["item"]["name"],
"album": response["item"]["album"]["name"],
"artist": response["item"]["artists"][0]["name"],
"image": response["item"]["album"]["images"][0]["url"],
}
return jsonify(track), HTTPStatus.OK
except:
return jsonify("Something went wrong"), HTTPStatus.NOT_FOUND
elif r.status_code == 401:
token = await api_get_token(juke_id)
if token == False:
return (
jsonify({"error": "Invoice not paid"}),
HTTPStatus.FORBIDDEN,
)
elif retry:
return (
jsonify({"error": "Failed to get auth"}),
HTTPStatus.FORBIDDEN,
)
else:
return await api_get_jukebox_currently(juke_id, retry=True)
else:
return jsonify("Something went wrong"), HTTPStatus.NOT_FOUND
except AssertionError:
return jsonify("Something went wrong"), HTTPStatus.NOT_FOUND

View File

@ -0,0 +1,45 @@
# DJ Livestream
## Help DJ's and music producers conduct music livestreams
LNBits Livestream extension produces a static QR code that can be shown on screen while livestreaming a DJ set for example. If someone listening to the livestream likes a song and want to support the DJ and/or the producer he can scan the QR code with a LNURL-pay capable wallet.
When scanned, the QR code sends up information about the song playing at the moment (name and the producer of that song). Also, if the user likes the song and would like to support the producer, he can send a tip and a message for that specific track. If the user sends an amount over a specific threshold they will be given a link to download it (optional).
The revenue will be sent to a wallet created specifically for that producer, with optional revenue splitting between the DJ and the producer.
[**Wallets supporting LNURL**](https://github.com/fiatjaf/awesome-lnurl#wallets)
[![video tutorial livestream](http://img.youtube.com/vi/zDrSWShKz7k/0.jpg)](https://youtu.be/zDrSWShKz7k 'video tutorial offline shop')
## Usage
1. Start by adding a track\
![add new track](https://i.imgur.com/Cu0eGrW.jpg)
- set the producer, or choose an existing one
- set the track name
- define a minimum price where a user can download the track
- set the download URL, where user will be redirected if he tips the livestream and the tip is equal or above the set price\
![track settings](https://i.imgur.com/HTJYwcW.jpg)
2. Adjust the percentage of the pay you want to take from the user's tips. 10%, the default, means that the DJ will keep 10% of all the tips sent by users. The other 90% will go to an auto generated producer wallet\
![adjust percentage](https://i.imgur.com/9weHKAB.jpg)
3. For every different producer added, when adding tracks, a wallet is generated for them\
![producer wallet](https://i.imgur.com/YFIZ7Tm.jpg)
4. On the bottom of the LNBits DJ Livestream extension you'll find the static QR code ([LNURL-pay](https://github.com/lnbits/lnbits/blob/master/lnbits/extensions/lnurlp/README.md)) you can add to the livestream or if you're a street performer you can print it and have it displayed
5. After all tracks and producers are added, you can start "playing" songs\
![play tracks](https://i.imgur.com/7ytiBkq.jpg)
6. You'll see the current track playing and a green icon indicating active track also\
![active track](https://i.imgur.com/W1vBz54.jpg)
7. When a user scans the QR code, and sends a tip, you'll receive 10%, in the example case, in your wallet and the producer's wallet will get the rest. For example someone tips 100 sats, you'll get 10 sats and the producer will get 90 sats
- producer's wallet receiving 18 sats from 20 sats tips\
![producer wallet](https://i.imgur.com/OM9LawA.jpg)
## Use cases
You can print the QR code and display it on a live gig, a street performance, etc... OR you can use the QR as an overlay in an online stream of you playing music, doing a DJ set, making a podcast.
You can use the extension's API to trigger updates for the current track, update fees, add tracks...
## Sponsored by
[![](https://cdn.shopify.com/s/files/1/0826/9235/files/cryptograffiti_logo_clear_background.png?v=1504730421)](https://cryptograffiti.com/)

View File

@ -0,0 +1,19 @@
from quart import Blueprint
from lnbits.db import Database
db = Database("ext_livestream")
livestream_ext: Blueprint = Blueprint(
"livestream", __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
livestream_ext.record(record_async(register_listeners))

View File

@ -0,0 +1,10 @@
{
"name": "DJ Livestream",
"short_description": "Sell tracks and split revenue (lnurl-pay)",
"icon": "speaker",
"contributors": [
"fiatjaf",
"cryptograffiti"
],
"hidden": false
}

View File

@ -0,0 +1,199 @@
from typing import List, Optional
from lnbits.core.crud import create_account, create_wallet
from lnbits.db import SQLITE
from . import db
from .models import Livestream, Track, Producer
async def create_livestream(*, wallet_id: str) -> int:
returning = "" if db.type == SQLITE else "RETURNING ID"
method = db.execute if db.type == SQLITE else db.fetchone
result = await (method)(
f"""
INSERT INTO livestream.livestreams (wallet)
VALUES (?)
{returning}
""",
(wallet_id,),
)
if db.type == SQLITE:
return result._result_proxy.lastrowid
else:
return result[0]
async def get_livestream(ls_id: int) -> Optional[Livestream]:
row = await db.fetchone(
"SELECT * FROM livestream.livestreams WHERE id = ?", (ls_id,)
)
return Livestream(**dict(row)) if row else None
async def get_livestream_by_track(track_id: int) -> Optional[Livestream]:
row = await db.fetchone(
"""
SELECT livestreams.* FROM livestream.livestreams
INNER JOIN tracks ON tracks.livestream = livestreams.id
WHERE tracks.id = ?
""",
(track_id,),
)
return Livestream(**dict(row)) if row else None
async def get_or_create_livestream_by_wallet(wallet: str) -> Optional[Livestream]:
row = await db.fetchone(
"SELECT * FROM livestream.livestreams WHERE wallet = ?", (wallet,)
)
if not row:
# create on the fly
ls_id = await create_livestream(wallet_id=wallet)
return await get_livestream(ls_id)
return Livestream(**dict(row)) if row else None
async def update_current_track(ls_id: int, track_id: Optional[int]):
await db.execute(
"UPDATE livestream.livestreams SET current_track = ? WHERE id = ?",
(track_id, ls_id),
)
async def update_livestream_fee(ls_id: int, fee_pct: int):
await db.execute(
"UPDATE livestream.livestreams SET fee_pct = ? WHERE id = ?",
(fee_pct, ls_id),
)
async def add_track(
livestream: int,
name: str,
download_url: Optional[str],
price_msat: int,
producer: Optional[int],
) -> int:
result = await db.execute(
"""
INSERT INTO livestream.tracks (livestream, name, download_url, price_msat, producer)
VALUES (?, ?, ?, ?, ?)
""",
(livestream, name, download_url, price_msat, producer),
)
return result._result_proxy.lastrowid
async def update_track(
livestream: int,
track_id: int,
name: str,
download_url: Optional[str],
price_msat: int,
producer: int,
) -> int:
result = await db.execute(
"""
UPDATE livestream.tracks SET
name = ?,
download_url = ?,
price_msat = ?,
producer = ?
WHERE livestream = ? AND id = ?
""",
(name, download_url, price_msat, producer, livestream, track_id),
)
return result._result_proxy.lastrowid
async def get_track(track_id: Optional[int]) -> Optional[Track]:
if not track_id:
return None
row = await db.fetchone(
"""
SELECT id, download_url, price_msat, name, producer
FROM livestream.tracks WHERE id = ?
""",
(track_id,),
)
return Track(**dict(row)) if row else None
async def get_tracks(livestream: int) -> List[Track]:
rows = await db.fetchall(
"""
SELECT id, download_url, price_msat, name, producer
FROM livestream.tracks WHERE livestream = ?
""",
(livestream,),
)
return [Track(**dict(row)) for row in rows]
async def delete_track_from_livestream(livestream: int, track_id: int):
await db.execute(
"""
DELETE FROM livestream.tracks WHERE livestream = ? AND id = ?
""",
(livestream, track_id),
)
async def add_producer(livestream: int, name: str) -> int:
name = name.strip()
existing = await db.fetchall(
"""
SELECT id FROM livestream.producers
WHERE livestream = ? AND lower(name) = ?
""",
(livestream, name.lower()),
)
if existing:
return existing[0].id
user = await create_account()
wallet = await create_wallet(user_id=user.id, wallet_name="livestream: " + name)
returning = "" if db.type == SQLITE else "RETURNING ID"
method = db.execute if db.type == SQLITE else db.fetchone
result = await method(
f"""
INSERT INTO livestream.producers (livestream, name, "user", wallet)
VALUES (?, ?, ?, ?)
{returning}
""",
(livestream, name, user.id, wallet.id),
)
if db.type == SQLITE:
return result._result_proxy.lastrowid
else:
return result[0]
async def get_producer(producer_id: int) -> Optional[Producer]:
row = await db.fetchone(
"""
SELECT id, "user", wallet, name
FROM livestream.producers WHERE id = ?
""",
(producer_id,),
)
return Producer(**dict(row)) if row else None
async def get_producers(livestream: int) -> List[Producer]:
rows = await db.fetchall(
"""
SELECT id, "user", wallet, name
FROM livestream.producers WHERE livestream = ?
""",
(livestream,),
)
return [Producer(**dict(row)) for row in rows]

View File

@ -0,0 +1,114 @@
import hashlib
import math
from quart import jsonify, url_for, request
from lnurl import LnurlPayResponse, LnurlPayActionResponse, LnurlErrorResponse # type: ignore
from lnbits.core.services import create_invoice
from . import livestream_ext
from .crud import get_livestream, get_livestream_by_track, get_track
@livestream_ext.route("/lnurl/<ls_id>", methods=["GET"])
async def lnurl_livestream(ls_id):
ls = await get_livestream(ls_id)
if not ls:
return jsonify({"status": "ERROR", "reason": "Livestream not found."})
track = await get_track(ls.current_track)
if not track:
return jsonify({"status": "ERROR", "reason": "This livestream is offline."})
resp = LnurlPayResponse(
callback=url_for(
"livestream.lnurl_callback", track_id=track.id, _external=True
),
min_sendable=track.min_sendable,
max_sendable=track.max_sendable,
metadata=await track.lnurlpay_metadata(),
)
params = resp.dict()
params["commentAllowed"] = 300
return jsonify(params)
@livestream_ext.route("/lnurl/t/<track_id>", methods=["GET"])
async def lnurl_track(track_id):
track = await get_track(track_id)
if not track:
return jsonify({"status": "ERROR", "reason": "Track not found."})
resp = LnurlPayResponse(
callback=url_for(
"livestream.lnurl_callback", track_id=track.id, _external=True
),
min_sendable=track.min_sendable,
max_sendable=track.max_sendable,
metadata=await track.lnurlpay_metadata(),
)
params = resp.dict()
params["commentAllowed"] = 300
return jsonify(params)
@livestream_ext.route("/lnurl/cb/<track_id>", methods=["GET"])
async def lnurl_callback(track_id):
track = await get_track(track_id)
if not track:
return jsonify({"status": "ERROR", "reason": "Couldn't find track."})
amount_received = int(request.args.get("amount") or 0)
if amount_received < track.min_sendable:
return (
jsonify(
LnurlErrorResponse(
reason=f"Amount {round(amount_received / 1000)} is smaller than minimum {math.floor(track.min_sendable)}."
).dict()
),
)
elif track.max_sendable < amount_received:
return (
jsonify(
LnurlErrorResponse(
reason=f"Amount {round(amount_received / 1000)} is greater than maximum {math.floor(track.max_sendable)}."
).dict()
),
)
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()
)
ls = await get_livestream_by_track(track_id)
payment_hash, payment_request = await create_invoice(
wallet_id=ls.wallet,
amount=int(amount_received / 1000),
memo=await track.fullname(),
description_hash=hashlib.sha256(
(await track.lnurlpay_metadata()).encode("utf-8")
).digest(),
extra={"tag": "livestream", "track": track.id, "comment": comment},
)
if amount_received < track.price_msat:
success_action = None
else:
success_action = track.success_action(payment_hash)
resp = LnurlPayActionResponse(
pr=payment_request,
success_action=success_action,
routes=[],
)
return jsonify(resp.dict())

View File

@ -0,0 +1,39 @@
async def m001_initial(db):
"""
Initial livestream tables.
"""
await db.execute(
f"""
CREATE TABLE livestream.livestreams (
id {db.serial_primary_key},
wallet TEXT NOT NULL,
fee_pct INTEGER NOT NULL DEFAULT 10,
current_track INTEGER
);
"""
)
await db.execute(
f"""
CREATE TABLE livestream.producers (
livestream INTEGER NOT NULL REFERENCES {db.references_schema}livestreams (id),
id {db.serial_primary_key},
"user" TEXT NOT NULL,
wallet TEXT NOT NULL,
name TEXT NOT NULL
);
"""
)
await db.execute(
f"""
CREATE TABLE livestream.tracks (
livestream INTEGER NOT NULL REFERENCES {db.references_schema}livestreams (id),
id {db.serial_primary_key},
download_url TEXT,
price_msat INTEGER NOT NULL DEFAULT 0,
name TEXT,
producer INTEGER REFERENCES {db.references_schema}producers (id) NOT NULL
);
"""
)

Some files were not shown because too many files have changed in this diff Show More