Merge branch 'master' into StreamerCopilot

This commit is contained in:
Ben Arc 2021-06-23 09:08:37 +01:00
commit 9961551dac
99 changed files with 3473 additions and 452 deletions

View file

@ -8,7 +8,7 @@ PORT=5000
LNBITS_SITE_TITLE=LNbits
LNBITS_ALLOWED_USERS=""
LNBITS_DEFAULT_WALLET_NAME="LNbits wallet"
LNBITS_DATA_FOLDER="."
LNBITS_DATA_FOLDER="./data"
LNBITS_DISABLED_EXTENSIONS="amilk"
LNBITS_FORCE_HTTPS=true
LNBITS_SERVICE_FEE="0.0"
@ -44,7 +44,9 @@ LND_REST_MACAROON="HEXSTRING"
# LNPayWallet
LNPAY_API_ENDPOINT=https://api.lnpay.co/v1/
# Secret API Key under developers tab
LNPAY_API_KEY=LNPAY_API_KEY
# Wallet Admin in Wallet Access Keys
LNPAY_WALLET_KEY=LNPAY_ADMIN_KEY
# LntxbotWallet

View file

@ -35,3 +35,4 @@ pytest = "*"
pytest-cov = "*"
mypy = "latest"
pytest-trio = "*"
trio-typing = "*"

443
Pipfile.lock generated
View file

@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "e12af74353e8bea3f97bf2aea16a1ba0a6e4c3a08042ce7368187a06e7791e2c"
"sha256": "8c4056a80c682fac834266c11892573ce53807226c0810e4564976656ea5ff45"
},
"pipfile-spec": 6,
"requires": {
@ -18,10 +18,19 @@
"default": {
"aiofiles": {
"hashes": [
"sha256:bd3019af67f83b739f8e4053c6c0512a7f545b9a8d91aaeab55e6e0f9d123c27",
"sha256:e0281b157d3d5d59d803e3f4557dcc9a3dff28a4dd4829a9ff478adae50ca092"
"sha256:a1c4fc9b2ff81568c83e21392a82f344ea9d23da906e4f6a52662764545e19d4",
"sha256:c67a6823b5f23fcab0a2595a289cec7d8c863ffcb4322fb8cd6b90400aedfdbc"
],
"version": "==0.6.0"
"markers": "python_version >= '3.6' and python_version < '4.0'",
"version": "==0.7.0"
},
"anyio": {
"hashes": [
"sha256:41c4be842c284222b197a625d76a7ab85adf9d52788f563172fe180c2744b6c1",
"sha256:89e19b1498c8a6f12277e0bd2949597e445aa1b14361fbab2c36943639ef5190"
],
"markers": "python_full_version >= '3.6.2'",
"version": "==3.2.0"
},
"async-generator": {
"hashes": [
@ -33,11 +42,11 @@
},
"attrs": {
"hashes": [
"sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6",
"sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700"
"sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1",
"sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==20.3.0"
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
"version": "==21.2.0"
},
"bech32": {
"hashes": [
@ -99,41 +108,40 @@
},
"cerberus": {
"hashes": [
"sha256:7aff49bc793e58a88ac14bffc3eca0f67e077881d3c62c621679a621294dd174",
"sha256:eec10585c33044fb7c69650bc5b68018dac0443753337e2b07684ee0f3c83329"
"sha256:d1b21b3954b2498d9a79edf16b3170a3ac1021df88d197dc2ce5928ba519237c"
],
"index": "pypi",
"version": "==1.3.3"
"version": "==1.3.4"
},
"certifi": {
"hashes": [
"sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c",
"sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"
"sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee",
"sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8"
],
"version": "==2020.12.5"
"version": "==2021.5.30"
},
"click": {
"hashes": [
"sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a",
"sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"
"sha256:8c04c11192119b1ef78ea049e0a6f0463e4c48ef00a30160c704337586f3ad7a",
"sha256:fba402a4a47334742d782209a7c79bc448911afe1149d07bdabdf480b3e2f4b6"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
"version": "==7.1.2"
"markers": "python_version >= '3.6'",
"version": "==8.0.1"
},
"ecdsa": {
"hashes": [
"sha256:881fa5e12bb992972d3d1b3d4dfbe149ab76a89f13da02daa5ea1ec7dea6e747",
"sha256:cfc046a2ddd425adbd1a78b3c46f0d1325c657811c0f45ecc3a0a6236c1e50ff"
"sha256:5cf31d5b33743abe0dfc28999036c849a69d548f994b535e527ee3cb7f3ef676",
"sha256:b9f500bb439e4153d0330610f5d26baaf18d17b8ced1bc54410d189385ea68aa"
],
"index": "pypi",
"version": "==0.16.1"
"version": "==0.17.0"
},
"embit": {
"hashes": [
"sha256:7c4264d7ede8e2c114db10585270874c9df809c68d2e21db918872e3245b5f2b"
"sha256:d67fc0f7fbdb7588c3eb24441bf8e05770056260bc8e5537399a1b3ce5ccf12a"
],
"index": "pypi",
"version": "==0.2.1"
"version": "==0.4.2"
},
"environs": {
"hashes": [
@ -169,19 +177,19 @@
},
"httpcore": {
"hashes": [
"sha256:37ae835fb370049b2030c3290e12ed298bf1473c41bb72ca4aa78681eba9b7c9",
"sha256:93e822cd16c32016b414b789aeff4e855d0ccbfc51df563ee34d4dbadbb3bcdc"
"sha256:b0d16f0012ec88d8cc848f5a55f8a03158405f4bca02ee49bc4ca2c1fda49f3e",
"sha256:db4c0dcb8323494d01b8c6d812d80091a31e520033e7b0120883d6f52da649ff"
],
"markers": "python_version >= '3.6'",
"version": "==0.12.3"
"version": "==0.13.6"
},
"httpx": {
"hashes": [
"sha256:cc2a55188e4b25272d2bcd46379d300f632045de4377682aa98a8a6069d55967",
"sha256:d379653bd457e8257eb0df99cb94557e4aac441b7ba948e333be969298cac272"
"sha256:979afafecb7d22a1d10340bafb403cf2cb75aff214426ff206521fc79d26408c",
"sha256:9f99c15d33642d38bce8405df088c1c4cfd940284b4290cacbfb02e64f4877c6"
],
"index": "pypi",
"version": "==0.17.1"
"version": "==0.18.2"
},
"hypercorn": {
"extras": [
@ -196,34 +204,34 @@
},
"hyperframe": {
"hashes": [
"sha256:742d2a4bc3152a340a49d59f32e33ec420aa8e7054c1444ef5c7efff255842f1",
"sha256:a51026b1591cac726fc3d0b7994fbc7dc5efab861ef38503face2930fd7b2d34"
"sha256:0ec6bafd80d8ad2195c4f03aacba3a8265e57bc4cff261e802bf39970ed02a15",
"sha256:ae510046231dc8e9ecb1a6586f63d2347bf4c8905914aa84ba585ae85f28a914"
],
"markers": "python_full_version >= '3.6.1'",
"version": "==6.0.0"
"version": "==6.0.1"
},
"idna": {
"hashes": [
"sha256:5205d03e7bcbb919cc9c19885f9920d622ca52448306f2377daede5cf3faac16",
"sha256:c5b02147e01ea9920e6b0a3f1f7bb833612d507592c837a6c49552768f4054e1"
"sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a",
"sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3"
],
"version": "==3.1"
"version": "==3.2"
},
"itsdangerous": {
"hashes": [
"sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19",
"sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749"
"sha256:5174094b9637652bdb841a3029700391451bd092ba3db90600dea710ba28e97c",
"sha256:9e724d68fc22902a1435351f84c3fb8623f303fffcc566a4cb952df8c572cff0"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.1.0"
"markers": "python_version >= '3.6'",
"version": "==2.0.1"
},
"jinja2": {
"hashes": [
"sha256:03e47ad063331dd6a3f04a43eddca8a966a26ba0c5b7207a9a9e4e08f1b29419",
"sha256:a6d58433de0ae800347cab1fa3043cebbabe8baa9d29e668f1c768cb87a333c6"
"sha256:1f06f2da51e7b56b8f238affdd6b4e2c61e39598a378cc49345bc1bd42a978a4",
"sha256:703f484b47a6af502e743c9122595cc812b0271f661722403114f71a79d0f5a4"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
"version": "==2.11.3"
"markers": "python_version >= '3.6'",
"version": "==3.0.1"
},
"lnurl": {
"hashes": [
@ -235,69 +243,87 @@
},
"markupsafe": {
"hashes": [
"sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473",
"sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161",
"sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235",
"sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5",
"sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42",
"sha256:195d7d2c4fbb0ee8139a6cf67194f3973a6b3042d742ebe0a9ed36d8b6f0c07f",
"sha256:22c178a091fc6630d0d045bdb5992d2dfe14e3259760e713c490da5323866c39",
"sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff",
"sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b",
"sha256:2beec1e0de6924ea551859edb9e7679da6e4870d32cb766240ce17e0a0ba2014",
"sha256:3b8a6499709d29c2e2399569d96719a1b21dcd94410a586a18526b143ec8470f",
"sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1",
"sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e",
"sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183",
"sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66",
"sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b",
"sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1",
"sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15",
"sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1",
"sha256:6f1e273a344928347c1290119b493a1f0303c52f5a5eae5f16d74f48c15d4a85",
"sha256:6fffc775d90dcc9aed1b89219549b329a9250d918fd0b8fa8d93d154918422e1",
"sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e",
"sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b",
"sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905",
"sha256:7fed13866cf14bba33e7176717346713881f56d9d2bcebab207f7a036f41b850",
"sha256:84dee80c15f1b560d55bcfe6d47b27d070b4681c699c572af2e3c7cc90a3b8e0",
"sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735",
"sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d",
"sha256:98bae9582248d6cf62321dcb52aaf5d9adf0bad3b40582925ef7c7f0ed85fceb",
"sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e",
"sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d",
"sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c",
"sha256:a6a744282b7718a2a62d2ed9d993cad6f5f585605ad352c11de459f4108df0a1",
"sha256:acf08ac40292838b3cbbb06cfe9b2cb9ec78fce8baca31ddb87aaac2e2dc3bc2",
"sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21",
"sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2",
"sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5",
"sha256:b1dba4527182c95a0db8b6060cc98ac49b9e2f5e64320e2b56e47cb2831978c7",
"sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b",
"sha256:b7d644ddb4dbd407d31ffb699f1d140bc35478da613b441c582aeb7c43838dd8",
"sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6",
"sha256:bf5aa3cbcfdf57fa2ee9cd1822c862ef23037f5c832ad09cfea57fa846dec193",
"sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f",
"sha256:caabedc8323f1e93231b52fc32bdcde6db817623d33e100708d9a68e1f53b26b",
"sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f",
"sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2",
"sha256:d53bc011414228441014aa71dbec320c66468c1030aae3a6e29778a3382d96e5",
"sha256:d73a845f227b0bfe8a7455ee623525ee656a9e2e749e4742706d80a6065d5e2c",
"sha256:d9be0ba6c527163cbed5e0857c451fcd092ce83947944d6c14bc95441203f032",
"sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7",
"sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be",
"sha256:feb7b34d6325451ef96bc0e36e1a6c0c1c64bc1fbec4b854f4529e51887b1621"
"sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298",
"sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64",
"sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b",
"sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567",
"sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff",
"sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74",
"sha256:1d609f577dc6e1aa17d746f8bd3c31aa4d258f4070d61b2aa5c4166c1539de35",
"sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26",
"sha256:3c112550557578c26af18a1ccc9e090bfe03832ae994343cfdacd287db6a6ae7",
"sha256:47ab1e7b91c098ab893b828deafa1203de86d0bc6ab587b160f78fe6c4011f75",
"sha256:49e3ceeabbfb9d66c3aef5af3a60cc43b85c33df25ce03d0031a608b0a8b2e3f",
"sha256:4efca8f86c54b22348a5467704e3fec767b2db12fc39c6d963168ab1d3fc9135",
"sha256:53edb4da6925ad13c07b6d26c2a852bd81e364f95301c66e930ab2aef5b5ddd8",
"sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a",
"sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914",
"sha256:6557b31b5e2c9ddf0de32a691f2312a32f77cd7681d8af66c2692efdbef84c18",
"sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8",
"sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2",
"sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d",
"sha256:7d91275b0245b1da4d4cfa07e0faedd5b0812efc15b702576d103293e252af1b",
"sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f",
"sha256:97383d78eb34da7e1fa37dd273c20ad4320929af65d156e35a5e2d89566d9dfb",
"sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833",
"sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415",
"sha256:ab3ef638ace319fa26553db0624c4699e31a28bb2a835c5faca8f8acf6a5a902",
"sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9",
"sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d",
"sha256:be98f628055368795d818ebf93da628541e10b75b41c559fdf36d104c5787066",
"sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f",
"sha256:f5653a225f31e113b152e56f154ccbe59eeb1c7487b39b9d9f9cdb58e6c79dc5",
"sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94",
"sha256:f8ba0e8349a38d3001fae7eadded3f6606f0da5d748ee53cc1dab1d6527b9509",
"sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51",
"sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.1.1"
"markers": "python_version >= '3.6'",
"version": "==2.0.1"
},
"marshmallow": {
"hashes": [
"sha256:0dd42891a5ef288217ed6410917f3c6048f585f8692075a0052c24f9bfff9dfd",
"sha256:16e99cb7f630c0ef4d7d364ed0109ac194268dde123966076ab3dafb9ae3906b"
"sha256:8050475b70470cc58f4441ee92375db611792ba39ca1ad41d39cad193ea9e040",
"sha256:b45cde981d1835145257b4a3c5cb7b80786dcf5f50dd2990749a50c16cb48e01"
],
"markers": "python_version >= '3.5'",
"version": "==3.11.1"
"version": "==3.12.1"
},
"mypy": {
"hashes": [
"sha256:0190fb77e93ce971954c9e54ea61de2802065174e5e990c9d4c1d0f54fbeeca2",
"sha256:0756529da2dd4d53d26096b7969ce0a47997123261a5432b48cc6848a2cb0bd4",
"sha256:2f9fedc1f186697fda191e634ac1d02f03d4c260212ccb018fabbb6d4b03eee8",
"sha256:353aac2ce41ddeaf7599f1c73fed2b75750bef3b44b6ad12985a991bc002a0da",
"sha256:3f12705eabdd274b98f676e3e5a89f247ea86dc1af48a2d5a2b080abac4e1243",
"sha256:4efc67b9b3e2fddbe395700f91d5b8deb5980bfaaccb77b306310bd0b9e002eb",
"sha256:517e7528d1be7e187a5db7f0a3e479747307c1b897d9706b1c662014faba3116",
"sha256:68a098c104ae2b75e946b107ef69dd8398d54cb52ad57580dfb9fc78f7f997f0",
"sha256:746e0b0101b8efec34902810047f26a8c80e1efbb4fc554956d848c05ef85d76",
"sha256:8be7bbd091886bde9fcafed8dd089a766fa76eb223135fe5c9e9798f78023a20",
"sha256:9236c21194fde5df1b4d8ebc2ef2c1f2a5dc7f18bcbea54274937cae2e20a01c",
"sha256:9ef5355eaaf7a23ab157c21a44c614365238a7bdb3552ec3b80c393697d974e1",
"sha256:9f1d74eeb3f58c7bd3f3f92b8f63cb1678466a55e2c4612bf36909105d0724ab",
"sha256:a26d0e53e90815c765f91966442775cf03b8a7514a4e960de7b5320208b07269",
"sha256:ae94c31bb556ddb2310e4f913b706696ccbd43c62d3331cd3511caef466871d2",
"sha256:b5ba1f0d5f9087e03bf5958c28d421a03a4c1ad260bf81556195dffeccd979c4",
"sha256:b5dfcd22c6bab08dfeded8d5b44bdcb68c6f1ab261861e35c470b89074f78a70",
"sha256:cd01c599cf9f897b6b6c6b5d8b182557fb7d99326bcdf5d449a0fbbb4ccee4b9",
"sha256:e89880168c67cf4fde4506b80ee42f1537ad66ad366c101d388b3fd7d7ce2afd",
"sha256:ebe2bc9cb638475f5d39068d2dbe8ae1d605bb8d8d3ff281c695df1670ab3987",
"sha256:f89bfda7f0f66b789792ab64ce0978e4a991a0e4dd6197349d0767b0f1095b21",
"sha256:fc4d63da57ef0e8cd4ab45131f3fe5c286ce7dd7f032650d0fbc239c6190e167",
"sha256:fd634bc17b1e2d6ce716f0e43446d0d61cdadb1efcad5c56ca211c22b246ebc8"
],
"markers": "implementation_name == 'cpython'",
"version": "==0.902"
},
"mypy-extensions": {
"hashes": [
"sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d",
"sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"
],
"version": "==0.4.3"
},
"outcome": {
"hashes": [
@ -316,31 +342,31 @@
},
"pydantic": {
"hashes": [
"sha256:0c40162796fc8d0aa744875b60e4dc36834db9f2a25dbf9ba9664b1915a23850",
"sha256:20d42f1be7c7acc352b3d09b0cf505a9fab9deb93125061b376fbe1f06a5459f",
"sha256:2287ebff0018eec3cc69b1d09d4b7cebf277726fa1bd96b45806283c1d808683",
"sha256:258576f2d997ee4573469633592e8b99aa13bda182fcc28e875f866016c8e07e",
"sha256:26cf3cb2e68ec6c0cfcb6293e69fb3450c5fd1ace87f46b64f678b0d29eac4c3",
"sha256:2f2736d9a996b976cfdfe52455ad27462308c9d3d0ae21a2aa8b4cd1a78f47b9",
"sha256:3114d74329873af0a0e8004627f5389f3bb27f956b965ddd3e355fe984a1789c",
"sha256:3bbd023c981cbe26e6e21c8d2ce78485f85c2e77f7bab5ec15b7d2a1f491918f",
"sha256:3bcb9d7e1f9849a6bdbd027aabb3a06414abd6068cb3b21c49427956cce5038a",
"sha256:4bbc47cf7925c86a345d03b07086696ed916c7663cb76aa409edaa54546e53e2",
"sha256:6388ef4ef1435364c8cc9a8192238aed030595e873d8462447ccef2e17387125",
"sha256:830ef1a148012b640186bf4d9789a206c56071ff38f2460a32ae67ca21880eb8",
"sha256:8fbb677e4e89c8ab3d450df7b1d9caed23f254072e8597c33279460eeae59b99",
"sha256:c17a0b35c854049e67c68b48d55e026c84f35593c66d69b278b8b49e2484346f",
"sha256:dd4888b300769ecec194ca8f2699415f5f7760365ddbe243d4fd6581485fa5f0",
"sha256:dde4ca368e82791de97c2ec019681ffb437728090c0ff0c3852708cf923e0c7d",
"sha256:e3f8790c47ac42549dc8b045a67b0ca371c7f66e73040d0197ce6172b385e520",
"sha256:e8bc082afef97c5fd3903d05c6f7bb3a6af9fc18631b4cc9fedeb4720efb0c58",
"sha256:eb8ccf12295113ce0de38f80b25f736d62f0a8d87c6b88aca645f168f9c78771",
"sha256:fb77f7a7e111db1832ae3f8f44203691e15b1fa7e5a1cb9691d4e2659aee41c4",
"sha256:fbfb608febde1afd4743c6822c19060a8dbdd3eb30f98e36061ba4973308059e",
"sha256:fff29fe54ec419338c522b908154a2efabeee4f483e48990f87e189661f31ce3"
"sha256:021ea0e4133e8c824775a0cfe098677acf6fa5a3cbf9206a376eed3fc09302cd",
"sha256:05ddfd37c1720c392f4e0d43c484217b7521558302e7069ce8d318438d297739",
"sha256:05ef5246a7ffd2ce12a619cbb29f3307b7c4509307b1b49f456657b43529dc6f",
"sha256:10e5622224245941efc193ad1d159887872776df7a8fd592ed746aa25d071840",
"sha256:18b5ea242dd3e62dbf89b2b0ec9ba6c7b5abaf6af85b95a97b00279f65845a23",
"sha256:234a6c19f1c14e25e362cb05c68afb7f183eb931dd3cd4605eafff055ebbf287",
"sha256:244ad78eeb388a43b0c927e74d3af78008e944074b7d0f4f696ddd5b2af43c62",
"sha256:26464e57ccaafe72b7ad156fdaa4e9b9ef051f69e175dbbb463283000c05ab7b",
"sha256:41b542c0b3c42dc17da70554bc6f38cbc30d7066d2c2815a94499b5684582ecb",
"sha256:4a03cbbe743e9c7247ceae6f0d8898f7a64bb65800a45cbdc52d65e370570820",
"sha256:4be75bebf676a5f0f87937c6ddb061fa39cbea067240d98e298508c1bda6f3f3",
"sha256:54cd5121383f4a461ff7644c7ca20c0419d58052db70d8791eacbbe31528916b",
"sha256:589eb6cd6361e8ac341db97602eb7f354551482368a37f4fd086c0733548308e",
"sha256:8621559dcf5afacf0069ed194278f35c255dc1a1385c28b32dd6c110fd6531b3",
"sha256:8b223557f9510cf0bfd8b01316bf6dd281cf41826607eada99662f5e4963f316",
"sha256:99a9fc39470010c45c161a1dc584997f1feb13f689ecf645f59bb4ba623e586b",
"sha256:a7c6002203fe2c5a1b5cbb141bb85060cbff88c2d78eccbc72d97eb7022c43e4",
"sha256:a83db7205f60c6a86f2c44a61791d993dff4b73135df1973ecd9eed5ea0bda20",
"sha256:ac8eed4ca3bd3aadc58a13c2aa93cd8a884bcf21cb019f8cfecaae3b6ce3746e",
"sha256:e710876437bc07bd414ff453ac8ec63d219e7690128d925c6e82889d674bb505",
"sha256:ea5cb40a3b23b3265f6325727ddfc45141b08ed665458be8c6285e7b85bd73a1",
"sha256:fec866a0b59f372b7e776f2d7308511784dace622e0992a0b59ea3ccee0ae833"
],
"markers": "python_full_version >= '3.6.1'",
"version": "==1.8.1"
"version": "==1.8.2"
},
"pypng": {
"hashes": [
@ -366,18 +392,18 @@
},
"python-dotenv": {
"hashes": [
"sha256:471b782da0af10da1a80341e8438fca5fadeba2881c54360d5fd8d03d03a4f4a",
"sha256:49782a97c9d641e8a09ae1d9af0856cc587c8d2474919342d5104d85be9890b2"
"sha256:dd8fe852847f4fbfadabf6183ddd4c824a9651f02d51714fa075c95561959c7d",
"sha256:effaac3c1e58d89b3ccb4d04a40dc7ad6e0275fda25fd75ae9d323e2465e202d"
],
"version": "==0.17.0"
"version": "==0.18.0"
},
"quart": {
"hashes": [
"sha256:429c5b4ff27e1d2f9ca0aacc38f6aba0ff49b38b815448bf24b613d3de12ea02",
"sha256:7b13786e07541cc9ce1466fdc6a6ccd5f36eb39118edd25a42d617593cd17707"
"sha256:f35134fb1d81af61624e6d89bca33cd611dcedce2dc4e291f527ab04395f4e1a",
"sha256:f80c91d1e0588662483e22dd9c368a5778886b62e128c5399d2cc1b1898482cf"
],
"index": "pypi",
"version": "==0.14.1"
"version": "==0.15.1"
},
"quart-compress": {
"hashes": [
@ -389,19 +415,19 @@
},
"quart-cors": {
"hashes": [
"sha256:0ea23ea8db2c21835f6698b91a09d99ab59f98f8d90a2a739475ef0409591573",
"sha256:e526e9929934ad31301853efe357a3bd2e08c3282aff37184fa8671ed854f052"
"sha256:c2be932f20413a56b176527090229afe8f725a3ee029d45ea08a174cdc319823",
"sha256:ea08d26aef918d59194fbf065cde9b6cae90dc5f21120dcd254d7d46190cd293"
],
"index": "pypi",
"version": "==0.4.0"
"version": "==0.5.0"
},
"quart-trio": {
"hashes": [
"sha256:1e7fce0df41afc3038bf0431b20614f90984de50341b19f9d4d3b9ba1ac7574a",
"sha256:933e3c18e232ece30ccbac7579fdc5f62f2f9c79c3273d6c341f5a1686791eb1"
"sha256:27617f0c9fa8759d3056e9ddcdc038d44093af45eb5f84f8d5714872aaaa8c7d",
"sha256:30dfab5e382f06c605d4a5960e8188e8e05d10198f02097f0a16c1dca41b3574"
],
"index": "pypi",
"version": "==0.7.0"
"version": "==0.8.0"
},
"represent": {
"hashes": [
@ -416,18 +442,18 @@
"idna2008"
],
"hashes": [
"sha256:112398da31a3344dc25dbf477d8df6cb34f9278a94fee2625d89e4514be8bb9d",
"sha256:af9147e9aceda37c91a05f4deb128d4b4b49d6b199775fd2d2927768abdc8f50"
"sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835",
"sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97"
],
"version": "==1.4.0"
"version": "==1.5.0"
},
"secure": {
"hashes": [
"sha256:4dc8dd4b548831c3ad7f94079332c41d67c781eccc32215ff5a8a49582c1a447",
"sha256:b3bf1e39ebf40040fc3248392343a5052aa14cb45fc87ec91b0bd11f19cc46bd"
"sha256:6e30939d8f95bf3b8effb8a36ebb5ed57f265daeeae905e3aa9677ea538ab64e",
"sha256:a93b720c7614809c131ca80e477263140107c6c212829d0a6e1f7bc8d859c608"
],
"index": "pypi",
"version": "==0.2.1"
"version": "==0.3.0"
},
"shortuuid": {
"hashes": [
@ -439,11 +465,11 @@
},
"six": {
"hashes": [
"sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259",
"sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"
"sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926",
"sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.15.0"
"version": "==1.16.0"
},
"sniffio": {
"hashes": [
@ -455,10 +481,10 @@
},
"sortedcontainers": {
"hashes": [
"sha256:37257a32add0a3ee490bb170b599e93095eed89a55da91fa9f48753ea12fd73f",
"sha256:59cc937650cf60d677c16775597c89a960658a09cf7c1a668f86e1e4464b10a1"
"sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88",
"sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"
],
"version": "==2.3.0"
"version": "==2.4.0"
},
"sqlalchemy": {
"hashes": [
@ -528,22 +554,30 @@
"index": "pypi",
"version": "==0.16.0"
},
"typing-extensions": {
"trio-typing": {
"hashes": [
"sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918",
"sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c",
"sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"
"sha256:35f1bec8df2150feab6c8b073b54135321722c9d9289bbffa78a9a091ea83b72",
"sha256:f2007df617a6c26a2294db0dd63645b5451149757e1bde4cb8dbf3e1369174fb"
],
"index": "pypi",
"version": "==3.7.4.3"
"version": "==0.5.0"
},
"typing-extensions": {
"hashes": [
"sha256:0ac0f89795dd19de6b97debb0c6af1c70987fd80a2d62d1958f7e56fcc31b497",
"sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342",
"sha256:779383f6086d90c99ae41cf0ff39aac8a7937a9283ce0a414e5dd782f4c94a84"
],
"index": "pypi",
"version": "==3.10.0.0"
},
"werkzeug": {
"hashes": [
"sha256:2de2a5db0baeae7b2d2664949077c2ac63fbd16d98da0ff71837f7d1dea3fd43",
"sha256:6c80b1e5ad3665290ea39320b91e1be1e0d5f60652b964a3070216de83d2e47c"
"sha256:1de1db30d010ff1af14a009224ec49ab2329ad2cde454c8a708130642d579c42",
"sha256:6c1ec500dcdba0baa27600f6a22f6333d8b662d22027ff9f6202e3367413caa8"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
"version": "==1.0.1"
"markers": "python_version >= '3.6'",
"version": "==2.0.1"
},
"wsproto": {
"hashes": [
@ -572,11 +606,11 @@
},
"attrs": {
"hashes": [
"sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6",
"sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700"
"sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1",
"sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==20.3.0"
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
"version": "==21.2.0"
},
"black": {
"hashes": [
@ -587,11 +621,11 @@
},
"click": {
"hashes": [
"sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a",
"sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"
"sha256:8c04c11192119b1ef78ea049e0a6f0463e4c48ef00a30160c704337586f3ad7a",
"sha256:fba402a4a47334742d782209a7c79bc448911afe1149d07bdabdf480b3e2f4b6"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
"version": "==7.1.2"
"markers": "python_version >= '3.6'",
"version": "==8.0.1"
},
"coverage": {
"hashes": [
@ -653,10 +687,10 @@
},
"idna": {
"hashes": [
"sha256:5205d03e7bcbb919cc9c19885f9920d622ca52448306f2377daede5cf3faac16",
"sha256:c5b02147e01ea9920e6b0a3f1f7bb833612d507592c837a6c49552768f4054e1"
"sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a",
"sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3"
],
"version": "==3.1"
"version": "==3.2"
},
"iniconfig": {
"hashes": [
@ -667,31 +701,32 @@
},
"mypy": {
"hashes": [
"sha256:0d0a87c0e7e3a9becdfbe936c981d32e5ee0ccda3e0f07e1ef2c3d1a817cf73e",
"sha256:25adde9b862f8f9aac9d2d11971f226bd4c8fbaa89fb76bdadb267ef22d10064",
"sha256:28fb5479c494b1bab244620685e2eb3c3f988d71fd5d64cc753195e8ed53df7c",
"sha256:2f9b3407c58347a452fc0736861593e105139b905cca7d097e413453a1d650b4",
"sha256:33f159443db0829d16f0a8d83d94df3109bb6dd801975fe86bacb9bf71628e97",
"sha256:3f2aca7f68580dc2508289c729bd49ee929a436208d2b2b6aab15745a70a57df",
"sha256:499c798053cdebcaa916eef8cd733e5584b5909f789de856b482cd7d069bdad8",
"sha256:4eec37370483331d13514c3f55f446fc5248d6373e7029a29ecb7b7494851e7a",
"sha256:552a815579aa1e995f39fd05dde6cd378e191b063f031f2acfe73ce9fb7f9e56",
"sha256:5873888fff1c7cf5b71efbe80e0e73153fe9212fafdf8e44adfe4c20ec9f82d7",
"sha256:61a3d5b97955422964be6b3baf05ff2ce7f26f52c85dd88db11d5e03e146a3a6",
"sha256:674e822aa665b9fd75130c6c5f5ed9564a38c6cea6a6432ce47eafb68ee578c5",
"sha256:7ce3175801d0ae5fdfa79b4f0cfed08807af4d075b402b7e294e6aa72af9aa2a",
"sha256:9743c91088d396c1a5a3c9978354b61b0382b4e3c440ce83cf77994a43e8c521",
"sha256:9f94aac67a2045ec719ffe6111df543bac7874cee01f41928f6969756e030564",
"sha256:a26f8ec704e5a7423c8824d425086705e381b4f1dfdef6e3a1edab7ba174ec49",
"sha256:abf7e0c3cf117c44d9285cc6128856106183938c68fd4944763003decdcfeb66",
"sha256:b09669bcda124e83708f34a94606e01b614fa71931d356c1f1a5297ba11f110a",
"sha256:cd07039aa5df222037005b08fbbfd69b3ab0b0bd7a07d7906de75ae52c4e3119",
"sha256:d23e0ea196702d918b60c8288561e722bf437d82cb7ef2edcd98cfa38905d506",
"sha256:d65cc1df038ef55a99e617431f0553cd77763869eebdf9042403e16089fe746c",
"sha256:d7da2e1d5f558c37d6e8c1246f1aec1e7349e4913d8fb3cb289a35de573fe2eb"
"sha256:0190fb77e93ce971954c9e54ea61de2802065174e5e990c9d4c1d0f54fbeeca2",
"sha256:0756529da2dd4d53d26096b7969ce0a47997123261a5432b48cc6848a2cb0bd4",
"sha256:2f9fedc1f186697fda191e634ac1d02f03d4c260212ccb018fabbb6d4b03eee8",
"sha256:353aac2ce41ddeaf7599f1c73fed2b75750bef3b44b6ad12985a991bc002a0da",
"sha256:3f12705eabdd274b98f676e3e5a89f247ea86dc1af48a2d5a2b080abac4e1243",
"sha256:4efc67b9b3e2fddbe395700f91d5b8deb5980bfaaccb77b306310bd0b9e002eb",
"sha256:517e7528d1be7e187a5db7f0a3e479747307c1b897d9706b1c662014faba3116",
"sha256:68a098c104ae2b75e946b107ef69dd8398d54cb52ad57580dfb9fc78f7f997f0",
"sha256:746e0b0101b8efec34902810047f26a8c80e1efbb4fc554956d848c05ef85d76",
"sha256:8be7bbd091886bde9fcafed8dd089a766fa76eb223135fe5c9e9798f78023a20",
"sha256:9236c21194fde5df1b4d8ebc2ef2c1f2a5dc7f18bcbea54274937cae2e20a01c",
"sha256:9ef5355eaaf7a23ab157c21a44c614365238a7bdb3552ec3b80c393697d974e1",
"sha256:9f1d74eeb3f58c7bd3f3f92b8f63cb1678466a55e2c4612bf36909105d0724ab",
"sha256:a26d0e53e90815c765f91966442775cf03b8a7514a4e960de7b5320208b07269",
"sha256:ae94c31bb556ddb2310e4f913b706696ccbd43c62d3331cd3511caef466871d2",
"sha256:b5ba1f0d5f9087e03bf5958c28d421a03a4c1ad260bf81556195dffeccd979c4",
"sha256:b5dfcd22c6bab08dfeded8d5b44bdcb68c6f1ab261861e35c470b89074f78a70",
"sha256:cd01c599cf9f897b6b6c6b5d8b182557fb7d99326bcdf5d449a0fbbb4ccee4b9",
"sha256:e89880168c67cf4fde4506b80ee42f1537ad66ad366c101d388b3fd7d7ce2afd",
"sha256:ebe2bc9cb638475f5d39068d2dbe8ae1d605bb8d8d3ff281c695df1670ab3987",
"sha256:f89bfda7f0f66b789792ab64ce0978e4a991a0e4dd6197349d0767b0f1095b21",
"sha256:fc4d63da57ef0e8cd4ab45131f3fe5c286ce7dd7f032650d0fbc239c6190e167",
"sha256:fd634bc17b1e2d6ce716f0e43446d0d61cdadb1efcad5c56ca211c22b246ebc8"
],
"index": "pypi",
"version": "==0.812"
"markers": "implementation_name == 'cpython'",
"version": "==0.902"
},
"mypy-extensions": {
"hashes": [
@ -749,19 +784,19 @@
},
"pytest": {
"hashes": [
"sha256:671238a46e4df0f3498d1c3270e5deb9b32d25134c99b7d75370a68cfbe9b634",
"sha256:6ad9c7bdf517a808242b998ac20063c41532a570d088d77eec1ee12b0b5574bc"
"sha256:50bcad0a0b9c5a72c8e4e7c9855a3ad496ca6a881a3641b4260605450772c54b",
"sha256:91ef2131a9bd6be8f76f1f08eac5c5317221d6ad1e143ae03894b862e8976890"
],
"index": "pypi",
"version": "==6.2.3"
"version": "==6.2.4"
},
"pytest-cov": {
"hashes": [
"sha256:359952d9d39b9f822d9d29324483e7ba04a3a17dd7d05aa6beb7ea01e359e5f7",
"sha256:bdb9fdb0b85a7cc825269a4c56b48ccaa5c7e365054b6038772c32ddcdc969da"
"sha256:261bb9e47e65bd099c89c3edf92972865210c36813f80ede5277dceb77a4a62a",
"sha256:261ceeb8c227b726249b376b8526b600f38667ee314f910353fa318caa01f4d7"
],
"index": "pypi",
"version": "==2.11.1"
"version": "==2.12.1"
},
"pytest-trio": {
"hashes": [
@ -826,10 +861,10 @@
},
"sortedcontainers": {
"hashes": [
"sha256:37257a32add0a3ee490bb170b599e93095eed89a55da91fa9f48753ea12fd73f",
"sha256:59cc937650cf60d677c16775597c89a960658a09cf7c1a668f86e1e4464b10a1"
"sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88",
"sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"
],
"version": "==2.3.0"
"version": "==2.4.0"
},
"toml": {
"hashes": [
@ -884,12 +919,12 @@
},
"typing-extensions": {
"hashes": [
"sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918",
"sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c",
"sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"
"sha256:0ac0f89795dd19de6b97debb0c6af1c70987fd80a2d62d1958f7e56fcc31b497",
"sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342",
"sha256:779383f6086d90c99ae41cf0ff39aac8a7937a9283ce0a414e5dd782f4c94a84"
],
"index": "pypi",
"version": "==3.7.4.3"
"version": "==3.10.0.0"
}
}
}

View file

@ -22,7 +22,7 @@ LNbits is a very simple Python server that sits on top of any funding source, an
* Fallback wallet for the LNURL scheme
* Instant wallet for LN demonstrations
LNbits can run on top of any lightning-network funding source, currently there is support for LND, c-lightning, Spark, LNpay, OpenNode, lntxbot, with more being added regularily.
LNbits can run on top of any lightning-network funding source, currently there is support for LND, c-lightning, Spark, LNpay, OpenNode, lntxbot, with more being added regularly.
See [lnbits.org](https://lnbits.org) for more detailed documentation.

View file

@ -5,15 +5,11 @@ title: Installation
nav_order: 1
---
Installation
============
# Installation
Download the latest stable release https://github.com/lnbits/lnbits/releases
Application dependencies
------------------------
## Application dependencies
The application uses [Pipenv][pipenv] to manage Python packages.
While in development, you will need to install all dependencies:
@ -24,7 +20,7 @@ $ pipenv install --dev
```
If any of the modules fails to install, try checking and upgrading your setupTool module.
`pip install -U setuptools`
`pip install -U setuptools`
If you wish to use a version of Python higher than 3.7:
@ -41,22 +37,33 @@ E.g. when you want to use LND you have to `pipenv run pip install lndgrpc` and `
Take a look at [Polar][polar] for an excellent way of spinning up a Lightning Network dev environment.
Running the server
------------------
## Running the server
LNbits uses [Quart][quart] as an application server.
Before running the server for the first time, make sure to create the data folder:
```sh
$ mkdir data
```
To then run the server, use:
```sh
$ pipenv run python -m lnbits
```
Frontend
--------
**Note**: You'll need to use _https_ for some endpoints and/or extensions. You can use [ngrok](https://ngrok.com/) for that. Follow the installation instructions on the website and when it's all set you can run:
```sh
$ ./nrok http 5000
```
this will give you an _https_ tunnel for the _localhost_, use that URL for navigating to LNBits.
## Frontend
The frontend uses [Vue.js and Quasar][quasar].
[quart]: https://pgjones.gitlab.io/
[pipenv]: https://pipenv.pypa.io/
[polar]: https://lightningpolar.com/

View file

@ -13,18 +13,48 @@ Download this repo and install the dependencies:
```sh
git clone https://github.com/lnbits/lnbits.git
cd lnbits/
# ensure you have virtualenv installed, on debian/ubuntu 'apt install python3-venv' should work
python3 -m venv venv
./venv/bin/pip install -r requirements.txt
cp .env.example .env
mkdir data
./venv/bin/quart assets
./venv/bin/quart migrate
./venv/bin/hypercorn -k trio --bind 0.0.0.0:5000 'lnbits.app:create_app()'
```
No you can visit your LNbits at http://localhost:5000/.
Now you can visit your LNbits at http://localhost:5000/.
Now modify the `.env` file with any settings you prefer and add a proper [funding source](./wallets.md) by modifying the value of `LNBITS_BACKEND_WALLET_CLASS` and providing the extra information and credentials related to the chosen funding source.
Then you can run restart it and it will be using the new settings.
Then you can restart it and it will be using the new settings.
You might also need to install additional packages or perform additional setup steps, depending on the chosen backend. See [the short guide](./wallets.md) on each different funding source.
Docker installation
===================
To install using docker you first need to build the docker image as:
```
git clone https://github.com/lnbits/lnbits.git
cd lnbits/ # ${PWD} referred as <lnbits_repo>
docker build -t lnbits .
```
You can launch the docker in a different directory, but make sure to copy `.env.example` from lnbits there
```
cp <lnbits_repo>/.env.example .env
```
and change the configuration in `.env` as required.
Then create the data directory for the user ID 1000, which is the user that runs the lnbits within the docker container.
```
mkdir data
sudo chown 1000:1000 ./data/
```
Then the image can be run as:
```
docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits
```
Finally you can access your lnbits on your machine at port 5000.

View file

@ -54,7 +54,7 @@ Using this wallet requires the installation of the `lndgrpc` and `purerpc` Pytho
### LNPay
For the invoice listener to work you have a publicly accessible URL in your LNbits and must set up [LNPay webhooks](https://dashboard.lnpay.co/webhook/) pointing to `<your LNbits host>/wallet/webhook` with the "Wallet Receive" event and no secret.
For the invoice listener to work you have a publicly accessible URL in your LNbits and must set up [LNPay webhooks](https://dashboard.lnpay.co/webhook/) pointing to `<your LNbits host>/wallet/webhook` with the "Wallet Receive" event and no secret. For example, `https://mylnbits/wallet/webhook` will be the Endpoint Url that gets notified about the payment.
- `LNBITS_BACKEND_WALLET_CLASS`: **LNPayWallet**
- `LNPAY_API_ENDPOINT`: https://api.lnpay.co/v1/

View file

@ -1,4 +1,4 @@
import trio # type: ignore
import trio
from .commands import migrate_databases, transpile_scss, bundle_vendored

View file

@ -1,6 +1,7 @@
import sys
import importlib
import warnings
import importlib
import traceback
from quart import g
from quart_trio import QuartTrio
@ -23,7 +24,6 @@ from .tasks import (
invoice_listener,
internal_invoice_listener,
webhook_handler,
grab_app_for_later,
)
from .settings import WALLET
@ -48,7 +48,7 @@ def create_app(config_object="lnbits.settings") -> QuartTrio:
register_commands(app)
register_request_hooks(app)
register_async_tasks(app)
grab_app_for_later(app)
register_exception_handlers(app)
return app
@ -108,16 +108,13 @@ def register_assets(app: QuartTrio):
def register_filters(app: QuartTrio):
"""Jinja filters."""
app.jinja_env.globals["SITE_TITLE"] = app.config["LNBITS_SITE_TITLE"]
app.jinja_env.globals["LNBITS_VERSION"] = app.config["LNBITS_COMMIT"]
app.jinja_env.globals["EXTENSIONS"] = get_valid_extensions()
def register_request_hooks(app: QuartTrio):
"""Open the core db for each request so everything happens in a big transaction"""
@app.before_request
async def before_request():
g.nursery = app.nursery
@app.after_request
async def set_secure_headers(response):
secure_headers.quart(response)
@ -139,3 +136,21 @@ def register_async_tasks(app):
@app.after_serving
async def stop_listeners():
pass
def register_exception_handlers(app):
@app.errorhandler(Exception)
async def basic_error(err):
etype, value, tb = sys.exc_info()
traceback.print_exception(etype, err, tb)
exc = traceback.format_exc()
return (
"\n\n".join(
[
"LNbits internal error!",
exc,
"If you believe this shouldn't be an error please bring it up on https://t.me/lnbits",
]
),
500,
)

View file

@ -1,4 +1,4 @@
import trio # type: ignore
import trio
import warnings
import click
import importlib

View file

@ -14,6 +14,7 @@ core_app: Blueprint = Blueprint(
from .views.api import * # noqa
from .views.generic import * # noqa
from .views.public_api import * # noqa
from .tasks import register_listeners
from lnbits.tasks import record_async

View file

@ -267,12 +267,23 @@ async def get_payments(
async def delete_expired_invoices(
conn: Optional[Connection] = None,
) -> None:
# first we delete all invoices older than one month
await (conn or db).execute(
"""
DELETE FROM apipayments
WHERE pending = 1 AND amount > 0 AND time < strftime('%s', 'now') - 2592000
"""
)
# then we delete all expired invoices, checking one by one
rows = await (conn or db).fetchall(
"""
SELECT bolt11
FROM apipayments
WHERE pending = 1 AND amount > 0 AND time < strftime('%s', 'now') - 86400
"""
WHERE pending = 1
AND bolt11 IS NOT NULL
AND amount > 0 AND time < strftime('%s', 'now') - 86400
"""
)
for (payment_request,) in rows:
try:

View file

@ -1,4 +1,4 @@
import trio # type: ignore
import trio
import json
import httpx
from io import BytesIO
@ -229,10 +229,10 @@ async def redeem_lnurl_withdraw(
pass
async with httpx.AsyncClient() as client:
await client.get(
res["callback"],
params=params,
)
try:
await client.get(res["callback"], params=params)
except Exception:
pass
async def perform_lnurlauth(

View file

@ -119,6 +119,8 @@ new Vue({
paymentHash: null,
minMax: [0, 2100000000000000],
lnurl: null,
units: ['sat'],
unit: 'sat',
data: {
amount: null,
memo: ''
@ -233,6 +235,7 @@ new Vue({
this.receive.paymentHash = null
this.receive.data.amount = null
this.receive.data.memo = null
this.receive.unit = 'sat'
this.receive.paymentChecker = null
this.receive.minMax = [0, 2100000000000000]
this.receive.lnurl = null
@ -269,11 +272,13 @@ new Vue({
},
createInvoice: function () {
this.receive.status = 'loading'
LNbits.api
.createInvoice(
this.g.wallet,
this.receive.data.amount,
this.receive.data.memo,
this.receive.unit,
this.receive.lnurl && this.receive.lnurl.callback
)
.then(response => {
@ -619,6 +624,15 @@ new Vue({
created: function () {
this.fetchBalance()
this.fetchPayments()
LNbits.api
.request('GET', '/api/v1/currencies')
.then(response => {
this.receive.units = ['sat', ...response.data]
})
.catch(err => {
LNbits.utils.notifyApiError(err)
})
},
mounted: function () {
// show disclaimer

View file

@ -1,4 +1,4 @@
import trio # type: ignore
import trio
import httpx
from typing import List
@ -8,7 +8,7 @@ from . import db
from .crud import get_balance_notify
from .models import Payment
sse_listeners: List[trio.MemorySendChannel] = []
api_invoice_listeners: List[trio.MemorySendChannel] = []
async def register_listeners():
@ -20,7 +20,7 @@ async def register_listeners():
async def wait_for_paid_invoices(invoice_paid_chan: trio.MemoryReceiveChannel):
async for payment in invoice_paid_chan:
# send information to sse channel
await dispatch_sse(payment)
await dispatch_invoice_listener(payment)
# dispatch webhook
if payment.webhook and not payment.webhook_status:
@ -40,13 +40,13 @@ async def wait_for_paid_invoices(invoice_paid_chan: trio.MemoryReceiveChannel):
pass
async def dispatch_sse(payment: Payment):
for send_channel in sse_listeners:
async def dispatch_invoice_listener(payment: Payment):
for send_channel in api_invoice_listeners:
try:
send_channel.send_nowait(payment)
except trio.WouldBlock:
print("removing sse listener", send_channel)
sse_listeners.remove(send_channel)
api_invoice_listeners.remove(send_channel)
async def dispatch_webhook(payment: Payment):

View file

@ -129,7 +129,7 @@
<q-badge v-if="props.row.tag" color="yellow" text-color="black">
<a
class="inherit"
:href="['/', props.row.tag, '?usr=', user.id].join('')"
:href="['/', props.row.tag, '/?usr=', user.id].join('')"
>
#{{ props.row.tag }}
</a>
@ -313,12 +313,21 @@
<b>{{receive.lnurl.domain}}</b> is requesting an invoice:
</p>
<q-select
filled
dense
v-model="receive.unit"
type="text"
label="Unit"
:options="receive.units"
></q-select>
<q-input
filled
dense
v-model.number="receive.data.amount"
type="number"
label="Amount (sat) *"
:label="`Amount (${receive.unit}) *`"
:step="receive.unit != 'sat' ? '0.001' : '1'"
:min="receive.minMax[0]"
:max="receive.minMax[1]"
:readonly="receive.lnurl && receive.lnurl.fixed"

View file

@ -1,15 +1,16 @@
import trio # type: ignore
import trio
import json
import lnurl # type: ignore
import httpx
from urllib.parse import urlparse, urlunparse, urlencode, parse_qs, ParseResult
from quart import g, jsonify, make_response, url_for
from quart import g, current_app, jsonify, make_response, url_for
from http import HTTPStatus
from binascii import unhexlify
from typing import Dict, Union
from lnbits import bolt11
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
from lnbits.utils.exchange_rates import currencies, fiat_amount_as_satoshis
from .. import core_app, db
from ..crud import save_balance_check
@ -20,7 +21,7 @@ from ..services import (
pay_invoice,
perform_lnurlauth,
)
from ..tasks import sse_listeners
from ..tasks import api_invoice_listeners
@core_app.route("/api/v1/wallet", methods=["GET"])
@ -47,13 +48,14 @@ async def api_payments():
@api_check_wallet_key("invoice")
@api_validate_post_request(
schema={
"amount": {"type": "integer", "min": 1, "required": True},
"amount": {"type": "number", "min": 0.001, "required": True},
"memo": {
"type": "string",
"empty": False,
"required": True,
"excludes": "description_hash",
},
"unit": {"type": "string", "empty": False, "required": False},
"description_hash": {
"type": "string",
"empty": False,
@ -74,11 +76,17 @@ async def api_payments_create_invoice():
description_hash = b""
memo = g.data["memo"]
if g.data.get("unit") or "sat" == "sat":
amount = g.data["amount"]
else:
price_in_sats = await fiat_amount_as_satoshis(g.data["amount"], g.data["unit"])
amount = price_in_sats
async with db.connect() as conn:
try:
payment_hash, payment_request = await create_invoice(
wallet_id=g.wallet.id,
amount=g.data["amount"],
amount=amount,
memo=memo,
description_hash=description_hash,
extra=g.data.get("extra"),
@ -295,7 +303,7 @@ async def api_payments_sse():
send_payment, receive_payment = trio.open_memory_channel(0)
print("adding sse listener", send_payment)
sse_listeners.append(send_payment)
api_invoice_listeners.append(send_payment)
send_event, event_to_send = trio.open_memory_channel(0)
@ -310,8 +318,8 @@ async def api_payments_sse():
await send_event.send(("keepalive", ""))
await trio.sleep(25)
g.nursery.start_soon(payment_received)
g.nursery.start_soon(repeat_keepalive)
current_app.nursery.start_soon(payment_received)
current_app.nursery.start_soon(repeat_keepalive)
async def send_events():
try:
@ -435,3 +443,8 @@ async def api_perform_lnurlauth():
if err:
return jsonify({"reason": err.reason}), HTTPStatus.SERVICE_UNAVAILABLE
return "", HTTPStatus.OK
@core_app.route("/api/v1/currencies", methods=["GET"])
async def api_list_currencies_available():
return jsonify(list(currencies.keys()))

View file

@ -2,6 +2,7 @@ from os import path
from http import HTTPStatus
from quart import (
g,
current_app,
abort,
jsonify,
request,
@ -128,7 +129,7 @@ async def lnurl_full_withdraw():
_external=True,
),
"k1": "0",
"minWithdrawable": 1 if wallet.withdrawable_balance else 0,
"minWithdrawable": 1000 if wallet.withdrawable_balance else 0,
"maxWithdrawable": wallet.withdrawable_balance,
"defaultDescription": f"{LNBITS_SITE_TITLE} balance withdraw from {wallet.id[0:5]}",
"balanceCheck": url_for(
@ -152,9 +153,12 @@ async def lnurl_full_withdraw_callback():
pr = request.args.get("pr")
async def pay():
await pay_invoice(wallet_id=wallet.id, payment_request=pr)
try:
await pay_invoice(wallet_id=wallet.id, payment_request=pr)
except:
pass
g.nursery.start_soon(pay)
current_app.nursery.start_soon(pay)
balance_notify = request.args.get("balanceNotify")
if balance_notify:
@ -197,7 +201,7 @@ async def lnurlwallet():
user = await get_user(account.id, conn=conn)
wallet = await create_wallet(user_id=user.id, conn=conn)
g.nursery.start_soon(
current_app.nursery.start_soon(
redeem_lnurl_withdraw,
wallet.id,
request.args.get("lightning"),

View file

@ -0,0 +1,37 @@
import trio
import datetime
from http import HTTPStatus
from quart import jsonify
from lnbits import bolt11
from .. import core_app
from ..crud import get_standalone_payment
from ..tasks import api_invoice_listeners
@core_app.route("/public/v1/payment/<payment_hash>", methods=["GET"])
async def api_public_payment_longpolling(payment_hash):
payment = await get_standalone_payment(payment_hash)
if not payment:
return jsonify({"message": "Payment does not exist."}), HTTPStatus.NOT_FOUND
elif not payment.pending:
return jsonify({"status": "paid"}), HTTPStatus.OK
try:
invoice = bolt11.decode(payment.bolt11)
expiration = datetime.datetime.fromtimestamp(invoice.date + invoice.expiry)
if expiration < datetime.datetime.now():
return jsonify({"status": "expired"}), HTTPStatus.OK
except:
return jsonify({"message": "Invalid bolt11 invoice."}), HTTPStatus.BAD_REQUEST
send_payment, receive_payment = trio.open_memory_channel(0)
print("adding standalone invoice listener", payment_hash, send_payment)
api_invoice_listeners.append(send_payment)
async for payment in receive_payment:
if payment.payment_hash == payment_hash:
return jsonify({"status": "paid"}), HTTPStatus.OK

View file

@ -3,7 +3,7 @@ import trio
from contextlib import asynccontextmanager
from sqlalchemy import create_engine # type: ignore
from sqlalchemy_aio import TRIO_STRATEGY # type: ignore
from sqlalchemy_aio.base import AsyncConnection
from sqlalchemy_aio.base import AsyncConnection # type: ignore
from .settings import LNBITS_DATA_FOLDER

View file

@ -1,3 +1,33 @@
<h1>Events</h1>
<h2>Events: Sell and register event tickets</h2>
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.
# 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

@ -82,7 +82,7 @@
<script>
Vue.component(VueQrcode.name, VueQrcode)
Vue.use(VueQrcodeReader)
var mapEvents = function (obj) {
var mapEvents = function(obj) {
obj.date = Quasar.utils.date.formatDate(
new Date(obj.time * 1000),
'YYYY-MM-DD HH:mm'
@ -94,7 +94,7 @@
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
data: function() {
return {
tickets: [],
ticketsTable: {
@ -119,35 +119,35 @@
}
},
methods: {
hoverEmail: function (tmp) {
hoverEmail: function(tmp) {
this.tickets.data.emailtemp = tmp
},
closeCamera: function () {
closeCamera: function() {
this.sendCamera.show = false
},
showCamera: function () {
showCamera: function() {
this.sendCamera.show = true
},
decodeQR: function (res) {
decodeQR: function(res) {
this.sendCamera.show = false
var self = this
LNbits.api
.request('GET', '/events/api/v1/register/ticket/' + res)
.then(function (response) {
.then(function(response) {
self.$q.notify({
type: 'positive',
message: 'Registered!'
})
setTimeout(function () {
setTimeout(function() {
window.location.reload()
}, 2000)
})
.catch(function (error) {
.catch(function(error) {
LNbits.utils.notifyApiError(error)
})
},
getEventTickets: function () {
getEventTickets: function() {
var self = this
console.log('obj')
LNbits.api
@ -155,17 +155,17 @@
'GET',
'/events/api/v1/eventtickets/{{ wallet_id }}/{{ event_id }}'
)
.then(function (response) {
self.tickets = response.data.map(function (obj) {
.then(function(response) {
self.tickets = response.data.map(function(obj) {
return mapEvents(obj)
})
})
.catch(function (error) {
.catch(function(error) {
LNbits.utils.notifyApiError(error)
})
}
},
created: function () {
created: function() {
this.getEventTickets()
}
})

View file

@ -195,6 +195,9 @@ async def api_event_register_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

View file

@ -0,0 +1,5 @@
# Jukebox
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 here https://developer.spotify.com/dashboard/applications
Select the playlists you want people to be able to pay for, share the frontend page, profit :)

View file

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

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 (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 SET {q} WHERE id = ?", (*kwargs.values(), juke_id)
)
row = await db.fetchone("SELECT * FROM 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 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 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 WHERE user = ?", (user,))
for row in rows:
if row.sp_playlists == "":
await delete_jukebox(row.id)
rows = await db.fetchall("SELECT * FROM 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 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_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_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_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 (
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_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,422 @@
/* 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'
},
{
name: 'profit',
align: 'left',
label: 'Profit',
field: 'profit'
}
],
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,19 @@
/* 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,101 @@
<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>

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">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,189 @@
{% 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="green-7" 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">LNbits 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="green-7" inactive-color="green-10" 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="green-7" @click="step = 2">Continue</q-btn>
<q-btn v-else color="green-7" disable>Continue</q-btn>
</div>
<div class="col-8">
<q-btn color="green-7" 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="green-7" @click="submitSpotifyKeys">Submit keys</q-btn>
<q-btn v-else color="green-7" disable color="green-7">Submit keys</q-btn>
</div>
<div class="col-8">
<q-btn color="green-7" 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="green-7" 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="green-7" @click="authAccess">Authorise access</q-btn>
<q-btn v-else color="green-7" disable color="green-7">Authorise access</q-btn>
</div>
<div class="col-8">
<q-btn color="green-7" 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="green-8" :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="green-7" @click="createJukebox">Create Jukebox</q-btn>
<q-btn v-else color="green-7" disable>Create Jukebox</q-btn>
</div>
<div class="col-7">
<q-btn color="green-7" 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,276 @@
{% 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: {},
cancelListener: () => { },
playlists: {},
playlist: '',
heavyList: [],
selectedWallet: {},
paid: false,
receive: {
dialogues: {
first: false,
second: false
},
paymentReq: '',
paymentHash: '',
name: '',
artist: '',
image: '',
id: '',
showQR: false
}
}
},
computed: {
currentPlay() {
return this.currentlyPlaying
}
},
methods: {
cancelPayment: function () {
this.paymentReq = null
clearInterval(this.paymentDialog.checker)
if (this.paymentDialog.dismissMsg) {
this.paymentDialog.dismissMsg()
}
},
closeReceiveDialog() { },
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
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
var paymentChecker = setInterval(function () {
if (!self.paid) {
self.checkInvoice(self.receive.paymentHash, '{{ juke_id }}')
}
if (self.paid) {
clearInterval(paymentChecker)
self.paid = true
self.receive.dialogues.first = false
self.receive.dialogues.second = false
self.$q.notify({
message:
'Processing',
})
LNbits.api
.request(
'GET',
'/jukebox/api/v1/jukebox/jb/invoicep/' + song_id + '/{{ juke_id }}/' + self.receive.paymentHash)
.then(function (response1) {
if (response1.data[2] == song_id) {
setTimeout(function () { self.getCurrent() }, 500)
self.$q.notify({
color: 'green',
message:
'Success! "' + self.receive.name + '" will be played soon',
timeout: 3000
})
self.paid = false
response1 = []
}
})
.catch(err => {
LNbits.utils.notifyApiError(err)
self.paid = false
response1 = []
})
}
}, 3000)
})
.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
})
})
},
checkInvoice(juke_id, paymentHash) {
var self = this
LNbits.api
.request(
'GET',
'/jukebox/api/v1/jukebox/jb/checkinvoice/' + juke_id + '/' + paymentHash,
'filla'
)
.then(function (response) {
self.paid = response.data.paid
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
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 }}')
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)
})
// this.startPaymentNotifier()
}
})
</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,490 @@
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,
)
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

@ -1,13 +1,45 @@
# DJ Livestream
An extension to help DJs to conduct music livestreams.
## Help DJ's and music producers conduct music livestreams
It produces a single static QR code that can be shown on screen. Once someone scans that QR code with an lnurl-pay capable wallet they will see the name of the track being played at that time and the name of the producer.
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.
They will then be given the opportunity to send a tip and a message related to that specific track and if they pay an amount over a specific threshold they will be given a link to download it (optional).
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

@ -61,7 +61,7 @@ async def lnurl_callback(track_id):
if not track:
return jsonify({"status": "ERROR", "reason": "Couldn't find track."})
amount_received = int(request.args.get("amount"))
amount_received = int(request.args.get("amount") or 0)
if amount_received < track.min_sendable:
return (

View file

@ -37,6 +37,7 @@ new Vue({
},
methods: {
getTrackLabel(trackId) {
if (!trackId) return
let track = this.tracksMap[trackId]
return `${track.name}, ${this.producersMap[track.producer].name}`
},
@ -162,6 +163,7 @@ new Vue({
})
},
updateCurrentTrack(track) {
console.log(this.nextCurrentTrack, this.livestream)
if (this.livestream.current_track === track) {
// if clicking the same, stop it
track = 0
@ -175,6 +177,7 @@ new Vue({
)
.then(() => {
this.livestream.current_track = track
this.nextCurrentTrack = track
this.$q.notify({
message: `Current track updated.`,
timeout: 700

View file

@ -1,5 +1,5 @@
import json
import trio # type: ignore
import trio
from lnbits.core.models import Payment
from lnbits.core.crud import create_payment

View file

@ -27,8 +27,8 @@
<div class="col">
{% raw %}
<q-btn unelevated color="deep-purple" type="submit">
{{ nextCurrentTrack === livestream.current_track ? 'Stop' : 'Set'
}} current track
{{ nextCurrentTrack && nextCurrentTrack ===
livestream.current_track ? 'Stop' : 'Set' }} current track
</q-btn>
{% endraw %}
</div>

View file

@ -1,3 +1,29 @@
<h1>Support Tickets</h1>
<h2>Get paid sats to answer questions</h2>
Charge people per word for contacting you. Possible applications include, paid support ticketing, PAYG language services, contact spam protection.
# Support Tickets
## Get paid sats to answer questions
Charge a per word amount for people to contact you.
Possible applications include, paid support ticketing, PAYG language services, contact spam protection.
1. Click "NEW FORM" to create a new contact form\
![new contact form](https://i.imgur.com/kZqWGPe.png)
2. Fill out the contact form
- set the wallet to use
- give your form a name
- set an optional webhook that will get called when the form receives a payment
- give it a small description
- set the amount you want to charge, per **word**, for people to contact you\
![form settings](https://i.imgur.com/AsXeVet.png)
3. Your new contact form will appear on the _Forms_ section. Note that you can create various forms with different rates per word, for different purposes\
![forms section](https://i.imgur.com/gg71HhM.png)
4. When a user wants to reach out to you, they will get to the contact form. They can fill out some information:
- a name
- an optional email if they want you to reply
- and the actual message
- at the bottom, a value in satoshis, will display how much it will cost them to send this message\
![user view of form](https://i.imgur.com/DWGJWQz.png)
- after submiting the Lightning Network invoice will pop up and after payment the message will be sent to you\
![contact form payment](https://i.imgur.com/7heGsiO.png)
5. Back in "Support ticket" extension you'll get the messages your fans, users, haters, etc, sent you on the _Tickets_ section\
![tickets](https://i.imgur.com/dGhJ6Ok.png)

View file

@ -10,3 +10,8 @@ lnticket_ext: Blueprint = Blueprint(
from .views_api import * # noqa
from .views import * # noqa
from .tasks import register_listeners
from lnbits.tasks import record_async
lnticket_ext.record(record_async(register_listeners))

View file

@ -0,0 +1,36 @@
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_ticket, set_ticket_paid
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 "lnticket" != payment.extra.get("tag"):
# not a lnticket invoice
return
ticket = await get_ticket(payment.checking_id)
if not ticket:
print("this should never happen", payment)
return
await payment.set_pending(False)
await set_ticket_paid(payment.payment_hash)
_ticket = await get_ticket(payment.checking_id)
print('ticket', _ticket)

View file

@ -77,7 +77,7 @@
{% endblock %} {% block scripts %}
<script>
console.log('{{ form_costpword }}')
//console.log('{{ form_costpword }}')
Vue.component(VueQrcode.name, VueQrcode)
new Vue({
@ -99,7 +99,11 @@
show: false,
status: 'pending',
paymentReq: null
}
},
wallet: {
inkey: ''
},
cancelListener: () => {}
}
},
computed: {
@ -128,12 +132,35 @@
},
closeReceiveDialog: function () {
var checker = this.receive.paymentChecker
var checker = this.startPaymentNotifier
dismissMsg()
clearInterval(paymentChecker)
setTimeout(function () {}, 10000)
},
startPaymentNotifier() {
this.cancelListener()
this.cancelListener = LNbits.events.onInvoicePaid(
this.wallet,
payment => {
this.receive = {
show: false,
status: 'complete',
paymentReq: null
}
dismissMsg()
this.formDialog.data.name = ''
this.formDialog.data.email = ''
this.formDialog.data.text = ''
this.$q.notify({
type: 'positive',
message: 'Sent, thank you!',
icon: 'thumb_up'
})
}
)
},
Invoice: function () {
var self = this
axios
@ -158,39 +185,15 @@
status: 'pending',
paymentReq: self.paymentReq
}
paymentChecker = setInterval(function () {
axios
.get('/lnticket/api/v1/tickets/' + self.paymentCheck)
.then(function (res) {
if (res.data.paid) {
clearInterval(paymentChecker)
self.receive = {
show: false,
status: 'complete',
paymentReq: null
}
dismissMsg()
self.formDialog.data.name = ''
self.formDialog.data.email = ''
self.formDialog.data.text = ''
self.$q.notify({
type: 'positive',
message: 'Sent, thank you!',
icon: 'thumb_up'
})
}
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
}, 2000)
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
}
},
created() {
this.wallet.inkey = '{{form_wallet}}'
this.startPaymentNotifier()
}
})
</script>

View file

@ -90,6 +90,16 @@
<div class="col">
<h5 class="text-subtitle1 q-my-none">Tickets</h5>
</div>
<!-- <div class="col-auto">
<q-btn
flat
color="grey"
icon="autorenew"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
@click="getTickets"
><q-tooltip> Refresh Tickets </q-tooltip></q-btn
>
</div> -->
<div class="col-auto">
<q-btn flat color="grey" @click="exportticketsCSV"
>Export to CSV</q-btn
@ -230,7 +240,7 @@
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script>
var mapLNTicket = function (obj) {
const mapLNTicket = function (obj) {
obj.date = Quasar.utils.date.formatDate(
new Date(obj.time * 1000),
'YYYY-MM-DD HH:mm'
@ -290,7 +300,8 @@
formDialog: {
show: false,
data: {}
}
},
cancelListener: () => {}
}
},
methods: {
@ -304,9 +315,12 @@
this.g.user.wallets[0].inkey
)
.then(function (response) {
self.tickets = response.data.map(function (obj) {
return mapLNTicket(obj)
})
self.tickets = response.data
.map(function (obj) {
if (!obj?.paid) return
return mapLNTicket(obj)
})
.filter(v => v)
})
},
deleteTicket: function (ticketId) {
@ -355,6 +369,7 @@
var wallet = _.findWhere(this.g.user.wallets, {
id: this.formDialog.data.wallet
})
this.formDialog.data.inkey = wallet.inkey
var data = this.formDialog.data
if (data.id) {
@ -366,6 +381,7 @@
createForm: function (wallet, data) {
var self = this
console.log('create', data)
LNbits.api
.request('POST', '/lnticket/api/v1/forms', wallet.inkey, data)
.then(function (response) {
@ -379,7 +395,7 @@
},
updateformDialog: function (formId) {
var link = _.findWhere(this.forms, {id: formId})
console.log(link.id)
this.formDialog.data.id = link.id
this.formDialog.data.wallet = link.wallet
this.formDialog.data.name = link.name
@ -389,8 +405,7 @@
},
updateForm: function (wallet, data) {
var self = this
console.log(data)
console.log('update', data)
LNbits.api
.request(
'PUT',
@ -435,6 +450,22 @@
},
exportformsCSV: function () {
LNbits.utils.exportCSV(this.formsTable.columns, this.forms)
},
startPaymentNotifier() {
this.cancelListener()
this.cancelListener = LNbits.events.onInvoicePaid(
this.g.user.wallets[0],
payment => {
this.getTickets()
this.$q.notify({
type: 'positive',
message: 'New ticket arrived!',
icon: 'textsms'
})
}
)
}
},
@ -442,6 +473,7 @@
if (this.g.user.wallets.length) {
this.getTickets()
this.getForms()
this.startPaymentNotifier()
}
}
})

View file

@ -1,5 +1,6 @@
from quart import g, abort, render_template
from lnbits.core.crud import get_wallet
from lnbits.decorators import check_user_exists, validate_uuids
from http import HTTPStatus
@ -20,10 +21,13 @@ async def display(form_id):
if not form:
abort(HTTPStatus.NOT_FOUND, "LNTicket does not exist.")
wallet = await get_wallet(form.wallet)
return await render_template(
"lnticket/display.html",
form_id=form.id,
form_name=form.name,
form_desc=form.description,
form_costpword=form.costpword,
form_wallet=wallet.inkey
)

View file

@ -1 +1,27 @@
# LNURLp
## Create a static QR code people can use to pay over Lightning Network
LNURL is a range of lightning-network standards that allow us to use lightning-network differently. An LNURL-pay is a link that wallets use to fetch an invoice from a server on-demand. The link or QR code is fixed, but each time it is read by a compatible wallet a new invoice is issued by the service and sent to the wallet.
[**Wallets supporting LNURL**](https://github.com/fiatjaf/awesome-lnurl#wallets)
## Usage
1. Create an LNURLp (New Pay link)\
![create lnurlp](https://i.imgur.com/rhUBJFy.jpg)
- select your wallets
- make a small description
- enter amount
- if _Fixed amount_ is unchecked you'll have the option to configure a Max and Min amount
- you can set the currency to something different than sats. For example if you choose EUR, the satoshi amount will be calculated when a user scans the LNURLp
- You can ask the user to send a comment that will be sent along with the payment (for example a comment to a blog post)
- Webhook URL allows to call an URL when the LNURLp is paid
- Success mesage, will send a message back to the user after a successful payment, for example a thank you note
- Success URL, will send back a clickable link to the user. Access to some hidden content, or a download link
2. Use the shareable link or view the LNURLp you just created\
![LNURLp](https://i.imgur.com/C8s1P0Q.jpg)
- you can now open your LNURLp and copy the LNURL, get the shareable link or print it\
![view lnurlp](https://i.imgur.com/4n41S7T.jpg)

View file

@ -54,7 +54,7 @@ async def api_lnurl_callback(link_id):
min = link.min * 1000
max = link.max * 1000
amount_received = int(request.args.get("amount"))
amount_received = int(request.args.get("amount") or 0)
if amount_received < min:
return (
jsonify(
@ -95,10 +95,17 @@ async def api_lnurl_callback(link_id):
extra={"tag": "lnurlp", "link": link.id, "comment": comment},
)
resp = LnurlPayActionResponse(
pr=payment_request,
success_action=link.success_action(payment_hash),
routes=[],
)
success_action = link.success_action(payment_hash)
if success_action:
resp = LnurlPayActionResponse(
pr=payment_request,
success_action=success_action,
routes=[],
)
else:
resp = LnurlPayActionResponse(
pr=payment_request,
routes=[],
)
return jsonify(resp.dict()), HTTPStatus.OK

View file

@ -5,7 +5,6 @@ from typing import NamedTuple, Optional, Dict
from sqlite3 import Row
from lnurl import Lnurl, encode as lnurl_encode # type: ignore
from lnurl.types import LnurlPayMetadata # type: ignore
from lnurl.models import LnurlPaySuccessAction, MessageAction, UrlAction # type: ignore
class PayLink(NamedTuple):
@ -36,15 +35,21 @@ class PayLink(NamedTuple):
def lnurlpay_metadata(self) -> LnurlPayMetadata:
return LnurlPayMetadata(json.dumps([["text/plain", self.description]]))
def success_action(self, payment_hash: str) -> Optional[LnurlPaySuccessAction]:
def success_action(self, payment_hash: str) -> Optional[Dict]:
if self.success_url:
url: ParseResult = urlparse(self.success_url)
qs: Dict = parse_qs(url.query)
qs["payment_hash"] = payment_hash
url = url._replace(query=urlencode(qs, doseq=True))
raw: str = urlunparse(url)
return UrlAction(url=raw, description=self.success_text)
return {
"tag": "url",
"description": self.success_text or "~",
"url": urlunparse(url),
}
elif self.success_text:
return MessageAction(message=self.success_text)
return {
"tag": "message",
"message": self.success_text,
}
else:
return None

View file

@ -62,7 +62,9 @@ new Vue({
LNbits.utils.notifyApiError(err)
})
},
closeFormDialog() {},
closeFormDialog() {
this.resetFormData()
},
openQrCodeDialog(linkId) {
var link = _.findWhere(this.payLinks, {id: linkId})
if (link.currency) this.updateFiatRate(link.currency)
@ -116,6 +118,13 @@ new Vue({
this.createPayLink(wallet, data)
}
},
resetFormData() {
this.formDialog = {
show: false,
fixedAmount: true,
data: {}
}
},
updatePayLink(wallet, data) {
let values = _.omit(
_.pick(
@ -147,6 +156,7 @@ new Vue({
this.payLinks = _.reject(this.payLinks, obj => obj.id === data.id)
this.payLinks.push(mapPayLink(response.data))
this.formDialog.show = false
this.resetFormData()
})
.catch(err => {
LNbits.utils.notifyApiError(err)
@ -158,12 +168,13 @@ new Vue({
.then(response => {
this.payLinks.push(mapPayLink(response.data))
this.formDialog.show = false
this.resetFormData()
})
.catch(err => {
LNbits.utils.notifyApiError(err)
})
},
deletePayLink: linkId => {
deletePayLink(linkId) {
var link = _.findWhere(this.payLinks, {id: linkId})
LNbits.utils
@ -204,7 +215,6 @@ new Vue({
getPayLinks()
}, 20000)
}
LNbits.api
.request('GET', '/lnurlp/api/v1/currencies')
.then(response => {

View file

@ -1,4 +1,4 @@
import trio # type: ignore
import trio
import json
import httpx

View file

@ -88,6 +88,12 @@ async def api_link_create_or_update(link_id=None):
):
return jsonify({"message": "Must use full satoshis."}), HTTPStatus.BAD_REQUEST
if "success_url" in g.data and g.data["success_url"][:8] != "https://":
return (
jsonify({"message": "Success URL must be secure https://..."}),
HTTPStatus.BAD_REQUEST,
)
if link_id:
link = await get_pay_link(link_id)

View file

@ -1 +1,36 @@
# Offline Shop
## Create QR codes for each product and display them on your store for receiving payments Offline
[![video tutorial offline shop](http://img.youtube.com/vi/_XAvM_LNsoo/0.jpg)](https://youtu.be/_XAvM_LNsoo 'video tutorial offline shop')
LNBits Offline Shop allows for merchants to receive Bitcoin payments while offline and without any electronic device.
Merchant will create items and associate a QR code ([a LNURLp](https://github.com/lnbits/lnbits/blob/master/lnbits/extensions/lnurlp/README.md)) with a price. He can then print the QR codes and display them on their shop. When a costumer chooses an item, scans the QR code, gets the description and price. After payment, the costumer gets a confirmation code that the merchant can validate to be sure the payment was successful.
Costumers must use an LNURL pay capable wallet.
[**Wallets supporting LNURL**](https://github.com/fiatjaf/awesome-lnurl#wallets)
## Usage
1. Entering the Offline shop extension you'll see an Items list, the Shop wallet and a Wordslist\
![offline shop back office](https://i.imgur.com/Ei7cxj9.png)
2. Begin by creating an item, click "ADD NEW ITEM"
- set the item name and a small description
- you can set an optional, preferably square image, that will show up on the costumer wallet - _depending on wallet_
- set the item price, if you choose a fiat currency the bitcoin conversion will happen at the time costumer scans to pay\
![add new item](https://i.imgur.com/pkZqRgj.png)
3. After creating some products, click on "PRINT QR CODES"\
![print qr codes](https://i.imgur.com/2GAiSTe.png)
4. You'll see a QR code for each product in your LNBits Offline Shop with a title and price ready for printing\
![qr codes sheet](https://i.imgur.com/faEqOcd.png)
5. Place the printed QR codes on your shop, or at the fair stall, or have them as a menu style laminated sheet
6. Choose what type of confirmation do you want costumers to report to merchant after a successful payment\
![wordlist](https://i.imgur.com/9aM6NUL.png)
- Wordlist is the default option: after a successful payment the costumer will receive a word from this list, **sequentially**. Starting in _albatross_ as costumers pay for the items they will get the next word in the list until _zebra_, then it starts at the top again. The list can be changed, for example if you think A-Z is a big list to track, you can use _apple_, _banana_, _coconut_\
![totp authenticator](https://i.imgur.com/MrJXFxz.png)
- TOTP (time-based one time password) can be used instead. If you use Google Authenticator just scan the presented QR with the app and after a successful payment the user will get the password that you can check with GA\
![disable confirmations](https://i.imgur.com/2OFs4yi.png)
- Nothing, disables the need for confirmation of payment, click the "DISABLE CONFIRMATION CODES"

View file

@ -49,7 +49,7 @@ async def lnurl_callback(item_id):
min = price * 995
max = price * 1010
amount_received = int(request.args.get("amount"))
amount_received = int(request.args.get("amount") or 0)
if amount_received < min:
return jsonify(
LnurlErrorResponse(

View file

@ -291,7 +291,8 @@
dense
v-model.number="itemDialog.data.price"
type="number"
min="1"
step="0.001"
min="0.001"
:label="`Item price (${itemDialog.data.unit})`"
></q-input>
<q-select

View file

@ -1,11 +1,22 @@
<h1>Example Extension</h1>
<h2>*tagline*</h2>
This is an example extension to help you organise and build you own.
# Paywall
Try to include an image
<img src="https://i.imgur.com/9i4xcQB.png">
## Hide content behind a paywall, a user has to pay some amount to access your hidden content
A Paywall is a way of restricting to content via a purchase or paid subscription. For example to read a determined blog post, or to continue reading further, to access a downloads area, etc...
<h2>If your extension has API endpoints, include useful ones here</h2>
## Usage
<code>curl -H "Content-type: application/json" -X POST https://YOUR-LNBITS/YOUR-EXTENSION/api/v1/EXAMPLE -d '{"amount":"100","memo":"example"}' -H "X-Api-Key: YOUR_WALLET-ADMIN/INVOICE-KEY"</code>
1. Create a paywall by clicking "NEW PAYWALL"\
![create new paywall](https://i.imgur.com/q0ZIekC.png)
2. Fill the options for your PAYWALL
- select the wallet
- set the link that will be unlocked after a successful payment
- give your paywall a _Title_
- an optional small description
- and set an amount a user must pay to access the hidden content. Note this is the minimum amount, a user can over pay if they wish
- if _Remember payments_ is checked, a returning paying user won't have to pay again for the same content.\
![paywall config](https://i.imgur.com/CBW48F6.png)
3. You can then use your paywall link to secure your awesome content\
![paywall link](https://i.imgur.com/hDQmCDf.png)
4. When a user wants to access your hidden content, he can use the minimum amount or increase and click the "_Check icon_" to generate an invoice, user will then be redirected to the content page\
![user paywall view](https://i.imgur.com/3pLywkZ.png)

View file

@ -1,4 +1,27 @@
# SatsPay Server
Create onchain and LN charges. Includes webhooks!
## Create onchain and LN charges. Includes webhooks!
Easilly create invoices that support Lightning Network and on-chain BTC payment.
1. Create a "NEW CHARGE"\
![new charge](https://i.imgur.com/fUl6p74.png)
2. Fill out the invoice fields
- set a descprition for the payment
- the amount in sats
- the time, in minutes, the invoice is valid for, after this period the invoice can't be payed
- set a webhook that will get the transaction details after a successful payment
- set to where the user should redirect after payment
- set the text for the button that will show after payment (not setting this, will display "NONE" in the button)
- select if you want onchain payment, LN payment or both
- depending on what you select you'll have to choose the respective wallets where to receive your payment\
![charge form](https://i.imgur.com/F10yRiW.png)
3. The charge will appear on the _Charges_ section\
![charges](https://i.imgur.com/zqHpVxc.png)
4. Your costumer/payee will get the payment page
- they can choose to pay on LN\
![offchain payment](https://i.imgur.com/4191SMV.png)
- or pay on chain\
![onchain payment](https://i.imgur.com/wzLRR5N.png)
5. You can check the state of your charges in LNBits\
![invoice state](https://i.imgur.com/JnBd22p.png)

View file

@ -22,7 +22,7 @@ async def create_charge(
lnbitswallet: Optional[str] = None,
webhook: Optional[str] = None,
completelink: Optional[str] = None,
completelinktext: Optional[str] = None,
completelinktext: Optional[str] = "Back to Merchant",
time: Optional[int] = None,
amount: Optional[int] = None,
) -> Charges:

View file

@ -169,7 +169,7 @@
></q-icon>
<q-btn
outline
v-if="'{{ charge.webhook }}' != 'None'"
v-if="'{{ charge.webhook }}' != None"
type="a"
href="{{ charge.completelink }}"
label="{{ charge.completelinktext }}"

View file

@ -0,0 +1,34 @@
# Split Payments
## Have payments split between multiple wallets
LNBits Split Payments extension allows for distributing payments across multiple wallets. Set it and forget it. It will keep splitting your payments across wallets forever.
## Usage
1. After enabling the extension, choose the source wallet that will receive and distribute the Payments
![choose wallet](https://i.imgur.com/nPQudqL.png)
2. Add the wallet or wallets info to split payments to
![split wallets](https://i.imgur.com/5hCNWpg.png) - get the wallet id, or an invoice key from a different wallet. It can be a completely different user as long as it's under the same LNbits instance/domain. You can get the wallet information on the API Info section on every wallet page\
![wallet info](https://i.imgur.com/betqflC.png) - set a wallet _Alias_ for your own identification\
- set how much, in percentage, this wallet will receive from every payment sent to the source wallets
3. When done, click "SAVE TARGETS" to make the splits effective
4. You can have several wallets to split to, as long as the sum of the percentages is under or equal to 100%
5. When the source wallet receives a payment, the extension will automatically split the corresponding values to every wallet\
- on receiving a 20 sats payment\
![get 20 sats payment](https://i.imgur.com/BKp0xvy.png)
- source wallet gets 18 sats\
![source wallet](https://i.imgur.com/GCxDZ5s.png)
- Ben's wallet (the wallet from the example) instantly, and feeless, gets the corresponding 10%, or 2 sats\
![ben wallet](https://i.imgur.com/MfsccNa.png)
## 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,18 @@
from quart import Blueprint
from lnbits.db import Database
db = Database("ext_splitpayments")
splitpayments_ext: Blueprint = Blueprint(
"splitpayments", __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
splitpayments_ext.record(record_async(register_listeners))

View file

@ -0,0 +1,9 @@
{
"name": "SplitPayments",
"short_description": "Split incoming payments to other wallets.",
"icon": "call_split",
"contributors": [
"fiatjaf",
"cryptograffiti"
]
}

View file

@ -0,0 +1,23 @@
from typing import List
from . import db
from .models import Target
async def get_targets(source_wallet: str) -> List[Target]:
rows = await db.fetchall("SELECT * FROM targets WHERE source = ?", (source_wallet,))
return [Target(**dict(row)) for row in rows]
async def set_targets(source_wallet: str, targets: List[Target]):
async with db.connect() as conn:
await conn.execute("DELETE FROM targets WHERE source = ?", (source_wallet,))
for target in targets:
await conn.execute(
"""
INSERT INTO targets
(source, wallet, percent, alias)
VALUES (?, ?, ?, ?)
""",
(source_wallet, target.wallet, target.percent, target.alias),
)

View file

@ -0,0 +1,16 @@
async def m001_initial(db):
"""
Initial split payment table.
"""
await db.execute(
"""
CREATE TABLE targets (
wallet TEXT NOT NULL,
source TEXT NOT NULL,
percent INTEGER NOT NULL CHECK (percent >= 0 AND percent <= 100),
alias TEXT,
UNIQUE (source, wallet)
);
"""
)

View file

@ -0,0 +1,8 @@
from typing import NamedTuple
class Target(NamedTuple):
wallet: str
source: str
percent: int
alias: str

View file

@ -0,0 +1,143 @@
/* globals Quasar, Vue, _, VueQrcode, windowMixin, LNbits, LOCALE */
Vue.component(VueQrcode.name, VueQrcode)
function hashTargets(targets) {
return targets
.filter(isTargetComplete)
.map(({wallet, percent, alias}) => `${wallet}${percent}${alias}`)
.join('')
}
function isTargetComplete(target) {
return target.wallet && target.wallet.trim() !== '' && target.percent > 0
}
new Vue({
el: '#vue',
mixins: [windowMixin],
data() {
return {
selectedWallet: null,
currentHash: '', // a string that must match if the edit data is unchanged
targets: []
}
},
computed: {
isDirty() {
return hashTargets(this.targets) !== this.currentHash
}
},
methods: {
clearTargets() {
this.targets = [{}]
this.$q.notify({
message:
'Cleared the form, but not saved. You must click to save manually.',
timeout: 500
})
},
getTargets() {
LNbits.api
.request(
'GET',
'/splitpayments/api/v1/targets',
this.selectedWallet.adminkey
)
.catch(err => {
LNbits.utils.notifyApiError(err)
})
.then(response => {
this.currentHash = hashTargets(response.data)
this.targets = response.data.concat({})
})
},
changedWallet(wallet) {
this.selectedWallet = wallet
this.getTargets()
},
targetChanged(isPercent, index) {
// fix percent min and max range
if (isPercent) {
if (this.targets[index].percent > 100) this.targets[index].percent = 100
if (this.targets[index].percent < 0) this.targets[index].percent = 0
}
// remove empty lines (except last)
if (this.targets.length >= 2) {
for (let i = this.targets.length - 2; i >= 0; i--) {
let target = this.targets[i]
if (
(!target.wallet || target.wallet.trim() === '') &&
(!target.alias || target.alias.trim() === '') &&
!target.percent
) {
this.targets.splice(i, 1)
}
}
}
// add a line at the end if the last one is filled
let last = this.targets[this.targets.length - 1]
if (last.wallet && last.wallet.trim() !== '' && last.percent > 0) {
this.targets.push({})
}
// sum of all percents
let currentTotal = this.targets.reduce(
(acc, target) => acc + (target.percent || 0),
0
)
// remove last (unfilled) line if the percent is already 100
if (currentTotal >= 100) {
let last = this.targets[this.targets.length - 1]
if (
(!last.wallet || last.wallet.trim() === '') &&
(!last.alias || last.alias.trim() === '') &&
!last.percent
) {
this.targets = this.targets.slice(0, -1)
}
}
// adjust percents of other lines (not this one)
if (currentTotal > 100 && isPercent) {
let diff = (currentTotal - 100) / (100 - this.targets[index].percent)
this.targets.forEach((target, t) => {
if (t !== index) target.percent -= Math.round(diff * target.percent)
})
}
// overwrite so changes appear
this.targets = this.targets
},
saveTargets() {
LNbits.api
.request(
'PUT',
'/splitpayments/api/v1/targets',
this.selectedWallet.adminkey,
{
targets: this.targets
.filter(isTargetComplete)
.map(({wallet, percent, alias}) => ({wallet, percent, alias}))
}
)
.then(response => {
this.$q.notify({
message: 'Split payments targets set.',
timeout: 700
})
this.getTargets()
})
.catch(err => {
LNbits.utils.notifyApiError(err)
})
}
},
created() {
this.selectedWallet = this.g.user.wallets[0]
this.getTargets()
}
})

View file

@ -0,0 +1,77 @@
import json
import trio
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_targets
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 "splitpayments" == payment.extra.get("tag") or payment.extra.get("splitted"):
# already splitted, ignore
return
# now we make some special internal transfers (from no one to the receiver)
targets = await get_targets(payment.wallet_id)
transfers = [
(target.wallet, int(target.percent * payment.amount / 100))
for target in targets
]
transfers = [(wallet, amount) for wallet, amount in transfers if amount > 0]
amount_left = payment.amount - sum([amount for _, amount in transfers])
if amount_left < 0:
print("splitpayments failure: amount_left is negative.", payment.payment_hash)
return
if not targets:
return
# mark the original payment with one extra key, "splitted"
# (this prevents us from doing this process again and it's informative)
# and reduce it by the amount we're going to send to the producer
await core_db.execute(
"""
UPDATE apipayments
SET extra = ?, amount = ?
WHERE hash = ?
AND checking_id NOT LIKE 'internal_%'
""",
(
json.dumps(dict(**payment.extra, splitted=True)),
amount_left,
payment.payment_hash,
),
)
# perform the internal transfer using the same payment_hash
for wallet, amount in transfers:
internal_checking_id = f"internal_{urlsafe_short_hash()}"
await create_payment(
wallet_id=wallet,
checking_id=internal_checking_id,
payment_request="",
payment_hash=payment.payment_hash,
amount=amount,
memo=payment.memo,
pending=False,
extra={"tag": "splitpayments"},
)
# manually send this for now
await internal_invoice_paid.send(internal_checking_id)

View file

@ -0,0 +1,90 @@
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="How to use"
:content-inset-level="0.5"
>
<q-card>
<q-card-section>
<p>
Add some wallets to the list of "Target Wallets", each with an
associated <em>percent</em>. After saving, every time any payment
arrives at the "Source Wallet" that payment will be split with the
target wallets according to their percent.
</p>
<p>This is valid for every payment, doesn't matter how it was created.</p>
<p>Target wallets can be any wallet from this same LNbits instance.</p>
<p>
To remove a wallet from the targets list, just erase its fields and
save. To remove all, click "Clear" then save.
</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="List Target Wallets"
>
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span>
/splitpayments/api/v1/targets</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
>[{"wallet": &lt;wallet id&gt;, "alias": &lt;chosen name for this
wallet&gt;, "percent": &lt;number between 1 and 100&gt;}, ...]</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root }}api/v1/livestream -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="Set Target Wallets"
>
<q-card>
<q-card-section>
<code
><span class="text-blue">PUT</span>
/splitpayments/api/v1/targets</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>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X PUT {{ request.url_root }}api/v1/splitpayments/targets -H
"X-Api-Key: {{ g.user.wallets[0].adminkey }}" -H 'Content-Type:
application/json' -d '{"targets": [{"wallet": &lt;wallet id or invoice
key&gt;, "alias": &lt;name to identify this&gt;, "percent": &lt;number
between 1 and 100&gt;}, ...]}'
</code>
</q-card-section>
</q-card>
</q-expansion-item>
</q-expansion-item>

View file

@ -0,0 +1,98 @@
{% 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 class="q-pa-sm col-5">
<q-card-section class="q-pa-none text-center">
<q-form class="q-gutter-md">
<q-select
filled
dense
:options="g.user.wallets"
:value="selectedWallet"
label="Source Wallet:"
option-label="name"
@input="changedWallet"
>
</q-select>
</q-form>
</q-card-section>
</q-card>
<q-card class="q-pa-sm col-5">
<q-card-section class="q-pa-none text-center">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Target Wallets</h5>
</div>
<q-form class="q-gutter-md" @submit="saveTargets">
<div
class="q-gutter-md row items-start"
style="flex-wrap: nowrap"
v-for="(target, t) in targets"
>
<q-input
dense
outlined
v-model="target.wallet"
label="Wallet"
:hint="t === targets.length - 1 ? 'A wallet ID or invoice key.' : undefined"
@input="targetChanged(false)"
></q-input>
<q-input
dense
outlined
v-model="target.alias"
label="Alias"
:hint="t === targets.length - 1 ? 'A name to identify this target wallet locally.' : undefined"
@input="targetChanged(false)"
></q-input>
<q-input
dense
outlined
v-model.number="target.percent"
label="Split Share"
:hint="t === targets.length - 1 ? 'How much of the incoming payments will go to the target wallet.' : undefined"
suffix="%"
@input="targetChanged(true, t)"
></q-input>
</div>
<q-row class="row justify-evenly q-pa-lg">
<q-col>
<q-btn unelevated outline color="purple" @click="clearTargets">
Clear
</q-btn>
</q-col>
<q-col>
<q-btn
unelevated
color="deep-purple"
type="submit"
:disabled="!isDirty"
>
Save Targets
</q-btn>
</q-col>
</q-row>
</q-form>
</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">LNbits SplitPayments extension</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list> {% include "splitpayments/_api_docs.html" %} </q-list>
</q-card-section>
</q-card>
</div>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script src="/splitpayments/static/js/index.js"></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 splitpayments_ext
@splitpayments_ext.route("/")
@validate_uuids(["usr"], required=True)
@check_user_exists()
async def index():
return await render_template("splitpayments/index.html", user=g.user)

View file

@ -0,0 +1,70 @@
from quart import g, jsonify
from http import HTTPStatus
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
from lnbits.core.crud import get_wallet, get_wallet_for_key
from . import splitpayments_ext
from .crud import get_targets, set_targets
from .models import Target
@splitpayments_ext.route("/api/v1/targets", methods=["GET"])
@api_check_wallet_key("admin")
async def api_targets_get():
targets = await get_targets(g.wallet.id)
return jsonify([target._asdict() for target in targets] or [])
@splitpayments_ext.route("/api/v1/targets", methods=["PUT"])
@api_check_wallet_key("admin")
@api_validate_post_request(
schema={
"targets": {
"type": "list",
"schema": {
"type": "dict",
"schema": {
"wallet": {"type": "string"},
"alias": {"type": "string"},
"percent": {"type": "integer"},
},
},
}
}
)
async def api_targets_set():
targets = []
for entry in g.data["targets"]:
wallet = await get_wallet(entry["wallet"])
if not wallet:
wallet = await get_wallet_for_key(entry["wallet"], "invoice")
if not wallet:
return (
jsonify({"message": f"Invalid wallet '{entry['wallet']}'."}),
HTTPStatus.BAD_REQUEST,
)
if wallet.id == g.wallet.id:
return (
jsonify({"message": "Can't split to itself."}),
HTTPStatus.BAD_REQUEST,
)
if entry["percent"] < 0:
return (
jsonify({"message": f"Invalid percent '{entry['percent']}'."}),
HTTPStatus.BAD_REQUEST,
)
targets.append(
Target(wallet.id, g.wallet.id, entry["percent"], entry["alias"] or "")
)
percent_sum = sum([target.percent for target in targets])
if percent_sum > 100:
return jsonify({"message": "Splitting over 100%."}), HTTPStatus.BAD_REQUEST
await set_targets(g.wallet.id, targets)
return "", HTTPStatus.OK

View file

@ -1,27 +1,29 @@
<h1>Subdomains Extension</h1>
So the goal of the extension is to allow the owner of a domain to sell their subdomain to the anyone who is willing to pay some money for it.
So the goal of the extension is to allow the owner of a domain to sell subdomains to anyone who is willing to pay some money for it.
[![video tutorial livestream](http://img.youtube.com/vi/O1X0fy3uNpw/0.jpg)](https://youtu.be/O1X0fy3uNpw 'video tutorial subdomains')
## Requirements
- Free cloudflare account
- Cloudflare as a dns server provider
- Cloudflare TOKEN and Cloudflare zone-id where the domain is parked
- Free Cloudflare account
- Cloudflare as a DNS server provider
- Cloudflare TOKEN and Cloudflare zone-ID where the domain is parked
## Usage
1. Register at cloudflare and setup your domain with them. (Just follow instructions they provide...)
2. Change DNS server at your domain registrar to point to cloudflare's
3. Get Cloudflare zoneID for your domain
1. Register at Cloudflare and setup your domain with them. (Just follow instructions they provide...)
2. Change DNS server at your domain registrar to point to Cloudflare's
3. Get Cloudflare zone-ID for your domain
<img src="https://i.imgur.com/xOgapHr.png">
4. get Cloudflare API TOKEN
4. Get Cloudflare API TOKEN
<img src="https://i.imgur.com/BZbktTy.png">
<img src="https://i.imgur.com/YDZpW7D.png">
5. Open the lnbits subdomains extension and register your domain with lnbits
5. Open the LNBits subdomains extension and register your domain
6. Click on the button in the table to open the public form that was generated for your domain
- Extension also supports webhooks so you can get notified when someone buys a new domain
<img src="https://i.imgur.com/hiauxeR.png">
- Extension also supports webhooks so you can get notified when someone buys a new subdomain\
<img src="https://i.imgur.com/hiauxeR.png">
## API Endpoints
@ -36,8 +38,6 @@ So the goal of the extension is to allow the owner of a domain to sell their sub
- GET /api/v1/subdomains/<payment_hash>
- DELETE /api/v1/subdomains/<subdomain_id>
## Useful
### Cloudflare
- Cloudflare offers programmatic subdomain registration... (create new A record)

View file

@ -1,6 +1,6 @@
from http import HTTPStatus
from quart.json import jsonify
import trio # type: ignore
import trio
import httpx
from .crud import get_domain, set_subdomain_paid

View file

@ -452,7 +452,10 @@
id: this.domainDialog.data.wallet
})
var data = this.domainDialog.data
data.allowed_record_types = data.allowed_record_types.join(', ')
data.allowed_record_types =
typeof data.allowed_record_types === 'string'
? data.allowed_record_types
: data.allowed_record_types.join(', ')
console.log(this.domainDialog)
if (data.id) {
this.updateDomain(wallet, data)

View file

@ -1,7 +1,15 @@
<h1> TPOS</h1>
<h2>A Shareable PoS that doesnt need to be installed and can run in phones browser!</h2>
<p>If someone drops your Quickening in a beer at your bar, staff can scan a QR and open/use a browser based PoS, linked to the same self-hosted lnbits instance!</p>
# TPoS
<img src="https://i.imgur.com/8wgTWDn.jpg">
## A Shareable PoS (Point of Sale) that doesn't need to be installed and can run in the browser!
An easy, fast and secure way to accept Bitcoin, over Lightning Network, at your business. The PoS is isolated from the wallet, so it's safe for any employee to use. You can create as many TPOS's as you need, for example one for each employee, or one for each branch of your business.
### Usage
1. Enable extension
2. Create a TPOS\
![create](https://imgur.com/8jNj8Zq.jpg)
3. Open TPOS on the browser\
![open](https://imgur.com/LZuoWzb.jpg)
4. Present invoice QR to costumer\
![pay](https://imgur.com/tOwxn77.jpg)

View file

@ -1,3 +1,26 @@
<h1>User Manager</h1>
<h2>Make and manager users/wallets</h2>
To help developers use LNbits to manage their users, the User Manager extension allows the creation and management of users and wallets. For example, a games developer may be developing a game that needs each user to have their own wallet, LNbits can be included in the develpoers stack as the user and wallet manager.
# User Manager
## Make and manage users/wallets
To help developers use LNbits to manage their users, the User Manager extension allows the creation and management of users and wallets.
For example, a games developer may be developing a game that needs each user to have their own wallet, LNbits can be included in the developers stack as the user and wallet manager. Or someone wanting to manage their family's wallets (wife, children, parents, etc...) or you want to host a community Lightning Network node and want to manage wallets for the users.
## Usage
1. Click the button "NEW USER" to create a new user\
![new user](https://i.imgur.com/4yZyfJE.png)
2. Fill the user information\
- username
- the generated wallet name, user can create other wallets later on
- email
- set a password
![user information](https://i.imgur.com/40du7W5.png)
3. After creating your user, it will appear in the **Users** section, and a user's wallet in the **Wallets** section.
4. Next you can share the wallet with the corresponding user\
![user wallet](https://i.imgur.com/gAyajbx.png)
5. If you need to create more wallets for some user, click "NEW WALLET" at the top\
![multiple wallets](https://i.imgur.com/wovVnim.png)
- select the existing user you wish to add the wallet
- set a wallet name\
![new wallet](https://i.imgur.com/sGwG8dC.png)

View file

@ -17,7 +17,11 @@ from .models import Users, Wallets
async def create_usermanager_user(
user_name: str, wallet_name: str, admin_id: str
user_name: str,
wallet_name: str,
admin_id: str,
email: Optional[str] = None,
password: Optional[str] = None,
) -> Users:
account = await create_account()
user = await get_user(account.id)
@ -27,10 +31,10 @@ async def create_usermanager_user(
await db.execute(
"""
INSERT INTO users (id, name, admin)
VALUES (?, ?, ?)
INSERT INTO users (id, name, admin, email, password)
VALUES (?, ?, ?, ?, ?)
""",
(user.id, user_name, admin_id),
(user.id, user_name, admin_id, email, password),
)
await db.execute(

View file

@ -48,6 +48,26 @@
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="GET user">
<q-card>
<q-card-section>
<code
><span class="text-light-blue">GET</span>
/usermanager/api/v1/users/&lt;user_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>JSON list of users</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root }}api/v1/users/&lt;user_id&gt; -H
"X-Api-Key: {{ g.user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="GET wallets">
<q-card>
<q-card-section>
@ -114,7 +134,8 @@
</h5>
<code
>{"admin_id": &lt;string&gt;, "user_name": &lt;string&gt;,
"wallet_name": &lt;string&gt;}</code
"wallet_name": &lt;string&gt;,"email": &lt;Optional string&gt;
,"password": &lt;Optional string&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
@ -128,7 +149,8 @@
<code
>curl -X POST {{ request.url_root }}api/v1/users -d '{"admin_id": "{{
g.user.id }}", "wallet_name": &lt;string&gt;, "user_name":
&lt;string&gt;}' -H "X-Api-Key: {{ g.user.wallets[0].inkey }}" -H
&lt;string&gt;, "email": &lt;Optional string&gt;, "password": &lt;
Optional string&gt;}' -H "X-Api-Key: {{ g.user.wallets[0].inkey }}" -H
"Content-type: application/json"
</code>
</q-card-section>

View file

@ -157,6 +157,18 @@
v-model.trim="userDialog.data.walname"
label="Initial wallet name"
></q-input>
<q-input
filled
dense
v-model.trim="userDialog.data.email"
label="Email"
></q-input>
<q-input
filled
dense
v-model.trim="userDialog.data.password"
label="Password"
></q-input>
<q-btn
unelevated
@ -202,7 +214,7 @@
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script>
var mapUserManager = function (obj) {
var mapUserManager = function(obj) {
obj.date = Quasar.utils.date.formatDate(
new Date(obj.time * 1000),
'YYYY-MM-DD HH:mm'
@ -216,7 +228,7 @@
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
data: function() {
return {
wallets: [],
users: [],
@ -224,7 +236,14 @@
usersTable: {
columns: [
{name: 'id', align: 'left', label: 'ID', field: 'id'},
{name: 'name', align: 'left', label: 'Username', field: 'name'}
{name: 'name', align: 'left', label: 'Username', field: 'name'},
{name: 'email', align: 'left', label: 'Email', field: 'email'},
{
name: 'password',
align: 'left',
label: 'Password',
field: 'password'
}
],
pagination: {
rowsPerPage: 10
@ -258,8 +277,8 @@
}
},
computed: {
userOptions: function () {
return this.users.map(function (obj) {
userOptions: function() {
return this.users.map(function(obj) {
console.log(obj.id)
return {
value: String(obj.id),
@ -271,7 +290,7 @@
methods: {
///////////////Users////////////////////////////
getUsers: function () {
getUsers: function() {
var self = this
LNbits.api
@ -280,26 +299,28 @@
'/usermanager/api/v1/users',
this.g.user.wallets[0].inkey
)
.then(function (response) {
self.users = response.data.map(function (obj) {
.then(function(response) {
self.users = response.data.map(function(obj) {
return mapUserManager(obj)
})
})
},
openUserUpdateDialog: function (linkId) {
openUserUpdateDialog: function(linkId) {
var link = _.findWhere(this.users, {id: linkId})
this.userDialog.data = _.clone(link._data)
this.userDialog.show = true
},
sendUserFormData: function () {
sendUserFormData: function() {
if (this.userDialog.data.id) {
} else {
var data = {
admin_id: this.g.user.id,
user_name: this.userDialog.data.usrname,
wallet_name: this.userDialog.data.walname
wallet_name: this.userDialog.data.walname,
email: this.userDialog.data.email,
password: this.userDialog.data.password
}
}
@ -308,7 +329,7 @@
}
},
createUser: function (data) {
createUser: function(data) {
var self = this
LNbits.api
.request(
@ -317,47 +338,48 @@
this.g.user.wallets[0].inkey,
data
)
.then(function (response) {
.then(function(response) {
self.users.push(mapUserManager(response.data))
self.userDialog.show = false
self.userDialog.data = {}
data = {}
self.getWallets()
})
.catch(function (error) {
.catch(function(error) {
LNbits.utils.notifyApiError(error)
})
},
deleteUser: function (userId) {
deleteUser: function(userId) {
var self = this
console.log(userId)
LNbits.utils
.confirmDialog('Are you sure you want to delete this User link?')
.onOk(function () {
.onOk(function() {
LNbits.api
.request(
'DELETE',
'/usermanager/api/v1/users/' + userId,
self.g.user.wallets[0].inkey
)
.then(function (response) {
self.users = _.reject(self.users, function (obj) {
.then(function(response) {
self.users = _.reject(self.users, function(obj) {
return obj.id == userId
})
})
.catch(function (error) {
.catch(function(error) {
LNbits.utils.notifyApiError(error)
})
})
},
exportUsersCSV: function () {
exportUsersCSV: function() {
LNbits.utils.exportCSV(this.usersTable.columns, this.users)
},
///////////////Wallets////////////////////////////
getWallets: function () {
getWallets: function() {
var self = this
LNbits.api
@ -366,19 +388,19 @@
'/usermanager/api/v1/wallets',
this.g.user.wallets[0].inkey
)
.then(function (response) {
self.wallets = response.data.map(function (obj) {
.then(function(response) {
self.wallets = response.data.map(function(obj) {
return mapUserManager(obj)
})
})
},
openWalletUpdateDialog: function (linkId) {
openWalletUpdateDialog: function(linkId) {
var link = _.findWhere(this.users, {id: linkId})
this.walletDialog.data = _.clone(link._data)
this.walletDialog.show = true
},
sendWalletFormData: function () {
sendWalletFormData: function() {
if (this.walletDialog.data.id) {
} else {
var data = {
@ -393,7 +415,7 @@
}
},
createWallet: function (data) {
createWallet: function(data) {
var self = this
LNbits.api
.request(
@ -402,43 +424,43 @@
this.g.user.wallets[0].inkey,
data
)
.then(function (response) {
.then(function(response) {
self.wallets.push(mapUserManager(response.data))
self.walletDialog.show = false
self.walletDialog.data = {}
data = {}
})
.catch(function (error) {
.catch(function(error) {
LNbits.utils.notifyApiError(error)
})
},
deleteWallet: function (userId) {
deleteWallet: function(userId) {
var self = this
LNbits.utils
.confirmDialog('Are you sure you want to delete this wallet link?')
.onOk(function () {
.onOk(function() {
LNbits.api
.request(
'DELETE',
'/usermanager/api/v1/wallets/' + userId,
self.g.user.wallets[0].inkey
)
.then(function (response) {
self.wallets = _.reject(self.wallets, function (obj) {
.then(function(response) {
self.wallets = _.reject(self.wallets, function(obj) {
return obj.id == userId
})
})
.catch(function (error) {
.catch(function(error) {
LNbits.utils.notifyApiError(error)
})
})
},
exportWalletsCSV: function () {
exportWalletsCSV: function() {
LNbits.utils.exportCSV(this.walletsTable.columns, this.wallets)
}
},
created: function () {
created: function() {
if (this.g.user.wallets.length) {
this.getUsers()
this.getWallets()

View file

@ -33,19 +33,29 @@ async def api_usermanager_users():
)
@usermanager_ext.route("/api/v1/users/<user_id>", methods=["GET"])
@api_check_wallet_key(key_type="invoice")
async def api_usermanager_user(user_id):
user = await get_usermanager_user(user_id)
return (
jsonify(user._asdict()),
HTTPStatus.OK,
)
@usermanager_ext.route("/api/v1/users", methods=["POST"])
@api_check_wallet_key(key_type="invoice")
@api_validate_post_request(
schema={
"admin_id": {"type": "string", "empty": False, "required": True},
"user_name": {"type": "string", "empty": False, "required": True},
"wallet_name": {"type": "string", "empty": False, "required": True},
"admin_id": {"type": "string", "empty": False, "required": True},
"email": {"type": "string", "required": False},
"password": {"type": "string", "required": False},
}
)
async def api_usermanager_users_create():
user = await create_usermanager_user(
g.data["user_name"], g.data["wallet_name"], g.data["admin_id"]
)
user = await create_usermanager_user(**g.data)
return jsonify(user._asdict()), HTTPStatus.CREATED

View file

@ -1,4 +1,19 @@
# Watch Only wallet
## Monitor an onchain wallet and generate addresses for onchain payments
Monitor an extended public key and generate deterministic fresh public keys with this simple watch only wallet. Invoice payments can also be generated, both through a publically shareable page and API.
1. Start by clicking "NEW WALLET"\
![new wallet](https://i.imgur.com/vgbAB7c.png)
2. Fill the requested fields:
- give the wallet a name
- paste an Extended Public Key (xpub, ypub, zpub)
- click "CREATE WATCH-ONLY WALLET"\
![fill wallet form](https://i.imgur.com/UVoG7LD.png)
3. You can then access your onchain addresses\
![get address](https://i.imgur.com/zkxTQ6l.png)
4. You can then generate bitcoin onchain adresses from LNbits\
![onchain address](https://i.imgur.com/4KVSSJn.png)
You can now use this wallet on the LNBits [SatsPayServer](https://github.com/lnbits/lnbits/blob/master/lnbits/extensions/satspay/README.md) extension

View file

@ -1,14 +1,46 @@
# LNURLw
## Withdraw link maker
LNURL withdraw is a very powerful tool and should not have his use limited to just faucet applications. With LNURL withdraw, you have the ability to give someone the right to spend a range, once or multiple times. This functionality has not existed in money before.
https://github.com/btcontract/lnurl-rfc/blob/master/spec.md#3-lnurl-withdraw
With this extension to can create/edit LNURL withdraws, set a min/max amount, set time (useful for subscription services)
## Create a static QR code people can use to withdraw funds from a Lightning Network wallet
![lnurl](https://i.imgur.com/qHi7ExL.png)
LNURL is a range of lightning-network standards that allow us to use lightning-network differently. An LNURL withdraw is the permission for someone to pull a certain amount of funds from a lightning wallet.
The most common use case for an LNURL withdraw is a faucet, although it is a very powerful technology, with much further reaching implications. For example, an LNURL withdraw could be minted to pay for a subscription service. Or you can have a LNURLw as an offline Lightning wallet (a pre paid "card"), you use to pay for something without having to even reach your smartphone.
## API endpoint - /withdraw/api/v1/lnurlmaker
Easily fetch one-off LNURLw
LNURL withdraw is a **very powerful tool** and should not have his use limited to just faucet applications. With LNURL withdraw, you have the ability to give someone the right to spend a range, once or multiple times. **This functionality has not existed in money before**.
curl -H "Content-type: application/json" -X POST https://YOUR-LNBITS/withdraw/api/v1/lnurlmaker -d '{"amount":"100","memo":"ATM"}' -H "X-Api-Key: YOUR-WALLET-ADMIN-KEY"
[**Wallets supporting LNURL**](https://github.com/fiatjaf/awesome-lnurl#wallets)
## Usage
#### Quick Vouchers
LNBits Quick Vouchers allows you to easily create a batch of LNURLw's QR codes that you can print and distribute as rewards, onboarding people into Lightning Network, gifts, etc...
1. Create Quick Vouchers\
![quick vouchers](https://i.imgur.com/IUfwdQz.jpg)
- select wallet
- set the amount each voucher will allow someone to withdraw
- set the amount of vouchers you want to create - _have in mind you need to have a balance on the wallet that supports the amount \* number of vouchers_
2. You can now print, share, display your LNURLw links or QR codes\
![lnurlw created](https://i.imgur.com/X00twiX.jpg)
- on details you can print the vouchers\
![printable vouchers](https://i.imgur.com/2xLHbob.jpg)
- every printed LNURLw QR code is unique, it can only be used once
#### Advanced
1. Create the Advanced LNURLw\
![create advanced lnurlw](https://i.imgur.com/OR0f885.jpg)
- set the wallet
- set a title for the LNURLw (it will show up in users wallet)
- define the minimum and maximum a user can withdraw, if you want a fixed amount set them both to an equal value
- set how many times can the LNURLw be scanned, if it's a one time use or it can be scanned 100 times
- LNBits has the "_Time between withdraws_" setting, you can define how long the LNURLw will be unavailable between scans
- you can set the time in _seconds, minutes or hours_
- the "_Use unique withdraw QR..._" reduces the chance of your LNURL withdraw being exploited and depleted by one person, by generating a new QR code every time it's scanned
2. Print, share or display your LNURLw link or it's QR code\
![lnurlw created](https://i.imgur.com/X00twiX.jpg)
**LNBits bonus:** If a user doesn't have a Lightning Network wallet and scans the LNURLw QR code with their smartphone camera, or a QR scanner app, they can follow the link provided to claim their satoshis and get an instant LNBits wallet!
![](https://i.imgur.com/2zZ7mi8.jpg)

View file

@ -98,19 +98,21 @@ async def api_lnurl_callback(unique_hash):
HTTPStatus.OK,
)
try:
await pay_invoice(
wallet_id=link.wallet,
payment_request=payment_request,
max_sat=link.max_withdrawable,
extra={"tag": "withdraw"},
)
try:
usescsv = ""
for x in range(1, link.uses - link.used):
usecv = link.usescsv.split(",")
usescsv += "," + str(usecv[x])
usecsvback = usescsv
usescsv = usescsv[1:]
changesback = {
"open_time": link.wait_time,
"used": link.used,
"usescsv": usecsvback,
}
changes = {
"open_time": link.wait_time + now,
"used": link.used + 1,
@ -118,11 +120,21 @@ async def api_lnurl_callback(unique_hash):
}
await update_withdraw_link(link.id, **changes)
await pay_invoice(
wallet_id=link.wallet,
payment_request=payment_request,
max_sat=link.max_withdrawable,
extra={"tag": "withdraw"},
)
except ValueError as e:
await update_withdraw_link(link.id, **changesback)
return jsonify({"status": "ERROR", "reason": str(e)})
except PermissionError:
await update_withdraw_link(link.id, **changesback)
return jsonify({"status": "ERROR", "reason": "Withdraw link is empty."})
except Exception as e:
await update_withdraw_link(link.id, **changesback)
return jsonify({"status": "ERROR", "reason": str(e)})
return jsonify({"status": "OK"}), HTTPStatus.OK

View file

@ -14,11 +14,18 @@ window.LNbits = {
data: data
})
},
createInvoice: function (wallet, amount, memo, lnurlCallback = null) {
createInvoice: async function (
wallet,
amount,
memo,
unit = 'sat',
lnurlCallback = null
) {
return this.request('post', '/api/v1/payments', wallet.inkey, {
out: false,
amount: amount,
memo: memo,
unit: unit,
lnurl_callback: lnurlCallback
})
},

View file

@ -1,5 +1,5 @@
import time
import trio # type: ignore
import trio
from http import HTTPStatus
from typing import Optional, List, Callable
from quart_trio import QuartTrio
@ -13,13 +13,6 @@ from lnbits.core.crud import (
)
from lnbits.core.services import redeem_lnurl_withdraw
main_app: Optional[QuartTrio] = None
def grab_app_for_later(app: QuartTrio):
global main_app
main_app = app
deferred_async: List[Callable] = []

View file

@ -86,6 +86,8 @@
<q-toolbar-title class="text-caption">
<strong>LN</strong>bits, free and open-source lightning
wallet/accounts system
<br />
<small>Commit version: {{LNBITS_VERSION}}</small>
</q-toolbar-title>
<q-space></q-space>
<q-btn

View file

@ -1,4 +1,4 @@
import trio # type: ignore
import trio
import httpx
from typing import Callable, NamedTuple

View file

@ -3,7 +3,7 @@ try:
except ImportError: # pragma: nocover
LightningRpc = None
import trio # type: ignore
import trio
import random
import json

View file

@ -1,4 +1,4 @@
import trio # type: ignore
import trio
import json
import httpx
from os import getenv

View file

@ -1,4 +1,4 @@
import trio # type: ignore
import trio
import httpx
import json
import base64

View file

@ -1,5 +1,5 @@
import json
import trio # type: ignore
import trio
import httpx
from os import getenv
from http import HTTPStatus

View file

@ -1,4 +1,4 @@
import trio # type: ignore
import trio
import json
import httpx
from os import getenv

View file

@ -1,4 +1,4 @@
import trio # type: ignore
import trio
import hmac
import httpx
from http import HTTPStatus

View file

@ -1,4 +1,4 @@
import trio # type: ignore
import trio
import json
import httpx
import random

2
mypy.ini Normal file
View file

@ -0,0 +1,2 @@
[mypy]
plugins = trio_typing.plugin