Merge pull request #360 from arcbtc/FastAPI
latest commits. Most extensions added
1
Pipfile
|
|
@ -28,6 +28,7 @@ fastapi = "*"
|
||||||
uvicorn = {extras = ["standard"], version = "*"}
|
uvicorn = {extras = ["standard"], version = "*"}
|
||||||
sse-starlette = "*"
|
sse-starlette = "*"
|
||||||
jinja2 = "3.0.1"
|
jinja2 = "3.0.1"
|
||||||
|
pyngrok = "*"
|
||||||
|
|
||||||
[dev-packages]
|
[dev-packages]
|
||||||
black = "==20.8b1"
|
black = "==20.8b1"
|
||||||
|
|
|
||||||
400
Pipfile.lock
generated
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"_meta": {
|
"_meta": {
|
||||||
"hash": {
|
"hash": {
|
||||||
"sha256": "97473b3cb250742ebabd8c3a71d4e4c42f8feeaff49dd4542cae24429f096535"
|
"sha256": "9c0e70708a7767ec1f6c4b3df1a0926184220014ab67ff82d4f352c634918085"
|
||||||
},
|
},
|
||||||
"pipfile-spec": 6,
|
"pipfile-spec": 6,
|
||||||
"requires": {
|
"requires": {
|
||||||
|
|
@ -26,11 +26,11 @@
|
||||||
},
|
},
|
||||||
"anyio": {
|
"anyio": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:85913b4e2fec030e8c72a8f9f98092eeb9e25847a6e00d567751b77e34f856fe",
|
"sha256:4fd09a25ab7fa01d34512b7249e366cd10358cdafc95022c7ff8c8f8a5026d66",
|
||||||
"sha256:d7c604dd491eca70e19c78664d685d5e4337612d574419d503e76f5d7d1590bd"
|
"sha256:67da67b5b21f96b9d3d65daa6ea99f5d5282cb09f50eb4456f8fb51dffefc3ff"
|
||||||
],
|
],
|
||||||
"markers": "python_full_version >= '3.6.2'",
|
"markers": "python_full_version >= '3.6.2'",
|
||||||
"version": "==3.3.1"
|
"version": "==3.3.4"
|
||||||
},
|
},
|
||||||
"asgiref": {
|
"asgiref": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
|
|
@ -63,7 +63,7 @@
|
||||||
"sha256:7d6db8214603bd7871fcfa6c0826ef68b85b0abd90fa21c285a9c5e21d2bd899",
|
"sha256:7d6db8214603bd7871fcfa6c0826ef68b85b0abd90fa21c285a9c5e21d2bd899",
|
||||||
"sha256:990dc8e5a5e4feabbdf55207b5315fdd9b73db40be294a19b3752cde9e79d981"
|
"sha256:990dc8e5a5e4feabbdf55207b5315fdd9b73db40be294a19b3752cde9e79d981"
|
||||||
],
|
],
|
||||||
"markers": "python_version >= '3.5'",
|
"markers": "python_full_version >= '3.5.0'",
|
||||||
"version": "==1.2.0"
|
"version": "==1.2.0"
|
||||||
},
|
},
|
||||||
"bitstring": {
|
"bitstring": {
|
||||||
|
|
@ -84,26 +84,26 @@
|
||||||
},
|
},
|
||||||
"certifi": {
|
"certifi": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee",
|
"sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872",
|
||||||
"sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8"
|
"sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"
|
||||||
],
|
],
|
||||||
"version": "==2021.5.30"
|
"version": "==2021.10.8"
|
||||||
},
|
},
|
||||||
"charset-normalizer": {
|
"charset-normalizer": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:5d209c0a931f215cee683b6445e2d77677e7e75e159f78def0db09d68fafcaa6",
|
"sha256:e019de665e2bcf9c2b64e2e5aa025fa991da8720daa3c1138cadd2fd1856aed0",
|
||||||
"sha256:5ec46d183433dcbd0ab716f2d7f29d8dee50505b3fdb40c6b985c7c4f5a3591f"
|
"sha256:f7af805c321bfa1ce6714c51f254e0d5bb5e5834039bc17db7ebe3a4cec9492b"
|
||||||
],
|
],
|
||||||
"markers": "python_version >= '3.5'",
|
"markers": "python_full_version >= '3.5.0'",
|
||||||
"version": "==2.0.6"
|
"version": "==2.0.7"
|
||||||
},
|
},
|
||||||
"click": {
|
"click": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:8c04c11192119b1ef78ea049e0a6f0463e4c48ef00a30160c704337586f3ad7a",
|
"sha256:353f466495adaeb40b6b5f592f9f91cb22372351c84caeb068132442a4518ef3",
|
||||||
"sha256:fba402a4a47334742d782209a7c79bc448911afe1149d07bdabdf480b3e2f4b6"
|
"sha256:410e932b050f5eed773c4cda94de75971c89cdb3155a72a0831139a79e5ecb5b"
|
||||||
],
|
],
|
||||||
"markers": "python_version >= '3.6'",
|
"markers": "python_version >= '3.6'",
|
||||||
"version": "==8.0.1"
|
"version": "==8.0.3"
|
||||||
},
|
},
|
||||||
"ecdsa": {
|
"ecdsa": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
|
|
@ -115,26 +115,26 @@
|
||||||
},
|
},
|
||||||
"embit": {
|
"embit": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:992332bd89af6e2d027e26fe437eb14aa33997db08c882c49064d49c3e6f4ab9"
|
"sha256:f6484bc495b45da27f3eb7fbe21a24c00cd72c0ab83c6e195660cf17db5cb5e2"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==0.4.9"
|
"version": "==0.4.10"
|
||||||
},
|
},
|
||||||
"environs": {
|
"environs": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:72b867ff7b553076cdd90f3ee01ecc1cf854987639c9c459f0ed0d3d44ae490c",
|
"sha256:7412eca2996027a0a1eafd89bbfec872568e7b4ca75fc980817bfd7788cb5a1f",
|
||||||
"sha256:ee5466156b50fe03aa9fec6e720feea577b5bf515d7f21b2c46608272557ba26"
|
"sha256:eecf57fb1b91f1166a8a16344a3fd12ea55b7a0f233c906d86506bdb40738a0f"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==9.3.3"
|
"version": "==9.3.4"
|
||||||
},
|
},
|
||||||
"fastapi": {
|
"fastapi": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:644bb815bae326575c4b2842469fb83053a4b974b82fa792ff9283d17fbbd99d",
|
"sha256:66da43cfe5185ea1df99552acffd201f1832c6b364e0f4136c0a99f933466ced",
|
||||||
"sha256:94d2820906c36b9b8303796fb7271337ec89c74223229e3cfcf056b5a7d59e23"
|
"sha256:a36d5f2fad931aa3575c07a3472c784e81f3e664e3bb5c8b9c88d0ec1104f59c"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==0.68.1"
|
"version": "==0.70.0"
|
||||||
},
|
},
|
||||||
"h11": {
|
"h11": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
|
|
@ -174,34 +174,26 @@
|
||||||
},
|
},
|
||||||
"httpx": {
|
"httpx": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:92ecd2c00c688b529eda11cedb15161eaf02dee9116712f621c70d9a40b2cdd0",
|
"sha256:09606d630f070d07f9ff28104fbcea429ea0014c1e89ac90b4d8de8286c40e7b",
|
||||||
"sha256:9bd728a6c5ec0a9e243932a9983d57d3cc4a87bb4f554e1360fce407f78f9435"
|
"sha256:33af5aad9bdc82ef1fc89219c1e36f5693bf9cd0ebe330884df563445682c0f8"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==0.19.0"
|
"version": "==0.20.0"
|
||||||
},
|
},
|
||||||
"idna": {
|
"idna": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a",
|
"sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff",
|
||||||
"sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3"
|
"sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"
|
||||||
],
|
],
|
||||||
"version": "==3.2"
|
"version": "==3.3"
|
||||||
},
|
|
||||||
"importlib-metadata": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:b618b6d2d5ffa2f16add5697cf57a46c76a56229b0ed1c438322e4e95645bd15",
|
|
||||||
"sha256:f284b3e11256ad1e5d03ab86bb2ccd6f5339688ff17a4d797a0fe7df326f23b1"
|
|
||||||
],
|
|
||||||
"markers": "python_version < '3.8'",
|
|
||||||
"version": "==4.8.1"
|
|
||||||
},
|
},
|
||||||
"jinja2": {
|
"jinja2": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:1f06f2da51e7b56b8f238affdd6b4e2c61e39598a378cc49345bc1bd42a978a4",
|
"sha256:827a0e32839ab1600d4eb1c4c33ec5a8edfbc5cb42dafa13b81f182f97784b45",
|
||||||
"sha256:703f484b47a6af502e743c9122595cc812b0271f661722403114f71a79d0f5a4"
|
"sha256:8569982d3f0889eed11dd620c706d39b60c36d6d25843961f33f77fb6bc6b20c"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==3.0.1"
|
"version": "==3.0.2"
|
||||||
},
|
},
|
||||||
"lnurl": {
|
"lnurl": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
|
|
@ -273,11 +265,11 @@
|
||||||
},
|
},
|
||||||
"marshmallow": {
|
"marshmallow": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:c67929438fd73a2be92128caa0325b1b5ed8b626d91a094d2f7f2771bf1f1c0e",
|
"sha256:6d00e42d6d6289f8cd3e77618a01689d57a078fe324ee579b00fa206d32e9b07",
|
||||||
"sha256:dd4724335d3c2b870b641ffe4a2f8728a1380cd2e7e2312756715ffeaa82b842"
|
"sha256:bba1a940985c052c5cc7849f97da196ebc81f3b85ec10c56ef1f3228aa9cbe74"
|
||||||
],
|
],
|
||||||
"markers": "python_version >= '3.5'",
|
"markers": "python_version >= '3.6'",
|
||||||
"version": "==3.13.0"
|
"version": "==3.14.0"
|
||||||
},
|
},
|
||||||
"outcome": {
|
"outcome": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
|
|
@ -297,12 +289,16 @@
|
||||||
"sha256:1e3a362790edc0a365385b1ac4cc0acc429a0c0d662d829a50b6ce743ae61b5a",
|
"sha256:1e3a362790edc0a365385b1ac4cc0acc429a0c0d662d829a50b6ce743ae61b5a",
|
||||||
"sha256:1e85b74cbbb3056e3656f1cc4781294df03383127a8114cbc6531e8b8367bf1e",
|
"sha256:1e85b74cbbb3056e3656f1cc4781294df03383127a8114cbc6531e8b8367bf1e",
|
||||||
"sha256:20f1ab44d8c352074e2d7ca67dc00843067788791be373e67a0911998787ce7d",
|
"sha256:20f1ab44d8c352074e2d7ca67dc00843067788791be373e67a0911998787ce7d",
|
||||||
|
"sha256:24b0b6688b9f31a911f2361fe818492650795c9e5d3a1bc647acbd7440142a4f",
|
||||||
"sha256:2f62c207d1740b0bde5c4e949f857b044818f734a3d57f1d0d0edc65050532ed",
|
"sha256:2f62c207d1740b0bde5c4e949f857b044818f734a3d57f1d0d0edc65050532ed",
|
||||||
"sha256:3242b9619de955ab44581a03a64bdd7d5e470cc4183e8fcadd85ab9d3756ce7a",
|
"sha256:3242b9619de955ab44581a03a64bdd7d5e470cc4183e8fcadd85ab9d3756ce7a",
|
||||||
"sha256:35c4310f8febe41f442d3c65066ca93cccefd75013df3d8c736c5b93ec288140",
|
"sha256:35c4310f8febe41f442d3c65066ca93cccefd75013df3d8c736c5b93ec288140",
|
||||||
"sha256:4235f9d5ddcab0b8dbd723dca56ea2922b485ea00e1dafacf33b0c7e840b3d32",
|
"sha256:4235f9d5ddcab0b8dbd723dca56ea2922b485ea00e1dafacf33b0c7e840b3d32",
|
||||||
|
"sha256:542875f62bc56e91c6eac05a0deadeae20e1730be4c6334d8f04c944fcd99759",
|
||||||
"sha256:5ced67f1e34e1a450cdb48eb53ca73b60aa0af21c46b9b35ac3e581cf9f00e31",
|
"sha256:5ced67f1e34e1a450cdb48eb53ca73b60aa0af21c46b9b35ac3e581cf9f00e31",
|
||||||
|
"sha256:661509f51531ec125e52357a489ea3806640d0ca37d9dada461ffc69ee1e7b6e",
|
||||||
"sha256:7360647ea04db2e7dff1648d1da825c8cf68dc5fbd80b8fb5b3ee9f068dcd21a",
|
"sha256:7360647ea04db2e7dff1648d1da825c8cf68dc5fbd80b8fb5b3ee9f068dcd21a",
|
||||||
|
"sha256:736b8797b58febabb85494142c627bd182b50d2a7ec65322983e71065ad3034c",
|
||||||
"sha256:8c13d72ed6af7fd2c8acbd95661cf9477f94e381fce0792c04981a8283b52917",
|
"sha256:8c13d72ed6af7fd2c8acbd95661cf9477f94e381fce0792c04981a8283b52917",
|
||||||
"sha256:988b47ac70d204aed01589ed342303da7c4d84b56c2f4c4b8b00deda123372bf",
|
"sha256:988b47ac70d204aed01589ed342303da7c4d84b56c2f4c4b8b00deda123372bf",
|
||||||
"sha256:995fc41ebda5a7a663a254a1dcac52638c3e847f48307b5416ee373da15075d7",
|
"sha256:995fc41ebda5a7a663a254a1dcac52638c3e847f48307b5416ee373da15075d7",
|
||||||
|
|
@ -314,6 +310,7 @@
|
||||||
"sha256:c250a7ec489b652c892e4f0a5d122cc14c3780f9f643e1a326754aedf82d9a76",
|
"sha256:c250a7ec489b652c892e4f0a5d122cc14c3780f9f643e1a326754aedf82d9a76",
|
||||||
"sha256:ca86db5b561b894f9e5f115d6a159fff2a2570a652e07889d8a383b5fae66eb4",
|
"sha256:ca86db5b561b894f9e5f115d6a159fff2a2570a652e07889d8a383b5fae66eb4",
|
||||||
"sha256:cfc523edecddaef56f6740d7de1ce24a2fdf94fd5e704091856a201872e37f9f",
|
"sha256:cfc523edecddaef56f6740d7de1ce24a2fdf94fd5e704091856a201872e37f9f",
|
||||||
|
"sha256:d92272c7c16e105788efe2cfa5d680f07e34e0c29b03c1908f8636f55d5f915a",
|
||||||
"sha256:da113b70f6ec40e7d81b43d1b139b9db6a05727ab8be1ee559f3a69854a69d34",
|
"sha256:da113b70f6ec40e7d81b43d1b139b9db6a05727ab8be1ee559f3a69854a69d34",
|
||||||
"sha256:f6fac64a38f6768e7bc7b035b9e10d8a538a9fadce06b983fb3e6fa55ac5f5ce",
|
"sha256:f6fac64a38f6768e7bc7b035b9e10d8a538a9fadce06b983fb3e6fa55ac5f5ce",
|
||||||
"sha256:f8559617b1fcf59a9aedba2c9838b5b6aa211ffedecabca412b92a1ff75aac1a",
|
"sha256:f8559617b1fcf59a9aedba2c9838b5b6aa211ffedecabca412b92a1ff75aac1a",
|
||||||
|
|
@ -350,6 +347,13 @@
|
||||||
"markers": "python_full_version >= '3.6.1'",
|
"markers": "python_full_version >= '3.6.1'",
|
||||||
"version": "==1.8.2"
|
"version": "==1.8.2"
|
||||||
},
|
},
|
||||||
|
"pyngrok": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:4d03f44a69c3cbc168b17377956a9edcf723e77dbc864eba34c272db15da443c"
|
||||||
|
],
|
||||||
|
"index": "pypi",
|
||||||
|
"version": "==5.1.0"
|
||||||
|
},
|
||||||
"pypng": {
|
"pypng": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:76f8a1539ec56451da7ab7121f12a361969fe0f2d48d703d198ce2a99d6c5afd"
|
"sha256:76f8a1539ec56451da7ab7121f12a361969fe0f2d48d703d198ce2a99d6c5afd"
|
||||||
|
|
@ -374,45 +378,49 @@
|
||||||
},
|
},
|
||||||
"python-dotenv": {
|
"python-dotenv": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:aae25dc1ebe97c420f50b81fb0e5c949659af713f31fdb63c749ca68748f34b1",
|
"sha256:14f8185cc8d494662683e6914addcb7e95374771e707601dfc70166946b4c4b8",
|
||||||
"sha256:f521bc2ac9a8e03c736f62911605c5d83970021e3fa95b37d769e2bbbe9b6172"
|
"sha256:bbd3da593fc49c249397cbfbcc449cf36cb02e75afc8157fcc6a81df6fb7750a"
|
||||||
],
|
],
|
||||||
"markers": "python_version >= '3.5'",
|
"markers": "python_full_version >= '3.5.0'",
|
||||||
"version": "==0.19.0"
|
"version": "==0.19.1"
|
||||||
},
|
},
|
||||||
"pyyaml": {
|
"pyyaml": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:08682f6b72c722394747bddaf0aa62277e02557c0fd1c42cb853016a38f8dedf",
|
"sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293",
|
||||||
"sha256:0f5f5786c0e09baddcd8b4b45f20a7b5d61a7e7e99846e3c799b05c7c53fa696",
|
"sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b",
|
||||||
"sha256:129def1b7c1bf22faffd67b8f3724645203b79d8f4cc81f674654d9902cb4393",
|
"sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57",
|
||||||
"sha256:294db365efa064d00b8d1ef65d8ea2c3426ac366c0c4368d930bf1c5fb497f77",
|
"sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b",
|
||||||
"sha256:3b2b1824fe7112845700f815ff6a489360226a5609b96ec2190a45e62a9fc922",
|
"sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4",
|
||||||
"sha256:3bd0e463264cf257d1ffd2e40223b197271046d09dadf73a0fe82b9c1fc385a5",
|
"sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07",
|
||||||
"sha256:4465124ef1b18d9ace298060f4eccc64b0850899ac4ac53294547536533800c8",
|
"sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba",
|
||||||
"sha256:49d4cdd9065b9b6e206d0595fee27a96b5dd22618e7520c33204a4a3239d5b10",
|
"sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9",
|
||||||
"sha256:4e0583d24c881e14342eaf4ec5fbc97f934b999a6828693a99157fde912540cc",
|
"sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287",
|
||||||
"sha256:5accb17103e43963b80e6f837831f38d314a0495500067cb25afab2e8d7a4018",
|
"sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513",
|
||||||
"sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e",
|
"sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0",
|
||||||
"sha256:6c78645d400265a062508ae399b60b8c167bf003db364ecb26dcab2bda048253",
|
"sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0",
|
||||||
"sha256:72a01f726a9c7851ca9bfad6fd09ca4e090a023c00945ea05ba1638c09dc3347",
|
"sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92",
|
||||||
"sha256:74c1485f7707cf707a7aef42ef6322b8f97921bd89be2ab6317fd782c2d53183",
|
"sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f",
|
||||||
"sha256:895f61ef02e8fed38159bb70f7e100e00f471eae2bc838cd0f4ebb21e28f8541",
|
"sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2",
|
||||||
"sha256:8c1be557ee92a20f184922c7b6424e8ab6691788e6d86137c5d93c1a6ec1b8fb",
|
"sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc",
|
||||||
"sha256:bb4191dfc9306777bc594117aee052446b3fa88737cd13b7188d0e7aa8162185",
|
"sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c",
|
||||||
"sha256:bfb51918d4ff3d77c1c856a9699f8492c612cde32fd3bcd344af9be34999bfdc",
|
"sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86",
|
||||||
"sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db",
|
"sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4",
|
||||||
"sha256:cb333c16912324fd5f769fff6bc5de372e9e7a202247b48870bc251ed40239aa",
|
"sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c",
|
||||||
"sha256:d2d9808ea7b4af864f35ea216be506ecec180628aced0704e34aca0b040ffe46",
|
"sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34",
|
||||||
"sha256:d483ad4e639292c90170eb6f7783ad19490e7a8defb3e46f97dfe4bacae89122",
|
"sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b",
|
||||||
"sha256:dd5de0646207f053eb0d6c74ae45ba98c3395a571a2891858e87df7c9b9bd51b",
|
"sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c",
|
||||||
"sha256:e1d4970ea66be07ae37a3c2e48b5ec63f7ba6804bdddfdbd3cfd954d25a82e63",
|
"sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb",
|
||||||
"sha256:e4fac90784481d221a8e4b1162afa7c47ed953be40d31ab4629ae917510051df",
|
"sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737",
|
||||||
"sha256:fa5ae20527d8e831e8230cbffd9f8fe952815b2b7dae6ffec25318803a7528fc",
|
"sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3",
|
||||||
"sha256:fd7f6999a8070df521b6384004ef42833b9bd62cfee11a09bda1079b4b704247",
|
"sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d",
|
||||||
"sha256:fdc842473cd33f45ff6bce46aea678a54e3d21f1b61a7750ce3c498eedfe25d6",
|
"sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53",
|
||||||
"sha256:fe69978f3f768926cfa37b867e3843918e012cf83f680806599ddce33c2c68b0"
|
"sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78",
|
||||||
|
"sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803",
|
||||||
|
"sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a",
|
||||||
|
"sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174",
|
||||||
|
"sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"
|
||||||
],
|
],
|
||||||
"version": "==5.4.1"
|
"version": "==6.0"
|
||||||
},
|
},
|
||||||
"represent": {
|
"represent": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
|
|
@ -453,7 +461,7 @@
|
||||||
"sha256:471b71698eac1c2112a40ce2752bb2f4a4814c22a54a3eed3676bc0f5ca9f663",
|
"sha256:471b71698eac1c2112a40ce2752bb2f4a4814c22a54a3eed3676bc0f5ca9f663",
|
||||||
"sha256:c4666eecec1d3f50960c6bdf61ab7bc350648da6c126e3cf6898d8cd4ddcd3de"
|
"sha256:c4666eecec1d3f50960c6bdf61ab7bc350648da6c126e3cf6898d8cd4ddcd3de"
|
||||||
],
|
],
|
||||||
"markers": "python_version >= '3.5'",
|
"markers": "python_full_version >= '3.5.0'",
|
||||||
"version": "==1.2.0"
|
"version": "==1.2.0"
|
||||||
},
|
},
|
||||||
"sqlalchemy": {
|
"sqlalchemy": {
|
||||||
|
|
@ -510,18 +518,19 @@
|
||||||
},
|
},
|
||||||
"sse-starlette": {
|
"sse-starlette": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:1c0cc62cc7d021a386dc06a16a9ddc3e2861d19da6bc2e654e65cc111e820456"
|
"sha256:8adc5bfe8c6ede3cf8f16dc741db813c580a13fd8510ec06d6d3e27987e972d2",
|
||||||
|
"sha256:bd572df6a74779090a1060759a8c3b94e1aa54240173d76c7d830f03e991875f"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==0.6.2"
|
"version": "==0.9.0"
|
||||||
},
|
},
|
||||||
"starlette": {
|
"starlette": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:3c8e48e52736b3161e34c9f0e8153b4f32ec5d8995a3ee1d59410d92f75162ed",
|
"sha256:38eb24bf705a2c317e15868e384c1b8a12ca396e5a3c3a003db7e667c43f939f",
|
||||||
"sha256:7d49f4a27f8742262ef1470608c59ddbc66baf37c148e938c7038e6bc7a998aa"
|
"sha256:e1904b5d0007aee24bdd3c43994be9b3b729f4f58e740200de1d623f8c3a8870"
|
||||||
],
|
],
|
||||||
"markers": "python_version >= '3.6'",
|
"markers": "python_version >= '3.6'",
|
||||||
"version": "==0.14.2"
|
"version": "==0.16.0"
|
||||||
},
|
},
|
||||||
"typing-extensions": {
|
"typing-extensions": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
|
|
@ -600,14 +609,6 @@
|
||||||
"sha256:ff59c6bdb87b31f7e2d596f09353d5a38c8c8ff571b0e2238e8ee2d55ad68465"
|
"sha256:ff59c6bdb87b31f7e2d596f09353d5a38c8c8ff571b0e2238e8ee2d55ad68465"
|
||||||
],
|
],
|
||||||
"version": "==10.0"
|
"version": "==10.0"
|
||||||
},
|
|
||||||
"zipp": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:957cfda87797e389580cb8b9e3870841ca991e2125350677b2ca83a0e99390a3",
|
|
||||||
"sha256:f5812b1e007e48cff63449a5e9f4e7ebea716b4111f9c4f9a645f91d579bf0c4"
|
|
||||||
],
|
|
||||||
"markers": "python_version >= '3.6'",
|
|
||||||
"version": "==3.5.0"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"develop": {
|
"develop": {
|
||||||
|
|
@ -635,77 +636,53 @@
|
||||||
},
|
},
|
||||||
"click": {
|
"click": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:8c04c11192119b1ef78ea049e0a6f0463e4c48ef00a30160c704337586f3ad7a",
|
"sha256:353f466495adaeb40b6b5f592f9f91cb22372351c84caeb068132442a4518ef3",
|
||||||
"sha256:fba402a4a47334742d782209a7c79bc448911afe1149d07bdabdf480b3e2f4b6"
|
"sha256:410e932b050f5eed773c4cda94de75971c89cdb3155a72a0831139a79e5ecb5b"
|
||||||
],
|
],
|
||||||
"markers": "python_version >= '3.6'",
|
"markers": "python_version >= '3.6'",
|
||||||
"version": "==8.0.1"
|
"version": "==8.0.3"
|
||||||
},
|
},
|
||||||
"coverage": {
|
"coverage": {
|
||||||
"hashes": [
|
"extras": [
|
||||||
"sha256:004d1880bed2d97151facef49f08e255a20ceb6f9432df75f4eef018fdd5a78c",
|
"toml"
|
||||||
"sha256:01d84219b5cdbfc8122223b39a954820929497a1cb1422824bb86b07b74594b6",
|
|
||||||
"sha256:040af6c32813fa3eae5305d53f18875bedd079960822ef8ec067a66dd8afcd45",
|
|
||||||
"sha256:06191eb60f8d8a5bc046f3799f8a07a2d7aefb9504b0209aff0b47298333302a",
|
|
||||||
"sha256:13034c4409db851670bc9acd836243aeee299949bd5673e11844befcb0149f03",
|
|
||||||
"sha256:13c4ee887eca0f4c5a247b75398d4114c37882658300e153113dafb1d76de529",
|
|
||||||
"sha256:184a47bbe0aa6400ed2d41d8e9ed868b8205046518c52464fde713ea06e3a74a",
|
|
||||||
"sha256:18ba8bbede96a2c3dde7b868de9dcbd55670690af0988713f0603f037848418a",
|
|
||||||
"sha256:1aa846f56c3d49205c952d8318e76ccc2ae23303351d9270ab220004c580cfe2",
|
|
||||||
"sha256:217658ec7187497e3f3ebd901afdca1af062b42cfe3e0dafea4cced3983739f6",
|
|
||||||
"sha256:24d4a7de75446be83244eabbff746d66b9240ae020ced65d060815fac3423759",
|
|
||||||
"sha256:2910f4d36a6a9b4214bb7038d537f015346f413a975d57ca6b43bf23d6563b53",
|
|
||||||
"sha256:2949cad1c5208b8298d5686d5a85b66aae46d73eec2c3e08c817dd3513e5848a",
|
|
||||||
"sha256:2a3859cb82dcbda1cfd3e6f71c27081d18aa251d20a17d87d26d4cd216fb0af4",
|
|
||||||
"sha256:2cafbbb3af0733db200c9b5f798d18953b1a304d3f86a938367de1567f4b5bff",
|
|
||||||
"sha256:2e0d881ad471768bf6e6c2bf905d183543f10098e3b3640fc029509530091502",
|
|
||||||
"sha256:30c77c1dc9f253283e34c27935fded5015f7d1abe83bc7821680ac444eaf7793",
|
|
||||||
"sha256:3487286bc29a5aa4b93a072e9592f22254291ce96a9fbc5251f566b6b7343cdb",
|
|
||||||
"sha256:372da284cfd642d8e08ef606917846fa2ee350f64994bebfbd3afb0040436905",
|
|
||||||
"sha256:41179b8a845742d1eb60449bdb2992196e211341818565abded11cfa90efb821",
|
|
||||||
"sha256:44d654437b8ddd9eee7d1eaee28b7219bec228520ff809af170488fd2fed3e2b",
|
|
||||||
"sha256:4a7697d8cb0f27399b0e393c0b90f0f1e40c82023ea4d45d22bce7032a5d7b81",
|
|
||||||
"sha256:51cb9476a3987c8967ebab3f0fe144819781fca264f57f89760037a2ea191cb0",
|
|
||||||
"sha256:52596d3d0e8bdf3af43db3e9ba8dcdaac724ba7b5ca3f6358529d56f7a166f8b",
|
|
||||||
"sha256:53194af30d5bad77fcba80e23a1441c71abfb3e01192034f8246e0d8f99528f3",
|
|
||||||
"sha256:5fec2d43a2cc6965edc0bb9e83e1e4b557f76f843a77a2496cbe719583ce8184",
|
|
||||||
"sha256:6c90e11318f0d3c436a42409f2749ee1a115cd8b067d7f14c148f1ce5574d701",
|
|
||||||
"sha256:74d881fc777ebb11c63736622b60cb9e4aee5cace591ce274fb69e582a12a61a",
|
|
||||||
"sha256:7501140f755b725495941b43347ba8a2777407fc7f250d4f5a7d2a1050ba8e82",
|
|
||||||
"sha256:796c9c3c79747146ebd278dbe1e5c5c05dd6b10cc3bcb8389dfdf844f3ead638",
|
|
||||||
"sha256:869a64f53488f40fa5b5b9dcb9e9b2962a66a87dab37790f3fcfb5144b996ef5",
|
|
||||||
"sha256:8963a499849a1fc54b35b1c9f162f4108017b2e6db2c46c1bed93a72262ed083",
|
|
||||||
"sha256:8d0a0725ad7c1a0bcd8d1b437e191107d457e2ec1084b9f190630a4fb1af78e6",
|
|
||||||
"sha256:900fbf7759501bc7807fd6638c947d7a831fc9fdf742dc10f02956ff7220fa90",
|
|
||||||
"sha256:92b017ce34b68a7d67bd6d117e6d443a9bf63a2ecf8567bb3d8c6c7bc5014465",
|
|
||||||
"sha256:970284a88b99673ccb2e4e334cfb38a10aab7cd44f7457564d11898a74b62d0a",
|
|
||||||
"sha256:972c85d205b51e30e59525694670de6a8a89691186012535f9d7dbaa230e42c3",
|
|
||||||
"sha256:9a1ef3b66e38ef8618ce5fdc7bea3d9f45f3624e2a66295eea5e57966c85909e",
|
|
||||||
"sha256:af0e781009aaf59e25c5a678122391cb0f345ac0ec272c7961dc5455e1c40066",
|
|
||||||
"sha256:b6d534e4b2ab35c9f93f46229363e17f63c53ad01330df9f2d6bd1187e5eaacf",
|
|
||||||
"sha256:b7895207b4c843c76a25ab8c1e866261bcfe27bfaa20c192de5190121770672b",
|
|
||||||
"sha256:c0891a6a97b09c1f3e073a890514d5012eb256845c451bd48f7968ef939bf4ae",
|
|
||||||
"sha256:c2723d347ab06e7ddad1a58b2a821218239249a9e4365eaff6649d31180c1669",
|
|
||||||
"sha256:d1f8bf7b90ba55699b3a5e44930e93ff0189aa27186e96071fac7dd0d06a1873",
|
|
||||||
"sha256:d1f9ce122f83b2305592c11d64f181b87153fc2c2bbd3bb4a3dde8303cfb1a6b",
|
|
||||||
"sha256:d314ed732c25d29775e84a960c3c60808b682c08d86602ec2c3008e1202e3bb6",
|
|
||||||
"sha256:d636598c8305e1f90b439dbf4f66437de4a5e3c31fdf47ad29542478c8508bbb",
|
|
||||||
"sha256:deee1077aae10d8fa88cb02c845cfba9b62c55e1183f52f6ae6a2df6a2187160",
|
|
||||||
"sha256:ebe78fe9a0e874362175b02371bdfbee64d8edc42a044253ddf4ee7d3c15212c",
|
|
||||||
"sha256:f030f8873312a16414c0d8e1a1ddff2d3235655a2174e3648b4fa66b3f2f1079",
|
|
||||||
"sha256:f0b278ce10936db1a37e6954e15a3730bea96a0997c26d7fee88e6c396c2086d",
|
|
||||||
"sha256:f11642dddbb0253cc8853254301b51390ba0081750a8ac03f20ea8103f0c56b6"
|
|
||||||
],
|
],
|
||||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'",
|
|
||||||
"version": "==5.5"
|
|
||||||
},
|
|
||||||
"importlib-metadata": {
|
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:b618b6d2d5ffa2f16add5697cf57a46c76a56229b0ed1c438322e4e95645bd15",
|
"sha256:04560539c19ec26995ecfb3d9307ff154fbb9a172cb57e3b3cfc4ced673103d1",
|
||||||
"sha256:f284b3e11256ad1e5d03ab86bb2ccd6f5339688ff17a4d797a0fe7df326f23b1"
|
"sha256:1549e1d08ce38259de2bc3e9a0d5f3642ff4a8f500ffc1b2df73fd621a6cdfc0",
|
||||||
|
"sha256:1db67c497688fd4ba85b373b37cc52c50d437fd7267520ecd77bddbd89ea22c9",
|
||||||
|
"sha256:30922626ce6f7a5a30bdba984ad21021529d3d05a68b4f71ea3b16bda35b8895",
|
||||||
|
"sha256:36e9040a43d2017f2787b28d365a4bb33fcd792c7ff46a047a04094dc0e2a30d",
|
||||||
|
"sha256:381d773d896cc7f8ba4ff3b92dee4ed740fb88dfe33b6e42efc5e8ab6dfa1cfe",
|
||||||
|
"sha256:3bbda1b550e70fa6ac40533d3f23acd4f4e9cb4e6e77251ce77fdf41b3309fb2",
|
||||||
|
"sha256:3be1206dc09fb6298de3fce70593e27436862331a85daee36270b6d0e1c251c4",
|
||||||
|
"sha256:424c44f65e8be58b54e2b0bd1515e434b940679624b1b72726147cfc6a9fc7ce",
|
||||||
|
"sha256:4b34ae4f51bbfa5f96b758b55a163d502be3dcb24f505d0227858c2b3f94f5b9",
|
||||||
|
"sha256:4e28d2a195c533b58fc94a12826f4431726d8eb029ac21d874345f943530c122",
|
||||||
|
"sha256:53a294dc53cfb39c74758edaa6305193fb4258a30b1f6af24b360a6c8bd0ffa7",
|
||||||
|
"sha256:60e51a3dd55540bec686d7fff61b05048ca31e804c1f32cbb44533e6372d9cc3",
|
||||||
|
"sha256:61b598cbdbaae22d9e34e3f675997194342f866bb1d781da5d0be54783dce1ff",
|
||||||
|
"sha256:6807947a09510dc31fa86f43595bf3a14017cd60bf633cc746d52141bfa6b149",
|
||||||
|
"sha256:6a6a9409223a27d5ef3cca57dd7cd4dfcb64aadf2fad5c3b787830ac9223e01a",
|
||||||
|
"sha256:7092eab374346121805fb637572483270324407bf150c30a3b161fc0c4ca5164",
|
||||||
|
"sha256:77b1da5767ed2f44611bc9bc019bc93c03fa495728ec389759b6e9e5039ac6b1",
|
||||||
|
"sha256:8251b37be1f2cd9c0e5ccd9ae0380909c24d2a5ed2162a41fcdbafaf59a85ebd",
|
||||||
|
"sha256:9f1627e162e3864a596486774876415a7410021f4b67fd2d9efdf93ade681afc",
|
||||||
|
"sha256:a1b73c7c4d2a42b9d37dd43199c5711d91424ff3c6c22681bc132db4a4afec6f",
|
||||||
|
"sha256:a82d79586a0a4f5fd1cf153e647464ced402938fbccb3ffc358c7babd4da1dd9",
|
||||||
|
"sha256:abbff240f77347d17306d3201e14431519bf64495648ca5a49571f988f88dee9",
|
||||||
|
"sha256:ad9b8c1206ae41d46ec7380b78ba735ebb77758a650643e841dd3894966c31d0",
|
||||||
|
"sha256:bbffde2a68398682623d9dd8c0ca3f46fda074709b26fcf08ae7a4c431a6ab2d",
|
||||||
|
"sha256:bcae10fccb27ca2a5f456bf64d84110a5a74144be3136a5e598f9d9fb48c0caa",
|
||||||
|
"sha256:c9cd3828bbe1a40070c11fe16a51df733fd2f0cb0d745fb83b7b5c1f05967df7",
|
||||||
|
"sha256:cd1cf1deb3d5544bd942356364a2fdc8959bad2b6cf6eb17f47d301ea34ae822",
|
||||||
|
"sha256:d036dc1ed8e1388e995833c62325df3f996675779541f682677efc6af71e96cc",
|
||||||
|
"sha256:db42baa892cba723326284490283a68d4de516bfb5aaba369b4e3b2787a778b7",
|
||||||
|
"sha256:e4fb7ced4d9dec77d6cf533acfbf8e1415fe799430366affb18d69ee8a3c6330",
|
||||||
|
"sha256:e7a0b42db2a47ecb488cde14e0f6c7679a2c5a9f44814393b162ff6397fcdfbb",
|
||||||
|
"sha256:f2f184bf38e74f152eed7f87e345b51f3ab0b703842f447c22efe35e59942c24"
|
||||||
],
|
],
|
||||||
"markers": "python_version < '3.8'",
|
"markers": "python_version >= '3.6'",
|
||||||
"version": "==4.8.1"
|
"version": "==6.0.2"
|
||||||
},
|
},
|
||||||
"iniconfig": {
|
"iniconfig": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
|
|
@ -799,57 +776,63 @@
|
||||||
},
|
},
|
||||||
"pytest-cov": {
|
"pytest-cov": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:261bb9e47e65bd099c89c3edf92972865210c36813f80ede5277dceb77a4a62a",
|
"sha256:578d5d15ac4a25e5f961c938b85a05b09fdaae9deef3bb6de9a6e766622ca7a6",
|
||||||
"sha256:261ceeb8c227b726249b376b8526b600f38667ee314f910353fa318caa01f4d7"
|
"sha256:e7f0f5b1617d2210a2cabc266dfe2f4c75a8d32fb89eafb7ad9d06f6d076d470"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==2.12.1"
|
"version": "==3.0.0"
|
||||||
},
|
},
|
||||||
"regex": {
|
"regex": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:04f6b9749e335bb0d2f68c707f23bb1773c3fb6ecd10edf0f04df12a8920d468",
|
"sha256:094a905e87a4171508c2a0e10217795f83c636ccc05ddf86e7272c26e14056ae",
|
||||||
"sha256:08d74bfaa4c7731b8dac0a992c63673a2782758f7cfad34cf9c1b9184f911354",
|
"sha256:09e1031e2059abd91177c302da392a7b6859ceda038be9e015b522a182c89e4f",
|
||||||
"sha256:0fc1f8f06977c2d4f5e3d3f0d4a08089be783973fc6b6e278bde01f0544ff308",
|
"sha256:176796cb7f82a7098b0c436d6daac82f57b9101bb17b8e8119c36eecf06a60a3",
|
||||||
"sha256:121f4b3185feaade3f85f70294aef3f777199e9b5c0c0245c774ae884b110a2d",
|
"sha256:19b8f6d23b2dc93e8e1e7e288d3010e58fafed323474cf7f27ab9451635136d9",
|
||||||
"sha256:1413b5022ed6ac0d504ba425ef02549a57d0f4276de58e3ab7e82437892704fc",
|
"sha256:1abbd95cbe9e2467cac65c77b6abd9223df717c7ae91a628502de67c73bf6838",
|
||||||
"sha256:1743345e30917e8c574f273f51679c294effba6ad372db1967852f12c76759d8",
|
"sha256:1ce02f420a7ec3b2480fe6746d756530f69769292eca363218c2291d0b116a01",
|
||||||
"sha256:28fc475f560d8f67cc8767b94db4c9440210f6958495aeae70fac8faec631797",
|
"sha256:1f51926db492440e66c89cd2be042f2396cf91e5b05383acd7372b8cb7da373f",
|
||||||
"sha256:31a99a4796bf5aefc8351e98507b09e1b09115574f7c9dbb9cf2111f7220d2e2",
|
"sha256:26895d7c9bbda5c52b3635ce5991caa90fbb1ddfac9c9ff1c7ce505e2282fb2a",
|
||||||
"sha256:328a1fad67445550b982caa2a2a850da5989fd6595e858f02d04636e7f8b0b13",
|
"sha256:2efd47704bbb016136fe34dfb74c805b1ef5c7313aef3ce6dcb5ff844299f432",
|
||||||
"sha256:473858730ef6d6ff7f7d5f19452184cd0caa062a20047f6d6f3e135a4648865d",
|
"sha256:36c98b013273e9da5790ff6002ab326e3f81072b4616fd95f06c8fa733d2745f",
|
||||||
"sha256:4cde065ab33bcaab774d84096fae266d9301d1a2f5519d7bd58fc55274afbf7a",
|
"sha256:39079ebf54156be6e6902f5c70c078f453350616cfe7bfd2dd15bdb3eac20ccc",
|
||||||
"sha256:5f6a808044faae658f546dd5f525e921de9fa409de7a5570865467f03a626fc0",
|
"sha256:3d52c5e089edbdb6083391faffbe70329b804652a53c2fdca3533e99ab0580d9",
|
||||||
"sha256:610b690b406653c84b7cb6091facb3033500ee81089867ee7d59e675f9ca2b73",
|
"sha256:45cb0f7ff782ef51bc79e227a87e4e8f24bc68192f8de4f18aae60b1d60bc152",
|
||||||
"sha256:66256b6391c057305e5ae9209941ef63c33a476b73772ca967d4a2df70520ec1",
|
"sha256:4786dae85c1f0624ac77cb3813ed99267c9adb72e59fdc7297e1cf4d6036d493",
|
||||||
"sha256:6eebf512aa90751d5ef6a7c2ac9d60113f32e86e5687326a50d7686e309f66ed",
|
"sha256:51feefd58ac38eb91a21921b047da8644155e5678e9066af7bcb30ee0dca7361",
|
||||||
"sha256:79aef6b5cd41feff359acaf98e040844613ff5298d0d19c455b3d9ae0bc8c35a",
|
"sha256:55ef044899706c10bc0aa052f2fc2e58551e2510694d6aae13f37c50f3f6ff61",
|
||||||
"sha256:808ee5834e06f57978da3e003ad9d6292de69d2bf6263662a1a8ae30788e080b",
|
"sha256:5e5796d2f36d3c48875514c5cd9e4325a1ca172fc6c78b469faa8ddd3d770593",
|
||||||
"sha256:8e44769068d33e0ea6ccdf4b84d80c5afffe5207aa4d1881a629cf0ef3ec398f",
|
"sha256:5f199419a81c1016e0560c39773c12f0bd924c37715bffc64b97140d2c314354",
|
||||||
"sha256:999ad08220467b6ad4bd3dd34e65329dd5d0df9b31e47106105e407954965256",
|
"sha256:5f55c4804797ef7381518e683249310f7f9646da271b71cb6b3552416c7894ee",
|
||||||
"sha256:9b006628fe43aa69259ec04ca258d88ed19b64791693df59c422b607b6ece8bb",
|
"sha256:6dcf53d35850ce938b4f044a43b33015ebde292840cef3af2c8eb4c860730fff",
|
||||||
"sha256:9d05ad5367c90814099000442b2125535e9d77581855b9bee8780f1b41f2b1a2",
|
"sha256:74e55f8d66f1b41d44bc44c891bcf2c7fad252f8f323ee86fba99d71fd1ad5e3",
|
||||||
"sha256:a577a21de2ef8059b58f79ff76a4da81c45a75fe0bfb09bc8b7bb4293fa18983",
|
"sha256:7f125fce0a0ae4fd5c3388d369d7a7d78f185f904c90dd235f7ecf8fe13fa741",
|
||||||
"sha256:a617593aeacc7a691cc4af4a4410031654f2909053bd8c8e7db837f179a630eb",
|
"sha256:82cfb97a36b1a53de32b642482c6c46b6ce80803854445e19bc49993655ebf3b",
|
||||||
"sha256:abb48494d88e8a82601af905143e0de838c776c1241d92021e9256d5515b3645",
|
"sha256:88dc3c1acd3f0ecfde5f95c32fcb9beda709dbdf5012acdcf66acbc4794468eb",
|
||||||
"sha256:ac88856a8cbccfc14f1b2d0b829af354cc1743cb375e7f04251ae73b2af6adf8",
|
"sha256:924079d5590979c0e961681507eb1773a142553564ccae18d36f1de7324e71ca",
|
||||||
"sha256:b4c220a1fe0d2c622493b0a1fd48f8f991998fb447d3cd368033a4b86cf1127a",
|
"sha256:951be934dc25d8779d92b530e922de44dda3c82a509cdb5d619f3a0b1491fafa",
|
||||||
"sha256:b844fb09bd9936ed158ff9df0ab601e2045b316b17aa8b931857365ea8586906",
|
"sha256:973499dac63625a5ef9dfa4c791aa33a502ddb7615d992bdc89cf2cc2285daa3",
|
||||||
"sha256:bdc178caebd0f338d57ae445ef8e9b737ddf8fbc3ea187603f65aec5b041248f",
|
"sha256:981c786293a3115bc14c103086ae54e5ee50ca57f4c02ce7cf1b60318d1e8072",
|
||||||
"sha256:c206587c83e795d417ed3adc8453a791f6d36b67c81416676cad053b4104152c",
|
"sha256:9c070d5895ac6aeb665bd3cd79f673775caf8d33a0b569e98ac434617ecea57d",
|
||||||
"sha256:c61dcc1cf9fd165127a2853e2c31eb4fb961a4f26b394ac9fe5669c7a6592892",
|
"sha256:9e3e2cea8f1993f476a6833ef157f5d9e8c75a59a8d8b0395a9a6887a097243b",
|
||||||
"sha256:c7cb4c512d2d3b0870e00fbbac2f291d4b4bf2634d59a31176a87afe2777c6f0",
|
"sha256:9e527ab1c4c7cf2643d93406c04e1d289a9d12966529381ce8163c4d2abe4faf",
|
||||||
"sha256:d4a332404baa6665b54e5d283b4262f41f2103c255897084ec8f5487ce7b9e8e",
|
"sha256:a37305eb3199d8f0d8125ec2fb143ba94ff6d6d92554c4b8d4a8435795a6eccd",
|
||||||
"sha256:d5111d4c843d80202e62b4fdbb4920db1dcee4f9366d6b03294f45ed7b18b42e",
|
"sha256:aa0ab3530a279a3b7f50f852f1bab41bc304f098350b03e30a3876b7dd89840e",
|
||||||
"sha256:e1e8406b895aba6caa63d9fd1b6b1700d7e4825f78ccb1e5260551d168db38ed",
|
"sha256:b04e512eb628ea82ed86eb31c0f7fc6842b46bf2601b66b1356a7008327f7700",
|
||||||
"sha256:e8690ed94481f219a7a967c118abaf71ccc440f69acd583cab721b90eeedb77c",
|
"sha256:b09d3904bf312d11308d9a2867427479d277365b1617e48ad09696fa7dfcdf59",
|
||||||
"sha256:ed283ab3a01d8b53de3a05bfdf4473ae24e43caee7dcb5584e86f3f3e5ab4374",
|
"sha256:b0f2f874c6a157c91708ac352470cb3bef8e8814f5325e3c5c7a0533064c6a24",
|
||||||
"sha256:ed4b50355b066796dacdd1cf538f2ce57275d001838f9b132fab80b75e8c84dd",
|
"sha256:b8b6ee6555b6fbae578f1468b3f685cdfe7940a65675611365a7ea1f8d724991",
|
||||||
"sha256:ee329d0387b5b41a5dddbb6243a21cb7896587a651bebb957e2d2bb8b63c0791",
|
"sha256:b9b5c215f3870aa9b011c00daeb7be7e1ae4ecd628e9beb6d7e6107e07d81287",
|
||||||
"sha256:f3bf1bc02bc421047bfec3343729c4bbbea42605bcfd6d6bfe2c07ade8b12d2a",
|
"sha256:c6569ba7b948c3d61d27f04e2b08ebee24fec9ff8e9ea154d8d1e975b175bfa7",
|
||||||
"sha256:f585cbbeecb35f35609edccb95efd95a3e35824cd7752b586503f7e6087303f1",
|
"sha256:e2ec1c106d3f754444abf63b31e5c4f9b5d272272a491fa4320475aba9e8157c",
|
||||||
"sha256:f60667673ff9c249709160529ab39667d1ae9fd38634e006bec95611f632e759"
|
"sha256:e4204708fa116dd03436a337e8e84261bc8051d058221ec63535c9403a1582a1",
|
||||||
|
"sha256:ea8de658d7db5987b11097445f2b1f134400e2232cb40e614e5f7b6f5428710e",
|
||||||
|
"sha256:f540f153c4f5617bc4ba6433534f8916d96366a08797cbbe4132c37b70403e92",
|
||||||
|
"sha256:fab3ab8aedfb443abb36729410403f0fe7f60ad860c19a979d47fb3eb98ef820",
|
||||||
|
"sha256:fb2baff66b7d2267e07ef71e17d01283b55b3cc51a81b54cc385e721ae172ba4",
|
||||||
|
"sha256:fe6ce4f3d3c48f9f402da1ceb571548133d3322003ce01b20d960a82251695d2",
|
||||||
|
"sha256:ff24897f6b2001c38a805d53b6ae72267025878d35ea225aa24675fbff2dba7f"
|
||||||
],
|
],
|
||||||
"version": "==2021.8.28"
|
"version": "==2021.10.8"
|
||||||
},
|
},
|
||||||
"toml": {
|
"toml": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
|
|
@ -859,6 +842,13 @@
|
||||||
"markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
"markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||||
"version": "==0.10.2"
|
"version": "==0.10.2"
|
||||||
},
|
},
|
||||||
|
"tomli": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:8dd0e9524d6f386271a36b41dbf6c57d8e32fd96fd22b6584679dc569d20899f",
|
||||||
|
"sha256:a5b75cb6f3968abb47af1b40c1819dc519ea82bcc065776a866e8d74c5ca9442"
|
||||||
|
],
|
||||||
|
"version": "==1.2.1"
|
||||||
|
},
|
||||||
"typed-ast": {
|
"typed-ast": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:01ae5f73431d21eead5015997ab41afa53aa1fbe252f9da060be5dad2c730ace",
|
"sha256:01ae5f73431d21eead5015997ab41afa53aa1fbe252f9da060be5dad2c730ace",
|
||||||
|
|
@ -903,14 +893,6 @@
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==3.10.0.2"
|
"version": "==3.10.0.2"
|
||||||
},
|
|
||||||
"zipp": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:957cfda87797e389580cb8b9e3870841ca991e2125350677b2ca83a0e99390a3",
|
|
||||||
"sha256:f5812b1e007e48cff63449a5e9f4e7ebea716b4111f9c4f9a645f91d579bf0c4"
|
|
||||||
],
|
|
||||||
"markers": "python_version >= '3.6'",
|
|
||||||
"version": "==3.5.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,15 @@ import uvloop
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
|
|
||||||
from .commands import bundle_vendored, migrate_databases, transpile_scss
|
from .commands import bundle_vendored, migrate_databases, transpile_scss
|
||||||
from .settings import (DEBUG, LNBITS_COMMIT, LNBITS_DATA_FOLDER,
|
from .settings import (
|
||||||
LNBITS_SITE_TITLE, PORT, SERVICE_FEE, WALLET)
|
DEBUG,
|
||||||
|
LNBITS_COMMIT,
|
||||||
|
LNBITS_DATA_FOLDER,
|
||||||
|
LNBITS_SITE_TITLE,
|
||||||
|
PORT,
|
||||||
|
SERVICE_FEE,
|
||||||
|
WALLET,
|
||||||
|
)
|
||||||
|
|
||||||
uvloop.install()
|
uvloop.install()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,12 +16,23 @@ import lnbits.settings
|
||||||
from .commands import db_migrate, handle_assets
|
from .commands import db_migrate, handle_assets
|
||||||
from .core import core_app
|
from .core import core_app
|
||||||
from .core.views.generic import core_html_routes
|
from .core.views.generic import core_html_routes
|
||||||
from .helpers import (get_css_vendored, get_js_vendored, get_valid_extensions,
|
from .helpers import (
|
||||||
template_renderer, url_for_vendored)
|
get_css_vendored,
|
||||||
|
get_js_vendored,
|
||||||
|
get_valid_extensions,
|
||||||
|
template_renderer,
|
||||||
|
url_for_vendored,
|
||||||
|
)
|
||||||
from .requestvars import g
|
from .requestvars import g
|
||||||
from .settings import WALLET
|
from .settings import WALLET
|
||||||
from .tasks import (catch_everything_and_restart, check_pending_payments, internal_invoice_listener,
|
from .tasks import (
|
||||||
invoice_listener, run_deferred_async, webhook_handler)
|
catch_everything_and_restart,
|
||||||
|
check_pending_payments,
|
||||||
|
internal_invoice_listener,
|
||||||
|
invoice_listener,
|
||||||
|
run_deferred_async,
|
||||||
|
webhook_handler,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def create_app(config_object="lnbits.settings") -> FastAPI:
|
def create_app(config_object="lnbits.settings") -> FastAPI:
|
||||||
|
|
@ -30,12 +41,11 @@ def create_app(config_object="lnbits.settings") -> FastAPI:
|
||||||
"""
|
"""
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
app.mount("/static", StaticFiles(directory="lnbits/static"), name="static")
|
app.mount("/static", StaticFiles(directory="lnbits/static"), name="static")
|
||||||
app.mount("/core/static", StaticFiles(directory="lnbits/core/static"), name="core_static")
|
app.mount(
|
||||||
|
"/core/static", StaticFiles(directory="lnbits/core/static"), name="core_static"
|
||||||
|
)
|
||||||
|
|
||||||
origins = [
|
origins = ["http://localhost", "http://localhost:5000"]
|
||||||
"http://localhost",
|
|
||||||
"http://localhost:5000",
|
|
||||||
]
|
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
|
|
@ -49,8 +59,13 @@ def create_app(config_object="lnbits.settings") -> FastAPI:
|
||||||
g().base_url = f"http://{lnbits.settings.HOST}:{lnbits.settings.PORT}"
|
g().base_url = f"http://{lnbits.settings.HOST}:{lnbits.settings.PORT}"
|
||||||
|
|
||||||
@app.exception_handler(RequestValidationError)
|
@app.exception_handler(RequestValidationError)
|
||||||
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
async def validation_exception_handler(
|
||||||
return template_renderer().TemplateResponse("error.html", {"request": request, "err": f"`{exc.errors()}` is not a valid UUID."})
|
request: Request, exc: RequestValidationError
|
||||||
|
):
|
||||||
|
return template_renderer().TemplateResponse(
|
||||||
|
"error.html",
|
||||||
|
{"request": request, "err": f"`{exc.errors()}` is not a valid UUID."},
|
||||||
|
)
|
||||||
|
|
||||||
# return HTMLResponse(
|
# return HTMLResponse(
|
||||||
# status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
# status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
|
@ -69,6 +84,7 @@ def create_app(config_object="lnbits.settings") -> FastAPI:
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
def check_funding_source(app: FastAPI) -> None:
|
def check_funding_source(app: FastAPI) -> None:
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def check_wallet_status():
|
async def check_wallet_status():
|
||||||
|
|
@ -150,6 +166,7 @@ def register_async_tasks(app):
|
||||||
async def stop_listeners():
|
async def stop_listeners():
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def register_exception_handlers(app: FastAPI):
|
def register_exception_handlers(app: FastAPI):
|
||||||
@app.exception_handler(Exception)
|
@app.exception_handler(Exception)
|
||||||
async def basic_error(request: Request, err):
|
async def basic_error(request: Request, err):
|
||||||
|
|
@ -157,5 +174,6 @@ def register_exception_handlers(app: FastAPI):
|
||||||
etype, _, tb = sys.exc_info()
|
etype, _, tb = sys.exc_info()
|
||||||
traceback.print_exception(etype, err, tb)
|
traceback.print_exception(etype, err, tb)
|
||||||
exc = traceback.format_exc()
|
exc = traceback.format_exc()
|
||||||
return template_renderer().TemplateResponse("error.html", {"request": request, "err": err})
|
return template_renderer().TemplateResponse(
|
||||||
|
"error.html", {"request": request, "err": err}
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ from fastapi.security.api_key import APIKeyQuery, APIKeyCookie, APIKeyHeader, AP
|
||||||
from fastapi.security.base import SecurityBase
|
from fastapi.security.base import SecurityBase
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
API_KEY = "usr"
|
API_KEY = "usr"
|
||||||
API_KEY_NAME = "X-API-key"
|
API_KEY_NAME = "X-API-key"
|
||||||
|
|
||||||
|
|
@ -16,7 +15,6 @@ api_key_query = APIKeyQuery(name=API_KEY_NAME, auto_error=False)
|
||||||
api_key_header = APIKeyHeader(name=API_KEY_NAME, auto_error=False)
|
api_key_header = APIKeyHeader(name=API_KEY_NAME, auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class AuthBearer(SecurityBase):
|
class AuthBearer(SecurityBase):
|
||||||
def __init__(self, scheme_name: str = None, auto_error: bool = True):
|
def __init__(self, scheme_name: str = None, auto_error: bool = True):
|
||||||
self.scheme_name = scheme_name or self.__class__.__name__
|
self.scheme_name = scheme_name or self.__class__.__name__
|
||||||
|
|
@ -37,7 +35,9 @@ class AuthBearer(SecurityBase):
|
||||||
# else:
|
# else:
|
||||||
# raise HTTPException(
|
# raise HTTPException(
|
||||||
# status_code=403, detail="Invalid authorization code.")
|
# status_code=403, detail="Invalid authorization code.")
|
||||||
async def get_api_key(self,
|
|
||||||
|
async def get_api_key(
|
||||||
|
self,
|
||||||
api_key_query: str = Security(api_key_query),
|
api_key_query: str = Security(api_key_query),
|
||||||
api_key_header: str = Security(api_key_header),
|
api_key_header: str = Security(api_key_header),
|
||||||
):
|
):
|
||||||
|
|
@ -46,4 +46,6 @@ class AuthBearer(SecurityBase):
|
||||||
elif api_key_header == API_KEY:
|
elif api_key_header == API_KEY:
|
||||||
return api_key_header
|
return api_key_header
|
||||||
else:
|
else:
|
||||||
raise HTTPException(status_code=403, detail="Could not validate credentials")
|
raise HTTPException(
|
||||||
|
status_code=403, detail="Could not validate credentials"
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -125,12 +125,7 @@ def _unshorten_amount(amount: str) -> int:
|
||||||
# * `u` (micro): multiply by 0.000001
|
# * `u` (micro): multiply by 0.000001
|
||||||
# * `n` (nano): multiply by 0.000000001
|
# * `n` (nano): multiply by 0.000000001
|
||||||
# * `p` (pico): multiply by 0.000000000001
|
# * `p` (pico): multiply by 0.000000000001
|
||||||
units = {
|
units = {"p": 10 ** 12, "n": 10 ** 9, "u": 10 ** 6, "m": 10 ** 3}
|
||||||
"p": 10 ** 12,
|
|
||||||
"n": 10 ** 9,
|
|
||||||
"u": 10 ** 6,
|
|
||||||
"m": 10 ** 3,
|
|
||||||
}
|
|
||||||
unit = str(amount)[-1]
|
unit = str(amount)[-1]
|
||||||
|
|
||||||
# BOLT #11:
|
# BOLT #11:
|
||||||
|
|
@ -161,9 +156,9 @@ def _trim_to_bytes(barr):
|
||||||
|
|
||||||
def _readable_scid(short_channel_id: int) -> str:
|
def _readable_scid(short_channel_id: int) -> str:
|
||||||
return "{blockheight}x{transactionindex}x{outputindex}".format(
|
return "{blockheight}x{transactionindex}x{outputindex}".format(
|
||||||
blockheight=((short_channel_id >> 40) & 0xffffff),
|
blockheight=((short_channel_id >> 40) & 0xFFFFFF),
|
||||||
transactionindex=((short_channel_id >> 16) & 0xffffff),
|
transactionindex=((short_channel_id >> 16) & 0xFFFFFF),
|
||||||
outputindex=(short_channel_id & 0xffff),
|
outputindex=(short_channel_id & 0xFFFF),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,5 +9,3 @@ core_app: APIRouter = APIRouter()
|
||||||
from .views.api import * # noqa
|
from .views.api import * # noqa
|
||||||
from .views.generic import * # noqa
|
from .views.generic import * # noqa
|
||||||
from .views.public_api import * # noqa
|
from .views.public_api import * # noqa
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -58,10 +58,11 @@ async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return User(
|
return User(
|
||||||
id = user['id'],
|
id=user["id"],
|
||||||
email = user['email'],
|
email=user["email"],
|
||||||
extensions = [e[0] for e in extensions],
|
extensions=[e[0] for e in extensions],
|
||||||
wallets = [Wallet(**w) for w in wallets])
|
wallets=[Wallet(**w) for w in wallets],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def update_user_extension(
|
async def update_user_extension(
|
||||||
|
|
@ -106,6 +107,7 @@ async def create_wallet(
|
||||||
|
|
||||||
return new_wallet
|
return new_wallet
|
||||||
|
|
||||||
|
|
||||||
async def update_wallet(
|
async def update_wallet(
|
||||||
wallet_id: str, new_name: str, conn: Optional[Connection] = None
|
wallet_id: str, new_name: str, conn: Optional[Connection] = None
|
||||||
) -> Optional[Wallet]:
|
) -> Optional[Wallet]:
|
||||||
|
|
@ -115,7 +117,7 @@ async def update_wallet(
|
||||||
name = ?
|
name = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
""",
|
""",
|
||||||
(new_name, wallet_id)
|
(new_name, wallet_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -276,9 +278,7 @@ async def get_payments(
|
||||||
return [Payment.from_row(row) for row in rows]
|
return [Payment.from_row(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
async def delete_expired_invoices(
|
async def delete_expired_invoices(conn: Optional[Connection] = None,) -> None:
|
||||||
conn: Optional[Connection] = None,
|
|
||||||
) -> None:
|
|
||||||
# first we delete all invoices older than one month
|
# first we delete all invoices older than one month
|
||||||
await (conn or db).execute(
|
await (conn or db).execute(
|
||||||
f"""
|
f"""
|
||||||
|
|
@ -367,31 +367,22 @@ async def create_payment(
|
||||||
|
|
||||||
|
|
||||||
async def update_payment_status(
|
async def update_payment_status(
|
||||||
checking_id: str,
|
checking_id: str, pending: bool, conn: Optional[Connection] = None
|
||||||
pending: bool,
|
|
||||||
conn: Optional[Connection] = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
await (conn or db).execute(
|
await (conn or db).execute(
|
||||||
"UPDATE apipayments SET pending = ? WHERE checking_id = ?",
|
"UPDATE apipayments SET pending = ? WHERE checking_id = ?",
|
||||||
(
|
(pending, checking_id),
|
||||||
pending,
|
|
||||||
checking_id,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def delete_payment(
|
async def delete_payment(checking_id: str, conn: Optional[Connection] = None) -> None:
|
||||||
checking_id: str,
|
|
||||||
conn: Optional[Connection] = None,
|
|
||||||
) -> None:
|
|
||||||
await (conn or db).execute(
|
await (conn or db).execute(
|
||||||
"DELETE FROM apipayments WHERE checking_id = ?", (checking_id,)
|
"DELETE FROM apipayments WHERE checking_id = ?", (checking_id,)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def check_internal(
|
async def check_internal(
|
||||||
payment_hash: str,
|
payment_hash: str, conn: Optional[Connection] = None
|
||||||
conn: Optional[Connection] = None,
|
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
row = await (conn or db).fetchone(
|
row = await (conn or db).fetchone(
|
||||||
"""
|
"""
|
||||||
|
|
@ -411,9 +402,7 @@ async def check_internal(
|
||||||
|
|
||||||
|
|
||||||
async def save_balance_check(
|
async def save_balance_check(
|
||||||
wallet_id: str,
|
wallet_id: str, url: str, conn: Optional[Connection] = None
|
||||||
url: str,
|
|
||||||
conn: Optional[Connection] = None,
|
|
||||||
):
|
):
|
||||||
domain = urlparse(url).netloc
|
domain = urlparse(url).netloc
|
||||||
|
|
||||||
|
|
@ -427,9 +416,7 @@ async def save_balance_check(
|
||||||
|
|
||||||
|
|
||||||
async def get_balance_check(
|
async def get_balance_check(
|
||||||
wallet_id: str,
|
wallet_id: str, domain: str, conn: Optional[Connection] = None
|
||||||
domain: str,
|
|
||||||
conn: Optional[Connection] = None,
|
|
||||||
) -> Optional[BalanceCheck]:
|
) -> Optional[BalanceCheck]:
|
||||||
row = await (conn or db).fetchone(
|
row = await (conn or db).fetchone(
|
||||||
"""
|
"""
|
||||||
|
|
@ -452,9 +439,7 @@ async def get_balance_checks(conn: Optional[Connection] = None) -> List[BalanceC
|
||||||
|
|
||||||
|
|
||||||
async def save_balance_notify(
|
async def save_balance_notify(
|
||||||
wallet_id: str,
|
wallet_id: str, url: str, conn: Optional[Connection] = None
|
||||||
url: str,
|
|
||||||
conn: Optional[Connection] = None,
|
|
||||||
):
|
):
|
||||||
await (conn or db).execute(
|
await (conn or db).execute(
|
||||||
"""
|
"""
|
||||||
|
|
@ -466,8 +451,7 @@ async def save_balance_notify(
|
||||||
|
|
||||||
|
|
||||||
async def get_balance_notify(
|
async def get_balance_notify(
|
||||||
wallet_id: str,
|
wallet_id: str, conn: Optional[Connection] = None
|
||||||
conn: Optional[Connection] = None,
|
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
row = await (conn or db).fetchone(
|
row = await (conn or db).fetchone(
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -31,12 +31,7 @@ class Wallet(BaseModel):
|
||||||
@property
|
@property
|
||||||
def lnurlwithdraw_full(self) -> str:
|
def lnurlwithdraw_full(self) -> str:
|
||||||
|
|
||||||
url = url_for(
|
url = url_for("/withdraw", external=True, usr=self.user, wal=self.id)
|
||||||
"/withdraw",
|
|
||||||
external=True,
|
|
||||||
usr=self.user,
|
|
||||||
wal=self.id,
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
return lnurl_encode(url)
|
return lnurl_encode(url)
|
||||||
except:
|
except:
|
||||||
|
|
@ -47,9 +42,7 @@ class Wallet(BaseModel):
|
||||||
linking_key = hmac.digest(hashing_key, domain.encode("utf-8"), "sha256")
|
linking_key = hmac.digest(hashing_key, domain.encode("utf-8"), "sha256")
|
||||||
|
|
||||||
return SigningKey.from_string(
|
return SigningKey.from_string(
|
||||||
linking_key,
|
linking_key, curve=SECP256k1, hashfunc=hashlib.sha256
|
||||||
curve=SECP256k1,
|
|
||||||
hashfunc=hashlib.sha256,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
async def get_payment(self, payment_hash: str) -> Optional["Payment"]:
|
async def get_payment(self, payment_hash: str) -> Optional["Payment"]:
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,36 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import httpx
|
|
||||||
from io import BytesIO
|
|
||||||
from binascii import unhexlify
|
from binascii import unhexlify
|
||||||
from typing import Optional, Tuple, Dict
|
from io import BytesIO
|
||||||
from urllib.parse import urlparse, parse_qs
|
from typing import Dict, Optional, Tuple
|
||||||
from lnurl import LnurlErrorResponse, decode as decode_lnurl # type: ignore
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from lnurl import LnurlErrorResponse
|
||||||
|
from lnurl import decode as decode_lnurl # type: ignore
|
||||||
|
|
||||||
|
from lnbits import bolt11
|
||||||
|
from lnbits.db import Connection
|
||||||
|
from lnbits.helpers import url_for, urlsafe_short_hash
|
||||||
|
from lnbits.requestvars import g
|
||||||
|
from lnbits.settings import WALLET
|
||||||
|
from lnbits.wallets.base import PaymentResponse, PaymentStatus
|
||||||
|
|
||||||
|
from . import db
|
||||||
|
from .crud import (
|
||||||
|
check_internal,
|
||||||
|
create_payment,
|
||||||
|
delete_payment,
|
||||||
|
get_wallet,
|
||||||
|
get_wallet_payment,
|
||||||
|
update_payment_status,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from typing import TypedDict # type: ignore
|
from typing import TypedDict # type: ignore
|
||||||
except ImportError: # pragma: nocover
|
except ImportError: # pragma: nocover
|
||||||
from typing_extensions import TypedDict
|
from typing_extensions import TypedDict
|
||||||
|
|
||||||
from lnbits import bolt11
|
|
||||||
from lnbits.db import Connection
|
|
||||||
from lnbits.helpers import url_for, urlsafe_short_hash
|
|
||||||
from lnbits.settings import WALLET
|
|
||||||
from lnbits.wallets.base import PaymentStatus, PaymentResponse
|
|
||||||
from lnbits.requestvars import g
|
|
||||||
|
|
||||||
from . import db
|
|
||||||
from .crud import (
|
|
||||||
get_wallet,
|
|
||||||
create_payment,
|
|
||||||
delete_payment,
|
|
||||||
check_internal,
|
|
||||||
update_payment_status,
|
|
||||||
get_wallet_payment,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class PaymentFailure(Exception):
|
class PaymentFailure(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
@ -49,7 +51,7 @@ async def create_invoice(
|
||||||
conn: Optional[Connection] = None,
|
conn: Optional[Connection] = None,
|
||||||
) -> Tuple[str, str]:
|
) -> Tuple[str, str]:
|
||||||
invoice_memo = None if description_hash else memo
|
invoice_memo = None if description_hash else memo
|
||||||
storeable_memo = memo
|
storeable_memo = memo or "LN payment"
|
||||||
|
|
||||||
ok, checking_id, payment_request, error_message = await WALLET.create_invoice(
|
ok, checking_id, payment_request, error_message = await WALLET.create_invoice(
|
||||||
amount=amount, memo=invoice_memo, description_hash=description_hash
|
amount=amount, memo=invoice_memo, description_hash=description_hash
|
||||||
|
|
@ -147,15 +149,14 @@ async def pay_invoice(
|
||||||
# so the other side only has access to his new money when we are sure
|
# so the other side only has access to his new money when we are sure
|
||||||
# the payer has enough to deduct from
|
# the payer has enough to deduct from
|
||||||
await update_payment_status(
|
await update_payment_status(
|
||||||
checking_id=internal_checking_id,
|
checking_id=internal_checking_id, pending=False, conn=conn
|
||||||
pending=False,
|
|
||||||
conn=conn,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# notify receiver asynchronously
|
# notify receiver asynchronously
|
||||||
from lnbits.tasks import internal_invoice_paid
|
|
||||||
|
|
||||||
await internal_invoice_paid.send(internal_checking_id)
|
from lnbits.tasks import internal_invoice_queue
|
||||||
|
|
||||||
|
await internal_invoice_queue.put(internal_checking_id)
|
||||||
else:
|
else:
|
||||||
# actually pay the external invoice
|
# actually pay the external invoice
|
||||||
payment: PaymentResponse = await WALLET.pay_invoice(payment_request)
|
payment: PaymentResponse = await WALLET.pay_invoice(payment_request)
|
||||||
|
|
@ -213,10 +214,7 @@ async def redeem_lnurl_withdraw(
|
||||||
if wait_seconds:
|
if wait_seconds:
|
||||||
await asyncio.sleep(wait_seconds)
|
await asyncio.sleep(wait_seconds)
|
||||||
|
|
||||||
params = {
|
params = {"k1": res["k1"], "pr": payment_request}
|
||||||
"k1": res["k1"],
|
|
||||||
"pr": payment_request,
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
params["balanceNotify"] = url_for(
|
params["balanceNotify"] = url_for(
|
||||||
|
|
@ -235,8 +233,7 @@ async def redeem_lnurl_withdraw(
|
||||||
|
|
||||||
|
|
||||||
async def perform_lnurlauth(
|
async def perform_lnurlauth(
|
||||||
callback: str,
|
callback: str, conn: Optional[Connection] = None
|
||||||
conn: Optional[Connection] = None,
|
|
||||||
) -> Optional[LnurlErrorResponse]:
|
) -> Optional[LnurlErrorResponse]:
|
||||||
cb = urlparse(callback)
|
cb = urlparse(callback)
|
||||||
|
|
||||||
|
|
@ -304,7 +301,7 @@ async def perform_lnurlauth(
|
||||||
return LnurlErrorResponse(reason=resp["reason"])
|
return LnurlErrorResponse(reason=resp["reason"])
|
||||||
except (KeyError, json.decoder.JSONDecodeError):
|
except (KeyError, json.decoder.JSONDecodeError):
|
||||||
return LnurlErrorResponse(
|
return LnurlErrorResponse(
|
||||||
reason=r.text[:200] + "..." if len(r.text) > 200 else r.text,
|
reason=r.text[:200] + "..." if len(r.text) > 200 else r.text
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -316,8 +313,19 @@ async def check_invoice_status(
|
||||||
payment = await get_wallet_payment(wallet_id, payment_hash, conn=conn)
|
payment = await get_wallet_payment(wallet_id, payment_hash, conn=conn)
|
||||||
if not payment:
|
if not payment:
|
||||||
return PaymentStatus(None)
|
return PaymentStatus(None)
|
||||||
|
status = await WALLET.get_invoice_status(payment.checking_id)
|
||||||
return await WALLET.get_invoice_status(payment.checking_id)
|
print(status)
|
||||||
|
if not payment.pending:
|
||||||
|
return status
|
||||||
|
if payment.is_out and status.failed:
|
||||||
|
print(f" - deleting outgoing failed payment {payment.checking_id}: {status}")
|
||||||
|
await payment.delete()
|
||||||
|
elif not status.pending:
|
||||||
|
print(
|
||||||
|
f" - marking '{'in' if payment.is_in else 'out'}' {payment.checking_id} as not pending anymore: {status}"
|
||||||
|
)
|
||||||
|
await payment.set_pending(status.pending)
|
||||||
|
return status
|
||||||
|
|
||||||
|
|
||||||
def fee_reserve(amount_msat: int) -> int:
|
def fee_reserve(amount_msat: int) -> int:
|
||||||
|
|
|
||||||
|
|
@ -33,10 +33,7 @@ async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue):
|
||||||
if url:
|
if url:
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
try:
|
try:
|
||||||
r = await client.post(
|
r = await client.post(url, timeout=4)
|
||||||
url,
|
|
||||||
timeout=4,
|
|
||||||
)
|
|
||||||
await mark_webhook_sent(payment, r.status_code)
|
await mark_webhook_sent(payment, r.status_code)
|
||||||
except (httpx.ConnectError, httpx.RequestError):
|
except (httpx.ConnectError, httpx.RequestError):
|
||||||
pass
|
pass
|
||||||
|
|
@ -55,11 +52,7 @@ async def dispatch_webhook(payment: Payment):
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
data = payment._asdict()
|
data = payment._asdict()
|
||||||
try:
|
try:
|
||||||
r = await client.post(
|
r = await client.post(payment.webhook, json=data, timeout=40)
|
||||||
payment.webhook,
|
|
||||||
json=data,
|
|
||||||
timeout=40,
|
|
||||||
)
|
|
||||||
await mark_webhook_sent(payment, r.status_code)
|
await mark_webhook_sent(payment, r.status_code)
|
||||||
except (httpx.ConnectError, httpx.RequestError):
|
except (httpx.ConnectError, httpx.RequestError):
|
||||||
await mark_webhook_sent(payment, -1)
|
await mark_webhook_sent(payment, -1)
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<code><span class="text-light-green">GET</span> /api/v1/wallet</code>
|
<code><span class="text-light-green">GET</span> /api/v1/wallet</code>
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||||
<code>{"X-Api-Key": "<i>{{ wallet.adminkey }}</i>"}</code><br />
|
<code>{"X-Api-Key": "<i>{{ wallet.inkey }}</i>"}</code><br />
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||||
Returns 200 OK (application/json)
|
Returns 200 OK (application/json)
|
||||||
</h5>
|
</h5>
|
||||||
|
|
@ -94,6 +94,35 @@
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-expansion-item>
|
</q-expansion-item>
|
||||||
|
|
||||||
|
<q-expansion-item
|
||||||
|
group="api"
|
||||||
|
dense
|
||||||
|
expand-separator
|
||||||
|
label="Decode an invoice"
|
||||||
|
>
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<code
|
||||||
|
><span class="text-light-green">POST</span>
|
||||||
|
/api/v1/payments/decode</code
|
||||||
|
>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||||
|
<code>{"X-Api-Key": "<i>{{ wallet.inkey }}</i>"}</code><br />
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||||
|
<code>{"invoice": <string>}</code>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||||
|
Returns 200 (application/json)
|
||||||
|
</h5>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
|
<code
|
||||||
|
>curl -X POST {{ request.url_root }}api/v1/payments/decode -d
|
||||||
|
'{"data": <bolt11/lnurl, string>}' -H "X-Api-Key:
|
||||||
|
<i>{{ wallet.inkey }}</i>" -H "Content-type: application/json"</code
|
||||||
|
>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
<q-expansion-item
|
<q-expansion-item
|
||||||
group="api"
|
group="api"
|
||||||
dense
|
dense
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ from binascii import unhexlify
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from typing import Dict, Optional, Union
|
from typing import Dict, Optional, Union
|
||||||
from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
|
from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
|
||||||
|
from lnbits.bolt11 import Invoice
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import Query, Request
|
from fastapi import Query, Request
|
||||||
from fastapi.exceptions import HTTPException
|
from fastapi.exceptions import HTTPException
|
||||||
|
|
@ -16,26 +16,42 @@ from pydantic import BaseModel
|
||||||
|
|
||||||
from lnbits import bolt11, lnurl
|
from lnbits import bolt11, lnurl
|
||||||
from lnbits.core.models import Payment, Wallet
|
from lnbits.core.models import Payment, Wallet
|
||||||
from lnbits.decorators import (WalletAdminKeyChecker, WalletInvoiceKeyChecker,
|
from lnbits.decorators import (
|
||||||
WalletTypeInfo, get_key_type)
|
WalletAdminKeyChecker,
|
||||||
|
WalletInvoiceKeyChecker,
|
||||||
|
WalletTypeInfo,
|
||||||
|
get_key_type,
|
||||||
|
)
|
||||||
from lnbits.helpers import url_for
|
from lnbits.helpers import url_for
|
||||||
from lnbits.requestvars import g
|
from lnbits.requestvars import g
|
||||||
from lnbits.utils.exchange_rates import currencies, fiat_amount_as_satoshis
|
from lnbits.utils.exchange_rates import currencies, fiat_amount_as_satoshis
|
||||||
|
|
||||||
from .. import core_app, db
|
from .. import core_app, db
|
||||||
from ..crud import get_payments, save_balance_check, update_wallet
|
from ..crud import get_payments, save_balance_check, update_wallet
|
||||||
from ..services import (InvoiceFailure, PaymentFailure, create_invoice,
|
from ..services import (
|
||||||
pay_invoice, perform_lnurlauth)
|
InvoiceFailure,
|
||||||
|
PaymentFailure,
|
||||||
|
create_invoice,
|
||||||
|
pay_invoice,
|
||||||
|
perform_lnurlauth,
|
||||||
|
check_invoice_status,
|
||||||
|
)
|
||||||
from ..tasks import api_invoice_listeners
|
from ..tasks import api_invoice_listeners
|
||||||
|
|
||||||
|
|
||||||
@core_app.get("/api/v1/wallet")
|
@core_app.get("/api/v1/wallet")
|
||||||
async def api_wallet(wallet: WalletTypeInfo = Depends(get_key_type)):
|
async def api_wallet(wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||||
return {"id": wallet.wallet.id, "name": wallet.wallet.name, "balance": wallet.wallet.balance_msat}
|
return {
|
||||||
|
"id": wallet.wallet.id,
|
||||||
|
"name": wallet.wallet.name,
|
||||||
|
"balance": wallet.wallet.balance_msat,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@core_app.put("/api/v1/wallet/{new_name}")
|
@core_app.put("/api/v1/wallet/{new_name}")
|
||||||
async def api_update_wallet(new_name: str, wallet: WalletTypeInfo = Depends(get_key_type)):
|
async def api_update_wallet(
|
||||||
|
new_name: str, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||||
|
):
|
||||||
await update_wallet(wallet.wallet.id, new_name)
|
await update_wallet(wallet.wallet.id, new_name)
|
||||||
return {
|
return {
|
||||||
"id": wallet.wallet.id,
|
"id": wallet.wallet.id,
|
||||||
|
|
@ -44,13 +60,21 @@ async def api_update_wallet(new_name: str, wallet: WalletTypeInfo = Depends(get_
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@core_app.get("/api/v1/payments")
|
@core_app.get("/api/v1/payments")
|
||||||
async def api_payments(wallet: WalletTypeInfo = Depends(get_key_type)):
|
async def api_payments(wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||||
|
await get_payments(
|
||||||
|
wallet_id=wallet.wallet.id,
|
||||||
|
pending=True,
|
||||||
|
complete=True,
|
||||||
|
)
|
||||||
|
pendingPayments = await get_payments(wallet_id=wallet.wallet.id, pending=True)
|
||||||
|
for payment in pendingPayments:
|
||||||
|
await check_invoice_status(
|
||||||
|
wallet_id=payment.wallet_id, payment_hash=payment.payment_hash
|
||||||
|
)
|
||||||
return await get_payments(wallet_id=wallet.wallet.id, pending=True, complete=True)
|
return await get_payments(wallet_id=wallet.wallet.id, pending=True, complete=True)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class CreateInvoiceData(BaseModel):
|
class CreateInvoiceData(BaseModel):
|
||||||
out: Optional[bool] = True
|
out: Optional[bool] = True
|
||||||
amount: int = Query(None, ge=1)
|
amount: int = Query(None, ge=1)
|
||||||
|
|
@ -63,6 +87,7 @@ class CreateInvoiceData(BaseModel):
|
||||||
webhook: Optional[str] = None
|
webhook: Optional[str] = None
|
||||||
bolt11: Optional[str] = None
|
bolt11: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
|
async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
|
||||||
if "description_hash" in data:
|
if "description_hash" in data:
|
||||||
description_hash = unhexlify(data.description_hash)
|
description_hash = unhexlify(data.description_hash)
|
||||||
|
|
@ -134,29 +159,15 @@ async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async def api_payments_pay_invoice(bolt11: str, wallet: Wallet):
|
async def api_payments_pay_invoice(bolt11: str, wallet: Wallet):
|
||||||
try:
|
try:
|
||||||
payment_hash = await pay_invoice(
|
payment_hash = await pay_invoice(wallet_id=wallet.id, payment_request=bolt11)
|
||||||
wallet_id=wallet.id,
|
|
||||||
payment_request=bolt11,
|
|
||||||
)
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e))
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
|
||||||
detail=str(e)
|
|
||||||
)
|
|
||||||
except PermissionError as e:
|
except PermissionError as e:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail=str(e))
|
||||||
status_code=HTTPStatus.FORBIDDEN,
|
|
||||||
detail=str(e)
|
|
||||||
)
|
|
||||||
except PaymentFailure as e:
|
except PaymentFailure as e:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=520, detail=str(e))
|
||||||
status_code=520,
|
|
||||||
detail=str(e)
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise exc
|
raise exc
|
||||||
|
|
||||||
|
|
@ -167,22 +178,32 @@ async def api_payments_pay_invoice(bolt11: str, wallet: Wallet):
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@core_app.post(
|
||||||
@core_app.post("/api/v1/payments", deprecated=True,
|
"/api/v1/payments",
|
||||||
|
deprecated=True,
|
||||||
description="DEPRECATED. Use /api/v2/TBD and /api/v2/TBD instead",
|
description="DEPRECATED. Use /api/v2/TBD and /api/v2/TBD instead",
|
||||||
status_code=HTTPStatus.CREATED)
|
status_code=HTTPStatus.CREATED,
|
||||||
async def api_payments_create(wallet: WalletTypeInfo = Depends(get_key_type),
|
)
|
||||||
invoiceData: CreateInvoiceData = Body(...)):
|
async def api_payments_create(
|
||||||
|
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||||
|
invoiceData: CreateInvoiceData = Body(...),
|
||||||
|
):
|
||||||
|
|
||||||
if wallet.wallet_type < 0 or wallet.wallet_type > 2:
|
if wallet.wallet_type < 0 or wallet.wallet_type > 2:
|
||||||
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail="Key is invalid")
|
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail="Key is invalid")
|
||||||
|
|
||||||
if invoiceData.out is True and wallet.wallet_type == 0:
|
if invoiceData.out is True and wallet.wallet_type == 0:
|
||||||
if not invoiceData.bolt11:
|
if not invoiceData.bolt11:
|
||||||
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail="BOLT11 string is invalid or not given")
|
raise HTTPException(
|
||||||
return await api_payments_pay_invoice(invoiceData.bolt11, wallet.wallet) # admin key
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
detail="BOLT11 string is invalid or not given",
|
||||||
|
)
|
||||||
|
return await api_payments_pay_invoice(
|
||||||
|
invoiceData.bolt11, wallet.wallet
|
||||||
|
) # admin key
|
||||||
return await api_payments_create_invoice(invoiceData, wallet.wallet) # invoice key
|
return await api_payments_create_invoice(invoiceData, wallet.wallet) # invoice key
|
||||||
|
|
||||||
|
|
||||||
class CreateLNURLData(BaseModel):
|
class CreateLNURLData(BaseModel):
|
||||||
description_hash: str
|
description_hash: str
|
||||||
callback: str
|
callback: str
|
||||||
|
|
@ -190,9 +211,11 @@ class CreateLNURLData(BaseModel):
|
||||||
comment: Optional[str] = None
|
comment: Optional[str] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
@core_app.post("/api/v1/payments/lnurl")
|
@core_app.post("/api/v1/payments/lnurl")
|
||||||
async def api_payments_pay_lnurl(data: CreateLNURLData,
|
async def api_payments_pay_lnurl(
|
||||||
wallet: WalletTypeInfo = Depends(get_key_type)):
|
data: CreateLNURLData, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||||
|
):
|
||||||
domain = urlparse(data.callback).netloc
|
domain = urlparse(data.callback).netloc
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
|
|
@ -207,31 +230,29 @@ async def api_payments_pay_lnurl(data: CreateLNURLData,
|
||||||
except (httpx.ConnectError, httpx.RequestError):
|
except (httpx.ConnectError, httpx.RequestError):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
detail=f"Failed to connect to {domain}."
|
detail=f"Failed to connect to {domain}.",
|
||||||
)
|
)
|
||||||
|
|
||||||
params = json.loads(r.text)
|
params = json.loads(r.text)
|
||||||
if params.get("status") == "ERROR":
|
if params.get("status") == "ERROR":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
detail=f"{domain} said: '{params.get('reason', '')}'"
|
detail=f"{domain} said: '{params.get('reason', '')}'",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
invoice = bolt11.decode(params["pr"])
|
invoice = bolt11.decode(params["pr"])
|
||||||
if invoice.amount_msat != data.amount:
|
if invoice.amount_msat != data.amount:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
detail=f"{domain} returned an invalid invoice. Expected {data['amount']} msat, got {invoice.amount_msat}."
|
detail=f"{domain} returned an invalid invoice. Expected {data['amount']} msat, got {invoice.amount_msat}.",
|
||||||
)
|
)
|
||||||
|
|
||||||
if invoice.description_hash != data.description_hash:
|
if invoice.description_hash != data.description_hash:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
detail=f"{domain} returned an invalid invoice. Expected description_hash == {data['description_hash']}, got {invoice.description_hash}."
|
detail=f"{domain} returned an invalid invoice. Expected description_hash == {data['description_hash']}, got {invoice.description_hash}.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
extra = {}
|
extra = {}
|
||||||
|
|
||||||
if params.get("successAction"):
|
if params.get("successAction"):
|
||||||
|
|
@ -288,8 +309,12 @@ async def subscribe(request: Request, wallet: Wallet):
|
||||||
|
|
||||||
|
|
||||||
@core_app.get("/api/v1/payments/sse")
|
@core_app.get("/api/v1/payments/sse")
|
||||||
async def api_payments_sse(request: Request, wallet: WalletTypeInfo = Depends(get_key_type)):
|
async def api_payments_sse(
|
||||||
return EventSourceResponse(subscribe(request, wallet), ping=20, media_type="text/event-stream")
|
request: Request, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||||
|
):
|
||||||
|
return EventSourceResponse(
|
||||||
|
subscribe(request, wallet), ping=20, media_type="text/event-stream"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@core_app.get("/api/v1/payments/{payment_hash}")
|
@core_app.get("/api/v1/payments/{payment_hash}")
|
||||||
|
|
@ -309,7 +334,9 @@ async def api_payment(payment_hash, wallet: WalletTypeInfo = Depends(get_key_typ
|
||||||
return {"paid": not payment.pending, "preimage": payment.preimage}
|
return {"paid": not payment.pending, "preimage": payment.preimage}
|
||||||
|
|
||||||
|
|
||||||
@core_app.get("/api/v1/lnurlscan/{code}", dependencies=[Depends(WalletInvoiceKeyChecker())])
|
@core_app.get(
|
||||||
|
"/api/v1/lnurlscan/{code}", dependencies=[Depends(WalletInvoiceKeyChecker())]
|
||||||
|
)
|
||||||
async def api_lnurlscan(code: str):
|
async def api_lnurlscan(code: str):
|
||||||
try:
|
try:
|
||||||
url = lnurl.decode(code)
|
url = lnurl.decode(code)
|
||||||
|
|
@ -327,7 +354,9 @@ async def api_lnurlscan(code: str):
|
||||||
)
|
)
|
||||||
# will proceed with these values
|
# will proceed with these values
|
||||||
else:
|
else:
|
||||||
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail="invalid lnurl")
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.BAD_REQUEST, detail="invalid lnurl"
|
||||||
|
)
|
||||||
|
|
||||||
# params is what will be returned to the client
|
# params is what will be returned to the client
|
||||||
params: Dict = {"domain": domain}
|
params: Dict = {"domain": domain}
|
||||||
|
|
@ -344,7 +373,7 @@ async def api_lnurlscan(code: str):
|
||||||
if r.is_error:
|
if r.is_error:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
|
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
|
||||||
detail={"domain": domain, "message": "failed to get parameters"}
|
detail={"domain": domain, "message": "failed to get parameters"},
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -352,7 +381,10 @@ async def api_lnurlscan(code: str):
|
||||||
except json.decoder.JSONDecodeError:
|
except json.decoder.JSONDecodeError:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
|
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
|
||||||
detail={"domain": domain, "message": f"got invalid response '{r.text[:200]}'"}
|
detail={
|
||||||
|
"domain": domain,
|
||||||
|
"message": f"got invalid response '{r.text[:200]}'",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -360,7 +392,11 @@ async def api_lnurlscan(code: str):
|
||||||
if tag == "channelRequest":
|
if tag == "channelRequest":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
detail={"domain": domain, "kind": "channel", "message": "unsupported"}
|
detail={
|
||||||
|
"domain": domain,
|
||||||
|
"kind": "channel",
|
||||||
|
"message": "unsupported",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
params.update(**data)
|
params.update(**data)
|
||||||
|
|
@ -410,16 +446,43 @@ async def api_lnurlscan(code: str):
|
||||||
detail={
|
detail={
|
||||||
"domain": domain,
|
"domain": domain,
|
||||||
"message": f"lnurl JSON response invalid: {exc}",
|
"message": f"lnurl JSON response invalid: {exc}",
|
||||||
})
|
},
|
||||||
|
)
|
||||||
|
|
||||||
return params
|
return params
|
||||||
|
|
||||||
|
|
||||||
|
@core_app.post("/api/v1/payments/decode")
|
||||||
|
async def api_payments_decode(data: str = Query(None)):
|
||||||
|
try:
|
||||||
|
if g.data["data"][:5] == "LNURL":
|
||||||
|
url = lnurl.decode(g.data["data"])
|
||||||
|
return {"domain": url}
|
||||||
|
else:
|
||||||
|
invoice = bolt11.decode(g.data["data"])
|
||||||
|
return {
|
||||||
|
"payment_hash": invoice.payment_hash,
|
||||||
|
"amount_msat": invoice.amount_msat,
|
||||||
|
"description": invoice.description,
|
||||||
|
"description_hash": invoice.description_hash,
|
||||||
|
"payee": invoice.payee,
|
||||||
|
"date": invoice.date,
|
||||||
|
"expiry": invoice.expiry,
|
||||||
|
"secret": invoice.secret,
|
||||||
|
"route_hints": invoice.route_hints,
|
||||||
|
"min_final_cltv_expiry": invoice.min_final_cltv_expiry,
|
||||||
|
}
|
||||||
|
except:
|
||||||
|
return {"message": "Failed to decode"}
|
||||||
|
|
||||||
|
|
||||||
@core_app.post("/api/v1/lnurlauth", dependencies=[Depends(WalletAdminKeyChecker())])
|
@core_app.post("/api/v1/lnurlauth", dependencies=[Depends(WalletAdminKeyChecker())])
|
||||||
async def api_perform_lnurlauth(callback: str):
|
async def api_perform_lnurlauth(callback: str):
|
||||||
err = await perform_lnurlauth(callback)
|
err = await perform_lnurlauth(callback)
|
||||||
if err:
|
if err:
|
||||||
raise HTTPException(status_code=HTTPStatus.SERVICE_UNAVAILABLE, detail=err.reason)
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.SERVICE_UNAVAILABLE, detail=err.reason
|
||||||
|
)
|
||||||
|
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,16 +17,22 @@ from lnbits.helpers import template_renderer, url_for
|
||||||
from lnbits.requestvars import g
|
from lnbits.requestvars import g
|
||||||
from lnbits.core.models import User
|
from lnbits.core.models import User
|
||||||
from lnbits.decorators import check_user_exists
|
from lnbits.decorators import check_user_exists
|
||||||
from lnbits.settings import (LNBITS_ALLOWED_USERS, LNBITS_SITE_TITLE,
|
from lnbits.settings import LNBITS_ALLOWED_USERS, LNBITS_SITE_TITLE, SERVICE_FEE
|
||||||
SERVICE_FEE)
|
|
||||||
|
|
||||||
from ..crud import (create_account, create_wallet, delete_wallet,
|
from ..crud import (
|
||||||
get_balance_check, get_user, save_balance_notify,
|
create_account,
|
||||||
update_user_extension)
|
create_wallet,
|
||||||
|
delete_wallet,
|
||||||
|
get_balance_check,
|
||||||
|
get_user,
|
||||||
|
save_balance_notify,
|
||||||
|
update_user_extension,
|
||||||
|
)
|
||||||
from ..services import pay_invoice, redeem_lnurl_withdraw
|
from ..services import pay_invoice, redeem_lnurl_withdraw
|
||||||
|
|
||||||
core_html_routes: APIRouter = APIRouter(tags=["Core NON-API Website Routes"])
|
core_html_routes: APIRouter = APIRouter(tags=["Core NON-API Website Routes"])
|
||||||
|
|
||||||
|
|
||||||
@core_html_routes.get("/favicon.ico")
|
@core_html_routes.get("/favicon.ico")
|
||||||
async def favicon():
|
async def favicon():
|
||||||
return FileResponse("lnbits/core/static/favicon.ico")
|
return FileResponse("lnbits/core/static/favicon.ico")
|
||||||
|
|
@ -34,21 +40,25 @@ async def favicon():
|
||||||
|
|
||||||
@core_html_routes.get("/", response_class=HTMLResponse)
|
@core_html_routes.get("/", response_class=HTMLResponse)
|
||||||
async def home(request: Request, lightning: str = None):
|
async def home(request: Request, lightning: str = None):
|
||||||
return template_renderer().TemplateResponse("core/index.html", {"request": request, "lnurl": lightning})
|
return template_renderer().TemplateResponse(
|
||||||
|
"core/index.html", {"request": request, "lnurl": lightning}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@core_html_routes.get("/extensions", name="core.extensions")
|
@core_html_routes.get("/extensions", name="core.extensions")
|
||||||
async def extensions(
|
async def extensions(
|
||||||
request: Request,
|
request: Request,
|
||||||
user: User = Depends(check_user_exists),
|
user: User = Depends(check_user_exists),
|
||||||
enable: str= Query(None),
|
enable: str = Query(None),
|
||||||
disable: str = Query(None)
|
disable: str = Query(None),
|
||||||
):
|
):
|
||||||
extension_to_enable = enable
|
extension_to_enable = enable
|
||||||
extension_to_disable = disable
|
extension_to_disable = disable
|
||||||
|
|
||||||
if extension_to_enable and extension_to_disable:
|
if extension_to_enable and extension_to_disable:
|
||||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "You can either `enable` or `disable` an extension.")
|
raise HTTPException(
|
||||||
|
HTTPStatus.BAD_REQUEST, "You can either `enable` or `disable` an extension."
|
||||||
|
)
|
||||||
|
|
||||||
if extension_to_enable:
|
if extension_to_enable:
|
||||||
await update_user_extension(
|
await update_user_extension(
|
||||||
|
|
@ -63,14 +73,20 @@ async def extensions(
|
||||||
if extension_to_enable or extension_to_disable:
|
if extension_to_enable or extension_to_disable:
|
||||||
user = await get_user(user.id)
|
user = await get_user(user.id)
|
||||||
|
|
||||||
return template_renderer().TemplateResponse("core/extensions.html", {"request": request, "user": user.dict()})
|
return template_renderer().TemplateResponse(
|
||||||
|
"core/extensions.html", {"request": request, "user": user.dict()}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@core_html_routes.get("/wallet", response_class=HTMLResponse)
|
@core_html_routes.get("/wallet", response_class=HTMLResponse)
|
||||||
#Not sure how to validate
|
# Not sure how to validate
|
||||||
# @validate_uuids(["usr", "nme"])
|
# @validate_uuids(["usr", "nme"])
|
||||||
async def wallet(request: Request = Query(None), nme: Optional[str] = Query(None),
|
async def wallet(
|
||||||
usr: Optional[UUID4] = Query(None), wal: Optional[UUID4] = Query(None)):
|
request: Request = Query(None),
|
||||||
|
nme: Optional[str] = Query(None),
|
||||||
|
usr: Optional[UUID4] = Query(None),
|
||||||
|
wal: Optional[UUID4] = Query(None),
|
||||||
|
):
|
||||||
user_id = usr.hex if usr else None
|
user_id = usr.hex if usr else None
|
||||||
wallet_id = wal.hex if wal else None
|
wallet_id = wal.hex if wal else None
|
||||||
wallet_name = nme
|
wallet_name = nme
|
||||||
|
|
@ -87,23 +103,38 @@ async def wallet(request: Request = Query(None), nme: Optional[str] = Query(None
|
||||||
else:
|
else:
|
||||||
user = await get_user(user_id)
|
user = await get_user(user_id)
|
||||||
if not user:
|
if not user:
|
||||||
return template_renderer().TemplateResponse("error.html", {"request": request, "err": "User does not exist."})
|
return template_renderer().TemplateResponse(
|
||||||
|
"error.html", {"request": request, "err": "User does not exist."}
|
||||||
|
)
|
||||||
if LNBITS_ALLOWED_USERS and user_id not in LNBITS_ALLOWED_USERS:
|
if LNBITS_ALLOWED_USERS and user_id not in LNBITS_ALLOWED_USERS:
|
||||||
return template_renderer().TemplateResponse("error.html", {"request": request, "err": "User not authorized."})
|
return template_renderer().TemplateResponse(
|
||||||
|
"error.html", {"request": request, "err": "User not authorized."}
|
||||||
|
)
|
||||||
if not wallet_id:
|
if not wallet_id:
|
||||||
if user.wallets and not wallet_name:
|
if user.wallets and not wallet_name:
|
||||||
wallet = user.wallets[0]
|
wallet = user.wallets[0]
|
||||||
else:
|
else:
|
||||||
wallet = await create_wallet(user_id=user.id, wallet_name=wallet_name)
|
wallet = await create_wallet(user_id=user.id, wallet_name=wallet_name)
|
||||||
|
|
||||||
return RedirectResponse(f"/wallet?usr={user.id}&wal={wallet.id}", status_code=status.HTTP_307_TEMPORARY_REDIRECT)
|
return RedirectResponse(
|
||||||
|
f"/wallet?usr={user.id}&wal={wallet.id}",
|
||||||
|
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
|
||||||
|
)
|
||||||
|
|
||||||
wallet = user.get_wallet(wallet_id)
|
wallet = user.get_wallet(wallet_id)
|
||||||
if not wallet:
|
if not wallet:
|
||||||
return template_renderer().TemplateResponse("error.html", {"request": request, "err": "Wallet not found"})
|
return template_renderer().TemplateResponse(
|
||||||
|
"error.html", {"request": request, "err": "Wallet not found"}
|
||||||
|
)
|
||||||
|
|
||||||
return template_renderer().TemplateResponse(
|
return template_renderer().TemplateResponse(
|
||||||
"core/wallet.html", {"request":request,"user":user.dict(), "wallet":wallet.dict(), "service_fee":service_fee}
|
"core/wallet.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"user": user.dict(),
|
||||||
|
"wallet": wallet.dict(),
|
||||||
|
"service_fee": service_fee,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -116,16 +147,11 @@ async def lnurl_full_withdraw(request: Request):
|
||||||
|
|
||||||
wallet = user.get_wallet(request.args.get("wal"))
|
wallet = user.get_wallet(request.args.get("wal"))
|
||||||
if not wallet:
|
if not wallet:
|
||||||
return{"status": "ERROR", "reason": "Wallet does not exist."}
|
return {"status": "ERROR", "reason": "Wallet does not exist."}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"tag": "withdrawRequest",
|
"tag": "withdrawRequest",
|
||||||
"callback": url_for(
|
"callback": url_for("/withdraw/cb", external=True, usr=user.id, wal=wallet.id),
|
||||||
"/withdraw/cb",
|
|
||||||
external=True,
|
|
||||||
usr=user.id,
|
|
||||||
wal=wallet.id,
|
|
||||||
),
|
|
||||||
"k1": "0",
|
"k1": "0",
|
||||||
"minWithdrawable": 1000 if wallet.withdrawable_balance else 0,
|
"minWithdrawable": 1000 if wallet.withdrawable_balance else 0,
|
||||||
"maxWithdrawable": wallet.withdrawable_balance,
|
"maxWithdrawable": wallet.withdrawable_balance,
|
||||||
|
|
@ -176,10 +202,14 @@ async def deletewallet(request: Request):
|
||||||
user_wallet_ids.remove(wallet_id)
|
user_wallet_ids.remove(wallet_id)
|
||||||
|
|
||||||
if user_wallet_ids:
|
if user_wallet_ids:
|
||||||
return RedirectResponse(url_for("/wallet", usr=g().user.id, wal=user_wallet_ids[0]),
|
return RedirectResponse(
|
||||||
status_code=status.HTTP_307_TEMPORARY_REDIRECT)
|
url_for("/wallet", usr=g().user.id, wal=user_wallet_ids[0]),
|
||||||
|
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
|
||||||
|
)
|
||||||
|
|
||||||
return RedirectResponse(url_for("/"), status_code=status.HTTP_307_TEMPORARY_REDIRECT)
|
return RedirectResponse(
|
||||||
|
url_for("/"), status_code=status.HTTP_307_TEMPORARY_REDIRECT
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@core_html_routes.get("/withdraw/notify/{service}")
|
@core_html_routes.get("/withdraw/notify/{service}")
|
||||||
|
|
@ -203,11 +233,14 @@ async def lnurlwallet(request: Request):
|
||||||
request.args.get("lightning"),
|
request.args.get("lightning"),
|
||||||
"LNbits initial funding: voucher redeem.",
|
"LNbits initial funding: voucher redeem.",
|
||||||
{"tag": "lnurlwallet"},
|
{"tag": "lnurlwallet"},
|
||||||
5 # wait 5 seconds before sending the invoice to the service
|
5, # wait 5 seconds before sending the invoice to the service
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
return RedirectResponse(f"/wallet?usr={user.id}&wal={wallet.id}", status_code=status.HTTP_307_TEMPORARY_REDIRECT)
|
return RedirectResponse(
|
||||||
|
f"/wallet?usr={user.id}&wal={wallet.id}",
|
||||||
|
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@core_html_routes.get("/manifest/{usr}.webmanifest")
|
@core_html_routes.get("/manifest/{usr}.webmanifest")
|
||||||
|
|
@ -240,4 +273,5 @@ async def manifest(usr: str):
|
||||||
"url": "/wallet?usr=" + usr + "&wal=" + wallet.id,
|
"url": "/wallet?usr=" + usr + "&wal=" + wallet.id,
|
||||||
}
|
}
|
||||||
for wallet in user.wallets
|
for wallet in user.wallets
|
||||||
],}
|
],
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,7 @@ async def api_public_payment_longpolling(payment_hash):
|
||||||
|
|
||||||
if not payment:
|
if not payment:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.NOT_FOUND,
|
status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
|
||||||
detail="Payment does not exist."
|
|
||||||
)
|
)
|
||||||
elif not payment.pending:
|
elif not payment.pending:
|
||||||
return {"status": "paid"}
|
return {"status": "paid"}
|
||||||
|
|
@ -28,8 +27,7 @@ async def api_public_payment_longpolling(payment_hash):
|
||||||
return {"status": "expired"}
|
return {"status": "expired"}
|
||||||
except:
|
except:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST, detail="Invalid bolt11 invoice."
|
||||||
detail="Invalid bolt11 invoice."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
payment_queue = asyncio.Queue(0)
|
payment_queue = asyncio.Queue(0)
|
||||||
|
|
@ -50,14 +48,10 @@ async def api_public_payment_longpolling(payment_hash):
|
||||||
await asyncio.sleep(45)
|
await asyncio.sleep(45)
|
||||||
cancel_scope.cancel()
|
cancel_scope.cancel()
|
||||||
|
|
||||||
|
|
||||||
asyncio.create_task(payment_info_receiver())
|
asyncio.create_task(payment_info_receiver())
|
||||||
asyncio.create_task(timeouter())
|
asyncio.create_task(timeouter())
|
||||||
|
|
||||||
if response:
|
if response:
|
||||||
return response
|
return response
|
||||||
else:
|
else:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=HTTPStatus.REQUEST_TIMEOUT, detail="timeout")
|
||||||
status_code=HTTPStatus.REQUEST_TIMEOUT,
|
|
||||||
detail="timeout"
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -1,54 +1,65 @@
|
||||||
from functools import wraps
|
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
|
|
||||||
from fastapi.security import api_key
|
|
||||||
from pydantic.types import UUID4
|
|
||||||
from lnbits.core.models import User, Wallet
|
|
||||||
from typing import List, Union
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from cerberus import Validator # type: ignore
|
from cerberus import Validator # type: ignore
|
||||||
|
from fastapi import status
|
||||||
from fastapi.exceptions import HTTPException
|
from fastapi.exceptions import HTTPException
|
||||||
from fastapi.openapi.models import APIKey, APIKeyIn
|
from fastapi.openapi.models import APIKey, APIKeyIn
|
||||||
from fastapi.params import Security
|
from fastapi.params import Security
|
||||||
from fastapi.security.api_key import APIKeyHeader, APIKeyQuery
|
from fastapi.security.api_key import APIKeyHeader, APIKeyQuery
|
||||||
from fastapi.security.base import SecurityBase
|
from fastapi.security.base import SecurityBase
|
||||||
|
from pydantic.types import UUID4
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
|
|
||||||
from lnbits.core.crud import get_user, get_wallet_for_key
|
from lnbits.core.crud import get_user, get_wallet_for_key
|
||||||
|
from lnbits.core.models import User, Wallet
|
||||||
from lnbits.requestvars import g
|
from lnbits.requestvars import g
|
||||||
from lnbits.settings import LNBITS_ALLOWED_USERS
|
from lnbits.settings import LNBITS_ALLOWED_USERS
|
||||||
|
|
||||||
|
|
||||||
class KeyChecker(SecurityBase):
|
class KeyChecker(SecurityBase):
|
||||||
def __init__(self, scheme_name: str = None, auto_error: bool = True, api_key: str = None):
|
def __init__(
|
||||||
|
self, scheme_name: str = None, auto_error: bool = True, api_key: str = None
|
||||||
|
):
|
||||||
self.scheme_name = scheme_name or self.__class__.__name__
|
self.scheme_name = scheme_name or self.__class__.__name__
|
||||||
self.auto_error = auto_error
|
self.auto_error = auto_error
|
||||||
self._key_type = "invoice"
|
self._key_type = "invoice"
|
||||||
self._api_key = api_key
|
self._api_key = api_key
|
||||||
if api_key:
|
if api_key:
|
||||||
self.model: APIKey= APIKey(
|
self.model: APIKey = APIKey(
|
||||||
**{"in": APIKeyIn.query}, name="X-API-KEY", description="Wallet API Key - QUERY"
|
**{"in": APIKeyIn.query},
|
||||||
|
name="X-API-KEY",
|
||||||
|
description="Wallet API Key - QUERY",
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.model: APIKey= APIKey(
|
self.model: APIKey = APIKey(
|
||||||
**{"in": APIKeyIn.header}, name="X-API-KEY", description="Wallet API Key - HEADER"
|
**{"in": APIKeyIn.header},
|
||||||
|
name="X-API-KEY",
|
||||||
|
description="Wallet API Key - HEADER",
|
||||||
)
|
)
|
||||||
self.wallet = None
|
self.wallet = None
|
||||||
|
|
||||||
async def __call__(self, request: Request) -> Wallet:
|
async def __call__(self, request: Request) -> Wallet:
|
||||||
try:
|
try:
|
||||||
key_value = self._api_key if self._api_key else request.headers.get("X-API-KEY") or request.query_params["api-key"]
|
key_value = (
|
||||||
|
self._api_key
|
||||||
|
if self._api_key
|
||||||
|
else request.headers.get("X-API-KEY") or request.query_params["api-key"]
|
||||||
|
)
|
||||||
# FIXME: Find another way to validate the key. A fetch from DB should be avoided here.
|
# FIXME: Find another way to validate the key. A fetch from DB should be avoided here.
|
||||||
# Also, we should not return the wallet here - thats silly.
|
# Also, we should not return the wallet here - thats silly.
|
||||||
# Possibly store it in a Redis DB
|
# Possibly store it in a Redis DB
|
||||||
self.wallet = await get_wallet_for_key(key_value, self._key_type)
|
self.wallet = await get_wallet_for_key(key_value, self._key_type)
|
||||||
if not self.wallet:
|
if not self.wallet:
|
||||||
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="Invalid key or expired key.")
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.UNAUTHORIZED,
|
||||||
|
detail="Invalid key or expired key.",
|
||||||
|
)
|
||||||
|
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST,
|
raise HTTPException(
|
||||||
detail="`X-API-KEY` header missing.")
|
status_code=HTTPStatus.BAD_REQUEST, detail="`X-API-KEY` header missing."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class WalletInvoiceKeyChecker(KeyChecker):
|
class WalletInvoiceKeyChecker(KeyChecker):
|
||||||
"""
|
"""
|
||||||
|
|
@ -58,10 +69,14 @@ class WalletInvoiceKeyChecker(KeyChecker):
|
||||||
|
|
||||||
The checker will raise an HTTPException when the key is wrong in some ways.
|
The checker will raise an HTTPException when the key is wrong in some ways.
|
||||||
"""
|
"""
|
||||||
def __init__(self, scheme_name: str = None, auto_error: bool = True, api_key: str = None):
|
|
||||||
|
def __init__(
|
||||||
|
self, scheme_name: str = None, auto_error: bool = True, api_key: str = None
|
||||||
|
):
|
||||||
super().__init__(scheme_name, auto_error, api_key)
|
super().__init__(scheme_name, auto_error, api_key)
|
||||||
self._key_type = "invoice"
|
self._key_type = "invoice"
|
||||||
|
|
||||||
|
|
||||||
class WalletAdminKeyChecker(KeyChecker):
|
class WalletAdminKeyChecker(KeyChecker):
|
||||||
"""
|
"""
|
||||||
WalletAdminKeyChecker will ensure that the provided admin
|
WalletAdminKeyChecker will ensure that the provided admin
|
||||||
|
|
@ -70,11 +85,15 @@ class WalletAdminKeyChecker(KeyChecker):
|
||||||
|
|
||||||
The checker will raise an HTTPException when the key is wrong in some ways.
|
The checker will raise an HTTPException when the key is wrong in some ways.
|
||||||
"""
|
"""
|
||||||
def __init__(self, scheme_name: str = None, auto_error: bool = True, api_key: str = None):
|
|
||||||
|
def __init__(
|
||||||
|
self, scheme_name: str = None, auto_error: bool = True, api_key: str = None
|
||||||
|
):
|
||||||
super().__init__(scheme_name, auto_error, api_key)
|
super().__init__(scheme_name, auto_error, api_key)
|
||||||
self._key_type = "admin"
|
self._key_type = "admin"
|
||||||
|
|
||||||
class WalletTypeInfo():
|
|
||||||
|
class WalletTypeInfo:
|
||||||
wallet_type: int
|
wallet_type: int
|
||||||
wallet: Wallet
|
wallet: Wallet
|
||||||
|
|
||||||
|
|
@ -83,16 +102,34 @@ class WalletTypeInfo():
|
||||||
self.wallet = wallet
|
self.wallet = wallet
|
||||||
|
|
||||||
|
|
||||||
api_key_header = APIKeyHeader(name="X-API-KEY", auto_error=False, description="Admin or Invoice key for wallet API's")
|
api_key_header = APIKeyHeader(
|
||||||
api_key_query = APIKeyQuery(name="api-key", auto_error=False, description="Admin or Invoice key for wallet API's")
|
name="X-API-KEY",
|
||||||
async def get_key_type(r: Request,
|
auto_error=False,
|
||||||
|
description="Admin or Invoice key for wallet API's",
|
||||||
|
)
|
||||||
|
api_key_query = APIKeyQuery(
|
||||||
|
name="api-key",
|
||||||
|
auto_error=False,
|
||||||
|
description="Admin or Invoice key for wallet API's",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_key_type(
|
||||||
|
r: Request,
|
||||||
api_key_header: str = Security(api_key_header),
|
api_key_header: str = Security(api_key_header),
|
||||||
api_key_query: str = Security(api_key_query)) -> WalletTypeInfo:
|
api_key_query: str = Security(api_key_query),
|
||||||
|
) -> WalletTypeInfo:
|
||||||
# 0: admin
|
# 0: admin
|
||||||
# 1: invoice
|
# 1: invoice
|
||||||
# 2: invalid
|
# 2: invalid
|
||||||
|
|
||||||
|
if not api_key_header and not api_key_query:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
token = api_key_header if api_key_header else api_key_query
|
||||||
|
|
||||||
try:
|
try:
|
||||||
checker = WalletAdminKeyChecker(api_key=api_key_query)
|
checker = WalletAdminKeyChecker(api_key=token)
|
||||||
await checker.__call__(r)
|
await checker.__call__(r)
|
||||||
return WalletTypeInfo(0, checker.wallet)
|
return WalletTypeInfo(0, checker.wallet)
|
||||||
except HTTPException as e:
|
except HTTPException as e:
|
||||||
|
|
@ -104,7 +141,7 @@ async def get_key_type(r: Request,
|
||||||
raise
|
raise
|
||||||
|
|
||||||
try:
|
try:
|
||||||
checker = WalletInvoiceKeyChecker()
|
checker = WalletInvoiceKeyChecker(api_key=token)
|
||||||
await checker.__call__(r)
|
await checker.__call__(r)
|
||||||
return WalletTypeInfo(1, checker.wallet)
|
return WalletTypeInfo(1, checker.wallet)
|
||||||
except HTTPException as e:
|
except HTTPException as e:
|
||||||
|
|
@ -115,46 +152,36 @@ async def get_key_type(r: Request,
|
||||||
except:
|
except:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def api_validate_post_request(*, schema: dict):
|
|
||||||
def wrap(view):
|
async def require_admin_key(
|
||||||
@wraps(view)
|
r: Request,
|
||||||
async def wrapped_view(**kwargs):
|
api_key_header: str = Security(api_key_header),
|
||||||
if "application/json" not in request.headers["Content-Type"]:
|
api_key_query: str = Security(api_key_query),
|
||||||
|
):
|
||||||
|
token = api_key_header if api_key_header else api_key_query
|
||||||
|
|
||||||
|
wallet = await get_key_type(r, token)
|
||||||
|
|
||||||
|
if wallet.wallet_type != 0:
|
||||||
|
# If wallet type is not admin then return the unauthorized status
|
||||||
|
# This also covers when the user passes an invalid key type
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=status.HTTP_401_UNAUTHORIZED, detail="Admin key required."
|
||||||
detail=jsonify({"message": "Content-Type must be `application/json`."})
|
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
v = Validator(schema)
|
return wallet
|
||||||
data = await request.get_json()
|
|
||||||
g().data = {key: data[key] for key in schema.keys() if key in data}
|
|
||||||
|
|
||||||
if not v.validate(g().data):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
|
||||||
detail=jsonify({"message": f"Errors in request data: {v.errors}"})
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
return await view(**kwargs)
|
|
||||||
|
|
||||||
return wrapped_view
|
|
||||||
|
|
||||||
return wrap
|
|
||||||
|
|
||||||
|
|
||||||
async def check_user_exists(usr: UUID4) -> User:
|
async def check_user_exists(usr: UUID4) -> User:
|
||||||
g().user = await get_user(usr.hex)
|
g().user = await get_user(usr.hex)
|
||||||
if not g().user:
|
if not g().user:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.NOT_FOUND,
|
status_code=HTTPStatus.NOT_FOUND, detail="User does not exist."
|
||||||
detail="User does not exist."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if LNBITS_ALLOWED_USERS and g().user.id not in LNBITS_ALLOWED_USERS:
|
if LNBITS_ALLOWED_USERS and g().user.id not in LNBITS_ALLOWED_USERS:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.UNAUTHORIZED,
|
status_code=HTTPStatus.UNAUTHORIZED, detail="User not authorized."
|
||||||
detail="User not authorized."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return g().user
|
return g().user
|
||||||
|
|
|
||||||
3
lnbits/extensions/copilot/README.md
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# StreamerCopilot
|
||||||
|
|
||||||
|
Tool to help streamers accept sats for tips
|
||||||
33
lnbits/extensions/copilot/__init__.py
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import asyncio
|
||||||
|
from fastapi import APIRouter, FastAPI
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from starlette.routing import Mount
|
||||||
|
from lnbits.db import Database
|
||||||
|
from lnbits.helpers import template_renderer
|
||||||
|
from lnbits.tasks import catch_everything_and_restart
|
||||||
|
|
||||||
|
db = Database("ext_copilot")
|
||||||
|
|
||||||
|
copilot_static_files = [
|
||||||
|
{
|
||||||
|
"path": "/copilot/static",
|
||||||
|
"app": StaticFiles(directory="lnbits/extensions/copilot/static"),
|
||||||
|
"name": "copilot_static",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
copilot_ext: APIRouter = APIRouter(prefix="/copilot", tags=["copilot"])
|
||||||
|
|
||||||
|
|
||||||
|
def copilot_renderer():
|
||||||
|
return template_renderer(["lnbits/extensions/copilot/templates"])
|
||||||
|
|
||||||
|
|
||||||
|
from .views_api import * # noqa
|
||||||
|
from .views import * # noqa
|
||||||
|
from .tasks import wait_for_paid_invoices
|
||||||
|
from .lnurl import * # noqa
|
||||||
|
|
||||||
|
|
||||||
|
def copilot_start():
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
|
||||||
8
lnbits/extensions/copilot/config.json
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"name": "Streamer Copilot",
|
||||||
|
"short_description": "Video tips/animations/webhooks",
|
||||||
|
"icon": "face",
|
||||||
|
"contributors": [
|
||||||
|
"arcbtc"
|
||||||
|
]
|
||||||
|
}
|
||||||
94
lnbits/extensions/copilot/crud.py
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
from typing import List, Optional, Union
|
||||||
|
|
||||||
|
from . import db
|
||||||
|
from .models import Copilots, CreateCopilotData
|
||||||
|
from lnbits.helpers import urlsafe_short_hash
|
||||||
|
|
||||||
|
###############COPILOTS##########################
|
||||||
|
|
||||||
|
|
||||||
|
async def create_copilot(
|
||||||
|
data: CreateCopilotData, inkey: Optional[str] = ""
|
||||||
|
) -> Copilots:
|
||||||
|
copilot_id = urlsafe_short_hash()
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO copilot.copilots (
|
||||||
|
id,
|
||||||
|
user,
|
||||||
|
lnurl_toggle,
|
||||||
|
wallet,
|
||||||
|
title,
|
||||||
|
animation1,
|
||||||
|
animation2,
|
||||||
|
animation3,
|
||||||
|
animation1threshold,
|
||||||
|
animation2threshold,
|
||||||
|
animation3threshold,
|
||||||
|
animation1webhook,
|
||||||
|
animation2webhook,
|
||||||
|
animation3webhook,
|
||||||
|
lnurl_title,
|
||||||
|
show_message,
|
||||||
|
show_ack,
|
||||||
|
show_price,
|
||||||
|
fullscreen_cam,
|
||||||
|
iframe_url,
|
||||||
|
amount_made
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
copilot_id,
|
||||||
|
data.user,
|
||||||
|
int(data.lnurl_toggle),
|
||||||
|
data.wallet,
|
||||||
|
data.title,
|
||||||
|
data.animation1,
|
||||||
|
data.animation2,
|
||||||
|
data.animation3,
|
||||||
|
data.animation1threshold,
|
||||||
|
data.animation2threshold,
|
||||||
|
data.animation3threshold,
|
||||||
|
data.animation1webhook,
|
||||||
|
data.animation2webhook,
|
||||||
|
data.animation3webhook,
|
||||||
|
data.lnurl_title,
|
||||||
|
int(data.show_message),
|
||||||
|
int(data.show_ack),
|
||||||
|
data.show_price,
|
||||||
|
0,
|
||||||
|
None,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return await get_copilot(copilot_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_copilot(
|
||||||
|
data: CreateCopilotData, copilot_id: Optional[str] = ""
|
||||||
|
) -> Optional[Copilots]:
|
||||||
|
q = ", ".join([f"{field[0]} = ?" for field in data])
|
||||||
|
items = [f"{field[1]}" for field in data]
|
||||||
|
items.append(copilot_id)
|
||||||
|
await db.execute(f"UPDATE copilot.copilots SET {q} WHERE id = ?", (items))
|
||||||
|
row = await db.fetchone(
|
||||||
|
"SELECT * FROM copilot.copilots WHERE id = ?", (copilot_id,)
|
||||||
|
)
|
||||||
|
return Copilots(**row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_copilot(copilot_id: str) -> Copilots:
|
||||||
|
row = await db.fetchone(
|
||||||
|
"SELECT * FROM copilot.copilots WHERE id = ?", (copilot_id,)
|
||||||
|
)
|
||||||
|
return Copilots(**row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_copilots(user: str) -> List[Copilots]:
|
||||||
|
rows = await db.fetchall("SELECT * FROM copilot.copilots WHERE user = ?", (user,))
|
||||||
|
return [Copilots(**row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_copilot(copilot_id: str) -> None:
|
||||||
|
await db.execute("DELETE FROM copilot.copilots WHERE id = ?", (copilot_id,))
|
||||||
87
lnbits/extensions/copilot/lnurl.py
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from http import HTTPStatus
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
from fastapi.param_functions import Query
|
||||||
|
from lnurl.types import LnurlPayMetadata
|
||||||
|
from starlette.exceptions import HTTPException
|
||||||
|
from starlette.responses import HTMLResponse # type: ignore
|
||||||
|
|
||||||
|
from lnbits.core.services import create_invoice
|
||||||
|
|
||||||
|
from . import copilot_ext
|
||||||
|
from .crud import get_copilot
|
||||||
|
|
||||||
|
|
||||||
|
@copilot_ext.get(
|
||||||
|
"/lnurl/{cp_id}", response_class=HTMLResponse, name="copilot.lnurl_response"
|
||||||
|
)
|
||||||
|
async def lnurl_response(req: Request, cp_id: str = Query(None)):
|
||||||
|
cp = await get_copilot(cp_id)
|
||||||
|
if not cp:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="Copilot not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
payResponse = {
|
||||||
|
"tag": "payRequest",
|
||||||
|
"callback": req.url_for("copilot.lnurl_callback", cp_id=cp_id),
|
||||||
|
"metadata": LnurlPayMetadata(json.dumps([["text/plain", str(cp.lnurl_title)]])),
|
||||||
|
"maxSendable": 50000000,
|
||||||
|
"minSendable": 10000,
|
||||||
|
}
|
||||||
|
|
||||||
|
if cp.show_message:
|
||||||
|
payResponse["commentAllowed"] = 300
|
||||||
|
return json.dumps(payResponse)
|
||||||
|
|
||||||
|
|
||||||
|
@copilot_ext.get(
|
||||||
|
"/lnurl/cb/{cp_id}", response_class=HTMLResponse, name="copilot.lnurl_callback"
|
||||||
|
)
|
||||||
|
async def lnurl_callback(
|
||||||
|
cp_id: str = Query(None), amount: str = Query(None), comment: str = Query(None)
|
||||||
|
):
|
||||||
|
cp = await get_copilot(cp_id)
|
||||||
|
if not cp:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="Copilot not found"
|
||||||
|
)
|
||||||
|
amount_received = int(amount)
|
||||||
|
|
||||||
|
if amount_received < 10000:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.FORBIDDEN,
|
||||||
|
detail="Amount {round(amount_received / 1000)} is smaller than minimum 10 sats.",
|
||||||
|
)
|
||||||
|
elif amount_received / 1000 > 10000000:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.FORBIDDEN,
|
||||||
|
detail="Amount {round(amount_received / 1000)} is greater than maximum 50000.",
|
||||||
|
)
|
||||||
|
comment = ""
|
||||||
|
if comment:
|
||||||
|
if len(comment or "") > 300:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.FORBIDDEN,
|
||||||
|
detail="Got a comment with {len(comment)} characters, but can only accept 300",
|
||||||
|
)
|
||||||
|
if len(comment) < 1:
|
||||||
|
comment = "none"
|
||||||
|
_, payment_request = await create_invoice(
|
||||||
|
wallet_id=cp.wallet,
|
||||||
|
amount=int(amount_received / 1000),
|
||||||
|
memo=cp.lnurl_title,
|
||||||
|
description_hash=hashlib.sha256(
|
||||||
|
(
|
||||||
|
LnurlPayMetadata(json.dumps([["text/plain", str(cp.lnurl_title)]]))
|
||||||
|
).encode("utf-8")
|
||||||
|
).digest(),
|
||||||
|
extra={"tag": "copilot", "copilotid": cp.id, "comment": comment},
|
||||||
|
)
|
||||||
|
payResponse = {
|
||||||
|
"pr": payment_request,
|
||||||
|
"routes": [],
|
||||||
|
}
|
||||||
|
return json.dumps(payResponse)
|
||||||
79
lnbits/extensions/copilot/migrations.py
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
async def m001_initial(db):
|
||||||
|
"""
|
||||||
|
Initial copilot table.
|
||||||
|
"""
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
f"""
|
||||||
|
CREATE TABLE copilot.copilots (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"user" TEXT,
|
||||||
|
title TEXT,
|
||||||
|
lnurl_toggle INTEGER,
|
||||||
|
wallet TEXT,
|
||||||
|
animation1 TEXT,
|
||||||
|
animation2 TEXT,
|
||||||
|
animation3 TEXT,
|
||||||
|
animation1threshold INTEGER,
|
||||||
|
animation2threshold INTEGER,
|
||||||
|
animation3threshold INTEGER,
|
||||||
|
animation1webhook TEXT,
|
||||||
|
animation2webhook TEXT,
|
||||||
|
animation3webhook TEXT,
|
||||||
|
lnurl_title TEXT,
|
||||||
|
show_message INTEGER,
|
||||||
|
show_ack INTEGER,
|
||||||
|
show_price TEXT,
|
||||||
|
amount_made INTEGER,
|
||||||
|
fullscreen_cam INTEGER,
|
||||||
|
iframe_url TEXT,
|
||||||
|
timestamp TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def m002_fix_data_types(db):
|
||||||
|
"""
|
||||||
|
Fix data types.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if db.type != "SQLITE":
|
||||||
|
await db.execute(
|
||||||
|
"ALTER TABLE copilot.copilots ALTER COLUMN show_price TYPE TEXT;"
|
||||||
|
)
|
||||||
|
|
||||||
|
# If needed, migration for SQLite (RENAME not working properly)
|
||||||
|
#
|
||||||
|
# await db.execute(
|
||||||
|
# f"""
|
||||||
|
# CREATE TABLE copilot.new_copilots (
|
||||||
|
# id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
# "user" TEXT,
|
||||||
|
# title TEXT,
|
||||||
|
# lnurl_toggle INTEGER,
|
||||||
|
# wallet TEXT,
|
||||||
|
# animation1 TEXT,
|
||||||
|
# animation2 TEXT,
|
||||||
|
# animation3 TEXT,
|
||||||
|
# animation1threshold INTEGER,
|
||||||
|
# animation2threshold INTEGER,
|
||||||
|
# animation3threshold INTEGER,
|
||||||
|
# animation1webhook TEXT,
|
||||||
|
# animation2webhook TEXT,
|
||||||
|
# animation3webhook TEXT,
|
||||||
|
# lnurl_title TEXT,
|
||||||
|
# show_message INTEGER,
|
||||||
|
# show_ack INTEGER,
|
||||||
|
# show_price TEXT,
|
||||||
|
# amount_made INTEGER,
|
||||||
|
# fullscreen_cam INTEGER,
|
||||||
|
# iframe_url TEXT,
|
||||||
|
# timestamp TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
|
||||||
|
# );
|
||||||
|
# """
|
||||||
|
# )
|
||||||
|
#
|
||||||
|
# await db.execute("INSERT INTO copilot.new_copilots SELECT * FROM copilot.copilots;")
|
||||||
|
# await db.execute("DROP TABLE IF EXISTS copilot.copilots;")
|
||||||
|
# await db.execute("ALTER TABLE copilot.new_copilots RENAME TO copilot.copilots;")
|
||||||
64
lnbits/extensions/copilot/models.py
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
from urllib.parse import urlparse, urlunparse, parse_qs, urlencode, ParseResult
|
||||||
|
from starlette.requests import Request
|
||||||
|
from fastapi.param_functions import Query
|
||||||
|
from typing import Optional, Dict
|
||||||
|
from lnbits.lnurl import encode as lnurl_encode # type: ignore
|
||||||
|
from lnurl.types import LnurlPayMetadata # type: ignore
|
||||||
|
from pydantic import BaseModel
|
||||||
|
import json
|
||||||
|
from sqlite3 import Row
|
||||||
|
|
||||||
|
|
||||||
|
class CreateCopilotData(BaseModel):
|
||||||
|
user: str = Query(None)
|
||||||
|
title: str = Query(None)
|
||||||
|
lnurl_toggle: int = Query(0)
|
||||||
|
wallet: str = Query(None)
|
||||||
|
animation1: str = Query(None)
|
||||||
|
animation2: str = Query(None)
|
||||||
|
animation3: str = Query(None)
|
||||||
|
animation1threshold: int = Query(None)
|
||||||
|
animation2threshold: int = Query(None)
|
||||||
|
animation3threshold: int = Query(None)
|
||||||
|
animation1webhook: str = Query(None)
|
||||||
|
animation2webhook: str = Query(None)
|
||||||
|
animation3webhook: str = Query(None)
|
||||||
|
lnurl_title: str = Query(None)
|
||||||
|
show_message: int = Query(0)
|
||||||
|
show_ack: int = Query(0)
|
||||||
|
show_price: str = Query(None)
|
||||||
|
amount_made: int = Query(0)
|
||||||
|
timestamp: int = Query(0)
|
||||||
|
fullscreen_cam: int = Query(0)
|
||||||
|
iframe_url: str = Query(None)
|
||||||
|
success_url: str = Query(None)
|
||||||
|
|
||||||
|
|
||||||
|
class Copilots(BaseModel):
|
||||||
|
id: str
|
||||||
|
user: str = Query(None)
|
||||||
|
title: str = Query(None)
|
||||||
|
lnurl_toggle: int = Query(0)
|
||||||
|
wallet: str = Query(None)
|
||||||
|
animation1: str = Query(None)
|
||||||
|
animation2: str = Query(None)
|
||||||
|
animation3: str = Query(None)
|
||||||
|
animation1threshold: int = Query(None)
|
||||||
|
animation2threshold: int = Query(None)
|
||||||
|
animation3threshold: int = Query(None)
|
||||||
|
animation1webhook: str = Query(None)
|
||||||
|
animation2webhook: str = Query(None)
|
||||||
|
animation3webhook: str = Query(None)
|
||||||
|
lnurl_title: str = Query(None)
|
||||||
|
show_message: int = Query(0)
|
||||||
|
show_ack: int = Query(0)
|
||||||
|
show_price: str = Query(None)
|
||||||
|
amount_made: int = Query(0)
|
||||||
|
timestamp: int = Query(0)
|
||||||
|
fullscreen_cam: int = Query(0)
|
||||||
|
iframe_url: str = Query(None)
|
||||||
|
success_url: str = Query(None)
|
||||||
|
|
||||||
|
def lnurl(self, req: Request) -> str:
|
||||||
|
url = req.url_for("copilot.lnurl_response", cp_id=self.id)
|
||||||
|
return lnurl_encode(url)
|
||||||
BIN
lnbits/extensions/copilot/static/bitcoin.gif
Normal file
|
After Width: | Height: | Size: 308 KiB |
BIN
lnbits/extensions/copilot/static/confetti.gif
Normal file
|
After Width: | Height: | Size: 333 KiB |
BIN
lnbits/extensions/copilot/static/face.gif
Normal file
|
After Width: | Height: | Size: 536 KiB |
BIN
lnbits/extensions/copilot/static/lnurl.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
lnbits/extensions/copilot/static/martijn.gif
Normal file
|
After Width: | Height: | Size: 504 KiB |
BIN
lnbits/extensions/copilot/static/rick.gif
Normal file
|
After Width: | Height: | Size: 2.3 MiB |
BIN
lnbits/extensions/copilot/static/rocket.gif
Normal file
|
After Width: | Height: | Size: 577 KiB |
81
lnbits/extensions/copilot/tasks.py
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from http import HTTPStatus
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from starlette.exceptions import HTTPException
|
||||||
|
|
||||||
|
from lnbits.core import db as core_db
|
||||||
|
from lnbits.core.models import Payment
|
||||||
|
from lnbits.tasks import register_invoice_listener
|
||||||
|
|
||||||
|
from .crud import get_copilot
|
||||||
|
from .views import updater
|
||||||
|
|
||||||
|
|
||||||
|
async def wait_for_paid_invoices():
|
||||||
|
invoice_queue = asyncio.Queue()
|
||||||
|
register_invoice_listener(invoice_queue)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
payment = await invoice_queue.get()
|
||||||
|
await on_invoice_paid(payment)
|
||||||
|
|
||||||
|
|
||||||
|
async def on_invoice_paid(payment: Payment) -> None:
|
||||||
|
webhook = None
|
||||||
|
data = None
|
||||||
|
if "copilot" != payment.extra.get("tag"):
|
||||||
|
# not an copilot invoice
|
||||||
|
return
|
||||||
|
|
||||||
|
copilot = await get_copilot(payment.extra.get("copilotid", -1))
|
||||||
|
|
||||||
|
if not copilot:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="Copilot does not exist"
|
||||||
|
)
|
||||||
|
if copilot.animation1threshold:
|
||||||
|
if int(payment.amount / 1000) >= copilot.animation1threshold:
|
||||||
|
data = copilot.animation1
|
||||||
|
webhook = copilot.animation1webhook
|
||||||
|
if copilot.animation2threshold:
|
||||||
|
if int(payment.amount / 1000) >= copilot.animation2threshold:
|
||||||
|
data = copilot.animation2
|
||||||
|
webhook = copilot.animation1webhook
|
||||||
|
if copilot.animation3threshold:
|
||||||
|
if int(payment.amount / 1000) >= copilot.animation3threshold:
|
||||||
|
data = copilot.animation3
|
||||||
|
webhook = copilot.animation1webhook
|
||||||
|
if webhook:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
try:
|
||||||
|
r = await client.post(
|
||||||
|
webhook,
|
||||||
|
json={
|
||||||
|
"payment_hash": payment.payment_hash,
|
||||||
|
"payment_request": payment.bolt11,
|
||||||
|
"amount": payment.amount,
|
||||||
|
"comment": payment.extra.get("comment"),
|
||||||
|
},
|
||||||
|
timeout=40,
|
||||||
|
)
|
||||||
|
await mark_webhook_sent(payment, r.status_code)
|
||||||
|
except (httpx.ConnectError, httpx.RequestError):
|
||||||
|
await mark_webhook_sent(payment, -1)
|
||||||
|
if payment.extra.get("comment"):
|
||||||
|
await updater(copilot.id, data, payment.extra.get("comment"))
|
||||||
|
|
||||||
|
await updater(copilot.id, data, "none")
|
||||||
|
|
||||||
|
|
||||||
|
async def mark_webhook_sent(payment: Payment, status: int) -> None:
|
||||||
|
payment.extra["wh_status"] = status
|
||||||
|
|
||||||
|
await core_db.execute(
|
||||||
|
"""
|
||||||
|
UPDATE apipayments SET extra = ?
|
||||||
|
WHERE hash = ?
|
||||||
|
""",
|
||||||
|
(json.dumps(payment.extra), payment.payment_hash),
|
||||||
|
)
|
||||||
172
lnbits/extensions/copilot/templates/copilot/_api_docs.html
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<p>
|
||||||
|
StreamerCopilot: get tips via static QR (lnurl-pay) and show an
|
||||||
|
animation<br />
|
||||||
|
<small>
|
||||||
|
Created by, <a href="https://github.com/benarc">Ben Arc</a></small
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
</q-card-section>
|
||||||
|
<q-expansion-item
|
||||||
|
group="extras"
|
||||||
|
icon="swap_vertical_circle"
|
||||||
|
label="API info"
|
||||||
|
:content-inset-level="0.5"
|
||||||
|
>
|
||||||
|
<q-expansion-item group="api" dense expand-separator label="Create copilot">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<code
|
||||||
|
><span class="text-blue">POST</span> /copilot/api/v1/copilot</code
|
||||||
|
>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||||
|
<code>{"X-Api-Key": <admin_key>}</code><br />
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||||
|
Body (application/json)
|
||||||
|
</h5>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||||
|
Returns 200 OK (application/json)
|
||||||
|
</h5>
|
||||||
|
<code>[<copilot_object>, ...]</code>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
|
<code
|
||||||
|
>curl -X POST {{ request.url_root }}api/v1/copilot -d '{"title":
|
||||||
|
<string>, "animation": <string>,
|
||||||
|
"show_message":<string>, "amount": <integer>,
|
||||||
|
"lnurl_title": <string>}' -H "Content-type: application/json"
|
||||||
|
-H "X-Api-Key: {{user.wallets[0].adminkey }}"
|
||||||
|
</code>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
<q-expansion-item group="api" dense expand-separator label="Update copilot">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<code
|
||||||
|
><span class="text-blue">PUT</span>
|
||||||
|
/copilot/api/v1/copilot/<copilot_id></code
|
||||||
|
>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||||
|
<code>{"X-Api-Key": <admin_key>}</code><br />
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||||
|
Body (application/json)
|
||||||
|
</h5>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||||
|
Returns 200 OK (application/json)
|
||||||
|
</h5>
|
||||||
|
<code>[<copilot_object>, ...]</code>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
|
<code
|
||||||
|
>curl -X POST {{ request.url_root
|
||||||
|
}}api/v1/copilot/<copilot_id> -d '{"title": <string>,
|
||||||
|
"animation": <string>, "show_message":<string>,
|
||||||
|
"amount": <integer>, "lnurl_title": <string>}' -H
|
||||||
|
"Content-type: application/json" -H "X-Api-Key:
|
||||||
|
{{user.wallets[0].adminkey }}"
|
||||||
|
</code>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
|
||||||
|
<q-expansion-item group="api" dense expand-separator label="Get copilot">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<code
|
||||||
|
><span class="text-blue">GET</span>
|
||||||
|
/copilot/api/v1/copilot/<copilot_id></code
|
||||||
|
>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||||
|
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||||
|
Body (application/json)
|
||||||
|
</h5>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||||
|
Returns 200 OK (application/json)
|
||||||
|
</h5>
|
||||||
|
<code>[<copilot_object>, ...]</code>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
|
<code
|
||||||
|
>curl -X GET {{ request.url_root }}api/v1/copilot/<copilot_id>
|
||||||
|
-H "X-Api-Key: {{ user.wallets[0].inkey }}"
|
||||||
|
</code>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
<q-expansion-item group="api" dense expand-separator label="Get copilots">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<code
|
||||||
|
><span class="text-blue">GET</span> /copilot/api/v1/copilots</code
|
||||||
|
>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||||
|
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||||
|
Body (application/json)
|
||||||
|
</h5>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||||
|
Returns 200 OK (application/json)
|
||||||
|
</h5>
|
||||||
|
<code>[<copilot_object>, ...]</code>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
|
<code
|
||||||
|
>curl -X GET {{ request.url_root }}api/v1/copilots -H "X-Api-Key: {{
|
||||||
|
user.wallets[0].inkey }}"
|
||||||
|
</code>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
<q-expansion-item
|
||||||
|
group="api"
|
||||||
|
dense
|
||||||
|
expand-separator
|
||||||
|
label="Delete a pay link"
|
||||||
|
class="q-pb-md"
|
||||||
|
>
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<code
|
||||||
|
><span class="text-pink">DELETE</span>
|
||||||
|
/copilot/api/v1/copilot/<copilot_id></code
|
||||||
|
>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||||
|
<code>{"X-Api-Key": <admin_key>}</code><br />
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Returns 204 NO CONTENT</h5>
|
||||||
|
<code></code>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
|
<code
|
||||||
|
>curl -X DELETE {{ request.url_root
|
||||||
|
}}api/v1/copilot/<copilot_id> -H "X-Api-Key: {{
|
||||||
|
user.wallets[0].adminkey }}"
|
||||||
|
</code>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
<q-expansion-item
|
||||||
|
group="api"
|
||||||
|
dense
|
||||||
|
expand-separator
|
||||||
|
label="Trigger an animation"
|
||||||
|
class="q-pb-md"
|
||||||
|
>
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<code
|
||||||
|
><span class="text-blue">GET</span>
|
||||||
|
/api/v1/copilot/ws/<copilot_id>/<comment>/<data></code
|
||||||
|
>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||||
|
<code>{"X-Api-Key": <admin_key>}</code><br />
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Returns 200</h5>
|
||||||
|
<code></code>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
|
<code
|
||||||
|
>curl -X GET {{ request.url_root }}/api/v1/copilot/ws/<string,
|
||||||
|
copilot_id>/<string, comment>/<string, gif name> -H
|
||||||
|
"X-Api-Key: {{ user.wallets[0].adminkey }}"
|
||||||
|
</code>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
</q-expansion-item>
|
||||||
|
</q-card>
|
||||||
287
lnbits/extensions/copilot/templates/copilot/compose.html
Normal file
|
|
@ -0,0 +1,287 @@
|
||||||
|
{% extends "public.html" %} {% block page %}<q-page>
|
||||||
|
<video
|
||||||
|
autoplay="true"
|
||||||
|
id="videoScreen"
|
||||||
|
style="width: 100%"
|
||||||
|
class="fixed-bottom-right"
|
||||||
|
></video>
|
||||||
|
<video
|
||||||
|
autoplay="true"
|
||||||
|
id="videoCamera"
|
||||||
|
style="width: 100%"
|
||||||
|
class="fixed-bottom-right"
|
||||||
|
></video>
|
||||||
|
<img src="" style="width: 100%" id="animations" class="fixed-bottom-left" />
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="copilot.lnurl_toggle == 1"
|
||||||
|
class="rounded-borders column fixed-right"
|
||||||
|
style="
|
||||||
|
width: 250px;
|
||||||
|
background-color: white;
|
||||||
|
height: 300px;
|
||||||
|
margin-top: 10%;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div class="col">
|
||||||
|
<qrcode
|
||||||
|
:value="copilot.lnurl"
|
||||||
|
:options="{width:250}"
|
||||||
|
class="rounded-borders"
|
||||||
|
></qrcode>
|
||||||
|
<center class="absolute-bottom" style="color: black; font-size: 20px">
|
||||||
|
{% raw %}{{ copilot.lnurl_title }}{% endraw %}
|
||||||
|
</center>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2
|
||||||
|
v-if="copilot.show_price != 0"
|
||||||
|
class="text-bold fixed-bottom-left"
|
||||||
|
style="
|
||||||
|
margin: 60px 60px;
|
||||||
|
font-size: 110px;
|
||||||
|
text-shadow: 4px 8px 4px black;
|
||||||
|
color: white;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{% raw %}{{ price }}{% endraw %}
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
v-if="copilot.show_ack != 0"
|
||||||
|
class="fixed-top"
|
||||||
|
style="
|
||||||
|
font-size: 22px;
|
||||||
|
text-shadow: 2px 4px 1px black;
|
||||||
|
color: white;
|
||||||
|
padding-left: 40%;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Powered by LNbits/StreamerCopilot
|
||||||
|
</p>
|
||||||
|
</q-page>
|
||||||
|
{% endblock %} {% block scripts %}
|
||||||
|
<style>
|
||||||
|
body.body--dark .q-drawer,
|
||||||
|
body.body--dark .q-footer,
|
||||||
|
body.body--dark .q-header,
|
||||||
|
.q-drawer,
|
||||||
|
.q-footer,
|
||||||
|
.q-header {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.q-page {
|
||||||
|
padding: 0px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script>
|
||||||
|
Vue.component(VueQrcode.name, VueQrcode)
|
||||||
|
|
||||||
|
new Vue({
|
||||||
|
el: '#vue',
|
||||||
|
mixins: [windowMixin],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
price: '',
|
||||||
|
counter: 1,
|
||||||
|
colours: ['teal', 'purple', 'indigo', 'pink', 'green'],
|
||||||
|
copilot: {},
|
||||||
|
animQueue: [],
|
||||||
|
queue: false,
|
||||||
|
lnurl: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
showNotif: function (userMessage) {
|
||||||
|
var colour = this.colours[
|
||||||
|
Math.floor(Math.random() * this.colours.length)
|
||||||
|
]
|
||||||
|
this.$q.notify({
|
||||||
|
color: colour,
|
||||||
|
icon: 'chat_bubble_outline',
|
||||||
|
html: true,
|
||||||
|
message: '<h4 style="color: white;">' + userMessage + '</h4>',
|
||||||
|
position: 'top-left',
|
||||||
|
timeout: 5000
|
||||||
|
})
|
||||||
|
},
|
||||||
|
openURL: function (url) {
|
||||||
|
return Quasar.utils.openURL(url)
|
||||||
|
},
|
||||||
|
initCamera() {
|
||||||
|
var video = document.querySelector('#videoCamera')
|
||||||
|
|
||||||
|
if (navigator.mediaDevices.getUserMedia) {
|
||||||
|
navigator.mediaDevices
|
||||||
|
.getUserMedia({video: true})
|
||||||
|
.then(function (stream) {
|
||||||
|
video.srcObject = stream
|
||||||
|
})
|
||||||
|
.catch(function (err0r) {
|
||||||
|
console.log('Something went wrong!')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
initScreenShare() {
|
||||||
|
var video = document.querySelector('#videoScreen')
|
||||||
|
navigator.mediaDevices
|
||||||
|
.getDisplayMedia({video: true})
|
||||||
|
.then(function (stream) {
|
||||||
|
video.srcObject = stream
|
||||||
|
})
|
||||||
|
.catch(function (err0r) {
|
||||||
|
console.log('Something went wrong!')
|
||||||
|
})
|
||||||
|
},
|
||||||
|
pushAnim(content) {
|
||||||
|
document.getElementById('animations').style.width = content[0]
|
||||||
|
document.getElementById('animations').src = content[1]
|
||||||
|
if (content[2] != 'none') {
|
||||||
|
self.showNotif(content[2])
|
||||||
|
}
|
||||||
|
setTimeout(function () {
|
||||||
|
document.getElementById('animations').src = ''
|
||||||
|
}, 5000)
|
||||||
|
},
|
||||||
|
launch() {
|
||||||
|
self = this
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'GET',
|
||||||
|
'/copilot/api/v1/copilot/ws/' +
|
||||||
|
self.copilot.id +
|
||||||
|
'/launching/rocket'
|
||||||
|
)
|
||||||
|
.then(function (response1) {
|
||||||
|
self.$q.notify({
|
||||||
|
color: 'green',
|
||||||
|
message: 'Sent!'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
LNbits.utils.notifyApiError(err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.initCamera()
|
||||||
|
},
|
||||||
|
created: function () {
|
||||||
|
self = this
|
||||||
|
self.copilot = JSON.parse(localStorage.getItem('copilot'))
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'GET',
|
||||||
|
'/copilot/api/v1/copilot/' + self.copilot.id,
|
||||||
|
localStorage.getItem('inkey')
|
||||||
|
)
|
||||||
|
.then(function (response) {
|
||||||
|
self.copilot = response.data
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
LNbits.utils.notifyApiError(err)
|
||||||
|
})
|
||||||
|
|
||||||
|
this.connectionBitStamp = new WebSocket('wss://ws.bitstamp.net')
|
||||||
|
|
||||||
|
const obj = JSON.stringify({
|
||||||
|
event: 'bts:subscribe',
|
||||||
|
data: {channel: 'live_trades_' + self.copilot.show_price}
|
||||||
|
})
|
||||||
|
|
||||||
|
this.connectionBitStamp.onmessage = function (e) {
|
||||||
|
if (self.copilot.show_price) {
|
||||||
|
if (self.copilot.show_price == 'btcusd') {
|
||||||
|
self.price = String(
|
||||||
|
new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'USD'
|
||||||
|
}).format(JSON.parse(e.data).data.price)
|
||||||
|
)
|
||||||
|
} else if (self.copilot.show_price == 'btceur') {
|
||||||
|
self.price = String(
|
||||||
|
new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'EUR'
|
||||||
|
}).format(JSON.parse(e.data).data.price)
|
||||||
|
)
|
||||||
|
} else if (self.copilot.show_price == 'btcgbp') {
|
||||||
|
self.price = String(
|
||||||
|
new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'GBP'
|
||||||
|
}).format(JSON.parse(e.data).data.price)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.connectionBitStamp.onopen = () => this.connectionBitStamp.send(obj)
|
||||||
|
|
||||||
|
const fetch = data =>
|
||||||
|
new Promise(resolve => setTimeout(resolve, 5000, this.pushAnim(data)))
|
||||||
|
|
||||||
|
const addTask = (() => {
|
||||||
|
let pending = Promise.resolve()
|
||||||
|
const run = async data => {
|
||||||
|
try {
|
||||||
|
await pending
|
||||||
|
} finally {
|
||||||
|
return fetch(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data => (pending = run(data))
|
||||||
|
})()
|
||||||
|
|
||||||
|
if (location.protocol !== 'http:') {
|
||||||
|
localUrl =
|
||||||
|
'wss://' +
|
||||||
|
document.domain +
|
||||||
|
':' +
|
||||||
|
location.port +
|
||||||
|
'/copilot/ws/' +
|
||||||
|
self.copilot.id
|
||||||
|
} else {
|
||||||
|
localUrl =
|
||||||
|
'ws://' +
|
||||||
|
document.domain +
|
||||||
|
':' +
|
||||||
|
location.port +
|
||||||
|
'/copilot/ws/' +
|
||||||
|
self.copilot.id
|
||||||
|
}
|
||||||
|
this.connection = new WebSocket(localUrl)
|
||||||
|
this.connection.onmessage = function (e) {
|
||||||
|
console.log(e)
|
||||||
|
res = e.data.split('-')
|
||||||
|
if (res[0] == 'rocket') {
|
||||||
|
addTask(['40%', '/copilot/static/rocket.gif', res[1]])
|
||||||
|
}
|
||||||
|
if (res[0] == 'face') {
|
||||||
|
addTask(['35%', '/copilot/static/face.gif', res[1]])
|
||||||
|
}
|
||||||
|
if (res[0] == 'bitcoin') {
|
||||||
|
addTask(['30%', '/copilot/static/bitcoin.gif', res[1]])
|
||||||
|
}
|
||||||
|
if (res[0] == 'confetti') {
|
||||||
|
addTask(['100%', '/copilot/static/confetti.gif', res[1]])
|
||||||
|
}
|
||||||
|
if (res[0] == 'martijn') {
|
||||||
|
addTask(['40%', '/copilot/static/martijn.gif', res[1]])
|
||||||
|
}
|
||||||
|
if (res[0] == 'rick') {
|
||||||
|
addTask(['40%', '/copilot/static/rick.gif', res[1]])
|
||||||
|
}
|
||||||
|
if (res[0] == 'true') {
|
||||||
|
document.getElementById('videoCamera').style.width = '20%'
|
||||||
|
self.initScreenShare()
|
||||||
|
}
|
||||||
|
if (res[0] == 'false') {
|
||||||
|
document.getElementById('videoCamera').style.width = '100%'
|
||||||
|
document.getElementById('videoScreen').src = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.connection.onopen = () => this.launch
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
660
lnbits/extensions/copilot/templates/copilot/index.html
Normal file
|
|
@ -0,0 +1,660 @@
|
||||||
|
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||||
|
%} {% block page %}
|
||||||
|
|
||||||
|
<div class="row q-col-gutter-md">
|
||||||
|
<div class="col-12 col-md-7 q-gutter-y-md">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
{% raw %}
|
||||||
|
<q-btn unelevated color="primary" @click="formDialogCopilot.show = true"
|
||||||
|
>New copilot instance
|
||||||
|
</q-btn>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<div class="row items-center no-wrap q-mb-md">
|
||||||
|
<div class="col">
|
||||||
|
<h5 class="text-subtitle1 q-my-none">Copilots</h5>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-auto">
|
||||||
|
<q-input
|
||||||
|
borderless
|
||||||
|
dense
|
||||||
|
debounce="300"
|
||||||
|
v-model="filter"
|
||||||
|
placeholder="Search"
|
||||||
|
>
|
||||||
|
<template v-slot:append>
|
||||||
|
<q-icon name="search"></q-icon>
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
<q-btn flat color="grey" @click="exportcopilotCSV"
|
||||||
|
>Export to CSV</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-table
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
:data="CopilotLinks"
|
||||||
|
row-key="id"
|
||||||
|
:columns="CopilotsTable.columns"
|
||||||
|
:pagination.sync="CopilotsTable.pagination"
|
||||||
|
:filter="filter"
|
||||||
|
>
|
||||||
|
<template v-slot:header="props">
|
||||||
|
<q-tr :props="props">
|
||||||
|
<q-th style="width: 5%"></q-th>
|
||||||
|
<q-th style="width: 5%"></q-th>
|
||||||
|
<q-th style="width: 5%"></q-th>
|
||||||
|
<q-th style="width: 5%"></q-th>
|
||||||
|
|
||||||
|
<q-th
|
||||||
|
v-for="col in props.cols"
|
||||||
|
:key="col.name"
|
||||||
|
:props="props"
|
||||||
|
auto-width
|
||||||
|
>
|
||||||
|
<div v-if="col.name == 'id'"></div>
|
||||||
|
<div v-else>{{ col.label }}</div>
|
||||||
|
</q-th>
|
||||||
|
<!-- <q-th auto-width></q-th> -->
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-slot:body="props">
|
||||||
|
<q-tr :props="props">
|
||||||
|
<q-td>
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
dense
|
||||||
|
size="xs"
|
||||||
|
icon="apps"
|
||||||
|
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||||
|
@click="openCopilotPanel(props.row.id)"
|
||||||
|
>
|
||||||
|
<q-tooltip> Panel </q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</q-td>
|
||||||
|
<q-td>
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
dense
|
||||||
|
size="xs"
|
||||||
|
icon="face"
|
||||||
|
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||||
|
@click="openCopilotCompose(props.row.id)"
|
||||||
|
>
|
||||||
|
<q-tooltip> Compose window </q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</q-td>
|
||||||
|
<q-td>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
size="xs"
|
||||||
|
@click="deleteCopilotLink(props.row.id)"
|
||||||
|
icon="cancel"
|
||||||
|
color="pink"
|
||||||
|
>
|
||||||
|
<q-tooltip> Delete copilot </q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</q-td>
|
||||||
|
<q-td>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
size="xs"
|
||||||
|
@click="openUpdateCopilotLink(props.row.id)"
|
||||||
|
icon="edit"
|
||||||
|
color="light-blue"
|
||||||
|
>
|
||||||
|
<q-tooltip> Edit copilot </q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</q-td>
|
||||||
|
<q-td
|
||||||
|
v-for="col in props.cols"
|
||||||
|
:key="col.name"
|
||||||
|
:props="props"
|
||||||
|
auto-width
|
||||||
|
>
|
||||||
|
<div v-if="col.name == 'id'"></div>
|
||||||
|
<div v-else>{{ col.value }}</div>
|
||||||
|
</q-td>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
{% endraw %}
|
||||||
|
</q-table>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-md-5 q-gutter-y-md">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<h6 class="text-subtitle1 q-my-none">
|
||||||
|
{{SITE_TITLE}} StreamCopilot Extension
|
||||||
|
</h6>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section class="q-pa-none">
|
||||||
|
<q-separator></q-separator>
|
||||||
|
<q-list> {% include "copilot/_api_docs.html" %} </q-list>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
<q-dialog
|
||||||
|
v-model="formDialogCopilot.show"
|
||||||
|
position="top"
|
||||||
|
@hide="closeFormDialog"
|
||||||
|
>
|
||||||
|
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||||
|
<q-form @submit="sendFormDataCopilot" class="q-gutter-md">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialogCopilot.data.title"
|
||||||
|
type="text"
|
||||||
|
label="Title"
|
||||||
|
></q-input>
|
||||||
|
<div class="row">
|
||||||
|
<q-checkbox
|
||||||
|
v-model="formDialogCopilot.data.lnurl_toggle"
|
||||||
|
label="Include lnurl payment QR? (requires https)"
|
||||||
|
left-label
|
||||||
|
></q-checkbox>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="formDialogCopilot.data.lnurl_toggle">
|
||||||
|
<q-checkbox
|
||||||
|
v-model="formDialogCopilot.data.show_message"
|
||||||
|
left-label
|
||||||
|
label="Show lnurl-pay messages? (supported by few wallets)"
|
||||||
|
></q-checkbox>
|
||||||
|
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
emit-value
|
||||||
|
v-model="formDialogCopilot.data.wallet"
|
||||||
|
:options="g.user.walletOptions"
|
||||||
|
label="Wallet *"
|
||||||
|
></q-select>
|
||||||
|
|
||||||
|
<q-expansion-item
|
||||||
|
group="api"
|
||||||
|
dense
|
||||||
|
expand-separator
|
||||||
|
label="Payment threshold 1"
|
||||||
|
>
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialogCopilot.data.animation1"
|
||||||
|
:options="options"
|
||||||
|
label="Animation"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col q-pl-xs">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialogCopilot.data.animation1threshold"
|
||||||
|
type="number"
|
||||||
|
step="1"
|
||||||
|
label="From *sats (min. 10)"
|
||||||
|
:rules="[ val => val >= 10 || 'Please use minimum 10' ]"
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col q-pl-xs">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialogCopilot.data.animation1webhook"
|
||||||
|
type="text"
|
||||||
|
label="Webhook"
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
|
||||||
|
<q-expansion-item
|
||||||
|
group="api"
|
||||||
|
dense
|
||||||
|
expand-separator
|
||||||
|
label="Payment threshold 2 (Must be higher than last)"
|
||||||
|
>
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<div
|
||||||
|
class="row"
|
||||||
|
v-if="formDialogCopilot.data.animation1threshold > 0"
|
||||||
|
>
|
||||||
|
<div class="col">
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialogCopilot.data.animation2"
|
||||||
|
:options="options"
|
||||||
|
label="Animation"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col q-pl-xs">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model="formDialogCopilot.data.animation2threshold"
|
||||||
|
type="number"
|
||||||
|
step="1"
|
||||||
|
label="From *sats"
|
||||||
|
:min="formDialogCopilot.data.animation1threshold"
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col q-pl-xs">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialogCopilot.data.animation2webhook"
|
||||||
|
type="text"
|
||||||
|
label="Webhook"
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
|
||||||
|
<q-expansion-item
|
||||||
|
group="api"
|
||||||
|
dense
|
||||||
|
expand-separator
|
||||||
|
label="Payment threshold 3 (Must be higher than last)"
|
||||||
|
>
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<div
|
||||||
|
class="row"
|
||||||
|
v-if="formDialogCopilot.data.animation2threshold > formDialogCopilot.data.animation1threshold"
|
||||||
|
>
|
||||||
|
<div class="col">
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialogCopilot.data.animation3"
|
||||||
|
:options="options"
|
||||||
|
label="Animation"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col q-pl-xs">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model="formDialogCopilot.data.animation3threshold"
|
||||||
|
type="number"
|
||||||
|
step="1"
|
||||||
|
label="From *sats"
|
||||||
|
:min="formDialogCopilot.data.animation2threshold"
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col q-pl-xs">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialogCopilot.data.animation3webhook"
|
||||||
|
type="text"
|
||||||
|
label="Webhook"
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialogCopilot.data.lnurl_title"
|
||||||
|
type="text"
|
||||||
|
max="1440"
|
||||||
|
label="Lnurl title (message with QR code)"
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="q-gutter-sm">
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
style="width: 50%"
|
||||||
|
v-model.trim="formDialogCopilot.data.show_price"
|
||||||
|
:options="currencyOptions"
|
||||||
|
label="Show price"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="q-gutter-sm">
|
||||||
|
<div class="row">
|
||||||
|
<q-checkbox
|
||||||
|
v-model="formDialogCopilot.data.show_ack"
|
||||||
|
left-label
|
||||||
|
label="Show 'powered by LNbits'"
|
||||||
|
></q-checkbox>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row q-mt-lg">
|
||||||
|
<q-btn
|
||||||
|
v-if="formDialogCopilot.data.id"
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
:disable="
|
||||||
|
formDialogCopilot.data.title == ''"
|
||||||
|
type="submit"
|
||||||
|
>Update Copilot</q-btn
|
||||||
|
>
|
||||||
|
<q-btn
|
||||||
|
v-else
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
:disable="
|
||||||
|
formDialogCopilot.data.title == ''"
|
||||||
|
type="submit"
|
||||||
|
>Create Copilot</q-btn
|
||||||
|
>
|
||||||
|
<q-btn @click="cancelCopilot" flat color="grey" class="q-ml-auto"
|
||||||
|
>Cancel</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</q-form>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</div>
|
||||||
|
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||||
|
<script>
|
||||||
|
Vue.component(VueQrcode.name, VueQrcode)
|
||||||
|
|
||||||
|
var mapCopilot = obj => {
|
||||||
|
obj._data = _.clone(obj)
|
||||||
|
|
||||||
|
obj.theTime = obj.time * 60 - (Date.now() / 1000 - obj.timestamp)
|
||||||
|
obj.time = obj.time + 'mins'
|
||||||
|
|
||||||
|
if (obj.time_elapsed) {
|
||||||
|
obj.date = 'Time elapsed'
|
||||||
|
} else {
|
||||||
|
obj.date = Quasar.utils.date.formatDate(
|
||||||
|
new Date((obj.theTime - 3600) * 1000),
|
||||||
|
'HH:mm:ss'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
obj.displayComposeUrl = ['/copilot/cp/', obj.id].join('')
|
||||||
|
obj.displayPanelUrl = ['/copilot/', obj.id].join('')
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
|
||||||
|
new Vue({
|
||||||
|
el: '#vue',
|
||||||
|
mixins: [windowMixin],
|
||||||
|
data: function () {
|
||||||
|
return {
|
||||||
|
filter: '',
|
||||||
|
CopilotLinks: [],
|
||||||
|
CopilotLinksObj: [],
|
||||||
|
CopilotsTable: {
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'theId',
|
||||||
|
align: 'left',
|
||||||
|
label: 'id',
|
||||||
|
field: 'id'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'lnurl_toggle',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Show lnurl pay link',
|
||||||
|
field: 'lnurl_toggle'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'title',
|
||||||
|
align: 'left',
|
||||||
|
label: 'title',
|
||||||
|
field: 'title'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'amount_made',
|
||||||
|
align: 'left',
|
||||||
|
label: 'amount made',
|
||||||
|
field: 'amount_made'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
pagination: {
|
||||||
|
rowsPerPage: 10
|
||||||
|
}
|
||||||
|
},
|
||||||
|
passedCopilot: {},
|
||||||
|
formDialog: {
|
||||||
|
show: false,
|
||||||
|
data: {}
|
||||||
|
},
|
||||||
|
formDialogCopilot: {
|
||||||
|
show: false,
|
||||||
|
data: {
|
||||||
|
lnurl_toggle: false,
|
||||||
|
show_message: false,
|
||||||
|
show_ack: false,
|
||||||
|
show_price: 'None',
|
||||||
|
title: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
qrCodeDialog: {
|
||||||
|
show: false,
|
||||||
|
data: null
|
||||||
|
},
|
||||||
|
options: ['bitcoin', 'confetti', 'rocket', 'face', 'martijn', 'rick'],
|
||||||
|
currencyOptions: ['None', 'btcusd', 'btceur', 'btcgbp']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
cancelCopilot: function (data) {
|
||||||
|
var self = this
|
||||||
|
self.formDialogCopilot.show = false
|
||||||
|
self.clearFormDialogCopilot()
|
||||||
|
},
|
||||||
|
closeFormDialog: function () {
|
||||||
|
this.clearFormDialogCopilot()
|
||||||
|
this.formDialog.data = {
|
||||||
|
is_unique: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
sendFormDataCopilot: function () {
|
||||||
|
var self = this
|
||||||
|
if (self.formDialogCopilot.data.id) {
|
||||||
|
this.updateCopilot(
|
||||||
|
self.g.user.wallets[0].adminkey,
|
||||||
|
self.formDialogCopilot.data
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
console.log(self.g.user.wallets[0].adminkey)
|
||||||
|
console.log(self.formDialogCopilot.data)
|
||||||
|
this.createCopilot(
|
||||||
|
self.g.user.wallets[0].adminkey,
|
||||||
|
self.formDialogCopilot.data
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
createCopilot: function (wallet, data) {
|
||||||
|
var self = this
|
||||||
|
var updatedData = {}
|
||||||
|
for (const property in data) {
|
||||||
|
if (data[property]) {
|
||||||
|
updatedData[property] = data[property]
|
||||||
|
}
|
||||||
|
if (property == 'animation1threshold' && data[property]) {
|
||||||
|
updatedData[property] = parseInt(data[property])
|
||||||
|
}
|
||||||
|
if (property == 'animation2threshold' && data[property]) {
|
||||||
|
updatedData[property] = parseInt(data[property])
|
||||||
|
}
|
||||||
|
if (property == 'animation3threshold' && data[property]) {
|
||||||
|
updatedData[property] = parseInt(data[property])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LNbits.api
|
||||||
|
.request('POST', '/copilot/api/v1/copilot', wallet, updatedData)
|
||||||
|
.then(function (response) {
|
||||||
|
self.CopilotLinks.push(mapCopilot(response.data))
|
||||||
|
self.formDialogCopilot.show = false
|
||||||
|
self.clearFormDialogCopilot()
|
||||||
|
})
|
||||||
|
.catch(function (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
getCopilots: function () {
|
||||||
|
var self = this
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'GET',
|
||||||
|
'/copilot/api/v1/copilot',
|
||||||
|
this.g.user.wallets[0].inkey
|
||||||
|
)
|
||||||
|
.then(function (response) {
|
||||||
|
if (response.data) {
|
||||||
|
self.CopilotLinks = response.data.map(mapCopilot)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
getCopilot: function (copilot_id) {
|
||||||
|
var self = this
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'GET',
|
||||||
|
'/copilot/api/v1/copilot/' + copilot_id,
|
||||||
|
this.g.user.wallets[0].inkey
|
||||||
|
)
|
||||||
|
.then(function (response) {
|
||||||
|
localStorage.setItem('copilot', JSON.stringify(response.data))
|
||||||
|
localStorage.setItem('inkey', self.g.user.wallets[0].inkey)
|
||||||
|
})
|
||||||
|
.catch(function (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
openCopilotCompose: function (copilot_id) {
|
||||||
|
this.getCopilot(copilot_id)
|
||||||
|
let params =
|
||||||
|
'scrollbars=no, resizable=no,status=no,location=no,toolbar=no,menubar=no,width=1200,height=644,left=410,top=100'
|
||||||
|
open('../copilot/cp/', '_blank', params)
|
||||||
|
},
|
||||||
|
openCopilotPanel: function (copilot_id) {
|
||||||
|
this.getCopilot(copilot_id)
|
||||||
|
let params =
|
||||||
|
'scrollbars=no, resizable=no,status=no,location=no,toolbar=no,menubar=no,width=300,height=450,left=10,top=400'
|
||||||
|
open('../copilot/pn/', '_blank', params)
|
||||||
|
},
|
||||||
|
deleteCopilotLink: function (copilotId) {
|
||||||
|
var self = this
|
||||||
|
var link = _.findWhere(this.CopilotLinks, {id: copilotId})
|
||||||
|
LNbits.utils
|
||||||
|
.confirmDialog('Are you sure you want to delete this pay link?')
|
||||||
|
.onOk(function () {
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'DELETE',
|
||||||
|
'/copilot/api/v1/copilot/' + copilotId,
|
||||||
|
self.g.user.wallets[0].adminkey
|
||||||
|
)
|
||||||
|
.then(function (response) {
|
||||||
|
self.CopilotLinks = _.reject(self.CopilotLinks, function (obj) {
|
||||||
|
return obj.id === copilotId
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(function (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
openUpdateCopilotLink: function (copilotId) {
|
||||||
|
var self = this
|
||||||
|
var copilot = _.findWhere(this.CopilotLinks, {id: copilotId})
|
||||||
|
self.formDialogCopilot.data = _.clone(copilot._data)
|
||||||
|
self.formDialogCopilot.show = true
|
||||||
|
},
|
||||||
|
updateCopilot: function (wallet, data) {
|
||||||
|
var self = this
|
||||||
|
var updatedData = {}
|
||||||
|
for (const property in data) {
|
||||||
|
if (data[property]) {
|
||||||
|
updatedData[property] = data[property]
|
||||||
|
}
|
||||||
|
if (property == 'animation1threshold' && data[property]) {
|
||||||
|
updatedData[property] = parseInt(data[property])
|
||||||
|
}
|
||||||
|
if (property == 'animation2threshold' && data[property]) {
|
||||||
|
updatedData[property] = parseInt(data[property])
|
||||||
|
}
|
||||||
|
if (property == 'animation3threshold' && data[property]) {
|
||||||
|
updatedData[property] = parseInt(data[property])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'PUT',
|
||||||
|
'/copilot/api/v1/copilot/' + updatedData.id,
|
||||||
|
wallet,
|
||||||
|
updatedData
|
||||||
|
)
|
||||||
|
.then(function (response) {
|
||||||
|
self.CopilotLinks = _.reject(self.CopilotLinks, function (obj) {
|
||||||
|
return obj.id === updatedData.id
|
||||||
|
})
|
||||||
|
self.CopilotLinks.push(mapCopilot(response.data))
|
||||||
|
self.formDialogCopilot.show = false
|
||||||
|
self.clearFormDialogCopilot()
|
||||||
|
})
|
||||||
|
.catch(function (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
clearFormDialogCopilot() {
|
||||||
|
this.formDialogCopilot.data = {
|
||||||
|
lnurl_toggle: false,
|
||||||
|
show_message: false,
|
||||||
|
show_ack: false,
|
||||||
|
show_price: 'None',
|
||||||
|
title: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
exportcopilotCSV: function () {
|
||||||
|
var self = this
|
||||||
|
LNbits.utils.exportCSV(self.CopilotsTable.columns, this.CopilotLinks)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created: function () {
|
||||||
|
var self = this
|
||||||
|
var getCopilots = this.getCopilots
|
||||||
|
getCopilots()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
156
lnbits/extensions/copilot/templates/copilot/panel.html
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
{% extends "public.html" %} {% block page %}
|
||||||
|
<div class="q-pa-sm" style="width: 240px; margin: 10px auto">
|
||||||
|
<q-card class="my-card">
|
||||||
|
<div class="column">
|
||||||
|
<div class="col">
|
||||||
|
<center>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
round
|
||||||
|
dense
|
||||||
|
@click="openCompose"
|
||||||
|
icon="face"
|
||||||
|
style="font-size: 60px"
|
||||||
|
></q-btn>
|
||||||
|
</center>
|
||||||
|
</div>
|
||||||
|
<center>
|
||||||
|
<div class="col" style="margin: 15px; font-size: 22px">
|
||||||
|
Title: {% raw %} {{ copilot.title }} {% endraw %}
|
||||||
|
</div>
|
||||||
|
</center>
|
||||||
|
<q-separator></q-separator>
|
||||||
|
<div class="col">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<q-btn
|
||||||
|
class="q-mt-sm q-ml-sm"
|
||||||
|
color="primary"
|
||||||
|
@click="fullscreenToggle"
|
||||||
|
label="Screen share"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row q-pa-sm">
|
||||||
|
<div class="col">
|
||||||
|
<q-btn
|
||||||
|
style="width: 95%"
|
||||||
|
color="primary"
|
||||||
|
@click="animationBTN('rocket')"
|
||||||
|
label="rocket"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<q-btn
|
||||||
|
style="width: 95%"
|
||||||
|
color="primary"
|
||||||
|
@click="animationBTN('confetti')"
|
||||||
|
label="confetti"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<q-btn
|
||||||
|
style="width: 95%"
|
||||||
|
color="primary"
|
||||||
|
@click="animationBTN('face')"
|
||||||
|
label="face"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row q-pa-sm">
|
||||||
|
<div class="col">
|
||||||
|
<q-btn
|
||||||
|
style="width: 95%"
|
||||||
|
color="primary"
|
||||||
|
@click="animationBTN('rick')"
|
||||||
|
label="rick"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<q-btn
|
||||||
|
style="width: 95%"
|
||||||
|
color="primary"
|
||||||
|
@click="animationBTN('martijn')"
|
||||||
|
label="martijn"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<q-btn
|
||||||
|
style="width: 95%"
|
||||||
|
color="primary"
|
||||||
|
@click="animationBTN('bitcoin')"
|
||||||
|
label="bitcoin"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %} {% block scripts %}
|
||||||
|
<script>
|
||||||
|
Vue.component(VueQrcode.name, VueQrcode)
|
||||||
|
|
||||||
|
new Vue({
|
||||||
|
el: '#vue',
|
||||||
|
mixins: [windowMixin],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
fullscreen_cam: true,
|
||||||
|
textareaModel: '',
|
||||||
|
iframe: '',
|
||||||
|
copilot: {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
iframeChange: function (url) {
|
||||||
|
this.connection.send(String(url))
|
||||||
|
},
|
||||||
|
fullscreenToggle: function () {
|
||||||
|
self = this
|
||||||
|
self.animationBTN(String(this.fullscreen_cam))
|
||||||
|
if (this.fullscreen_cam) {
|
||||||
|
this.fullscreen_cam = false
|
||||||
|
} else {
|
||||||
|
this.fullscreen_cam = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
openCompose: function () {
|
||||||
|
let params =
|
||||||
|
'scrollbars=no, resizable=no,status=no,location=no,toolbar=no,menubar=no,width=1200,height=644,left=410,top=100'
|
||||||
|
open('../cp/', 'test', params)
|
||||||
|
},
|
||||||
|
animationBTN: function (name) {
|
||||||
|
self = this
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'GET',
|
||||||
|
'/copilot/api/v1/copilot/ws/' + self.copilot.id + '/none/' + name
|
||||||
|
)
|
||||||
|
.then(function (response1) {
|
||||||
|
self.$q.notify({
|
||||||
|
color: 'green',
|
||||||
|
message: 'Sent!'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
LNbits.utils.notifyApiError(err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created: function () {
|
||||||
|
self = this
|
||||||
|
self.copilot = JSON.parse(localStorage.getItem('copilot'))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
88
lnbits/extensions/copilot/views.py
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
from http import HTTPStatus
|
||||||
|
from typing import List
|
||||||
|
import httpx
|
||||||
|
from collections import defaultdict
|
||||||
|
from lnbits.decorators import check_user_exists
|
||||||
|
from .crud import get_copilot
|
||||||
|
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
from lnbits.decorators import check_user_exists
|
||||||
|
|
||||||
|
from . import copilot_ext, copilot_renderer
|
||||||
|
from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
|
||||||
|
from fastapi.params import Depends
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from fastapi.param_functions import Query
|
||||||
|
from starlette.exceptions import HTTPException
|
||||||
|
from starlette.responses import HTMLResponse, JSONResponse # type: ignore
|
||||||
|
from lnbits.core.models import User
|
||||||
|
import base64
|
||||||
|
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
|
||||||
|
@copilot_ext.get("/", response_class=HTMLResponse)
|
||||||
|
async def index(request: Request, user: User = Depends(check_user_exists)):
|
||||||
|
return copilot_renderer().TemplateResponse(
|
||||||
|
"copilot/index.html", {"request": request, "user": user.dict()}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@copilot_ext.get("/cp/", response_class=HTMLResponse)
|
||||||
|
async def compose(request: Request):
|
||||||
|
return copilot_renderer().TemplateResponse(
|
||||||
|
"copilot/compose.html", {"request": request}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@copilot_ext.get("/pn/", response_class=HTMLResponse)
|
||||||
|
async def panel(request: Request):
|
||||||
|
return copilot_renderer().TemplateResponse(
|
||||||
|
"copilot/panel.html", {"request": request}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
##################WEBSOCKET ROUTES########################
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectionManager:
|
||||||
|
def __init__(self):
|
||||||
|
self.active_connections: List[WebSocket] = []
|
||||||
|
|
||||||
|
async def connect(self, websocket: WebSocket, copilot_id: str):
|
||||||
|
await websocket.accept()
|
||||||
|
websocket.id = copilot_id
|
||||||
|
self.active_connections.append(websocket)
|
||||||
|
|
||||||
|
def disconnect(self, websocket: WebSocket):
|
||||||
|
self.active_connections.remove(websocket)
|
||||||
|
|
||||||
|
async def send_personal_message(self, message: str, copilot_id: str):
|
||||||
|
for connection in self.active_connections:
|
||||||
|
if connection.id == copilot_id:
|
||||||
|
await connection.send_text(message)
|
||||||
|
|
||||||
|
async def broadcast(self, message: str):
|
||||||
|
for connection in self.active_connections:
|
||||||
|
await connection.send_text(message)
|
||||||
|
|
||||||
|
|
||||||
|
manager = ConnectionManager()
|
||||||
|
|
||||||
|
|
||||||
|
@copilot_ext.websocket("/copilot/ws/{copilot_id}", name="copilot.websocket_by_id")
|
||||||
|
async def websocket_endpoint(websocket: WebSocket, copilot_id: str):
|
||||||
|
await manager.connect(websocket, copilot_id)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
data = await websocket.receive_text()
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
manager.disconnect(websocket)
|
||||||
|
|
||||||
|
|
||||||
|
async def updater(copilot_id, data, comment):
|
||||||
|
copilot = await get_copilot(copilot_id)
|
||||||
|
if not copilot:
|
||||||
|
return
|
||||||
|
await manager.send_personal_message(f"{data + '-' + comment}", copilot_id)
|
||||||
97
lnbits/extensions/copilot/views_api.py
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
from http import HTTPStatus
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
from fastapi.param_functions import Query
|
||||||
|
from fastapi.params import Depends
|
||||||
|
from starlette.exceptions import HTTPException
|
||||||
|
|
||||||
|
from lnbits.decorators import WalletTypeInfo, get_key_type
|
||||||
|
|
||||||
|
from . import copilot_ext
|
||||||
|
from .crud import (
|
||||||
|
create_copilot,
|
||||||
|
delete_copilot,
|
||||||
|
get_copilot,
|
||||||
|
get_copilots,
|
||||||
|
update_copilot,
|
||||||
|
)
|
||||||
|
from .models import CreateCopilotData
|
||||||
|
from .views import updater
|
||||||
|
|
||||||
|
#######################COPILOT##########################
|
||||||
|
|
||||||
|
|
||||||
|
@copilot_ext.get("/api/v1/copilot")
|
||||||
|
async def api_copilots_retrieve(
|
||||||
|
req: Request, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||||
|
):
|
||||||
|
wallet_user = wallet.wallet.user
|
||||||
|
copilots = [copilot.dict() for copilot in await get_copilots(wallet_user)]
|
||||||
|
try:
|
||||||
|
return copilots
|
||||||
|
except:
|
||||||
|
raise HTTPException(status_code=HTTPStatus.NO_CONTENT, detail="No copilots")
|
||||||
|
|
||||||
|
|
||||||
|
@copilot_ext.get("/api/v1/copilot/{copilot_id}")
|
||||||
|
async def api_copilot_retrieve(
|
||||||
|
req: Request,
|
||||||
|
copilot_id: str = Query(None),
|
||||||
|
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||||
|
):
|
||||||
|
copilot = await get_copilot(copilot_id)
|
||||||
|
if not copilot:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="Copilot not found"
|
||||||
|
)
|
||||||
|
if not copilot.lnurl_toggle:
|
||||||
|
return copilot.dict()
|
||||||
|
return {**copilot.dict(), **{"lnurl": copilot.lnurl(req)}}
|
||||||
|
|
||||||
|
|
||||||
|
@copilot_ext.post("/api/v1/copilot")
|
||||||
|
@copilot_ext.put("/api/v1/copilot/{juke_id}")
|
||||||
|
async def api_copilot_create_or_update(
|
||||||
|
data: CreateCopilotData,
|
||||||
|
copilot_id: str = Query(None),
|
||||||
|
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||||
|
):
|
||||||
|
data.user = wallet.wallet.user
|
||||||
|
data.wallet = wallet.wallet.id
|
||||||
|
if copilot_id:
|
||||||
|
copilot = await update_copilot(data, copilot_id=copilot_id)
|
||||||
|
else:
|
||||||
|
copilot = await create_copilot(data, inkey=wallet.wallet.inkey)
|
||||||
|
return copilot
|
||||||
|
|
||||||
|
|
||||||
|
@copilot_ext.delete("/api/v1/copilot/{copilot_id}")
|
||||||
|
async def api_copilot_delete(
|
||||||
|
copilot_id: str = Query(None), wallet: WalletTypeInfo = Depends(get_key_type)
|
||||||
|
):
|
||||||
|
copilot = await get_copilot(copilot_id)
|
||||||
|
|
||||||
|
if not copilot:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="Copilot does not exist"
|
||||||
|
)
|
||||||
|
|
||||||
|
await delete_copilot(copilot_id)
|
||||||
|
|
||||||
|
return "", HTTPStatus.NO_CONTENT
|
||||||
|
|
||||||
|
|
||||||
|
@copilot_ext.get("/api/v1/copilot/ws/{copilot_id}/{comment}/{data}")
|
||||||
|
async def api_copilot_ws_relay(
|
||||||
|
copilot_id: str = Query(None), comment: str = Query(None), data: str = Query(None)
|
||||||
|
):
|
||||||
|
copilot = await get_copilot(copilot_id)
|
||||||
|
if not copilot:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="Copilot does not exist"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await updater(copilot_id, data, comment)
|
||||||
|
except:
|
||||||
|
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your copilot")
|
||||||
|
return ""
|
||||||
33
lnbits/extensions/events/README.md
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
# Events
|
||||||
|
|
||||||
|
## Sell tickets for events and use the built-in scanner for registering attendants
|
||||||
|
|
||||||
|
Events alows you to make tickets for an event. Each ticket is in the form of a uniqque QR code. After registering, and paying for ticket, the user gets a QR code to present at registration/entrance.
|
||||||
|
|
||||||
|
Events includes a shareable ticket scanner, which can be used to register attendees.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
1. Create an event\
|
||||||
|

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

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

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

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

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

|
||||||
19
lnbits/extensions/events/__init__.py
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from lnbits.db import Database
|
||||||
|
from lnbits.helpers import template_renderer
|
||||||
|
|
||||||
|
db = Database("ext_events")
|
||||||
|
|
||||||
|
|
||||||
|
events_ext: APIRouter = APIRouter(
|
||||||
|
prefix="/events",
|
||||||
|
tags=["Events"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def events_renderer():
|
||||||
|
return template_renderer(["lnbits/extensions/events/templates"])
|
||||||
|
|
||||||
|
from .views import * # noqa
|
||||||
|
from .views_api import * # noqa
|
||||||
|
|
||||||
6
lnbits/extensions/events/config.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"name": "Events",
|
||||||
|
"short_description": "Sell and register event tickets",
|
||||||
|
"icon": "local_activity",
|
||||||
|
"contributors": ["benarc"]
|
||||||
|
}
|
||||||
159
lnbits/extensions/events/crud.py
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
from typing import List, Optional, Union
|
||||||
|
|
||||||
|
from lnbits.helpers import urlsafe_short_hash
|
||||||
|
|
||||||
|
from . import db
|
||||||
|
from .models import CreateEvent, Events, Tickets
|
||||||
|
|
||||||
|
# TICKETS
|
||||||
|
|
||||||
|
|
||||||
|
async def create_ticket(
|
||||||
|
payment_hash: str, wallet: str, event: str, name: str, email: str
|
||||||
|
) -> Tickets:
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO events.ticket (id, wallet, event, name, email, registered, paid)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(payment_hash, wallet, event, name, email, False, False),
|
||||||
|
)
|
||||||
|
|
||||||
|
ticket = await get_ticket(payment_hash)
|
||||||
|
assert ticket, "Newly created ticket couldn't be retrieved"
|
||||||
|
return ticket
|
||||||
|
|
||||||
|
|
||||||
|
async def set_ticket_paid(payment_hash: str) -> Tickets:
|
||||||
|
row = await db.fetchone("SELECT * FROM events.ticket WHERE id = ?", (payment_hash,))
|
||||||
|
if row[6] != True:
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
UPDATE events.ticket
|
||||||
|
SET paid = true
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(payment_hash,),
|
||||||
|
)
|
||||||
|
|
||||||
|
eventdata = await get_event(row[2])
|
||||||
|
assert eventdata, "Couldn't get event from ticket being paid"
|
||||||
|
|
||||||
|
sold = eventdata.sold + 1
|
||||||
|
amount_tickets = eventdata.amount_tickets - 1
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
UPDATE events.events
|
||||||
|
SET sold = ?, amount_tickets = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(sold, amount_tickets, row[2]),
|
||||||
|
)
|
||||||
|
|
||||||
|
ticket = await get_ticket(payment_hash)
|
||||||
|
assert ticket, "Newly updated ticket couldn't be retrieved"
|
||||||
|
return ticket
|
||||||
|
|
||||||
|
|
||||||
|
async def get_ticket(payment_hash: str) -> Optional[Tickets]:
|
||||||
|
row = await db.fetchone("SELECT * FROM events.ticket WHERE id = ?", (payment_hash,))
|
||||||
|
return Tickets(**row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_tickets(wallet_ids: Union[str, List[str]]) -> List[Tickets]:
|
||||||
|
if isinstance(wallet_ids, str):
|
||||||
|
wallet_ids = [wallet_ids]
|
||||||
|
|
||||||
|
q = ",".join(["?"] * len(wallet_ids))
|
||||||
|
rows = await db.fetchall(
|
||||||
|
f"SELECT * FROM events.ticket WHERE wallet IN ({q})", (*wallet_ids,)
|
||||||
|
)
|
||||||
|
return [Tickets(**row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_ticket(payment_hash: str) -> None:
|
||||||
|
await db.execute("DELETE FROM events.ticket WHERE id = ?", (payment_hash,))
|
||||||
|
|
||||||
|
|
||||||
|
# EVENTS
|
||||||
|
|
||||||
|
|
||||||
|
async def create_event(
|
||||||
|
data: CreateEvent
|
||||||
|
) -> Events:
|
||||||
|
event_id = urlsafe_short_hash()
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO events.events (id, wallet, name, info, closing_date, event_start_date, event_end_date, amount_tickets, price_per_ticket, sold)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
event_id,
|
||||||
|
data.wallet,
|
||||||
|
data.name,
|
||||||
|
data.info,
|
||||||
|
data.closing_date,
|
||||||
|
data.event_start_date,
|
||||||
|
data.event_end_date,
|
||||||
|
data.amount_tickets,
|
||||||
|
data.price_per_ticket,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
event = await get_event(event_id)
|
||||||
|
assert event, "Newly created event couldn't be retrieved"
|
||||||
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
async def update_event(event_id: str, **kwargs) -> Events:
|
||||||
|
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
||||||
|
await db.execute(
|
||||||
|
f"UPDATE events.events SET {q} WHERE id = ?", (*kwargs.values(), event_id)
|
||||||
|
)
|
||||||
|
event = await get_event(event_id)
|
||||||
|
assert event, "Newly updated event couldn't be retrieved"
|
||||||
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
async def get_event(event_id: str) -> Optional[Events]:
|
||||||
|
row = await db.fetchone("SELECT * FROM events.events WHERE id = ?", (event_id,))
|
||||||
|
return Events(**row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_events(wallet_ids: Union[str, List[str]]) -> List[Events]:
|
||||||
|
if isinstance(wallet_ids, str):
|
||||||
|
wallet_ids = [wallet_ids]
|
||||||
|
|
||||||
|
q = ",".join(["?"] * len(wallet_ids))
|
||||||
|
rows = await db.fetchall(
|
||||||
|
f"SELECT * FROM events.events WHERE wallet IN ({q})", (*wallet_ids,)
|
||||||
|
)
|
||||||
|
|
||||||
|
return [Events(**row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_event(event_id: str) -> None:
|
||||||
|
await db.execute("DELETE FROM events.events WHERE id = ?", (event_id,))
|
||||||
|
|
||||||
|
|
||||||
|
# EVENTTICKETS
|
||||||
|
|
||||||
|
|
||||||
|
async def get_event_tickets(event_id: str, wallet_id: str) -> List[Tickets]:
|
||||||
|
rows = await db.fetchall(
|
||||||
|
"SELECT * FROM events.ticket WHERE wallet = ? AND event = ?",
|
||||||
|
(wallet_id, event_id),
|
||||||
|
)
|
||||||
|
return [Tickets(**row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def reg_ticket(ticket_id: str) -> List[Tickets]:
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE events.ticket SET registered = ? WHERE id = ?", (True, ticket_id)
|
||||||
|
)
|
||||||
|
ticket = await db.fetchone("SELECT * FROM events.ticket WHERE id = ?", (ticket_id,))
|
||||||
|
rows = await db.fetchall(
|
||||||
|
"SELECT * FROM events.ticket WHERE event = ?", (ticket[1],)
|
||||||
|
)
|
||||||
|
return [Tickets(**row) for row in rows]
|
||||||
91
lnbits/extensions/events/migrations.py
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
async def m001_initial(db):
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE events.events (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
wallet TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
info TEXT NOT NULL,
|
||||||
|
closing_date TEXT NOT NULL,
|
||||||
|
event_start_date TEXT NOT NULL,
|
||||||
|
event_end_date TEXT NOT NULL,
|
||||||
|
amount_tickets INTEGER NOT NULL,
|
||||||
|
price_per_ticket INTEGER NOT NULL,
|
||||||
|
sold INTEGER NOT NULL,
|
||||||
|
time TIMESTAMP NOT NULL DEFAULT """
|
||||||
|
+ db.timestamp_now
|
||||||
|
+ """
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE events.tickets (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
wallet TEXT NOT NULL,
|
||||||
|
event TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
registered BOOLEAN NOT NULL,
|
||||||
|
time TIMESTAMP NOT NULL DEFAULT """
|
||||||
|
+ db.timestamp_now
|
||||||
|
+ """
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def m002_changed(db):
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE events.ticket (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
wallet TEXT NOT NULL,
|
||||||
|
event TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
registered BOOLEAN NOT NULL,
|
||||||
|
paid BOOLEAN NOT NULL,
|
||||||
|
time TIMESTAMP NOT NULL DEFAULT """
|
||||||
|
+ db.timestamp_now
|
||||||
|
+ """
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
for row in [list(row) for row in await db.fetchall("SELECT * FROM events.tickets")]:
|
||||||
|
usescsv = ""
|
||||||
|
|
||||||
|
for i in range(row[5]):
|
||||||
|
if row[7]:
|
||||||
|
usescsv += "," + str(i + 1)
|
||||||
|
else:
|
||||||
|
usescsv += "," + str(1)
|
||||||
|
usescsv = usescsv[1:]
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO events.ticket (
|
||||||
|
id,
|
||||||
|
wallet,
|
||||||
|
event,
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
registered,
|
||||||
|
paid
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
row[0],
|
||||||
|
row[1],
|
||||||
|
row[2],
|
||||||
|
row[3],
|
||||||
|
row[4],
|
||||||
|
row[5],
|
||||||
|
True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await db.execute("DROP TABLE events.tickets")
|
||||||
41
lnbits/extensions/events/models.py
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
from fastapi.param_functions import Query
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class CreateEvent(BaseModel):
|
||||||
|
wallet: str
|
||||||
|
name: str
|
||||||
|
info: str
|
||||||
|
closing_date: str
|
||||||
|
event_start_date: str
|
||||||
|
event_end_date: str
|
||||||
|
amount_tickets: int = Query(..., ge=0)
|
||||||
|
price_per_ticket: int = Query(..., ge=0)
|
||||||
|
|
||||||
|
class CreateTicket(BaseModel):
|
||||||
|
name: str
|
||||||
|
email: str
|
||||||
|
|
||||||
|
class Events(BaseModel):
|
||||||
|
id: str
|
||||||
|
wallet: str
|
||||||
|
name: str
|
||||||
|
info: str
|
||||||
|
closing_date: str
|
||||||
|
event_start_date: str
|
||||||
|
event_end_date: str
|
||||||
|
amount_tickets: int
|
||||||
|
price_per_ticket: int
|
||||||
|
sold: int
|
||||||
|
time: int
|
||||||
|
|
||||||
|
|
||||||
|
class Tickets(BaseModel):
|
||||||
|
id: str
|
||||||
|
wallet: str
|
||||||
|
event: str
|
||||||
|
name: str
|
||||||
|
email: str
|
||||||
|
registered: bool
|
||||||
|
paid: bool
|
||||||
|
time: int
|
||||||
23
lnbits/extensions/events/templates/events/_api_docs.html
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
<q-expansion-item
|
||||||
|
group="extras"
|
||||||
|
icon="swap_vertical_circle"
|
||||||
|
label="Info"
|
||||||
|
:content-inset-level="0.5"
|
||||||
|
>
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<h5 class="text-subtitle1 q-my-none">
|
||||||
|
Events: Sell and register ticket waves for an event
|
||||||
|
</h5>
|
||||||
|
<p>
|
||||||
|
Events alows you to make a wave of tickets for an event, each ticket is
|
||||||
|
in the form of a unqiue QRcode, which the user presents at registration.
|
||||||
|
Events comes with a shareable ticket scanner, which can be used to
|
||||||
|
register attendees.<br />
|
||||||
|
<small>
|
||||||
|
Created by, <a href="https://github.com/benarc">Ben Arc</a>
|
||||||
|
</small>
|
||||||
|
</p>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
207
lnbits/extensions/events/templates/events/display.html
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
{% extends "public.html" %} {% block page %}
|
||||||
|
<div class="row q-col-gutter-md justify-center">
|
||||||
|
<div class="col-12 col-md-7 col-lg-6 q-gutter-y-md">
|
||||||
|
<q-card class="q-pa-lg">
|
||||||
|
<q-card-section class="q-pa-none">
|
||||||
|
<h3 class="q-my-none">{{ event_name }}</h3>
|
||||||
|
<br />
|
||||||
|
<h5 class="q-my-none">{{ event_info }}</h5>
|
||||||
|
<br />
|
||||||
|
<q-form @submit="Invoice()" class="q-gutter-md">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialog.data.name"
|
||||||
|
type="name"
|
||||||
|
label="Your name "
|
||||||
|
></q-input>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialog.data.email"
|
||||||
|
type="email"
|
||||||
|
label="Your email "
|
||||||
|
></q-input>
|
||||||
|
|
||||||
|
<div class="row q-mt-lg">
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
:disable="formDialog.data.name == '' || formDialog.data.email == '' || paymentReq"
|
||||||
|
type="submit"
|
||||||
|
>Submit</q-btn
|
||||||
|
>
|
||||||
|
<q-btn @click="resetForm" flat color="grey" class="q-ml-auto"
|
||||||
|
>Cancel</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</q-form>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
|
||||||
|
<q-card v-show="ticketLink.show" class="q-pa-lg">
|
||||||
|
<div class="text-center q-mb-lg">
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
size="xl"
|
||||||
|
:href="ticketLink.data.link"
|
||||||
|
target="_blank"
|
||||||
|
color="primary"
|
||||||
|
type="a"
|
||||||
|
>Link to your ticket!</q-btn
|
||||||
|
>
|
||||||
|
<br /><br />
|
||||||
|
<p>You'll be redirected in a few moments...</p>
|
||||||
|
</div>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-dialog v-model="receive.show" position="top" @hide="closeReceiveDialog">
|
||||||
|
<q-card
|
||||||
|
v-if="!receive.paymentReq"
|
||||||
|
class="q-pa-lg q-pt-xl lnbits__dialog-card"
|
||||||
|
>
|
||||||
|
</q-card>
|
||||||
|
<q-card v-else class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||||
|
<div class="text-center q-mb-lg">
|
||||||
|
<a :href="'lightning:' + receive.paymentReq">
|
||||||
|
<q-responsive :ratio="1" class="q-mx-xl">
|
||||||
|
<qrcode
|
||||||
|
:value="paymentReq"
|
||||||
|
:options="{width: 340}"
|
||||||
|
class="rounded-borders"
|
||||||
|
></qrcode>
|
||||||
|
</q-responsive>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="row q-mt-lg">
|
||||||
|
<q-btn outline color="grey" @click="copyText(receive.paymentReq)"
|
||||||
|
>Copy invoice</q-btn
|
||||||
|
>
|
||||||
|
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
|
||||||
|
</div>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %} {% block scripts %}
|
||||||
|
<script>
|
||||||
|
console.log('{{ form_costpword }}')
|
||||||
|
Vue.component(VueQrcode.name, VueQrcode)
|
||||||
|
|
||||||
|
new Vue({
|
||||||
|
el: '#vue',
|
||||||
|
mixins: [windowMixin],
|
||||||
|
data: function () {
|
||||||
|
return {
|
||||||
|
paymentReq: null,
|
||||||
|
redirectUrl: null,
|
||||||
|
formDialog: {
|
||||||
|
show: false,
|
||||||
|
data: {
|
||||||
|
name: '',
|
||||||
|
email: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ticketLink: {
|
||||||
|
show: false,
|
||||||
|
data: {
|
||||||
|
link: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
receive: {
|
||||||
|
show: false,
|
||||||
|
status: 'pending',
|
||||||
|
paymentReq: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
resetForm: function (e) {
|
||||||
|
e.preventDefault()
|
||||||
|
this.formDialog.data.name = ''
|
||||||
|
this.formDialog.data.email = ''
|
||||||
|
},
|
||||||
|
|
||||||
|
closeReceiveDialog: function () {
|
||||||
|
var checker = this.receive.paymentChecker
|
||||||
|
dismissMsg()
|
||||||
|
|
||||||
|
clearInterval(paymentChecker)
|
||||||
|
setTimeout(function () {}, 10000)
|
||||||
|
},
|
||||||
|
Invoice: function () {
|
||||||
|
var self = this
|
||||||
|
axios
|
||||||
|
|
||||||
|
.post(
|
||||||
|
'/events/api/v1/tickets/' + '{{ event_id }}/{{ event_price }}',
|
||||||
|
{
|
||||||
|
event: '{{ event_id }}',
|
||||||
|
event_name: '{{ event_name }}',
|
||||||
|
name: self.formDialog.data.name,
|
||||||
|
email: self.formDialog.data.email
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.then(function (response) {
|
||||||
|
self.paymentReq = response.data.payment_request
|
||||||
|
self.paymentCheck = response.data.payment_hash
|
||||||
|
|
||||||
|
dismissMsg = self.$q.notify({
|
||||||
|
timeout: 0,
|
||||||
|
message: 'Waiting for payment...'
|
||||||
|
})
|
||||||
|
|
||||||
|
self.receive = {
|
||||||
|
show: true,
|
||||||
|
status: 'pending',
|
||||||
|
paymentReq: self.paymentReq
|
||||||
|
}
|
||||||
|
|
||||||
|
paymentChecker = setInterval(function () {
|
||||||
|
axios
|
||||||
|
.get('/events/api/v1/tickets/' + self.paymentCheck)
|
||||||
|
.then(function (res) {
|
||||||
|
if (res.data.paid) {
|
||||||
|
clearInterval(paymentChecker)
|
||||||
|
dismissMsg()
|
||||||
|
self.formDialog.data.name = ''
|
||||||
|
self.formDialog.data.email = ''
|
||||||
|
|
||||||
|
self.$q.notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'Sent, thank you!',
|
||||||
|
icon: null
|
||||||
|
})
|
||||||
|
self.receive = {
|
||||||
|
show: false,
|
||||||
|
status: 'complete',
|
||||||
|
paymentReq: null
|
||||||
|
}
|
||||||
|
|
||||||
|
self.ticketLink = {
|
||||||
|
show: true,
|
||||||
|
data: {
|
||||||
|
link: '/events/ticket/' + res.data.ticket_id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setTimeout(function () {
|
||||||
|
window.location.href =
|
||||||
|
'/events/ticket/' + res.data.ticket_id
|
||||||
|
}, 5000)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
})
|
||||||
|
}, 2000)
|
||||||
|
})
|
||||||
|
.catch(function (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
35
lnbits/extensions/events/templates/events/error.html
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
{% extends "public.html" %} {% block page %}
|
||||||
|
<div class="row q-col-gutter-md justify-center">
|
||||||
|
<div class="col-12 col-md-7 col-lg-6 q-gutter-y-md">
|
||||||
|
<q-card class="q-pa-lg">
|
||||||
|
<q-card-section class="q-pa-none">
|
||||||
|
<center>
|
||||||
|
<h3 class="q-my-none">{{ event_name }} error</h3>
|
||||||
|
<br />
|
||||||
|
<q-icon
|
||||||
|
name="warning"
|
||||||
|
class="text-grey"
|
||||||
|
style="font-size: 20rem"
|
||||||
|
></q-icon>
|
||||||
|
|
||||||
|
<h5 class="q-my-none">{{ event_error }}</h5>
|
||||||
|
<br />
|
||||||
|
</center>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %} {% block scripts %}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
new Vue({
|
||||||
|
el: '#vue',
|
||||||
|
mixins: [windowMixin],
|
||||||
|
data: function () {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
538
lnbits/extensions/events/templates/events/index.html
Normal file
|
|
@ -0,0 +1,538 @@
|
||||||
|
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||||
|
%} {% block page %}
|
||||||
|
<div class="row q-col-gutter-md">
|
||||||
|
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<q-btn unelevated color="primary" @click="formDialog.show = true"
|
||||||
|
>New Event</q-btn
|
||||||
|
>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<div class="row items-center no-wrap q-mb-md">
|
||||||
|
<div class="col">
|
||||||
|
<h5 class="text-subtitle1 q-my-none">Events</h5>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<q-btn flat color="grey" @click="exporteventsCSV"
|
||||||
|
>Export to CSV</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-table
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
:data="events"
|
||||||
|
row-key="id"
|
||||||
|
:columns="eventsTable.columns"
|
||||||
|
:pagination.sync="eventsTable.pagination"
|
||||||
|
>
|
||||||
|
{% raw %}
|
||||||
|
<template v-slot:header="props">
|
||||||
|
<q-tr :props="props">
|
||||||
|
<q-th auto-width></q-th>
|
||||||
|
|
||||||
|
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
||||||
|
{{ col.label }}
|
||||||
|
</q-th>
|
||||||
|
|
||||||
|
<q-th auto-width></q-th>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
<template v-slot:body="props">
|
||||||
|
<q-tr :props="props">
|
||||||
|
<q-td auto-width>
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
dense
|
||||||
|
size="xs"
|
||||||
|
icon="link"
|
||||||
|
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||||
|
type="a"
|
||||||
|
:href="props.row.displayUrl"
|
||||||
|
target="_blank"
|
||||||
|
></q-btn>
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
dense
|
||||||
|
size="xs"
|
||||||
|
icon="how_to_reg"
|
||||||
|
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||||
|
type="a"
|
||||||
|
:href="'/events/register/' + props.row.id"
|
||||||
|
target="_blank"
|
||||||
|
></q-btn>
|
||||||
|
</q-td>
|
||||||
|
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||||
|
{{ col.value }}
|
||||||
|
</q-td>
|
||||||
|
<q-td auto-width>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
size="xs"
|
||||||
|
@click="updateformDialog(props.row.id)"
|
||||||
|
icon="edit"
|
||||||
|
color="light-blue"
|
||||||
|
></q-btn>
|
||||||
|
</q-td>
|
||||||
|
<q-td auto-width>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
size="xs"
|
||||||
|
@click="deleteEvent(props.row.id)"
|
||||||
|
icon="cancel"
|
||||||
|
color="pink"
|
||||||
|
></q-btn>
|
||||||
|
</q-td>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
{% endraw %}
|
||||||
|
</q-table>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<div class="row items-center no-wrap q-mb-md">
|
||||||
|
<div class="col">
|
||||||
|
<h5 class="text-subtitle1 q-my-none">Tickets</h5>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<q-btn flat color="grey" @click="exportticketsCSV"
|
||||||
|
>Export to CSV</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-table
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
:data="tickets"
|
||||||
|
row-key="id"
|
||||||
|
:columns="ticketsTable.columns"
|
||||||
|
:pagination.sync="ticketsTable.pagination"
|
||||||
|
>
|
||||||
|
{% raw %}
|
||||||
|
<template v-slot:header="props">
|
||||||
|
<q-tr :props="props">
|
||||||
|
<q-th auto-width></q-th>
|
||||||
|
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
||||||
|
{{ col.label }}
|
||||||
|
</q-th>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
<template v-slot:body="props">
|
||||||
|
<q-tr :props="props">
|
||||||
|
<q-td auto-width>
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
dense
|
||||||
|
size="xs"
|
||||||
|
icon="local_activity"
|
||||||
|
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||||
|
type="a"
|
||||||
|
:href="'/events/ticket/' + props.row.id"
|
||||||
|
target="_blank"
|
||||||
|
></q-btn>
|
||||||
|
</q-td>
|
||||||
|
|
||||||
|
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||||
|
{{ col.value }}
|
||||||
|
</q-td>
|
||||||
|
|
||||||
|
<q-td auto-width>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
size="xs"
|
||||||
|
@click="deleteTicket(props.row.id)"
|
||||||
|
icon="cancel"
|
||||||
|
color="pink"
|
||||||
|
></q-btn>
|
||||||
|
</q-td>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
{% endraw %}
|
||||||
|
</q-table>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-4 col-lg-5 q-gutter-y-md">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<h6 class="text-subtitle1 q-my-none">
|
||||||
|
{{SITE_TITLE}} Events extension
|
||||||
|
</h6>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section class="q-pa-none">
|
||||||
|
<q-separator></q-separator>
|
||||||
|
<q-list> {% include "events/_api_docs.html" %} </q-list>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-dialog v-model="formDialog.show" position="top">
|
||||||
|
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||||
|
<q-form @submit="sendEventData" class="q-gutter-md">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialog.data.name"
|
||||||
|
type="name"
|
||||||
|
label="Title of event "
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col q-pl-sm">
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
emit-value
|
||||||
|
v-model="formDialog.data.wallet"
|
||||||
|
:options="g.user.walletOptions"
|
||||||
|
label="Wallet *"
|
||||||
|
>
|
||||||
|
</q-select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialog.data.info"
|
||||||
|
type="textarea"
|
||||||
|
label="Info about the event "
|
||||||
|
></q-input>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-4">Ticket closing date</div>
|
||||||
|
<div class="col-8">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialog.data.closing_date"
|
||||||
|
type="date"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-4">Event begins</div>
|
||||||
|
<div class="col-8">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialog.data.event_start_date"
|
||||||
|
type="date"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-4">Event ends</div>
|
||||||
|
<div class="col-8">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialog.data.event_end_date"
|
||||||
|
type="date"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.number="formDialog.data.amount_tickets"
|
||||||
|
type="number"
|
||||||
|
label="Amount of tickets "
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col q-pl-sm">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.number="formDialog.data.price_per_ticket"
|
||||||
|
type="number"
|
||||||
|
label="Price per ticket "
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row q-mt-lg">
|
||||||
|
<q-btn
|
||||||
|
v-if="formDialog.data.id"
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
type="submit"
|
||||||
|
>Update Event</q-btn
|
||||||
|
>
|
||||||
|
<q-btn
|
||||||
|
v-else
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
:disable="formDialog.data.wallet == null || formDialog.data.name == null || formDialog.data.info == null || formDialog.data.closing_date == null || formDialog.data.event_start_date == null || formDialog.data.event_end_date == null || formDialog.data.amount_tickets == null || formDialog.data.price_per_ticket == null"
|
||||||
|
type="submit"
|
||||||
|
>Create Event</q-btn
|
||||||
|
>
|
||||||
|
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||||
|
>Cancel</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</q-form>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</div>
|
||||||
|
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||||
|
<script>
|
||||||
|
var mapEvents = function (obj) {
|
||||||
|
obj.date = Quasar.utils.date.formatDate(
|
||||||
|
new Date(obj.time * 1000),
|
||||||
|
'YYYY-MM-DD HH:mm'
|
||||||
|
)
|
||||||
|
obj.fsat = new Intl.NumberFormat(LOCALE).format(obj.amount)
|
||||||
|
obj.displayUrl = ['/events/', obj.id].join('')
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
|
||||||
|
new Vue({
|
||||||
|
el: '#vue',
|
||||||
|
mixins: [windowMixin],
|
||||||
|
data: function () {
|
||||||
|
return {
|
||||||
|
events: [],
|
||||||
|
tickets: [],
|
||||||
|
eventsTable: {
|
||||||
|
columns: [
|
||||||
|
{name: 'id', align: 'left', label: 'ID', field: 'id'},
|
||||||
|
{name: 'name', align: 'left', label: 'Name', field: 'name'},
|
||||||
|
{name: 'info', align: 'left', label: 'Info', field: 'info'},
|
||||||
|
{
|
||||||
|
name: 'event_start_date',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Start date',
|
||||||
|
field: 'event_start_date'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'event_end_date',
|
||||||
|
align: 'left',
|
||||||
|
label: 'End date',
|
||||||
|
field: 'event_end_date'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'closing_date',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Ticket close',
|
||||||
|
field: 'closing_date'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'price_per_ticket',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Price',
|
||||||
|
field: 'price_per_ticket'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'amount_tickets',
|
||||||
|
align: 'left',
|
||||||
|
label: 'No tickets',
|
||||||
|
field: 'amount_tickets'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'sold',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Sold',
|
||||||
|
field: 'sold'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
pagination: {
|
||||||
|
rowsPerPage: 10
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ticketsTable: {
|
||||||
|
columns: [
|
||||||
|
{name: 'id', align: 'left', label: 'ID', field: 'id'},
|
||||||
|
{name: 'event', align: 'left', label: 'Event', field: 'event'},
|
||||||
|
{name: 'name', align: 'left', label: 'Name', field: 'name'},
|
||||||
|
{name: 'email', align: 'left', label: 'Email', field: 'email'},
|
||||||
|
{
|
||||||
|
name: 'registered',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Registered',
|
||||||
|
field: 'registered'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
pagination: {
|
||||||
|
rowsPerPage: 10
|
||||||
|
}
|
||||||
|
},
|
||||||
|
formDialog: {
|
||||||
|
show: false,
|
||||||
|
data: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
getTickets: function () {
|
||||||
|
var self = this
|
||||||
|
console.log('obj')
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'GET',
|
||||||
|
'/events/api/v1/tickets?all_wallets',
|
||||||
|
this.g.user.wallets[0].inkey
|
||||||
|
)
|
||||||
|
.then(function (response) {
|
||||||
|
self.tickets = response.data.map(function (obj) {
|
||||||
|
console.log(obj)
|
||||||
|
return mapEvents(obj)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
deleteTicket: function (ticketId) {
|
||||||
|
var self = this
|
||||||
|
var tickets = _.findWhere(this.tickets, {id: ticketId})
|
||||||
|
|
||||||
|
LNbits.utils
|
||||||
|
.confirmDialog('Are you sure you want to delete this ticket')
|
||||||
|
.onOk(function () {
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'DELETE',
|
||||||
|
'/events/api/v1/tickets/' + ticketId,
|
||||||
|
_.findWhere(self.g.user.wallets, {id: tickets.wallet}).inkey
|
||||||
|
)
|
||||||
|
.then(function (response) {
|
||||||
|
self.tickets = _.reject(self.tickets, function (obj) {
|
||||||
|
return obj.id == ticketId
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(function (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
exportticketsCSV: function () {
|
||||||
|
LNbits.utils.exportCSV(this.ticketsTable.columns, this.tickets)
|
||||||
|
},
|
||||||
|
|
||||||
|
getEvents: function () {
|
||||||
|
var self = this
|
||||||
|
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'GET',
|
||||||
|
'/events/api/v1/events?all_wallets=true',
|
||||||
|
this.g.user.wallets[0].inkey
|
||||||
|
)
|
||||||
|
.then(function (response) {
|
||||||
|
self.events = response.data.map(function (obj) {
|
||||||
|
return mapEvents(obj)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
sendEventData: function () {
|
||||||
|
var wallet = _.findWhere(this.g.user.wallets, {
|
||||||
|
id: this.formDialog.data.wallet
|
||||||
|
})
|
||||||
|
var data = this.formDialog.data
|
||||||
|
|
||||||
|
if (data.id) {
|
||||||
|
this.updateEvent(wallet, data)
|
||||||
|
} else {
|
||||||
|
this.createEvent(wallet, data)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
createEvent: function (wallet, data) {
|
||||||
|
var self = this
|
||||||
|
LNbits.api
|
||||||
|
.request('POST', '/events/api/v1/events', wallet.inkey, data)
|
||||||
|
.then(function (response) {
|
||||||
|
self.events.push(mapEvents(response.data))
|
||||||
|
self.formDialog.show = false
|
||||||
|
self.formDialog.data = {}
|
||||||
|
})
|
||||||
|
.catch(function (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
updateformDialog: function (formId) {
|
||||||
|
var link = _.findWhere(this.events, {id: formId})
|
||||||
|
console.log(link.id)
|
||||||
|
this.formDialog.data.id = link.id
|
||||||
|
this.formDialog.data.wallet = link.wallet
|
||||||
|
this.formDialog.data.name = link.name
|
||||||
|
this.formDialog.data.info = link.info
|
||||||
|
this.formDialog.data.closing_date = link.closing_date
|
||||||
|
this.formDialog.data.event_start_date = link.event_start_date
|
||||||
|
this.formDialog.data.event_end_date = link.event_end_date
|
||||||
|
this.formDialog.data.amount_tickets = link.amount_tickets
|
||||||
|
this.formDialog.data.price_per_ticket = link.price_per_ticket
|
||||||
|
this.formDialog.show = true
|
||||||
|
},
|
||||||
|
updateEvent: function (wallet, data) {
|
||||||
|
var self = this
|
||||||
|
console.log(data)
|
||||||
|
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'PUT',
|
||||||
|
'/events/api/v1/events/' + data.id,
|
||||||
|
wallet.inkey,
|
||||||
|
data
|
||||||
|
)
|
||||||
|
.then(function (response) {
|
||||||
|
self.events = _.reject(self.events, function (obj) {
|
||||||
|
return obj.id == data.id
|
||||||
|
})
|
||||||
|
self.events.push(mapEvents(response.data))
|
||||||
|
self.formDialog.show = false
|
||||||
|
self.formDialog.data = {}
|
||||||
|
})
|
||||||
|
.catch(function (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
deleteEvent: function (eventsId) {
|
||||||
|
var self = this
|
||||||
|
var events = _.findWhere(this.events, {id: eventsId})
|
||||||
|
|
||||||
|
LNbits.utils
|
||||||
|
.confirmDialog('Are you sure you want to delete this form link?')
|
||||||
|
.onOk(function () {
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'DELETE',
|
||||||
|
'/events/api/v1/events/' + eventsId,
|
||||||
|
_.findWhere(self.g.user.wallets, {id: events.wallet}).inkey
|
||||||
|
)
|
||||||
|
.then(function (response) {
|
||||||
|
self.events = _.reject(self.events, function (obj) {
|
||||||
|
return obj.id == eventsId
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(function (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
exporteventsCSV: function () {
|
||||||
|
LNbits.utils.exportCSV(this.eventsTable.columns, this.events)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
created: function () {
|
||||||
|
if (this.g.user.wallets.length) {
|
||||||
|
this.getTickets()
|
||||||
|
this.getEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
173
lnbits/extensions/events/templates/events/register.html
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
{% extends "public.html" %} {% block page %}
|
||||||
|
|
||||||
|
<div class="row q-col-gutter-md justify-center">
|
||||||
|
<div class="col-12 col-md-7 col-lg-6 q-gutter-y-md">
|
||||||
|
<q-card class="q-pa-lg">
|
||||||
|
<q-card-section class="q-pa-none">
|
||||||
|
<center>
|
||||||
|
<h3 class="q-my-none">{{ event_name }} Registration</h3>
|
||||||
|
<br />
|
||||||
|
|
||||||
|
<br />
|
||||||
|
|
||||||
|
<q-btn unelevated color="primary" @click="showCamera" size="xl"
|
||||||
|
>Scan ticket</q-btn
|
||||||
|
>
|
||||||
|
</center>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<q-table
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
:data="tickets"
|
||||||
|
row-key="id"
|
||||||
|
:columns="ticketsTable.columns"
|
||||||
|
:pagination.sync="ticketsTable.pagination"
|
||||||
|
>
|
||||||
|
{% raw %}
|
||||||
|
<template v-slot:header="props">
|
||||||
|
<q-tr :props="props">
|
||||||
|
<q-th auto-width></q-th>
|
||||||
|
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
||||||
|
{{ col.label }}
|
||||||
|
</q-th>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
<template v-slot:body="props">
|
||||||
|
<q-tr :props="props">
|
||||||
|
<q-td auto-width>
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
dense
|
||||||
|
size="xs"
|
||||||
|
icon="local_activity"
|
||||||
|
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||||
|
type="a"
|
||||||
|
:href="'/events/ticket/' + props.row.id"
|
||||||
|
target="_blank"
|
||||||
|
></q-btn>
|
||||||
|
</q-td>
|
||||||
|
|
||||||
|
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||||
|
{{ col.value }}
|
||||||
|
</q-td>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
{% endraw %}
|
||||||
|
</q-table>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-dialog v-model="sendCamera.show" position="top">
|
||||||
|
<q-card class="q-pa-lg q-pt-xl">
|
||||||
|
<div class="text-center q-mb-lg">
|
||||||
|
<qrcode-stream
|
||||||
|
@decode="decodeQR"
|
||||||
|
class="rounded-borders"
|
||||||
|
></qrcode-stream>
|
||||||
|
</div>
|
||||||
|
<div class="row q-mt-lg">
|
||||||
|
<q-btn @click="closeCamera" flat color="grey" class="q-ml-auto"
|
||||||
|
>Cancel</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</div>
|
||||||
|
{% endblock %} {% block scripts %}
|
||||||
|
<script>
|
||||||
|
Vue.component(VueQrcode.name, VueQrcode)
|
||||||
|
Vue.use(VueQrcodeReader)
|
||||||
|
var mapEvents = function (obj) {
|
||||||
|
obj.date = Quasar.utils.date.formatDate(
|
||||||
|
new Date(obj.time * 1000),
|
||||||
|
'YYYY-MM-DD HH:mm'
|
||||||
|
)
|
||||||
|
obj.fsat = new Intl.NumberFormat(LOCALE).format(obj.amount)
|
||||||
|
obj.displayUrl = ['/events/', obj.id].join('')
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
new Vue({
|
||||||
|
el: '#vue',
|
||||||
|
mixins: [windowMixin],
|
||||||
|
data: function () {
|
||||||
|
return {
|
||||||
|
tickets: [],
|
||||||
|
ticketsTable: {
|
||||||
|
columns: [
|
||||||
|
{name: 'id', align: 'left', label: 'ID', field: 'id'},
|
||||||
|
{name: 'name', align: 'left', label: 'Name', field: 'name'},
|
||||||
|
{
|
||||||
|
name: 'registered',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Registered',
|
||||||
|
field: 'registered'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
pagination: {
|
||||||
|
rowsPerPage: 10
|
||||||
|
}
|
||||||
|
},
|
||||||
|
sendCamera: {
|
||||||
|
show: false,
|
||||||
|
camera: 'auto'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
hoverEmail: function (tmp) {
|
||||||
|
this.tickets.data.emailtemp = tmp
|
||||||
|
},
|
||||||
|
closeCamera: function () {
|
||||||
|
this.sendCamera.show = false
|
||||||
|
},
|
||||||
|
showCamera: function () {
|
||||||
|
this.sendCamera.show = true
|
||||||
|
},
|
||||||
|
decodeQR: function (res) {
|
||||||
|
this.sendCamera.show = false
|
||||||
|
var self = this
|
||||||
|
|
||||||
|
LNbits.api
|
||||||
|
.request('GET', '/events/api/v1/register/ticket/' + res)
|
||||||
|
.then(function (response) {
|
||||||
|
self.$q.notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'Registered!'
|
||||||
|
})
|
||||||
|
setTimeout(function () {
|
||||||
|
window.location.reload()
|
||||||
|
}, 2000)
|
||||||
|
})
|
||||||
|
.catch(function (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
getEventTickets: function () {
|
||||||
|
var self = this
|
||||||
|
console.log('obj')
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'GET',
|
||||||
|
'/events/api/v1/eventtickets/{{ wallet_id }}/{{ event_id }}'
|
||||||
|
)
|
||||||
|
.then(function (response) {
|
||||||
|
self.tickets = response.data.map(function (obj) {
|
||||||
|
return mapEvents(obj)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(function (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created: function () {
|
||||||
|
this.getEventTickets()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
45
lnbits/extensions/events/templates/events/ticket.html
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
{% extends "public.html" %} {% block page %}
|
||||||
|
<div class="row q-col-gutter-md justify-center">
|
||||||
|
<div class="col-12 col-md-7 col-lg-6 q-gutter-y-md">
|
||||||
|
<q-card class="q-pa-lg">
|
||||||
|
<q-card-section class="q-pa-none">
|
||||||
|
<center>
|
||||||
|
<h3 class="q-my-none">{{ ticket_name }} Ticket</h3>
|
||||||
|
<br />
|
||||||
|
<h5 class="q-my-none">
|
||||||
|
Bookmark, print or screenshot this page,<br />
|
||||||
|
and present it for registration!
|
||||||
|
</h5>
|
||||||
|
<br />
|
||||||
|
|
||||||
|
<qrcode
|
||||||
|
:value="'{{ ticket_id }}'"
|
||||||
|
:options="{width: 340}"
|
||||||
|
class="rounded-borders"
|
||||||
|
></qrcode>
|
||||||
|
<br />
|
||||||
|
<q-btn @click="printWindow" color="grey" class="q-ml-auto">
|
||||||
|
<q-icon left size="3em" name="print"></q-icon> Print</q-btn
|
||||||
|
>
|
||||||
|
</center>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %} {% block scripts %}
|
||||||
|
<script>
|
||||||
|
Vue.component(VueQrcode.name, VueQrcode)
|
||||||
|
new Vue({
|
||||||
|
el: '#vue',
|
||||||
|
mixins: [windowMixin],
|
||||||
|
data: function () {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
printWindow: function () {
|
||||||
|
window.print()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
107
lnbits/extensions/events/views.py
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
from datetime import date, datetime
|
||||||
|
from http import HTTPStatus
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
from fastapi.params import Depends
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from starlette.exceptions import HTTPException
|
||||||
|
from starlette.responses import HTMLResponse
|
||||||
|
|
||||||
|
from lnbits.core.models import User
|
||||||
|
from lnbits.decorators import check_user_exists
|
||||||
|
|
||||||
|
from . import events_ext, events_renderer
|
||||||
|
from .crud import get_event, get_ticket
|
||||||
|
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
|
||||||
|
@events_ext.get("/", response_class=HTMLResponse)
|
||||||
|
async def index(request: Request, user: User = Depends(check_user_exists)):
|
||||||
|
return events_renderer().TemplateResponse("events/index.html", {"request": request, "user": user.dict()})
|
||||||
|
|
||||||
|
|
||||||
|
@events_ext.get("/{event_id}", response_class=HTMLResponse)
|
||||||
|
async def display(request: Request, event_id):
|
||||||
|
event = await get_event(event_id)
|
||||||
|
if not event:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
|
||||||
|
)
|
||||||
|
|
||||||
|
if event.amount_tickets < 1:
|
||||||
|
return events_renderer().TemplateResponse(
|
||||||
|
"events/error.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"event_name": event.name,
|
||||||
|
"event_error": "Sorry, tickets are sold out :("
|
||||||
|
}
|
||||||
|
)
|
||||||
|
datetime_object = datetime.strptime(event.closing_date, "%Y-%m-%d").date()
|
||||||
|
if date.today() > datetime_object:
|
||||||
|
return events_renderer().TemplateResponse(
|
||||||
|
"events/error.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"event_name": event.name,
|
||||||
|
"event_error": "Sorry, ticket closing date has passed :("
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return events_renderer().TemplateResponse(
|
||||||
|
"events/display.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"event_id": event_id,
|
||||||
|
"event_name": event.name,
|
||||||
|
"event_info": event.info,
|
||||||
|
"event_price": event.price_per_ticket,
|
||||||
|
}
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@events_ext.get("/ticket/{ticket_id}", response_class=HTMLResponse)
|
||||||
|
async def ticket(request: Request, ticket_id):
|
||||||
|
ticket = await get_ticket(ticket_id)
|
||||||
|
if not ticket:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="Ticket does not exist."
|
||||||
|
)
|
||||||
|
|
||||||
|
event = await get_event(ticket.event)
|
||||||
|
if not event:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
|
||||||
|
)
|
||||||
|
|
||||||
|
return events_renderer().TemplateResponse(
|
||||||
|
"events/ticket.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"ticket_id": ticket_id,
|
||||||
|
"ticket_name": event.name,
|
||||||
|
"ticket_info": event.info,
|
||||||
|
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@events_ext.get("/register/{event_id}", response_class=HTMLResponse)
|
||||||
|
async def register(request: Request, event_id):
|
||||||
|
event = await get_event(event_id)
|
||||||
|
if not event:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
|
||||||
|
)
|
||||||
|
|
||||||
|
return events_renderer().TemplateResponse(
|
||||||
|
"events/register.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"event_id": event_id,
|
||||||
|
"event_name": event.name,
|
||||||
|
"wallet_id": event.wallet,
|
||||||
|
}
|
||||||
|
)
|
||||||
211
lnbits/extensions/events/views_api.py
Normal file
|
|
@ -0,0 +1,211 @@
|
||||||
|
from http import HTTPStatus
|
||||||
|
|
||||||
|
from fastapi.param_functions import Query
|
||||||
|
from fastapi.params import Depends
|
||||||
|
from starlette.exceptions import HTTPException
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
from lnbits.core.crud import get_user, get_wallet
|
||||||
|
from lnbits.core.services import check_invoice_status, create_invoice
|
||||||
|
from lnbits.decorators import WalletTypeInfo, get_key_type
|
||||||
|
from lnbits.extensions.events.models import CreateEvent, CreateTicket
|
||||||
|
|
||||||
|
from . import events_ext
|
||||||
|
from .crud import (
|
||||||
|
create_event,
|
||||||
|
create_ticket,
|
||||||
|
delete_event,
|
||||||
|
delete_ticket,
|
||||||
|
get_event,
|
||||||
|
get_event_tickets,
|
||||||
|
get_events,
|
||||||
|
get_ticket,
|
||||||
|
get_tickets,
|
||||||
|
reg_ticket,
|
||||||
|
set_ticket_paid,
|
||||||
|
update_event,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Events
|
||||||
|
|
||||||
|
|
||||||
|
@events_ext.get("/api/v1/events")
|
||||||
|
async def api_events(
|
||||||
|
r: Request,
|
||||||
|
all_wallets: bool = Query(False),
|
||||||
|
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||||
|
):
|
||||||
|
wallet_ids = [wallet.wallet.id]
|
||||||
|
|
||||||
|
if all_wallets:
|
||||||
|
wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids
|
||||||
|
|
||||||
|
return [event.dict() for event in await get_events(wallet_ids)]
|
||||||
|
|
||||||
|
@events_ext.post("/api/v1/events")
|
||||||
|
@events_ext.put("/api/v1/events/{event_id}")
|
||||||
|
async def api_event_create(data: CreateEvent, event_id=None, wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||||
|
if event_id:
|
||||||
|
event = await get_event(event_id)
|
||||||
|
if not event:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND,
|
||||||
|
detail=f"Event does not exist."
|
||||||
|
)
|
||||||
|
|
||||||
|
if event.wallet != wallet.wallet.id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.FORBIDDEN,
|
||||||
|
detail=f"Not your event."
|
||||||
|
)
|
||||||
|
event = await update_event(event_id, **data.dict())
|
||||||
|
else:
|
||||||
|
event = await create_event(data=data)
|
||||||
|
|
||||||
|
return event.dict()
|
||||||
|
|
||||||
|
|
||||||
|
@events_ext.delete("/api/v1/events/{event_id}")
|
||||||
|
async def api_form_delete(event_id, wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||||
|
event = await get_event(event_id)
|
||||||
|
if not event:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND,
|
||||||
|
detail=f"Event does not exist."
|
||||||
|
)
|
||||||
|
|
||||||
|
if event.wallet != wallet.wallet.id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.FORBIDDEN,
|
||||||
|
detail=f"Not your event."
|
||||||
|
)
|
||||||
|
|
||||||
|
await delete_event(event_id)
|
||||||
|
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
|
#########Tickets##########
|
||||||
|
|
||||||
|
|
||||||
|
@events_ext.get("/api/v1/tickets")
|
||||||
|
async def api_tickets(
|
||||||
|
r: Request,
|
||||||
|
all_wallets: bool = Query(False),
|
||||||
|
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||||
|
):
|
||||||
|
wallet_ids = [wallet.wallet.id]
|
||||||
|
|
||||||
|
if all_wallets:
|
||||||
|
wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids
|
||||||
|
|
||||||
|
return [ticket.dict() for ticket in await get_tickets(wallet_ids)]
|
||||||
|
|
||||||
|
|
||||||
|
@events_ext.post("/api/v1/tickets/{event_id}/{sats}")
|
||||||
|
async def api_ticket_make_ticket(event_id, sats, data: CreateTicket):
|
||||||
|
event = await get_event(event_id)
|
||||||
|
if not event:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND,
|
||||||
|
detail=f"Event does not exist."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
payment_hash, payment_request = await create_invoice(
|
||||||
|
wallet_id=event.wallet,
|
||||||
|
amount=int(sats),
|
||||||
|
memo=f"{event_id}",
|
||||||
|
extra={"tag": "events"},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
|
||||||
|
|
||||||
|
ticket = await create_ticket(
|
||||||
|
payment_hash=payment_hash, wallet=event.wallet, event=event_id, name=data.name, email=data.email
|
||||||
|
)
|
||||||
|
|
||||||
|
if not ticket:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND,
|
||||||
|
detail=f"Event could not be fetched."
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"payment_hash": payment_hash, "payment_request": payment_request}
|
||||||
|
|
||||||
|
|
||||||
|
@events_ext.get("/api/v1/tickets/{payment_hash}")
|
||||||
|
async def api_ticket_send_ticket(payment_hash):
|
||||||
|
ticket = await get_ticket(payment_hash)
|
||||||
|
|
||||||
|
try:
|
||||||
|
status = await check_invoice_status(ticket.wallet, payment_hash)
|
||||||
|
is_paid = not status.pending
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Not paid")
|
||||||
|
|
||||||
|
if is_paid:
|
||||||
|
wallet = await get_wallet(ticket.wallet)
|
||||||
|
payment = await wallet.get_payment(payment_hash)
|
||||||
|
await payment.set_pending(False)
|
||||||
|
ticket = await set_ticket_paid(payment_hash=payment_hash)
|
||||||
|
|
||||||
|
return {"paid": True, "ticket_id": ticket.id}
|
||||||
|
|
||||||
|
return {"paid": False}
|
||||||
|
|
||||||
|
|
||||||
|
@events_ext.delete("/api/v1/tickets/{ticket_id}")
|
||||||
|
async def api_ticket_delete(ticket_id, wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||||
|
ticket = await get_ticket(ticket_id)
|
||||||
|
if not ticket:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND,
|
||||||
|
detail=f"Ticket does not exist."
|
||||||
|
)
|
||||||
|
|
||||||
|
if ticket.wallet != wallet.wallet.id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.FORBIDDEN,
|
||||||
|
detail=f"Not your ticket."
|
||||||
|
)
|
||||||
|
|
||||||
|
await delete_ticket(ticket_id)
|
||||||
|
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Event Tickets
|
||||||
|
|
||||||
|
|
||||||
|
@events_ext.get("/api/v1/eventtickets/{wallet_id}/{event_id}")
|
||||||
|
async def api_event_tickets(wallet_id, event_id):
|
||||||
|
return [
|
||||||
|
ticket.dict()
|
||||||
|
for ticket in await get_event_tickets(
|
||||||
|
wallet_id=wallet_id, event_id=event_id
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@events_ext.get("/api/v1/register/ticket/{ticket_id}")
|
||||||
|
async def api_event_register_ticket(ticket_id):
|
||||||
|
ticket = await get_ticket(ticket_id)
|
||||||
|
if not ticket:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND,
|
||||||
|
detail="Ticket does not exist."
|
||||||
|
)
|
||||||
|
|
||||||
|
if not ticket.paid:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.FORBIDDEN,
|
||||||
|
detail="Ticket not paid for."
|
||||||
|
)
|
||||||
|
|
||||||
|
if ticket.registered == True:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.FORBIDDEN,
|
||||||
|
detail="Ticket already registered"
|
||||||
|
)
|
||||||
|
|
||||||
|
return [ticket.dict() for ticket in await reg_ticket(ticket_id)]
|
||||||
36
lnbits/extensions/jukebox/README.md
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
# Jukebox
|
||||||
|
|
||||||
|
## An actual Jukebox where users pay sats to play their favourite music from your playlists
|
||||||
|
|
||||||
|
**Note:** To use this extension you need a Premium Spotify subscription.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
1. Click on "ADD SPOTIFY JUKEBOX"\
|
||||||
|

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

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

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

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

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

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

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

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

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

|
||||||
|
6. After payment, the song will automatically start playing on the device selected or enter the queue if some other music is already playing
|
||||||
33
lnbits/extensions/jukebox/__init__.py
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import asyncio
|
||||||
|
from fastapi import APIRouter, FastAPI
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from starlette.routing import Mount
|
||||||
|
from lnbits.db import Database
|
||||||
|
from lnbits.helpers import template_renderer
|
||||||
|
from lnbits.tasks import catch_everything_and_restart
|
||||||
|
|
||||||
|
db = Database("ext_jukebox")
|
||||||
|
|
||||||
|
jukebox_static_files = [
|
||||||
|
{
|
||||||
|
"path": "/jukebox/static",
|
||||||
|
"app": StaticFiles(directory="lnbits/extensions/jukebox/static"),
|
||||||
|
"name": "jukebox_static",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
jukebox_ext: APIRouter = APIRouter(prefix="/jukebox", tags=["jukebox"])
|
||||||
|
|
||||||
|
|
||||||
|
def jukebox_renderer():
|
||||||
|
return template_renderer(["lnbits/extensions/jukebox/templates"])
|
||||||
|
|
||||||
|
|
||||||
|
from .views_api import * # noqa
|
||||||
|
from .views import * # noqa
|
||||||
|
from .tasks import wait_for_paid_invoices
|
||||||
|
|
||||||
|
|
||||||
|
def jukebox_start():
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
|
||||||
6
lnbits/extensions/jukebox/config.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"name": "Spotify Jukebox",
|
||||||
|
"short_description": "Spotify jukebox middleware",
|
||||||
|
"icon": "radio",
|
||||||
|
"contributors": ["benarc"]
|
||||||
|
}
|
||||||
111
lnbits/extensions/jukebox/crud.py
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from . import db
|
||||||
|
from .models import Jukebox, JukeboxPayment, CreateJukeLinkData, CreateJukeboxPayment
|
||||||
|
from lnbits.helpers import urlsafe_short_hash
|
||||||
|
|
||||||
|
|
||||||
|
async def create_jukebox(
|
||||||
|
data: CreateJukeLinkData, inkey: Optional[str] = ""
|
||||||
|
) -> Jukebox:
|
||||||
|
juke_id = urlsafe_short_hash()
|
||||||
|
result = await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO jukebox.jukebox (id, user, title, wallet, sp_user, sp_secret, sp_access_token, sp_refresh_token, sp_device, sp_playlists, price, profit)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
juke_id,
|
||||||
|
data.user,
|
||||||
|
data.title,
|
||||||
|
data.wallet,
|
||||||
|
data.sp_user,
|
||||||
|
data.sp_secret,
|
||||||
|
data.sp_access_token,
|
||||||
|
data.sp_refresh_token,
|
||||||
|
data.sp_device,
|
||||||
|
data.sp_playlists,
|
||||||
|
data.price,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
jukebox = await get_jukebox(juke_id)
|
||||||
|
assert jukebox, "Newly created Jukebox couldn't be retrieved"
|
||||||
|
return jukebox
|
||||||
|
|
||||||
|
|
||||||
|
async def update_jukebox(
|
||||||
|
data: CreateJukeLinkData, juke_id: Optional[str] = ""
|
||||||
|
) -> Optional[Jukebox]:
|
||||||
|
q = ", ".join([f"{field[0]} = ?" for field in data])
|
||||||
|
items = [f"{field[1]}" for field in data]
|
||||||
|
items.append(juke_id)
|
||||||
|
print(q)
|
||||||
|
print(items)
|
||||||
|
await db.execute(f"UPDATE jukebox.jukebox SET {q} WHERE id = ?", (items))
|
||||||
|
row = await db.fetchone("SELECT * FROM jukebox.jukebox WHERE id = ?", (juke_id,))
|
||||||
|
return Jukebox(**row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_jukebox(juke_id: str) -> Optional[Jukebox]:
|
||||||
|
row = await db.fetchone("SELECT * FROM jukebox.jukebox WHERE id = ?", (juke_id,))
|
||||||
|
return Jukebox(**row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_jukebox_by_user(user: str) -> Optional[Jukebox]:
|
||||||
|
row = await db.fetchone("SELECT * FROM jukebox.jukebox WHERE sp_user = ?", (user,))
|
||||||
|
return Jukebox(**row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_jukeboxs(user: str) -> List[Jukebox]:
|
||||||
|
rows = await db.fetchall("SELECT * FROM jukebox.jukebox WHERE user = ?", (user,))
|
||||||
|
for row in rows:
|
||||||
|
if row.sp_playlists == None:
|
||||||
|
print("cunt")
|
||||||
|
await delete_jukebox(row.id)
|
||||||
|
rows = await db.fetchall("SELECT * FROM jukebox.jukebox WHERE user = ?", (user,))
|
||||||
|
|
||||||
|
return [Jukebox(**row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_jukebox(juke_id: str):
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM jukebox.jukebox WHERE id = ?
|
||||||
|
""",
|
||||||
|
(juke_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
#####################################PAYMENTS
|
||||||
|
|
||||||
|
|
||||||
|
async def create_jukebox_payment(data: CreateJukeboxPayment) -> JukeboxPayment:
|
||||||
|
result = await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO jukebox.jukebox_payment (payment_hash, juke_id, song_id, paid)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(data.payment_hash, data.juke_id, data.song_id, False),
|
||||||
|
)
|
||||||
|
jukebox_payment = await get_jukebox_payment(data.payment_hash)
|
||||||
|
assert jukebox_payment, "Newly created Jukebox Payment couldn't be retrieved"
|
||||||
|
return jukebox_payment
|
||||||
|
|
||||||
|
|
||||||
|
async def update_jukebox_payment(
|
||||||
|
payment_hash: str, **kwargs
|
||||||
|
) -> Optional[JukeboxPayment]:
|
||||||
|
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
||||||
|
await db.execute(
|
||||||
|
f"UPDATE jukebox.jukebox_payment SET {q} WHERE payment_hash = ?",
|
||||||
|
(*kwargs.values(), payment_hash),
|
||||||
|
)
|
||||||
|
return await get_jukebox_payment(payment_hash)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_jukebox_payment(payment_hash: str) -> Optional[JukeboxPayment]:
|
||||||
|
row = await db.fetchone(
|
||||||
|
"SELECT * FROM jukebox.jukebox_payment WHERE payment_hash = ?", (payment_hash,)
|
||||||
|
)
|
||||||
|
return JukeboxPayment(**row) if row else None
|
||||||
39
lnbits/extensions/jukebox/migrations.py
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
async def m001_initial(db):
|
||||||
|
"""
|
||||||
|
Initial jukebox table.
|
||||||
|
"""
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE jukebox.jukebox (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
"user" TEXT,
|
||||||
|
title TEXT,
|
||||||
|
wallet TEXT,
|
||||||
|
inkey TEXT,
|
||||||
|
sp_user TEXT NOT NULL,
|
||||||
|
sp_secret TEXT NOT NULL,
|
||||||
|
sp_access_token TEXT,
|
||||||
|
sp_refresh_token TEXT,
|
||||||
|
sp_device TEXT,
|
||||||
|
sp_playlists TEXT,
|
||||||
|
price INTEGER,
|
||||||
|
profit INTEGER
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def m002_initial(db):
|
||||||
|
"""
|
||||||
|
Initial jukebox_payment table.
|
||||||
|
"""
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE jukebox.jukebox_payment (
|
||||||
|
payment_hash TEXT PRIMARY KEY,
|
||||||
|
juke_id TEXT,
|
||||||
|
song_id TEXT,
|
||||||
|
paid BOOL
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
50
lnbits/extensions/jukebox/models.py
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
from typing import NamedTuple
|
||||||
|
from sqlite3 import Row
|
||||||
|
from fastapi.param_functions import Query
|
||||||
|
from pydantic.main import BaseModel
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class CreateJukeLinkData(BaseModel):
|
||||||
|
user: str = Query(None)
|
||||||
|
title: str = Query(None)
|
||||||
|
wallet: str = Query(None)
|
||||||
|
sp_user: str = Query(None)
|
||||||
|
sp_secret: str = Query(None)
|
||||||
|
sp_access_token: str = Query(None)
|
||||||
|
sp_refresh_token: str = Query(None)
|
||||||
|
sp_device: str = Query(None)
|
||||||
|
sp_playlists: str = Query(None)
|
||||||
|
price: str = Query(None)
|
||||||
|
|
||||||
|
|
||||||
|
class Jukebox(BaseModel):
|
||||||
|
id: Optional[str]
|
||||||
|
user: Optional[str]
|
||||||
|
title: Optional[str]
|
||||||
|
wallet: Optional[str]
|
||||||
|
inkey: Optional[str]
|
||||||
|
sp_user: Optional[str]
|
||||||
|
sp_secret: Optional[str]
|
||||||
|
sp_access_token: Optional[str]
|
||||||
|
sp_refresh_token: Optional[str]
|
||||||
|
sp_device: Optional[str]
|
||||||
|
sp_playlists: Optional[str]
|
||||||
|
price: Optional[int]
|
||||||
|
profit: Optional[int]
|
||||||
|
|
||||||
|
|
||||||
|
class JukeboxPayment(BaseModel):
|
||||||
|
payment_hash: str
|
||||||
|
juke_id: str
|
||||||
|
song_id: str
|
||||||
|
paid: bool
|
||||||
|
|
||||||
|
|
||||||
|
class CreateJukeboxPayment(BaseModel):
|
||||||
|
invoice: str = Query(None)
|
||||||
|
payment_hash: str = Query(None)
|
||||||
|
juke_id: str = Query(None)
|
||||||
|
song_id: str = Query(None)
|
||||||
|
paid: bool = Query(False)
|
||||||
415
lnbits/extensions/jukebox/static/js/index.js
Normal file
|
|
@ -0,0 +1,415 @@
|
||||||
|
/* globals Quasar, Vue, _, VueQrcode, windowMixin, LNbits, LOCALE */
|
||||||
|
|
||||||
|
Vue.component(VueQrcode.name, VueQrcode)
|
||||||
|
|
||||||
|
var mapJukebox = obj => {
|
||||||
|
if(obj.sp_device){
|
||||||
|
obj._data = _.clone(obj)
|
||||||
|
|
||||||
|
obj.sp_id = obj._data.id
|
||||||
|
obj.device = obj._data.sp_device.split('-')[0]
|
||||||
|
playlists = obj._data.sp_playlists.split(',')
|
||||||
|
var i
|
||||||
|
playlistsar = []
|
||||||
|
for (i = 0; i < playlists.length; i++) {
|
||||||
|
playlistsar.push(playlists[i].split('-')[0])
|
||||||
|
}
|
||||||
|
obj.playlist = playlistsar.join()
|
||||||
|
console.log(obj)
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
new Vue({
|
||||||
|
el: '#vue',
|
||||||
|
mixins: [windowMixin],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
JukeboxTable: {
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'title',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Title',
|
||||||
|
field: 'title'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'device',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Device',
|
||||||
|
field: 'device'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'playlist',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Playlist',
|
||||||
|
field: 'playlist'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'price',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Price',
|
||||||
|
field: 'price'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
pagination: {
|
||||||
|
rowsPerPage: 10
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isPwd: true,
|
||||||
|
tokenFetched: true,
|
||||||
|
devices: [],
|
||||||
|
filter: '',
|
||||||
|
jukebox: {},
|
||||||
|
playlists: [],
|
||||||
|
JukeboxLinks: [],
|
||||||
|
step: 1,
|
||||||
|
locationcbPath: '',
|
||||||
|
locationcb: '',
|
||||||
|
jukeboxDialog: {
|
||||||
|
show: false,
|
||||||
|
data: {}
|
||||||
|
},
|
||||||
|
spotifyDialog: false,
|
||||||
|
qrCodeDialog: {
|
||||||
|
show: false,
|
||||||
|
data: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {},
|
||||||
|
methods: {
|
||||||
|
openQrCodeDialog: function (linkId) {
|
||||||
|
var link = _.findWhere(this.JukeboxLinks, {id: linkId})
|
||||||
|
|
||||||
|
this.qrCodeDialog.data = _.clone(link)
|
||||||
|
|
||||||
|
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(function (obj) {
|
||||||
|
|
||||||
|
return mapJukebox(obj)
|
||||||
|
})
|
||||||
|
console.log(self.JukeboxLinks)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
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)
|
||||||
|
|
||||||
|
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()
|
||||||
|
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
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'PUT',
|
||||||
|
'/jukebox/api/v1/jukebox/' + self.jukeboxDialog.data.sp_id,
|
||||||
|
self.g.user.wallets[0].adminkey,
|
||||||
|
self.jukeboxDialog.data
|
||||||
|
)
|
||||||
|
.then(function (response) {
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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
|
||||||
|
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() {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
})
|
||||||
BIN
lnbits/extensions/jukebox/static/spotapi.gif
Normal file
|
After Width: | Height: | Size: 215 KiB |
BIN
lnbits/extensions/jukebox/static/spotapi1.gif
Normal file
|
After Width: | Height: | Size: 241 KiB |
25
lnbits/extensions/jukebox/tasks.py
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from lnbits.core import db as core_db
|
||||||
|
from lnbits.core.models import Payment
|
||||||
|
from lnbits.tasks import register_invoice_listener
|
||||||
|
|
||||||
|
from .crud import get_jukebox, update_jukebox_payment
|
||||||
|
|
||||||
|
|
||||||
|
async def wait_for_paid_invoices():
|
||||||
|
invoice_queue = asyncio.Queue()
|
||||||
|
register_invoice_listener(invoice_queue)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
payment = await invoice_queue.get()
|
||||||
|
await on_invoice_paid(payment)
|
||||||
|
|
||||||
|
|
||||||
|
async def on_invoice_paid(payment: Payment) -> None:
|
||||||
|
if "jukebox" != payment.extra.get("tag"):
|
||||||
|
# not a jukebox invoice
|
||||||
|
return
|
||||||
|
await update_jukebox_payment(payment.payment_hash, paid=True)
|
||||||
125
lnbits/extensions/jukebox/templates/jukebox/_api_docs.html
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
<q-card-section>
|
||||||
|
To use this extension you need a Spotify client ID and client secret. You get
|
||||||
|
these by creating an app in the Spotify developers dashboard
|
||||||
|
<a
|
||||||
|
style="color: #43a047"
|
||||||
|
href="https://developer.spotify.com/dashboard/applications"
|
||||||
|
>here
|
||||||
|
</a>
|
||||||
|
<br /><br />Select the playlists you want people to be able to pay for, share
|
||||||
|
the frontend page, profit :) <br /><br />
|
||||||
|
Made by,
|
||||||
|
<a style="color: #43a047" href="https://twitter.com/arcbtc">benarc</a>.
|
||||||
|
Inspired by,
|
||||||
|
<a
|
||||||
|
style="color: #43a047"
|
||||||
|
href="https://twitter.com/pirosb3/status/1056263089128161280"
|
||||||
|
>pirosb3</a
|
||||||
|
>.
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-expansion-item
|
||||||
|
group="extras"
|
||||||
|
icon="swap_vertical_circle"
|
||||||
|
label="API info"
|
||||||
|
:content-inset-level="0.5"
|
||||||
|
>
|
||||||
|
<q-expansion-item group="api" dense expand-separator label="List jukeboxes">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<code><span class="text-blue">GET</span> /jukebox/api/v1/jukebox</code>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||||
|
<code>{"X-Api-Key": <admin_key>}</code><br />
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||||
|
Returns 200 OK (application/json)
|
||||||
|
</h5>
|
||||||
|
<code>[<jukebox_object>, ...]</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: {{
|
||||||
|
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/<juke_id></code
|
||||||
|
>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||||
|
<code>{"X-Api-Key": <admin_key>}</code><br />
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||||
|
Returns 200 OK (application/json)
|
||||||
|
</h5>
|
||||||
|
<code><jukebox_object></code>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
|
<code
|
||||||
|
>curl -X GET {{ request.url_root }}api/v1/jukebox/<juke_id> -H
|
||||||
|
"X-Api-Key: {{ 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": <admin_key>}</code><br />
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||||
|
Returns 200 OK (application/json)
|
||||||
|
</h5>
|
||||||
|
<code><jukbox_object></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":
|
||||||
|
<string, user_id>, "title": <string>,
|
||||||
|
"wallet":<string>, "sp_user": <string,
|
||||||
|
spotify_user_account>, "sp_secret": <string,
|
||||||
|
spotify_user_secret>, "sp_access_token": <string,
|
||||||
|
not_required>, "sp_refresh_token": <string, not_required>,
|
||||||
|
"sp_device": <string, spotify_user_secret>, "sp_playlists":
|
||||||
|
<string, not_required>, "price": <integer, not_required>}'
|
||||||
|
-H "Content-type: application/json" -H "X-Api-Key:
|
||||||
|
{{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/<juke_id></code
|
||||||
|
>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||||
|
<code>{"X-Api-Key": <admin_key>}</code><br />
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||||
|
Returns 200 OK (application/json)
|
||||||
|
</h5>
|
||||||
|
<code><jukebox_object></code>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
|
<code
|
||||||
|
>curl -X DELETE {{ request.url_root }}api/v1/jukebox/<juke_id>
|
||||||
|
-H "X-Api-Key: {{ user.wallets[0].adminkey }}"
|
||||||
|
</code>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item></q-expansion-item
|
||||||
|
>
|
||||||
37
lnbits/extensions/jukebox/templates/jukebox/error.html
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
{% extends "public.html" %} {% block page %}
|
||||||
|
<div class="row q-col-gutter-md justify-center">
|
||||||
|
<div class="col-12 col-md-7 col-lg-6 q-gutter-y-md">
|
||||||
|
<q-card class="q-pa-lg">
|
||||||
|
<q-card-section class="q-pa-none">
|
||||||
|
<center>
|
||||||
|
<h3 class="q-my-none">Jukebox error</h3>
|
||||||
|
<br />
|
||||||
|
<q-icon
|
||||||
|
name="warning"
|
||||||
|
class="text-grey"
|
||||||
|
style="font-size: 20rem"
|
||||||
|
></q-icon>
|
||||||
|
|
||||||
|
<h5 class="q-my-none">
|
||||||
|
Ask the host to turn on the device and launch spotify
|
||||||
|
</h5>
|
||||||
|
<br />
|
||||||
|
</center>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %} {% block scripts %}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
new Vue({
|
||||||
|
el: '#vue',
|
||||||
|
mixins: [windowMixin],
|
||||||
|
data: function () {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
368
lnbits/extensions/jukebox/templates/jukebox/index.html
Normal file
|
|
@ -0,0 +1,368 @@
|
||||||
|
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||||
|
%} {% block page %}
|
||||||
|
<div class="row q-col-gutter-md">
|
||||||
|
<div class="col-12 col-md-7 q-gutter-y-md">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
class="q-ma-lg"
|
||||||
|
@click="openNewDialog()"
|
||||||
|
>Add Spotify Jukebox</q-btn
|
||||||
|
>
|
||||||
|
|
||||||
|
{% raw %}
|
||||||
|
|
||||||
|
<q-table
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
:data="JukeboxLinks"
|
||||||
|
row-key="id"
|
||||||
|
:columns="JukeboxTable.columns"
|
||||||
|
:pagination.sync="JukeboxTable.pagination"
|
||||||
|
:filter="filter"
|
||||||
|
>
|
||||||
|
<template v-slot:header="props">
|
||||||
|
<q-tr :props="props">
|
||||||
|
<q-th auto-width></q-th>
|
||||||
|
<q-th auto-width></q-th>
|
||||||
|
|
||||||
|
<q-th
|
||||||
|
v-for="col in props.cols"
|
||||||
|
:key="col.name"
|
||||||
|
:props="props"
|
||||||
|
auto-width
|
||||||
|
>
|
||||||
|
<div v-if="col.name == 'id'"></div>
|
||||||
|
<div v-else>{{ col.label }}</div>
|
||||||
|
</q-th>
|
||||||
|
<q-th auto-width></q-th>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-slot:body="props">
|
||||||
|
<q-tr :props="props">
|
||||||
|
<q-td auto-width>
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
dense
|
||||||
|
size="xs"
|
||||||
|
icon="launch"
|
||||||
|
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||||
|
@click="openQrCodeDialog(props.row.sp_id)"
|
||||||
|
>
|
||||||
|
<q-tooltip> Jukebox QR </q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</q-td>
|
||||||
|
<q-td auto-width>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
size="xs"
|
||||||
|
@click="updateJukebox(props.row.id)"
|
||||||
|
icon="edit"
|
||||||
|
color="light-blue"
|
||||||
|
></q-btn>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
size="xs"
|
||||||
|
@click="deleteJukebox(props.row.id)"
|
||||||
|
icon="cancel"
|
||||||
|
color="pink"
|
||||||
|
>
|
||||||
|
<q-tooltip> Delete Jukebox </q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</q-td>
|
||||||
|
<q-td
|
||||||
|
v-for="col in props.cols"
|
||||||
|
:key="col.name"
|
||||||
|
:props="props"
|
||||||
|
auto-width
|
||||||
|
>
|
||||||
|
<div v-if="col.name == 'id'"></div>
|
||||||
|
<div v-else>{{ col.value }}</div>
|
||||||
|
</q-td>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
</q-table>
|
||||||
|
{% endraw %}
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-md-5 q-gutter-y-md">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<h6 class="text-subtitle1 q-my-none">
|
||||||
|
{{SITE_TITLE}} jukebox extension
|
||||||
|
</h6>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section class="q-pa-none">
|
||||||
|
<q-separator></q-separator>
|
||||||
|
<q-list> {% include "jukebox/_api_docs.html" %} </q-list>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-dialog v-model="jukeboxDialog.show" position="top" @hide="closeFormDialog">
|
||||||
|
<q-card class="q-pa-md q-pt-lg q-mt-md" style="width: 100%">
|
||||||
|
<q-stepper
|
||||||
|
v-model="step"
|
||||||
|
active-color="primary"
|
||||||
|
inactive-color="secondary"
|
||||||
|
vertical
|
||||||
|
animated
|
||||||
|
>
|
||||||
|
<q-step
|
||||||
|
:name="1"
|
||||||
|
title="Pick wallet, price"
|
||||||
|
icon="account_balance_wallet"
|
||||||
|
:done="step > 1"
|
||||||
|
>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
class="q-pt-md"
|
||||||
|
dense
|
||||||
|
v-model.trim="jukeboxDialog.data.title"
|
||||||
|
label="Jukebox name"
|
||||||
|
></q-input>
|
||||||
|
<q-select
|
||||||
|
class="q-pb-md q-pt-md"
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
emit-value
|
||||||
|
v-model="jukeboxDialog.data.wallet"
|
||||||
|
:options="g.user.walletOptions"
|
||||||
|
label="Wallet to use"
|
||||||
|
></q-select>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="jukeboxDialog.data.price"
|
||||||
|
type="number"
|
||||||
|
max="1440"
|
||||||
|
label="Price per track"
|
||||||
|
class="q-pb-lg"
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-4">
|
||||||
|
<q-btn
|
||||||
|
v-if="jukeboxDialog.data.title != null && jukeboxDialog.data.price != null && jukeboxDialog.data.wallet != null"
|
||||||
|
color="primary"
|
||||||
|
@click="step = 2"
|
||||||
|
>Continue</q-btn
|
||||||
|
>
|
||||||
|
<q-btn v-else color="primary" disable>Continue</q-btn>
|
||||||
|
</div>
|
||||||
|
<div class="col-8">
|
||||||
|
<q-btn
|
||||||
|
color="primary"
|
||||||
|
class="float-right"
|
||||||
|
@click="closeFormDialog"
|
||||||
|
>Cancel</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<br />
|
||||||
|
</q-step>
|
||||||
|
|
||||||
|
<q-step :name="2" title="Add api keys" icon="vpn_key" :done="step > 2">
|
||||||
|
<img src="/jukebox/static/spotapi.gif" />
|
||||||
|
To use this extension you need a Spotify client ID and client secret.
|
||||||
|
You get these by creating an app in the Spotify developers dashboard
|
||||||
|
<a
|
||||||
|
target="_blank"
|
||||||
|
style="color: #43a047"
|
||||||
|
href="https://developer.spotify.com/dashboard/applications"
|
||||||
|
>here</a
|
||||||
|
>.
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
class="q-pb-md q-pt-md"
|
||||||
|
dense
|
||||||
|
v-model.trim="jukeboxDialog.data.sp_user"
|
||||||
|
label="Client ID"
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
|
||||||
|
<q-input
|
||||||
|
dense
|
||||||
|
v-model="jukeboxDialog.data.sp_secret"
|
||||||
|
filled
|
||||||
|
:type="isPwd ? 'password' : 'text'"
|
||||||
|
label="Client secret"
|
||||||
|
>
|
||||||
|
<template #append>
|
||||||
|
<q-icon
|
||||||
|
:name="isPwd ? 'visibility_off' : 'visibility'"
|
||||||
|
class="cursor-pointer"
|
||||||
|
@click="isPwd = !isPwd"
|
||||||
|
>
|
||||||
|
</q-icon>
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
|
||||||
|
<div class="row q-mt-md">
|
||||||
|
<div class="col-4">
|
||||||
|
<q-btn
|
||||||
|
v-if="jukeboxDialog.data.sp_secret != null && jukeboxDialog.data.sp_user != null && tokenFetched"
|
||||||
|
color="primary"
|
||||||
|
@click="submitSpotifyKeys"
|
||||||
|
>Submit keys</q-btn
|
||||||
|
>
|
||||||
|
<q-btn v-else color="primary" disable color="primary"
|
||||||
|
>Submit keys</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-8">
|
||||||
|
<q-btn
|
||||||
|
color="primary"
|
||||||
|
class="float-right"
|
||||||
|
@click="closeFormDialog"
|
||||||
|
>Cancel</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<br />
|
||||||
|
</q-step>
|
||||||
|
|
||||||
|
<q-step :name="3" title="Add Redirect URI" icon="link" :done="step > 3">
|
||||||
|
<img src="/jukebox/static/spotapi1.gif" />
|
||||||
|
In the app go to edit-settings, set the redirect URI to this link
|
||||||
|
<br />
|
||||||
|
<q-btn
|
||||||
|
dense
|
||||||
|
outline
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
size="xs"
|
||||||
|
@click="copyText(locationcb + jukeboxDialog.data.sp_id, 'Link copied to clipboard!')"
|
||||||
|
>{% raw %}{{ locationcb }}{{ jukeboxDialog.data.sp_id }}{% endraw
|
||||||
|
%}<q-tooltip> Click to copy URL </q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
<br />
|
||||||
|
Settings can be found
|
||||||
|
<a
|
||||||
|
target="_blank"
|
||||||
|
style="color: #43a047"
|
||||||
|
href="https://developer.spotify.com/dashboard/applications"
|
||||||
|
>here</a
|
||||||
|
>.
|
||||||
|
|
||||||
|
<div class="row q-mt-md">
|
||||||
|
<div class="col-4">
|
||||||
|
<q-btn
|
||||||
|
v-if="jukeboxDialog.data.sp_secret != null && jukeboxDialog.data.sp_user != null && tokenFetched"
|
||||||
|
color="primary"
|
||||||
|
@click="authAccess"
|
||||||
|
>Authorise access</q-btn
|
||||||
|
>
|
||||||
|
<q-btn v-else color="primary" disable color="primary"
|
||||||
|
>Authorise access</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-8">
|
||||||
|
<q-btn
|
||||||
|
color="primary"
|
||||||
|
class="float-right"
|
||||||
|
@click="closeFormDialog"
|
||||||
|
>Cancel</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<br />
|
||||||
|
</q-step>
|
||||||
|
|
||||||
|
<q-step
|
||||||
|
:name="4"
|
||||||
|
title="Select playlists"
|
||||||
|
icon="queue_music"
|
||||||
|
active-color="primary"
|
||||||
|
:done="step > 4"
|
||||||
|
>
|
||||||
|
<q-select
|
||||||
|
class="q-pb-md q-pt-md"
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
emit-value
|
||||||
|
v-model="jukeboxDialog.data.sp_device"
|
||||||
|
:options="devices"
|
||||||
|
label="Device jukebox will play to"
|
||||||
|
></q-select>
|
||||||
|
<q-select
|
||||||
|
class="q-pb-md"
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
multiple
|
||||||
|
emit-value
|
||||||
|
v-model="jukeboxDialog.data.sp_playlists"
|
||||||
|
:options="playlists"
|
||||||
|
label="Playlists available to the jukebox"
|
||||||
|
></q-select>
|
||||||
|
<div class="row q-mt-md">
|
||||||
|
<div class="col-5">
|
||||||
|
<q-btn
|
||||||
|
v-if="jukeboxDialog.data.sp_device != null && jukeboxDialog.data.sp_playlists != null"
|
||||||
|
color="primary"
|
||||||
|
@click="createJukebox"
|
||||||
|
>Create Jukebox</q-btn
|
||||||
|
>
|
||||||
|
<q-btn v-else color="primary" disable>Create Jukebox</q-btn>
|
||||||
|
</div>
|
||||||
|
<div class="col-7">
|
||||||
|
<q-btn
|
||||||
|
color="primary"
|
||||||
|
class="float-right"
|
||||||
|
@click="closeFormDialog"
|
||||||
|
>Cancel</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-step>
|
||||||
|
</q-stepper>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
|
||||||
|
<q-dialog v-model="qrCodeDialog.show" position="top">
|
||||||
|
<q-card v-if="qrCodeDialog.data" class="q-pa-lg lnbits__dialog-card">
|
||||||
|
<center>
|
||||||
|
<h5 class="q-my-none">Shareable Jukebox QR</h5>
|
||||||
|
</center>
|
||||||
|
<q-responsive :ratio="1" class="q-mx-xl q-mb-md">
|
||||||
|
<qrcode
|
||||||
|
:value="qrCodeDialog.data.url + '/jukebox/' + qrCodeDialog.data.id"
|
||||||
|
:options="{width: 800}"
|
||||||
|
class="rounded-borders"
|
||||||
|
></qrcode>
|
||||||
|
</q-responsive>
|
||||||
|
<div class="row q-mt-lg q-gutter-sm">
|
||||||
|
<q-btn
|
||||||
|
outline
|
||||||
|
color="grey"
|
||||||
|
@click="copyText(qrCodeDialog.data.url + '/jukebox/' + qrCodeDialog.data.id, 'Link copied to clipboard!')"
|
||||||
|
>
|
||||||
|
Copy jukebox link</q-btn
|
||||||
|
>
|
||||||
|
<q-btn
|
||||||
|
outline
|
||||||
|
color="grey"
|
||||||
|
type="a"
|
||||||
|
:href="qrCodeDialog.data.url + '/jukebox/' + qrCodeDialog.data.id"
|
||||||
|
target="_blank"
|
||||||
|
>Open jukebox</q-btn
|
||||||
|
>
|
||||||
|
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
|
||||||
|
</div>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</div>
|
||||||
|
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/pica@6.1.1/dist/pica.min.js"></script>
|
||||||
|
<script src="/jukebox/static/js/index.js"></script>
|
||||||
|
{% endblock %}
|
||||||
281
lnbits/extensions/jukebox/templates/jukebox/jukebox.html
Normal file
|
|
@ -0,0 +1,281 @@
|
||||||
|
{% extends "public.html" %} {% block page %}
|
||||||
|
<div class="row q-col-gutter-md justify-center">
|
||||||
|
<div class="col-12 col-sm-6 col-md-5 col-lg-4">
|
||||||
|
<q-card class="q-pa-lg">
|
||||||
|
<q-card-section class="q-pa-none">
|
||||||
|
<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">
|
||||||
|
{% raw %}
|
||||||
|
<strong style="font-size: 20px">{{ currentPlay.name }}</strong
|
||||||
|
><br />
|
||||||
|
<strong style="font-size: 15px">{{ currentPlay.artist }}</strong>
|
||||||
|
</div>
|
||||||
|
{% endraw %}
|
||||||
|
</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>
|
||||||
|
{% raw %} {{ item.name }} - ({{ item.artist }}){% endraw %}
|
||||||
|
</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</template>
|
||||||
|
</q-virtual-scroll>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
{% raw %}
|
||||||
|
<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 }}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>
|
||||||
|
</div>
|
||||||
|
{% endblock %} {% block scripts %}
|
||||||
|
<script>
|
||||||
|
Vue.component(VueQrcode.name, VueQrcode)
|
||||||
|
|
||||||
|
new Vue({
|
||||||
|
el: '#vue',
|
||||||
|
mixins: [windowMixin],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
currentPlaylist: [],
|
||||||
|
currentlyPlaying: {},
|
||||||
|
playlists: {},
|
||||||
|
playlist: '',
|
||||||
|
heavyList: [],
|
||||||
|
selectedWallet: {},
|
||||||
|
paid: false,
|
||||||
|
receive: {
|
||||||
|
dialogues: {
|
||||||
|
first: false,
|
||||||
|
second: false
|
||||||
|
},
|
||||||
|
paymentReq: '',
|
||||||
|
paymentHash: '',
|
||||||
|
name: '',
|
||||||
|
artist: '',
|
||||||
|
image: '',
|
||||||
|
id: '',
|
||||||
|
showQR: false,
|
||||||
|
data: null,
|
||||||
|
dismissMsg: null,
|
||||||
|
paymentChecker: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
currentPlay() {
|
||||||
|
return this.currentlyPlaying
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
payForSong(song_id, name, artist, image) {
|
||||||
|
self = this
|
||||||
|
self.receive.name = name
|
||||||
|
self.receive.artist = artist
|
||||||
|
self.receive.image = image
|
||||||
|
self.receive.id = song_id
|
||||||
|
self.receive.dialogues.first = true
|
||||||
|
},
|
||||||
|
getInvoice(song_id) {
|
||||||
|
self = this
|
||||||
|
var dialog = this.receive
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'GET',
|
||||||
|
'/jukebox/api/v1/jukebox/jb/invoice/' +
|
||||||
|
'{{ juke_id }}' +
|
||||||
|
'/' +
|
||||||
|
song_id
|
||||||
|
)
|
||||||
|
.then(function (response) {
|
||||||
|
console.log(response.data)
|
||||||
|
self.receive.paymentReq = response.data.invoice
|
||||||
|
self.receive.paymentHash = response.data.payment_hash
|
||||||
|
self.receive.dialogues.second = true
|
||||||
|
dialog.data = response.data
|
||||||
|
|
||||||
|
dialog.dismissMsg = self.$q.notify({
|
||||||
|
timeout: 0,
|
||||||
|
message: 'Waiting for payment...'
|
||||||
|
})
|
||||||
|
dialog.paymentChecker = setInterval(function () {
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'GET',
|
||||||
|
'/jukebox/api/v1/jukebox/jb/checkinvoice/' +
|
||||||
|
self.receive.paymentHash +
|
||||||
|
'/{{ juke_id }}'
|
||||||
|
)
|
||||||
|
.then(function (res) {
|
||||||
|
console.log(res)
|
||||||
|
if (res.data.paid == true) {
|
||||||
|
clearInterval(dialog.paymentChecker)
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'GET',
|
||||||
|
'/jukebox/api/v1/jukebox/jb/invoicep/' +
|
||||||
|
self.receive.id +
|
||||||
|
'/{{ juke_id }}/' +
|
||||||
|
self.receive.paymentHash
|
||||||
|
)
|
||||||
|
.then(function (ress) {
|
||||||
|
console.log('ress')
|
||||||
|
console.log(ress)
|
||||||
|
console.log('ress')
|
||||||
|
if (ress.data.song_id == self.receive.id) {
|
||||||
|
clearInterval(dialog.paymentChecker)
|
||||||
|
dialog.dismissMsg()
|
||||||
|
self.receive.dialogues.second = false
|
||||||
|
|
||||||
|
self.$q.notify({
|
||||||
|
type: 'positive',
|
||||||
|
message:
|
||||||
|
'Success! "' +
|
||||||
|
self.receive.name +
|
||||||
|
'" will be played soon',
|
||||||
|
timeout: 3000
|
||||||
|
})
|
||||||
|
self.receive.dialogues.first = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, 3000)
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
self.getCurrent()
|
||||||
|
}, 500)
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
self.$q.notify({
|
||||||
|
color: 'warning',
|
||||||
|
html: true,
|
||||||
|
message:
|
||||||
|
'<center>Device is not connected! <br/> Ask the host to turn on their device and have Spotify open</center>',
|
||||||
|
timeout: 5000
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
getCurrent() {
|
||||||
|
LNbits.api
|
||||||
|
.request('GET', '/jukebox/api/v1/jukebox/jb/currently/{{juke_id}}')
|
||||||
|
.then(function (res) {
|
||||||
|
if (res.data.id) {
|
||||||
|
self.currentlyPlaying = res.data
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
selectPlaylist() {
|
||||||
|
self = this
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'GET',
|
||||||
|
'/jukebox/api/v1/jukebox/jb/playlist/' +
|
||||||
|
'{{ juke_id }}' +
|
||||||
|
'/' +
|
||||||
|
self.playlist.split(',')[0].split('-')[1]
|
||||||
|
)
|
||||||
|
.then(function (response) {
|
||||||
|
self.currentPlaylist = response.data
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
LNbits.utils.notifyApiError(err)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
currentSong() {}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.getCurrent()
|
||||||
|
this.playlists = JSON.parse('{{ playlists | tojson }}')
|
||||||
|
this.selectedWallet.inkey = '{{ inkey }}'
|
||||||
|
self = this
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'GET',
|
||||||
|
'/jukebox/api/v1/jukebox/jb/playlist/' +
|
||||||
|
'{{ juke_id }}' +
|
||||||
|
'/' +
|
||||||
|
self.playlists[0].split(',')[0].split('-')[1]
|
||||||
|
)
|
||||||
|
.then(function (response) {
|
||||||
|
self.currentPlaylist = response.data
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
LNbits.utils.notifyApiError(err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
52
lnbits/extensions/jukebox/views.py
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
from http import HTTPStatus
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
from fastapi.params import Depends
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from starlette.exceptions import HTTPException
|
||||||
|
from starlette.responses import HTMLResponse
|
||||||
|
|
||||||
|
from lnbits.core.models import User
|
||||||
|
from lnbits.decorators import WalletTypeInfo, check_user_exists, get_key_type
|
||||||
|
|
||||||
|
from . import jukebox_ext, jukebox_renderer
|
||||||
|
from .crud import get_jukebox
|
||||||
|
from .views_api import api_get_jukebox_device_check
|
||||||
|
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
|
||||||
|
@jukebox_ext.get("/", response_class=HTMLResponse)
|
||||||
|
async def index(request: Request, user: User = Depends(check_user_exists)):
|
||||||
|
return jukebox_renderer().TemplateResponse(
|
||||||
|
"jukebox/index.html", {"request": request, "user": user.dict()}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@jukebox_ext.get("/{juke_id}", response_class=HTMLResponse)
|
||||||
|
async def connect_to_jukebox(request: Request, juke_id):
|
||||||
|
jukebox = await get_jukebox(juke_id)
|
||||||
|
if not jukebox:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="Jukebox does not exist."
|
||||||
|
)
|
||||||
|
devices = await api_get_jukebox_device_check(juke_id)
|
||||||
|
for device in devices["devices"]:
|
||||||
|
if device["id"] == jukebox.sp_device.split("-")[1]:
|
||||||
|
deviceConnected = True
|
||||||
|
if deviceConnected:
|
||||||
|
return jukebox_renderer().TemplateResponse(
|
||||||
|
"jukebox/jukebox.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"playlists": jukebox.sp_playlists.split(","),
|
||||||
|
"juke_id": juke_id,
|
||||||
|
"price": jukebox.price,
|
||||||
|
"inkey": jukebox.inkey,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return jukebox_renderer().TemplateResponse(
|
||||||
|
"jukebox/error.html",
|
||||||
|
{"request": request, "jukebox": jukebox.jukebox(req=request)},
|
||||||
|
)
|
||||||
463
lnbits/extensions/jukebox/views_api.py
Normal file
|
|
@ -0,0 +1,463 @@
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
from http import HTTPStatus
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import Request
|
||||||
|
from fastapi.param_functions import Query
|
||||||
|
from fastapi.params import Depends
|
||||||
|
from starlette.exceptions import HTTPException
|
||||||
|
from starlette.responses import HTMLResponse, JSONResponse # type: ignore
|
||||||
|
|
||||||
|
from lnbits.core.crud import get_wallet
|
||||||
|
from lnbits.core.services import check_invoice_status, create_invoice
|
||||||
|
from lnbits.decorators import WalletTypeInfo, get_key_type
|
||||||
|
|
||||||
|
from . import jukebox_ext
|
||||||
|
from .crud import (
|
||||||
|
create_jukebox,
|
||||||
|
create_jukebox_payment,
|
||||||
|
delete_jukebox,
|
||||||
|
get_jukebox,
|
||||||
|
get_jukebox_payment,
|
||||||
|
get_jukeboxs,
|
||||||
|
update_jukebox,
|
||||||
|
update_jukebox_payment,
|
||||||
|
)
|
||||||
|
from .models import CreateJukeboxPayment, CreateJukeLinkData
|
||||||
|
|
||||||
|
|
||||||
|
@jukebox_ext.get("/api/v1/jukebox")
|
||||||
|
async def api_get_jukeboxs(
|
||||||
|
req: Request,
|
||||||
|
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||||
|
all_wallets: bool = Query(False),
|
||||||
|
):
|
||||||
|
wallet_user = wallet.wallet.user
|
||||||
|
|
||||||
|
try:
|
||||||
|
jukeboxs = [jukebox.dict() for jukebox in await get_jukeboxs(wallet_user)]
|
||||||
|
return jukeboxs
|
||||||
|
|
||||||
|
except:
|
||||||
|
raise HTTPException(status_code=HTTPStatus.NO_CONTENT, detail="No Jukeboxes")
|
||||||
|
|
||||||
|
|
||||||
|
##################SPOTIFY AUTH#####################
|
||||||
|
|
||||||
|
|
||||||
|
@jukebox_ext.get("/api/v1/jukebox/spotify/cb/{juke_id}", response_class=HTMLResponse)
|
||||||
|
async def api_check_credentials_callbac(
|
||||||
|
juke_id: str = Query(None),
|
||||||
|
code: str = Query(None),
|
||||||
|
access_token: str = Query(None),
|
||||||
|
refresh_token: str = Query(None),
|
||||||
|
):
|
||||||
|
sp_code = ""
|
||||||
|
sp_access_token = ""
|
||||||
|
sp_refresh_token = ""
|
||||||
|
try:
|
||||||
|
jukebox = await get_jukebox(juke_id)
|
||||||
|
except:
|
||||||
|
raise HTTPException(detail="No Jukebox", status_code=HTTPStatus.FORBIDDEN)
|
||||||
|
if code:
|
||||||
|
jukebox.sp_access_token = code
|
||||||
|
jukebox = await update_jukebox(jukebox, juke_id=juke_id)
|
||||||
|
if access_token:
|
||||||
|
jukebox.sp_access_token = access_token
|
||||||
|
jukebox.sp_refresh_token = refresh_token
|
||||||
|
jukebox = await update_jukebox(jukebox, juke_id=juke_id)
|
||||||
|
return "<h1>Success!</h1><h2>You can close this window</h2>"
|
||||||
|
|
||||||
|
|
||||||
|
@jukebox_ext.get("/api/v1/jukebox/{juke_id}")
|
||||||
|
async def api_check_credentials_check(
|
||||||
|
juke_id: str = Query(None), wallet: WalletTypeInfo = Depends(get_key_type)
|
||||||
|
):
|
||||||
|
print(juke_id)
|
||||||
|
jukebox = await get_jukebox(juke_id)
|
||||||
|
|
||||||
|
return jukebox
|
||||||
|
|
||||||
|
|
||||||
|
@jukebox_ext.post("/api/v1/jukebox", status_code=HTTPStatus.CREATED)
|
||||||
|
@jukebox_ext.put("/api/v1/jukebox/{juke_id}", status_code=HTTPStatus.OK)
|
||||||
|
async def api_create_update_jukebox(
|
||||||
|
data: CreateJukeLinkData,
|
||||||
|
juke_id: str = Query(None),
|
||||||
|
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||||
|
):
|
||||||
|
if juke_id:
|
||||||
|
jukebox = await update_jukebox(data, juke_id=juke_id)
|
||||||
|
else:
|
||||||
|
jukebox = await create_jukebox(data, inkey=wallet.wallet.inkey)
|
||||||
|
return jukebox
|
||||||
|
|
||||||
|
|
||||||
|
@jukebox_ext.delete("/api/v1/jukebox/{juke_id}")
|
||||||
|
async def api_delete_item(juke_id=None, wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||||
|
await delete_jukebox(juke_id)
|
||||||
|
try:
|
||||||
|
return [{**jukebox} for jukebox in await get_jukeboxs(wallet.wallet.user)]
|
||||||
|
except:
|
||||||
|
raise HTTPException(status_code=HTTPStatus.NO_CONTENT, detail="No Jukebox")
|
||||||
|
|
||||||
|
|
||||||
|
################JUKEBOX ENDPOINTS##################
|
||||||
|
|
||||||
|
######GET ACCESS TOKEN######
|
||||||
|
|
||||||
|
|
||||||
|
@jukebox_ext.get("/api/v1/jukebox/jb/playlist/{juke_id}/{sp_playlist}")
|
||||||
|
async def api_get_jukebox_song(
|
||||||
|
juke_id: str = Query(None),
|
||||||
|
sp_playlist: str = Query(None),
|
||||||
|
retry: bool = Query(False),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
jukebox = await get_jukebox(juke_id)
|
||||||
|
except:
|
||||||
|
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="No Jukeboxes")
|
||||||
|
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:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.FORBIDDEN,
|
||||||
|
detail="Failed to get auth",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return await api_get_jukebox_song(
|
||||||
|
juke_id, sp_playlist, retry=True
|
||||||
|
)
|
||||||
|
return r
|
||||||
|
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:
|
||||||
|
something = None
|
||||||
|
return [track for track in tracks]
|
||||||
|
|
||||||
|
|
||||||
|
async def api_get_token(juke_id=None):
|
||||||
|
try:
|
||||||
|
jukebox = await get_jukebox(juke_id)
|
||||||
|
except:
|
||||||
|
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="No Jukeboxes")
|
||||||
|
|
||||||
|
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:
|
||||||
|
jukebox.sp_access_token = r.json()["access_token"]
|
||||||
|
await update_jukebox(jukebox, juke_id=juke_id)
|
||||||
|
except:
|
||||||
|
something = None
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
######CHECK DEVICE
|
||||||
|
|
||||||
|
|
||||||
|
@jukebox_ext.get("/api/v1/jukebox/jb/{juke_id}")
|
||||||
|
async def api_get_jukebox_device_check(
|
||||||
|
juke_id: str = Query(None), retry: bool = Query(False)
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
jukebox = await get_jukebox(juke_id)
|
||||||
|
except:
|
||||||
|
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="No Jukeboxes")
|
||||||
|
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 json.loads(rDevice.text)
|
||||||
|
elif rDevice.status_code == 401 or rDevice.status_code == 403:
|
||||||
|
token = await api_get_token(juke_id)
|
||||||
|
if token == False:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.FORBIDDEN, detail="No devices connected"
|
||||||
|
)
|
||||||
|
elif retry:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.FORBIDDEN, detail="Failed to get auth"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return api_get_jukebox_device_check(juke_id, retry=True)
|
||||||
|
else:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.FORBIDDEN, detail="No device connected"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
######GET INVOICE STUFF
|
||||||
|
|
||||||
|
|
||||||
|
@jukebox_ext.get("/api/v1/jukebox/jb/invoice/{juke_id}/{song_id}")
|
||||||
|
async def api_get_jukebox_invoice(juke_id, song_id):
|
||||||
|
try:
|
||||||
|
jukebox = await get_jukebox(juke_id)
|
||||||
|
print(jukebox)
|
||||||
|
except:
|
||||||
|
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="No jukebox")
|
||||||
|
try:
|
||||||
|
|
||||||
|
devices = await api_get_jukebox_device_check(juke_id)
|
||||||
|
deviceConnected = False
|
||||||
|
for device in devices["devices"]:
|
||||||
|
if device["id"] == jukebox.sp_device.split("-")[1]:
|
||||||
|
deviceConnected = True
|
||||||
|
if not deviceConnected:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="No device connected"
|
||||||
|
)
|
||||||
|
except:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="No device connected"
|
||||||
|
)
|
||||||
|
|
||||||
|
invoice = await create_invoice(
|
||||||
|
wallet_id=jukebox.wallet,
|
||||||
|
amount=jukebox.price,
|
||||||
|
memo=jukebox.title,
|
||||||
|
extra={"tag": "jukebox"},
|
||||||
|
)
|
||||||
|
|
||||||
|
payment_hash = invoice[0]
|
||||||
|
data = CreateJukeboxPayment(
|
||||||
|
invoice=invoice[1], payment_hash=payment_hash, juke_id=juke_id, song_id=song_id
|
||||||
|
)
|
||||||
|
jukebox_payment = await create_jukebox_payment(data)
|
||||||
|
print(data)
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
@jukebox_ext.get("/api/v1/jukebox/jb/checkinvoice/{pay_hash}/{juke_id}")
|
||||||
|
async def api_get_jukebox_invoice_check(
|
||||||
|
pay_hash: str = Query(None), juke_id: str = Query(None)
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
jukebox = await get_jukebox(juke_id)
|
||||||
|
except:
|
||||||
|
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="No jukebox")
|
||||||
|
try:
|
||||||
|
status = await check_invoice_status(jukebox.wallet, pay_hash)
|
||||||
|
is_paid = not status.pending
|
||||||
|
except:
|
||||||
|
return {"paid": False}
|
||||||
|
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 {"paid": True}
|
||||||
|
return {"paid": False}
|
||||||
|
|
||||||
|
|
||||||
|
@jukebox_ext.get("/api/v1/jukebox/jb/invoicep/{song_id}/{juke_id}/{pay_hash}")
|
||||||
|
async def api_get_jukebox_invoice_paid(
|
||||||
|
song_id: str = Query(None),
|
||||||
|
juke_id: str = Query(None),
|
||||||
|
pay_hash: str = Query(None),
|
||||||
|
retry: bool = Query(False),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
jukebox = await get_jukebox(juke_id)
|
||||||
|
except:
|
||||||
|
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="No jukebox")
|
||||||
|
await api_get_jukebox_invoice_check(pay_hash, juke_id)
|
||||||
|
jukebox_payment = await get_jukebox_payment(pay_hash)
|
||||||
|
if jukebox_payment.paid:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
r = await client.get(
|
||||||
|
"https://api.spotify.com/v1/me/player/currently-playing?market=ES",
|
||||||
|
timeout=40,
|
||||||
|
headers={"Authorization": "Bearer " + jukebox.sp_access_token},
|
||||||
|
)
|
||||||
|
rDevice = await client.get(
|
||||||
|
"https://api.spotify.com/v1/me/player",
|
||||||
|
timeout=40,
|
||||||
|
headers={"Authorization": "Bearer " + jukebox.sp_access_token},
|
||||||
|
)
|
||||||
|
isPlaying = False
|
||||||
|
if rDevice.status_code == 200:
|
||||||
|
isPlaying = rDevice.json()["is_playing"]
|
||||||
|
|
||||||
|
if r.status_code == 204 or isPlaying == False:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
uri = ["spotify:track:" + song_id]
|
||||||
|
r = await client.put(
|
||||||
|
"https://api.spotify.com/v1/me/player/play?device_id="
|
||||||
|
+ jukebox.sp_device.split("-")[1],
|
||||||
|
json={"uris": uri},
|
||||||
|
timeout=40,
|
||||||
|
headers={"Authorization": "Bearer " + jukebox.sp_access_token},
|
||||||
|
)
|
||||||
|
if r.status_code == 204:
|
||||||
|
return jukebox_payment
|
||||||
|
elif r.status_code == 401 or r.status_code == 403:
|
||||||
|
token = await api_get_token(juke_id)
|
||||||
|
if token == False:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.FORBIDDEN,
|
||||||
|
detail="Invoice not paid",
|
||||||
|
)
|
||||||
|
elif retry:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.FORBIDDEN,
|
||||||
|
detail="Failed to get auth",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return api_get_jukebox_invoice_paid(
|
||||||
|
song_id, juke_id, pay_hash, retry=True
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.FORBIDDEN, detail="Invoice not paid"
|
||||||
|
)
|
||||||
|
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 jukebox_payment
|
||||||
|
|
||||||
|
elif r.status_code == 401 or r.status_code == 403:
|
||||||
|
token = await api_get_token(juke_id)
|
||||||
|
if token == False:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.FORBIDDEN,
|
||||||
|
detail="Invoice not paid",
|
||||||
|
)
|
||||||
|
elif retry:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.FORBIDDEN,
|
||||||
|
detail="Failed to get auth",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return await api_get_jukebox_invoice_paid(
|
||||||
|
song_id, juke_id, pay_hash
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.OK, detail="Invoice not paid"
|
||||||
|
)
|
||||||
|
elif r.status_code == 401 or r.status_code == 403:
|
||||||
|
token = await api_get_token(juke_id)
|
||||||
|
if token == False:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.OK, detail="Invoice not paid"
|
||||||
|
)
|
||||||
|
elif retry:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.FORBIDDEN, detail="Failed to get auth"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return await api_get_jukebox_invoice_paid(
|
||||||
|
song_id, juke_id, pay_hash
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=HTTPStatus.OK, detail="Invoice not paid")
|
||||||
|
|
||||||
|
|
||||||
|
############################GET TRACKS
|
||||||
|
|
||||||
|
|
||||||
|
@jukebox_ext.get("/api/v1/jukebox/jb/currently/{juke_id}")
|
||||||
|
async def api_get_jukebox_currently(
|
||||||
|
retry: bool = Query(False), juke_id: str = Query(None)
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
jukebox = await get_jukebox(juke_id)
|
||||||
|
except:
|
||||||
|
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="No jukebox")
|
||||||
|
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:
|
||||||
|
raise HTTPException(status_code=HTTPStatus.OK, detail="Nothing")
|
||||||
|
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 track
|
||||||
|
except:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="Something went wrong"
|
||||||
|
)
|
||||||
|
|
||||||
|
elif r.status_code == 401:
|
||||||
|
token = await api_get_token(juke_id)
|
||||||
|
if token == False:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.FORBIDDEN, detail="INvoice not paid"
|
||||||
|
)
|
||||||
|
elif retry:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.FORBIDDEN, detail="Failed to get auth"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return await api_get_jukebox_currently(retry=True, juke_id=juke_id)
|
||||||
|
else:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="Something went wrong"
|
||||||
|
)
|
||||||
|
except:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="Something went wrong"
|
||||||
|
)
|
||||||
6
lnbits/extensions/lndhub/README.md
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
<h1>lndhub Extension</h1>
|
||||||
|
<h2>*connect to your lnbits wallet from BlueWallet or Zeus*</h2>
|
||||||
|
|
||||||
|
Lndhub has nothing to do with lnd, it is just the name of the HTTP/JSON protocol https://bluewallet.io/ uses to talk to their Lightning custodian server at https://lndhub.io/.
|
||||||
|
|
||||||
|
Despite not having been planned to this, Lndhub because somewhat a standard for custodian wallet communication when https://t.me/lntxbot and https://zeusln.app/ implemented the same interface. And with this extension LNBits joins the same club.
|
||||||
18
lnbits/extensions/lndhub/__init__.py
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from lnbits.db import Database
|
||||||
|
from lnbits.helpers import template_renderer
|
||||||
|
|
||||||
|
db = Database("ext_lndhub")
|
||||||
|
|
||||||
|
lndhub_ext: APIRouter = APIRouter(prefix="/lndhub", tags=["lndhub"])
|
||||||
|
|
||||||
|
|
||||||
|
def lndhub_renderer():
|
||||||
|
return template_renderer(["lnbits/extensions/lndhub/templates"])
|
||||||
|
|
||||||
|
|
||||||
|
from .decorators import * # noqa
|
||||||
|
from .utils import * # noqa
|
||||||
|
from .views import * # noqa
|
||||||
|
from .views_api import * # noqa
|
||||||
6
lnbits/extensions/lndhub/config.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"name": "LndHub",
|
||||||
|
"short_description": "Access lnbits from BlueWallet or Zeus",
|
||||||
|
"icon": "navigation",
|
||||||
|
"contributors": ["fiatjaf"]
|
||||||
|
}
|
||||||
44
lnbits/extensions/lndhub/decorators.py
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
from base64 import b64decode
|
||||||
|
from fastapi.param_functions import Security
|
||||||
|
|
||||||
|
from fastapi.security.api_key import APIKeyHeader
|
||||||
|
|
||||||
|
from fastapi import Request, status
|
||||||
|
from starlette.exceptions import HTTPException
|
||||||
|
|
||||||
|
from lnbits.decorators import WalletTypeInfo, get_key_type # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
api_key_header_auth = APIKeyHeader(
|
||||||
|
name="AUTHORIZATION",
|
||||||
|
auto_error=False,
|
||||||
|
description="Admin or Invoice key for LNDHub API's",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def check_wallet(
|
||||||
|
r: Request, api_key_header_auth: str = Security(api_key_header_auth)
|
||||||
|
) -> WalletTypeInfo:
|
||||||
|
if not api_key_header_auth:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid auth key"
|
||||||
|
)
|
||||||
|
|
||||||
|
t = api_key_header_auth.split(" ")[1]
|
||||||
|
_, token = b64decode(t).decode("utf-8").split(":")
|
||||||
|
|
||||||
|
return await get_key_type(r, api_key_header=token)
|
||||||
|
|
||||||
|
|
||||||
|
async def require_admin_key(
|
||||||
|
r: Request, api_key_header_auth: str = Security(api_key_header_auth)
|
||||||
|
):
|
||||||
|
wallet = await check_wallet(r, api_key_header_auth)
|
||||||
|
if wallet.wallet_type != 0:
|
||||||
|
# If wallet type is not admin then return the unauthorized status
|
||||||
|
# This also covers when the user passes an invalid key type
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED, detail="Admin key required."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return wallet
|
||||||
2
lnbits/extensions/lndhub/migrations.py
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
async def migrate():
|
||||||
|
pass
|
||||||
35
lnbits/extensions/lndhub/templates/lndhub/_instructions.html
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
<q-expansion-item
|
||||||
|
group="extras"
|
||||||
|
icon="info"
|
||||||
|
label="Instructions"
|
||||||
|
default-opened
|
||||||
|
>
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
To access an LNbits wallet from a mobile phone,
|
||||||
|
<ol>
|
||||||
|
<li>
|
||||||
|
Install either <a href="https://zeusln.app">Zeus</a> or
|
||||||
|
<a href="https://bluewallet.io/">BlueWallet</a>;
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Go to <code>Add a wallet / Import wallet</code> on BlueWallet or
|
||||||
|
<code>Settings / Add a new node</code> on Zeus.
|
||||||
|
</li>
|
||||||
|
<li>Select the desired wallet on this page;</li>
|
||||||
|
<li>Scan one of the two QR codes from the mobile wallet.</li>
|
||||||
|
</ol>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<em>Invoice</em> URLs mean the mobile wallet will only have the
|
||||||
|
authorization to read your payments and invoices and generate new
|
||||||
|
invoices.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<em>Admin</em> URLs mean the mobile wallet will be able to pay
|
||||||
|
invoices..
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
19
lnbits/extensions/lndhub/templates/lndhub/_lndhub.html
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
<q-expansion-item group="extras" icon="info" label="LndHub info">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<p>
|
||||||
|
LndHub is a protocol invented by
|
||||||
|
<a href="https://bluewallet.io/">BlueWallet</a> that allows mobile
|
||||||
|
wallets to query payments and balances, generate invoices and make
|
||||||
|
payments from accounts that exist on a server. The protocol is a
|
||||||
|
collection of HTTP endpoints exposed through the internet.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
For a wallet that supports it, reading a QR code that contains the URL
|
||||||
|
along with secret access credentials should enable access. Currently it
|
||||||
|
is supported by <a href="https://zeusln.app">Zeus</a> and
|
||||||
|
<a href="https://bluewallet.io/">BlueWallet</a>.
|
||||||
|
</p>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
94
lnbits/extensions/lndhub/templates/lndhub/index.html
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||||
|
%} {% block page %} {% raw %}
|
||||||
|
<div class="row q-col-gutter-md">
|
||||||
|
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
|
||||||
|
<div class="row q-col-gutter-md">
|
||||||
|
<q-card
|
||||||
|
v-for="type in ['invoice', 'admin']"
|
||||||
|
v-bind:key="type"
|
||||||
|
class="q-pa-sm q-ma-sm col-5"
|
||||||
|
>
|
||||||
|
<q-card-section class="q-pa-none">
|
||||||
|
<div class="text-center">
|
||||||
|
<a :href="selectedWallet[type]">
|
||||||
|
<q-responsive :ratio="1" class="q-mx-sm">
|
||||||
|
<qrcode
|
||||||
|
:value="selectedWallet[type]"
|
||||||
|
:options="{width: 800}"
|
||||||
|
class="rounded-borders"
|
||||||
|
></qrcode>
|
||||||
|
</q-responsive>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="row q-mt-lg items-center justify-center">
|
||||||
|
<q-btn
|
||||||
|
outline
|
||||||
|
color="grey"
|
||||||
|
@click="copyText(selectedWallet[type])"
|
||||||
|
class="text-center q-mb-md"
|
||||||
|
>Copy LndHub {{type}} URL</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||||
|
<q-form class="q-gutter-md">
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model="selectedWallet"
|
||||||
|
:options="wallets"
|
||||||
|
label="Select wallet:"
|
||||||
|
>
|
||||||
|
</q-select>
|
||||||
|
</q-form>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endraw %}
|
||||||
|
|
||||||
|
<div class="col-12 col-md-4 col-lg-5 q-gutter-y-md">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<h6 class="text-subtitle1 q-my-none">
|
||||||
|
{{SITE_TITLE}} LndHub extension
|
||||||
|
</h6>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section class="q-pa-none">
|
||||||
|
<q-separator></q-separator>
|
||||||
|
<q-list>
|
||||||
|
{% include "lndhub/_instructions.html" %}
|
||||||
|
<q-separator></q-separator>
|
||||||
|
{% include "lndhub/_lndhub.html" %}
|
||||||
|
</q-list>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||||
|
<script>
|
||||||
|
Vue.component(VueQrcode.name, VueQrcode)
|
||||||
|
|
||||||
|
new Vue({
|
||||||
|
el: '#vue',
|
||||||
|
mixins: [windowMixin],
|
||||||
|
data: function () {
|
||||||
|
var wallets = JSON.parse('{{ user.wallets | tojson }}')
|
||||||
|
.map(LNbits.map.wallet)
|
||||||
|
.map(wallet => ({
|
||||||
|
label: wallet.name,
|
||||||
|
admin: `lndhub://admin:${wallet.adminkey}@${location.protocol}//${location.host}/lndhub/ext/`,
|
||||||
|
invoice: `lndhub://invoice:${wallet.inkey}@${location.protocol}//${location.host}/lndhub/ext/`
|
||||||
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
wallets: wallets,
|
||||||
|
selectedWallet: wallets[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
21
lnbits/extensions/lndhub/utils.py
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
from binascii import unhexlify
|
||||||
|
|
||||||
|
from lnbits.bolt11 import Invoice
|
||||||
|
|
||||||
|
|
||||||
|
def to_buffer(payment_hash: str):
|
||||||
|
return {"type": "Buffer", "data": [b for b in unhexlify(payment_hash)]}
|
||||||
|
|
||||||
|
|
||||||
|
def decoded_as_lndhub(invoice: Invoice):
|
||||||
|
return {
|
||||||
|
"destination": invoice.payee,
|
||||||
|
"payment_hash": invoice.payment_hash,
|
||||||
|
"num_satoshis": invoice.amount_msat / 1000,
|
||||||
|
"timestamp": str(invoice.date),
|
||||||
|
"expiry": str(invoice.expiry),
|
||||||
|
"description": invoice.description,
|
||||||
|
"fallback_addr": "",
|
||||||
|
"cltv_expiry": invoice.min_final_cltv_expiry,
|
||||||
|
"route_hints": "",
|
||||||
|
}
|
||||||
12
lnbits/extensions/lndhub/views.py
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
from lnbits.decorators import check_user_exists
|
||||||
|
from . import lndhub_ext, lndhub_renderer
|
||||||
|
from fastapi import Request
|
||||||
|
from fastapi.params import Depends
|
||||||
|
from lnbits.core.models import User
|
||||||
|
|
||||||
|
|
||||||
|
@lndhub_ext.get("/")
|
||||||
|
async def lndhub_index(request: Request, user: User = Depends(check_user_exists)):
|
||||||
|
return lndhub_renderer().TemplateResponse(
|
||||||
|
"lndhub/index.html", {"request": request, "user": user.dict()}
|
||||||
|
)
|
||||||
219
lnbits/extensions/lndhub/views_api.py
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
import time
|
||||||
|
from base64 import urlsafe_b64encode
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from lnbits.core.services import pay_invoice, create_invoice
|
||||||
|
from lnbits.core.crud import get_payments, delete_expired_invoices
|
||||||
|
from lnbits.decorators import WalletTypeInfo
|
||||||
|
from lnbits.settings import WALLET
|
||||||
|
from lnbits import bolt11
|
||||||
|
|
||||||
|
from . import lndhub_ext
|
||||||
|
from .decorators import check_wallet, require_admin_key
|
||||||
|
from .utils import to_buffer, decoded_as_lndhub
|
||||||
|
from http import HTTPStatus
|
||||||
|
from starlette.exceptions import HTTPException
|
||||||
|
from fastapi.params import Depends
|
||||||
|
from fastapi.param_functions import Query
|
||||||
|
|
||||||
|
|
||||||
|
@lndhub_ext.get("/ext/getinfo")
|
||||||
|
async def lndhub_getinfo():
|
||||||
|
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="bad auth")
|
||||||
|
|
||||||
|
|
||||||
|
class AuthData(BaseModel):
|
||||||
|
login: str = Query(None)
|
||||||
|
password: str = Query(None)
|
||||||
|
refresh_token: str = Query(None)
|
||||||
|
|
||||||
|
|
||||||
|
@lndhub_ext.post("/ext/auth")
|
||||||
|
async def lndhub_auth(data: AuthData):
|
||||||
|
token = (
|
||||||
|
data.refresh_token
|
||||||
|
if data.refresh_token
|
||||||
|
else urlsafe_b64encode(
|
||||||
|
(data.login + ":" + data.password).encode("utf-8")
|
||||||
|
).decode("ascii")
|
||||||
|
)
|
||||||
|
return {"refresh_token": token, "access_token": token}
|
||||||
|
|
||||||
|
|
||||||
|
class AddInvoice(BaseModel):
|
||||||
|
amt: str = Query(None)
|
||||||
|
memo: str = Query(None)
|
||||||
|
preimage: str = Query(None)
|
||||||
|
|
||||||
|
|
||||||
|
@lndhub_ext.post("/ext/addinvoice")
|
||||||
|
async def lndhub_addinvoice(
|
||||||
|
data: AddInvoice, wallet: WalletTypeInfo = Depends(check_wallet)
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
_, pr = await create_invoice(
|
||||||
|
wallet_id=wallet.wallet.id,
|
||||||
|
amount=int(data.amt),
|
||||||
|
memo=data.memo,
|
||||||
|
extra={"tag": "lndhub"},
|
||||||
|
)
|
||||||
|
except:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="Failed to create invoice"
|
||||||
|
)
|
||||||
|
|
||||||
|
invoice = bolt11.decode(pr)
|
||||||
|
return {
|
||||||
|
"pay_req": pr,
|
||||||
|
"payment_request": pr,
|
||||||
|
"add_index": "500",
|
||||||
|
"r_hash": to_buffer(invoice.payment_hash),
|
||||||
|
"hash": invoice.payment_hash,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Invoice(BaseModel):
|
||||||
|
invoice: str
|
||||||
|
|
||||||
|
|
||||||
|
@lndhub_ext.post("/ext/payinvoice")
|
||||||
|
async def lndhub_payinvoice(
|
||||||
|
r_invoice: Invoice, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
await pay_invoice(
|
||||||
|
wallet_id=wallet.wallet.id,
|
||||||
|
payment_request=r_invoice.invoice,
|
||||||
|
extra={"tag": "lndhub"},
|
||||||
|
)
|
||||||
|
except:
|
||||||
|
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Payment failed")
|
||||||
|
|
||||||
|
invoice: bolt11.Invoice = bolt11.decode(r_invoice.invoice)
|
||||||
|
print("INV2", invoice)
|
||||||
|
return {
|
||||||
|
"payment_error": "",
|
||||||
|
"payment_preimage": "0" * 64,
|
||||||
|
"route": {},
|
||||||
|
"payment_hash": invoice.payment_hash,
|
||||||
|
"decoded": decoded_as_lndhub(invoice),
|
||||||
|
"fee_msat": 0,
|
||||||
|
"type": "paid_invoice",
|
||||||
|
"fee": 0,
|
||||||
|
"value": invoice.amount_msat / 1000,
|
||||||
|
"timestamp": int(time.time()),
|
||||||
|
"memo": invoice.description,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@lndhub_ext.get("/ext/balance")
|
||||||
|
async def lndhub_balance(wallet: WalletTypeInfo = Depends(check_wallet),):
|
||||||
|
return {"BTC": {"AvailableBalance": wallet.wallet.balance}}
|
||||||
|
|
||||||
|
|
||||||
|
@lndhub_ext.get("/ext/gettxs")
|
||||||
|
async def lndhub_gettxs(
|
||||||
|
wallet: WalletTypeInfo = Depends(check_wallet), limit: int = Query(0, ge=0, lt=200)
|
||||||
|
):
|
||||||
|
for payment in await get_payments(
|
||||||
|
wallet_id=wallet.wallet.id,
|
||||||
|
complete=False,
|
||||||
|
pending=True,
|
||||||
|
outgoing=True,
|
||||||
|
incoming=False,
|
||||||
|
exclude_uncheckable=True,
|
||||||
|
):
|
||||||
|
await payment.set_pending(
|
||||||
|
(await WALLET.get_payment_status(payment.checking_id)).pending
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"payment_preimage": payment.preimage,
|
||||||
|
"payment_hash": payment.payment_hash,
|
||||||
|
"fee_msat": payment.fee * 1000,
|
||||||
|
"type": "paid_invoice",
|
||||||
|
"fee": payment.fee,
|
||||||
|
"value": int(payment.amount / 1000),
|
||||||
|
"timestamp": payment.time,
|
||||||
|
"memo": payment.memo if not payment.pending else "Payment in transition",
|
||||||
|
}
|
||||||
|
for payment in reversed(
|
||||||
|
(
|
||||||
|
await get_payments(
|
||||||
|
wallet_id=wallet.wallet.id,
|
||||||
|
pending=True,
|
||||||
|
complete=True,
|
||||||
|
outgoing=True,
|
||||||
|
incoming=False,
|
||||||
|
)
|
||||||
|
)[:limit]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@lndhub_ext.get("/ext/getuserinvoices")
|
||||||
|
async def lndhub_getuserinvoices(
|
||||||
|
wallet: WalletTypeInfo = Depends(check_wallet), limit: int = Query(0, ge=0, lt=200)
|
||||||
|
):
|
||||||
|
await delete_expired_invoices()
|
||||||
|
for invoice in await get_payments(
|
||||||
|
wallet_id=wallet.wallet.id,
|
||||||
|
complete=False,
|
||||||
|
pending=True,
|
||||||
|
outgoing=False,
|
||||||
|
incoming=True,
|
||||||
|
exclude_uncheckable=True,
|
||||||
|
):
|
||||||
|
await invoice.set_pending(
|
||||||
|
(await WALLET.get_invoice_status(invoice.checking_id)).pending
|
||||||
|
)
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"r_hash": to_buffer(invoice.payment_hash),
|
||||||
|
"payment_request": invoice.bolt11,
|
||||||
|
"add_index": "500",
|
||||||
|
"description": invoice.memo,
|
||||||
|
"payment_hash": invoice.payment_hash,
|
||||||
|
"ispaid": not invoice.pending,
|
||||||
|
"amt": int(invoice.amount / 1000),
|
||||||
|
"expire_time": int(time.time() + 1800),
|
||||||
|
"timestamp": invoice.time,
|
||||||
|
"type": "user_invoice",
|
||||||
|
}
|
||||||
|
for invoice in reversed(
|
||||||
|
(
|
||||||
|
await get_payments(
|
||||||
|
wallet_id=wallet.wallet.id,
|
||||||
|
pending=True,
|
||||||
|
complete=True,
|
||||||
|
incoming=True,
|
||||||
|
outgoing=False,
|
||||||
|
)
|
||||||
|
)[:limit]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@lndhub_ext.get("/ext/getbtc")
|
||||||
|
async def lndhub_getbtc(wallet: WalletTypeInfo = Depends(check_wallet)):
|
||||||
|
"load an address for incoming onchain btc"
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
@lndhub_ext.get("/ext/getpending")
|
||||||
|
async def lndhub_getpending(wallet: WalletTypeInfo = Depends(check_wallet)):
|
||||||
|
"pending onchain transactions"
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
@lndhub_ext.get("/ext/decodeinvoice")
|
||||||
|
async def lndhub_decodeinvoice(invoice: str = Query(None)):
|
||||||
|
inv = bolt11.decode(invoice)
|
||||||
|
return decoded_as_lndhub(inv)
|
||||||
|
|
||||||
|
|
||||||
|
@lndhub_ext.get("/ext/checkrouteinvoice")
|
||||||
|
async def lndhub_checkrouteinvoice():
|
||||||
|
"not implemented on canonical lndhub"
|
||||||
|
pass
|
||||||
|
|
@ -14,12 +14,9 @@ lnticket_ext: APIRouter = APIRouter(
|
||||||
# "lnticket", __name__, static_folder="static", template_folder="templates"
|
# "lnticket", __name__, static_folder="static", template_folder="templates"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def lnticket_renderer():
|
def lnticket_renderer():
|
||||||
return template_renderer(
|
return template_renderer(["lnbits/extensions/lnticket/templates"])
|
||||||
[
|
|
||||||
"lnbits/extensions/lnticket/templates",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
from .views_api import * # noqa
|
from .views_api import * # noqa
|
||||||
|
|
@ -30,4 +27,3 @@ from .tasks import wait_for_paid_invoices
|
||||||
def lnticket_start():
|
def lnticket_start():
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
|
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,16 +9,23 @@ import httpx
|
||||||
|
|
||||||
|
|
||||||
async def create_ticket(
|
async def create_ticket(
|
||||||
payment_hash: str,
|
payment_hash: str, wallet: str, data: CreateTicketData
|
||||||
wallet: str,
|
|
||||||
data: CreateTicketData
|
|
||||||
) -> Tickets:
|
) -> Tickets:
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO lnticket.ticket (id, form, email, ltext, name, wallet, sats, paid)
|
INSERT INTO lnticket.ticket (id, form, email, ltext, name, wallet, sats, paid)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(payment_hash, data.form, data.email, data.ltext, data.name, wallet, data.sats, False),
|
(
|
||||||
|
payment_hash,
|
||||||
|
data.form,
|
||||||
|
data.email,
|
||||||
|
data.ltext,
|
||||||
|
data.name,
|
||||||
|
wallet,
|
||||||
|
data.sats,
|
||||||
|
False,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
ticket = await get_ticket(payment_hash)
|
ticket = await get_ticket(payment_hash)
|
||||||
|
|
@ -99,17 +106,23 @@ async def delete_ticket(ticket_id: str) -> None:
|
||||||
# FORMS
|
# FORMS
|
||||||
|
|
||||||
|
|
||||||
async def create_form(
|
async def create_form(data: CreateFormData, wallet: Wallet) -> Forms:
|
||||||
data: CreateFormData,
|
|
||||||
wallet: Wallet,
|
|
||||||
) -> Forms:
|
|
||||||
form_id = urlsafe_short_hash()
|
form_id = urlsafe_short_hash()
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO lnticket.form2 (id, wallet, name, webhook, description, flatrate, amount, amountmade)
|
INSERT INTO lnticket.form2 (id, wallet, name, webhook, description, flatrate, amount, amountmade)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(form_id, wallet.id, wallet.name, data.webhook, data.description, data.flatrate, data.amount, 0),
|
(
|
||||||
|
form_id,
|
||||||
|
wallet.id,
|
||||||
|
wallet.name,
|
||||||
|
data.webhook,
|
||||||
|
data.description,
|
||||||
|
data.flatrate,
|
||||||
|
data.amount,
|
||||||
|
0,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
form = await get_form(form_id)
|
form = await get_form(form_id)
|
||||||
|
|
|
||||||
|
|
@ -79,16 +79,7 @@ async def m002_changed(db):
|
||||||
)
|
)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(
|
(row[0], row[1], row[2], row[3], row[4], row[5], row[6], True),
|
||||||
row[0],
|
|
||||||
row[1],
|
|
||||||
row[2],
|
|
||||||
row[3],
|
|
||||||
row[4],
|
|
||||||
row[5],
|
|
||||||
row[6],
|
|
||||||
True,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
await db.execute("DROP TABLE lnticket.tickets")
|
await db.execute("DROP TABLE lnticket.tickets")
|
||||||
|
|
||||||
|
|
@ -134,15 +125,7 @@ async def m003_changed(db):
|
||||||
)
|
)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(
|
(row[0], row[1], row[2], row[3], row[4], row[5], row[6]),
|
||||||
row[0],
|
|
||||||
row[1],
|
|
||||||
row[2],
|
|
||||||
row[3],
|
|
||||||
row[4],
|
|
||||||
row[5],
|
|
||||||
row[6],
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
await db.execute("DROP TABLE lnticket.forms")
|
await db.execute("DROP TABLE lnticket.forms")
|
||||||
|
|
||||||
|
|
@ -189,14 +172,6 @@ async def m004_changed(db):
|
||||||
)
|
)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(
|
(row[0], row[1], row[2], row[3], row[4], row[5], row[6]),
|
||||||
row[0],
|
|
||||||
row[1],
|
|
||||||
row[2],
|
|
||||||
row[3],
|
|
||||||
row[4],
|
|
||||||
row[5],
|
|
||||||
row[6],
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
await db.execute("DROP TABLE lnticket.form")
|
await db.execute("DROP TABLE lnticket.form")
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ from typing import Optional
|
||||||
from fastapi.param_functions import Query
|
from fastapi.param_functions import Query
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
class CreateFormData(BaseModel):
|
class CreateFormData(BaseModel):
|
||||||
name: str = Query(...)
|
name: str = Query(...)
|
||||||
webhook: str = Query(None)
|
webhook: str = Query(None)
|
||||||
|
|
@ -9,6 +10,7 @@ class CreateFormData(BaseModel):
|
||||||
amount: int = Query(..., ge=0)
|
amount: int = Query(..., ge=0)
|
||||||
flatrate: int = Query(...)
|
flatrate: int = Query(...)
|
||||||
|
|
||||||
|
|
||||||
class CreateTicketData(BaseModel):
|
class CreateTicketData(BaseModel):
|
||||||
form: str = Query(...)
|
form: str = Query(...)
|
||||||
name: str = Query(...)
|
name: str = Query(...)
|
||||||
|
|
@ -16,6 +18,7 @@ class CreateTicketData(BaseModel):
|
||||||
ltext: str = Query(...)
|
ltext: str = Query(...)
|
||||||
sats: int = Query(..., ge=0)
|
sats: int = Query(..., ge=0)
|
||||||
|
|
||||||
|
|
||||||
class Forms(BaseModel):
|
class Forms(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
wallet: str
|
wallet: str
|
||||||
|
|
|
||||||
|
|
@ -14,13 +14,16 @@ from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
templates = Jinja2Templates(directory="templates")
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
|
||||||
@lnticket_ext.get("/", response_class=HTMLResponse)
|
@lnticket_ext.get("/", response_class=HTMLResponse)
|
||||||
# not needed as we automatically get the user with the given ID
|
# not needed as we automatically get the user with the given ID
|
||||||
# If no user with this ID is found, an error is raised
|
# If no user with this ID is found, an error is raised
|
||||||
# @validate_uuids(["usr"], required=True)
|
# @validate_uuids(["usr"], required=True)
|
||||||
# @check_user_exists()
|
# @check_user_exists()
|
||||||
async def index(request: Request, user: User = Depends(check_user_exists)):
|
async def index(request: Request, user: User = Depends(check_user_exists)):
|
||||||
return lnticket_renderer().TemplateResponse("lnticket/index.html", {"request": request,"user": user.dict()})
|
return lnticket_renderer().TemplateResponse(
|
||||||
|
"lnticket/index.html", {"request": request, "user": user.dict()}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@lnticket_ext.get("/{form_id}")
|
@lnticket_ext.get("/{form_id}")
|
||||||
|
|
@ -28,8 +31,7 @@ async def display(request: Request, form_id):
|
||||||
form = await get_form(form_id)
|
form = await get_form(form_id)
|
||||||
if not form:
|
if not form:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.NOT_FOUND,
|
status_code=HTTPStatus.NOT_FOUND, detail="LNTicket does not exist."
|
||||||
detail="LNTicket does not exist."
|
|
||||||
)
|
)
|
||||||
# abort(HTTPStatus.NOT_FOUND, "LNTicket does not exist.")
|
# abort(HTTPStatus.NOT_FOUND, "LNTicket does not exist.")
|
||||||
|
|
||||||
|
|
@ -37,11 +39,13 @@ async def display(request: Request, form_id):
|
||||||
|
|
||||||
return lnticket_renderer().TemplateResponse(
|
return lnticket_renderer().TemplateResponse(
|
||||||
"lnticket/display.html",
|
"lnticket/display.html",
|
||||||
{"request": request,
|
{
|
||||||
"form_id":form.id,
|
"request": request,
|
||||||
"form_name":form.name,
|
"form_id": form.id,
|
||||||
"form_desc":form.description,
|
"form_name": form.name,
|
||||||
"form_amount":form.amount,
|
"form_desc": form.description,
|
||||||
"form_flatrate":form.flatrate,
|
"form_amount": form.amount,
|
||||||
"form_wallet":wallet.inkey}
|
"form_flatrate": form.flatrate,
|
||||||
|
"form_wallet": wallet.inkey,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,11 @@ from .crud import (
|
||||||
|
|
||||||
|
|
||||||
@lnticket_ext.get("/api/v1/forms")
|
@lnticket_ext.get("/api/v1/forms")
|
||||||
async def api_forms_get(r: Request, all_wallets: bool = Query(False), wallet: WalletTypeInfo = Depends(get_key_type)):
|
async def api_forms_get(
|
||||||
|
r: Request,
|
||||||
|
all_wallets: bool = Query(False),
|
||||||
|
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||||
|
):
|
||||||
wallet_ids = [wallet.wallet.id]
|
wallet_ids = [wallet.wallet.id]
|
||||||
|
|
||||||
if all_wallets:
|
if all_wallets:
|
||||||
|
|
@ -42,6 +46,7 @@ async def api_forms_get(r: Request, all_wallets: bool = Query(False), wallet: Wa
|
||||||
|
|
||||||
return [form.dict() for form in await get_forms(wallet_ids)]
|
return [form.dict() for form in await get_forms(wallet_ids)]
|
||||||
|
|
||||||
|
|
||||||
@lnticket_ext.post("/api/v1/forms", status_code=HTTPStatus.CREATED)
|
@lnticket_ext.post("/api/v1/forms", status_code=HTTPStatus.CREATED)
|
||||||
@lnticket_ext.put("/api/v1/forms/{form_id}")
|
@lnticket_ext.put("/api/v1/forms/{form_id}")
|
||||||
# @api_check_wallet_key("invoice")
|
# @api_check_wallet_key("invoice")
|
||||||
|
|
@ -55,21 +60,21 @@ async def api_forms_get(r: Request, all_wallets: bool = Query(False), wallet: Wa
|
||||||
# "flatrate": {"type": "integer", "required": True},
|
# "flatrate": {"type": "integer", "required": True},
|
||||||
# }
|
# }
|
||||||
# )
|
# )
|
||||||
async def api_form_create(data: CreateFormData, form_id=None, wallet: WalletTypeInfo = Depends(get_key_type)):
|
async def api_form_create(
|
||||||
|
data: CreateFormData, form_id=None, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||||
|
):
|
||||||
if form_id:
|
if form_id:
|
||||||
form = await get_form(form_id)
|
form = await get_form(form_id)
|
||||||
|
|
||||||
if not form:
|
if not form:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.NOT_FOUND,
|
status_code=HTTPStatus.NOT_FOUND, detail=f"Form does not exist."
|
||||||
detail=f"Form does not exist."
|
|
||||||
)
|
)
|
||||||
# return {"message": "Form does not exist."}, HTTPStatus.NOT_FOUND
|
# return {"message": "Form does not exist."}, HTTPStatus.NOT_FOUND
|
||||||
|
|
||||||
if form.wallet != wallet.wallet.id:
|
if form.wallet != wallet.wallet.id:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.FORBIDDEN,
|
status_code=HTTPStatus.FORBIDDEN, detail=f"Not your form."
|
||||||
detail=f"Not your form."
|
|
||||||
)
|
)
|
||||||
# return {"message": "Not your form."}, HTTPStatus.FORBIDDEN
|
# return {"message": "Not your form."}, HTTPStatus.FORBIDDEN
|
||||||
|
|
||||||
|
|
@ -86,16 +91,12 @@ async def api_form_delete(form_id, wallet: WalletTypeInfo = Depends(get_key_type
|
||||||
|
|
||||||
if not form:
|
if not form:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.NOT_FOUND,
|
status_code=HTTPStatus.NOT_FOUND, detail=f"Form does not exist."
|
||||||
detail=f"Form does not exist."
|
|
||||||
)
|
)
|
||||||
# return {"message": "Form does not exist."}, HTTPStatus.NOT_FOUND
|
# return {"message": "Form does not exist."}, HTTPStatus.NOT_FOUND
|
||||||
|
|
||||||
if form.wallet != wallet.wallet.id:
|
if form.wallet != wallet.wallet.id:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail=f"Not your form.")
|
||||||
status_code=HTTPStatus.FORBIDDEN,
|
|
||||||
detail=f"Not your form."
|
|
||||||
)
|
|
||||||
# return {"message": "Not your form."}, HTTPStatus.FORBIDDEN
|
# return {"message": "Not your form."}, HTTPStatus.FORBIDDEN
|
||||||
|
|
||||||
await delete_form(form_id)
|
await delete_form(form_id)
|
||||||
|
|
@ -109,7 +110,9 @@ async def api_form_delete(form_id, wallet: WalletTypeInfo = Depends(get_key_type
|
||||||
|
|
||||||
@lnticket_ext.get("/api/v1/tickets")
|
@lnticket_ext.get("/api/v1/tickets")
|
||||||
# @api_check_wallet_key("invoice")
|
# @api_check_wallet_key("invoice")
|
||||||
async def api_tickets(all_wallets: bool = Query(False), wallet: WalletTypeInfo = Depends(get_key_type)):
|
async def api_tickets(
|
||||||
|
all_wallets: bool = Query(False), wallet: WalletTypeInfo = Depends(get_key_type)
|
||||||
|
):
|
||||||
wallet_ids = [wallet.wallet.id]
|
wallet_ids = [wallet.wallet.id]
|
||||||
|
|
||||||
if all_wallets:
|
if all_wallets:
|
||||||
|
|
@ -117,6 +120,7 @@ async def api_tickets(all_wallets: bool = Query(False), wallet: WalletTypeInfo =
|
||||||
|
|
||||||
return [form.dict() for form in await get_tickets(wallet_ids)]
|
return [form.dict() for form in await get_tickets(wallet_ids)]
|
||||||
|
|
||||||
|
|
||||||
@lnticket_ext.post("/api/v1/tickets/{form_id}", status_code=HTTPStatus.CREATED)
|
@lnticket_ext.post("/api/v1/tickets/{form_id}", status_code=HTTPStatus.CREATED)
|
||||||
# @api_validate_post_request(
|
# @api_validate_post_request(
|
||||||
# schema={
|
# schema={
|
||||||
|
|
@ -131,8 +135,7 @@ async def api_ticket_make_ticket(data: CreateTicketData, form_id):
|
||||||
form = await get_form(form_id)
|
form = await get_form(form_id)
|
||||||
if not form:
|
if not form:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.NOT_FOUND,
|
status_code=HTTPStatus.NOT_FOUND, detail=f"LNTicket does not exist."
|
||||||
detail=f"LNTicket does not exist."
|
|
||||||
)
|
)
|
||||||
# return {"message": "LNTicket does not exist."}, HTTPStatus.NOT_FOUND
|
# return {"message": "LNTicket does not exist."}, HTTPStatus.NOT_FOUND
|
||||||
|
|
||||||
|
|
@ -146,10 +149,7 @@ async def api_ticket_make_ticket(data: CreateTicketData, form_id):
|
||||||
extra={"tag": "lnticket"},
|
extra={"tag": "lnticket"},
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
||||||
detail=str(e)
|
|
||||||
)
|
|
||||||
# return {"message": str(e)}, HTTPStatus.INTERNAL_SERVER_ERROR
|
# return {"message": str(e)}, HTTPStatus.INTERNAL_SERVER_ERROR
|
||||||
|
|
||||||
ticket = await create_ticket(
|
ticket = await create_ticket(
|
||||||
|
|
@ -158,18 +158,14 @@ async def api_ticket_make_ticket(data: CreateTicketData, form_id):
|
||||||
|
|
||||||
if not ticket:
|
if not ticket:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.NOT_FOUND,
|
status_code=HTTPStatus.NOT_FOUND, detail="LNTicket could not be fetched."
|
||||||
detail="LNTicket could not be fetched."
|
|
||||||
)
|
)
|
||||||
# return (
|
# return (
|
||||||
# {"message": "LNTicket could not be fetched."},
|
# {"message": "LNTicket could not be fetched."},
|
||||||
# HTTPStatus.NOT_FOUND,
|
# HTTPStatus.NOT_FOUND,
|
||||||
# )
|
# )
|
||||||
|
|
||||||
return {
|
return {"payment_hash": payment_hash, "payment_request": payment_request}
|
||||||
"payment_hash": payment_hash,
|
|
||||||
"payment_request": payment_request
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@lnticket_ext.get("/api/v1/tickets/{payment_hash}", status_code=HTTPStatus.OK)
|
@lnticket_ext.get("/api/v1/tickets/{payment_hash}", status_code=HTTPStatus.OK)
|
||||||
|
|
@ -198,16 +194,12 @@ async def api_ticket_delete(ticket_id, wallet: WalletTypeInfo = Depends(get_key_
|
||||||
|
|
||||||
if not ticket:
|
if not ticket:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.NOT_FOUND,
|
status_code=HTTPStatus.NOT_FOUND, detail=f"LNTicket does not exist."
|
||||||
detail=f"LNTicket does not exist."
|
|
||||||
)
|
)
|
||||||
# return {"message": "Paywall does not exist."}, HTTPStatus.NOT_FOUND
|
# return {"message": "Paywall does not exist."}, HTTPStatus.NOT_FOUND
|
||||||
|
|
||||||
if ticket.wallet != wallet.wallet.id:
|
if ticket.wallet != wallet.wallet.id:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your ticket.")
|
||||||
status_code=HTTPStatus.FORBIDDEN,
|
|
||||||
detail="Not your ticket."
|
|
||||||
)
|
|
||||||
# return {"message": "Not your ticket."}, HTTPStatus.FORBIDDEN
|
# return {"message": "Not your ticket."}, HTTPStatus.FORBIDDEN
|
||||||
|
|
||||||
await delete_ticket(ticket_id)
|
await delete_ticket(ticket_id)
|
||||||
|
|
|
||||||
|
|
@ -24,12 +24,9 @@ lnurlp_ext: APIRouter = APIRouter(
|
||||||
# "lnurlp", __name__, static_folder="static", template_folder="templates"
|
# "lnurlp", __name__, static_folder="static", template_folder="templates"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def lnurlp_renderer():
|
def lnurlp_renderer():
|
||||||
return template_renderer(
|
return template_renderer(["lnbits/extensions/lnurlp/templates"])
|
||||||
[
|
|
||||||
"lnbits/extensions/lnurlp/templates",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
from .views_api import * # noqa
|
from .views_api import * # noqa
|
||||||
|
|
@ -37,13 +34,12 @@ from .views import * # noqa
|
||||||
from .tasks import wait_for_paid_invoices
|
from .tasks import wait_for_paid_invoices
|
||||||
from .lnurl import * # noqa
|
from .lnurl import * # noqa
|
||||||
|
|
||||||
|
|
||||||
def lnurlp_start():
|
def lnurlp_start():
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
|
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# from lnbits.tasks import record_async
|
# from lnbits.tasks import record_async
|
||||||
|
|
||||||
# lnurlp_ext.record(record_async(register_listeners))
|
# lnurlp_ext.record(record_async(register_listeners))
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,7 @@ from . import db
|
||||||
from .models import PayLink, CreatePayLinkData
|
from .models import PayLink, CreatePayLinkData
|
||||||
|
|
||||||
|
|
||||||
async def create_pay_link(
|
async def create_pay_link(data: CreatePayLinkData, wallet_id: str) -> PayLink:
|
||||||
data: CreatePayLinkData,
|
|
||||||
wallet_id: str
|
|
||||||
) -> PayLink:
|
|
||||||
|
|
||||||
returning = "" if db.type == SQLITE else "RETURNING ID"
|
returning = "" if db.type == SQLITE else "RETURNING ID"
|
||||||
method = db.execute if db.type == SQLITE else db.fetchone
|
method = db.execute if db.type == SQLITE else db.fetchone
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,12 @@ import hashlib
|
||||||
import math
|
import math
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
from lnurl import LnurlPayResponse, LnurlPayActionResponse, LnurlErrorResponse # type: ignore
|
from starlette.exceptions import HTTPException
|
||||||
|
from lnurl import (
|
||||||
|
LnurlPayResponse,
|
||||||
|
LnurlPayActionResponse,
|
||||||
|
LnurlErrorResponse,
|
||||||
|
) # type: ignore
|
||||||
|
|
||||||
from lnbits.core.services import create_invoice
|
from lnbits.core.services import create_invoice
|
||||||
from lnbits.utils.exchange_rates import get_fiat_rate_satoshis
|
from lnbits.utils.exchange_rates import get_fiat_rate_satoshis
|
||||||
|
|
@ -11,13 +16,16 @@ from . import lnurlp_ext
|
||||||
from .crud import increment_pay_link
|
from .crud import increment_pay_link
|
||||||
|
|
||||||
|
|
||||||
@lnurlp_ext.get("/api/v1/lnurl/{link_id}", status_code=HTTPStatus.OK, name="lnurlp.api_lnurl_response")
|
@lnurlp_ext.get(
|
||||||
|
"/api/v1/lnurl/{link_id}",
|
||||||
|
status_code=HTTPStatus.OK,
|
||||||
|
name="lnurlp.api_lnurl_response",
|
||||||
|
)
|
||||||
async def api_lnurl_response(request: Request, link_id):
|
async def api_lnurl_response(request: Request, link_id):
|
||||||
link = await increment_pay_link(link_id, served_meta=1)
|
link = await increment_pay_link(link_id, served_meta=1)
|
||||||
if not link:
|
if not link:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.NOT_FOUND,
|
status_code=HTTPStatus.NOT_FOUND, detail="Pay link does not exist."
|
||||||
detail="Pay link does not exist."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
rate = await get_fiat_rate_satoshis(link.currency) if link.currency else 1
|
rate = await get_fiat_rate_satoshis(link.currency) if link.currency else 1
|
||||||
|
|
@ -36,13 +44,16 @@ async def api_lnurl_response(request: Request, link_id):
|
||||||
return params
|
return params
|
||||||
|
|
||||||
|
|
||||||
@lnurlp_ext.get("/api/v1/lnurl/cb/{link_id}", status_code=HTTPStatus.OK, name="lnurlp.api_lnurl_callback")
|
@lnurlp_ext.get(
|
||||||
|
"/api/v1/lnurl/cb/{link_id}",
|
||||||
|
status_code=HTTPStatus.OK,
|
||||||
|
name="lnurlp.api_lnurl_callback",
|
||||||
|
)
|
||||||
async def api_lnurl_callback(request: Request, link_id):
|
async def api_lnurl_callback(request: Request, link_id):
|
||||||
link = await increment_pay_link(link_id, served_pr=1)
|
link = await increment_pay_link(link_id, served_pr=1)
|
||||||
if not link:
|
if not link:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.NOT_FOUND,
|
status_code=HTTPStatus.NOT_FOUND, detail="Pay link does not exist."
|
||||||
detail="Pay link does not exist."
|
|
||||||
)
|
)
|
||||||
min, max = link.min, link.max
|
min, max = link.min, link.max
|
||||||
rate = await get_fiat_rate_satoshis(link.currency) if link.currency else 1
|
rate = await get_fiat_rate_satoshis(link.currency) if link.currency else 1
|
||||||
|
|
@ -54,7 +65,7 @@ async def api_lnurl_callback(request: Request, link_id):
|
||||||
min = link.min * 1000
|
min = link.min * 1000
|
||||||
max = link.max * 1000
|
max = link.max * 1000
|
||||||
|
|
||||||
amount_received = int(request.query_params.get('amount') or 0)
|
amount_received = int(request.query_params.get("amount") or 0)
|
||||||
if amount_received < min:
|
if amount_received < min:
|
||||||
return LnurlErrorResponse(
|
return LnurlErrorResponse(
|
||||||
reason=f"Amount {amount_received} is smaller than minimum {min}."
|
reason=f"Amount {amount_received} is smaller than minimum {min}."
|
||||||
|
|
@ -65,7 +76,6 @@ async def api_lnurl_callback(request: Request, link_id):
|
||||||
reason=f"Amount {amount_received} is greater than maximum {max}."
|
reason=f"Amount {amount_received} is greater than maximum {max}."
|
||||||
).dict()
|
).dict()
|
||||||
|
|
||||||
|
|
||||||
comment = request.query_params.get("comment")
|
comment = request.query_params.get("comment")
|
||||||
if len(comment or "") > link.comment_chars:
|
if len(comment or "") > link.comment_chars:
|
||||||
return LnurlErrorResponse(
|
return LnurlErrorResponse(
|
||||||
|
|
@ -79,20 +89,20 @@ async def api_lnurl_callback(request: Request, link_id):
|
||||||
description_hash=hashlib.sha256(
|
description_hash=hashlib.sha256(
|
||||||
link.lnurlpay_metadata.encode("utf-8")
|
link.lnurlpay_metadata.encode("utf-8")
|
||||||
).digest(),
|
).digest(),
|
||||||
extra={"tag": "lnurlp", "link": link.id, "comment": comment, 'extra': request.query_params.get('amount')},
|
extra={
|
||||||
|
"tag": "lnurlp",
|
||||||
|
"link": link.id,
|
||||||
|
"comment": comment,
|
||||||
|
"extra": request.query_params.get("amount"),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
success_action = link.success_action(payment_hash)
|
success_action = link.success_action(payment_hash)
|
||||||
if success_action:
|
if success_action:
|
||||||
resp = LnurlPayActionResponse(
|
resp = LnurlPayActionResponse(
|
||||||
pr=payment_request,
|
pr=payment_request, success_action=success_action, routes=[]
|
||||||
success_action=success_action,
|
|
||||||
routes=[],
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
resp = LnurlPayActionResponse(
|
resp = LnurlPayActionResponse(pr=payment_request, routes=[])
|
||||||
pr=payment_request,
|
|
||||||
routes=[],
|
|
||||||
)
|
|
||||||
|
|
||||||
return resp.dict()
|
return resp.dict()
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ from lnurl.types import LnurlPayMetadata # type: ignore
|
||||||
from sqlite3 import Row
|
from sqlite3 import Row
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
class CreatePayLinkData(BaseModel):
|
class CreatePayLinkData(BaseModel):
|
||||||
description: str
|
description: str
|
||||||
min: int = Query(0.01, ge=0.01)
|
min: int = Query(0.01, ge=0.01)
|
||||||
|
|
@ -18,6 +19,7 @@ class CreatePayLinkData(BaseModel):
|
||||||
success_text: str = Query(None)
|
success_text: str = Query(None)
|
||||||
success_url: str = Query(None)
|
success_url: str = Query(None)
|
||||||
|
|
||||||
|
|
||||||
class PayLink(BaseModel):
|
class PayLink(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
wallet: str
|
wallet: str
|
||||||
|
|
@ -37,7 +39,6 @@ class PayLink(BaseModel):
|
||||||
data = dict(row)
|
data = dict(row)
|
||||||
return cls(**data)
|
return cls(**data)
|
||||||
|
|
||||||
|
|
||||||
def lnurl(self, req: Request) -> str:
|
def lnurl(self, req: Request) -> str:
|
||||||
url = req.url_for("lnurlp.api_lnurl_response", link_id=self.id)
|
url = req.url_for("lnurlp.api_lnurl_response", link_id=self.id)
|
||||||
return lnurl_encode(url)
|
return lnurl_encode(url)
|
||||||
|
|
@ -58,9 +59,6 @@ class PayLink(BaseModel):
|
||||||
"url": urlunparse(url),
|
"url": urlunparse(url),
|
||||||
}
|
}
|
||||||
elif self.success_text:
|
elif self.success_text:
|
||||||
return {
|
return {"tag": "message", "message": self.success_text}
|
||||||
"tag": "message",
|
|
||||||
"message": self.success_text,
|
|
||||||
}
|
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ async def wait_for_paid_invoices():
|
||||||
payment = await invoice_queue.get()
|
payment = await invoice_queue.get()
|
||||||
await on_invoice_paid(payment)
|
await on_invoice_paid(payment)
|
||||||
|
|
||||||
|
|
||||||
async def on_invoice_paid(payment: Payment) -> None:
|
async def on_invoice_paid(payment: Payment) -> None:
|
||||||
if "lnurlp" != payment.extra.get("tag"):
|
if "lnurlp" != payment.extra.get("tag"):
|
||||||
# not an lnurlp invoice
|
# not an lnurlp invoice
|
||||||
|
|
|
||||||
|
|
@ -14,34 +14,35 @@ from lnbits.core.models import User
|
||||||
|
|
||||||
templates = Jinja2Templates(directory="templates")
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
|
||||||
@lnurlp_ext.get("/", response_class=HTMLResponse)
|
@lnurlp_ext.get("/", response_class=HTMLResponse)
|
||||||
# @validate_uuids(["usr"], required=True)
|
# @validate_uuids(["usr"], required=True)
|
||||||
# @check_user_exists()
|
# @check_user_exists()
|
||||||
async def index(request: Request, user: User = Depends(check_user_exists)):
|
async def index(request: Request, user: User = Depends(check_user_exists)):
|
||||||
return lnurlp_renderer().TemplateResponse("lnurlp/index.html", {"request": request, "user": user.dict()})
|
return lnurlp_renderer().TemplateResponse(
|
||||||
|
"lnurlp/index.html", {"request": request, "user": user.dict()}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@lnurlp_ext.get("/{link_id}", response_class=HTMLResponse)
|
@lnurlp_ext.get("/{link_id}", response_class=HTMLResponse)
|
||||||
async def display(request: Request,link_id):
|
async def display(request: Request, link_id):
|
||||||
link = await get_pay_link(link_id)
|
link = await get_pay_link(link_id)
|
||||||
if not link:
|
if not link:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.NOT_FOUND,
|
status_code=HTTPStatus.NOT_FOUND, detail="Pay link does not exist."
|
||||||
detail="Pay link does not exist."
|
|
||||||
)
|
)
|
||||||
# abort(HTTPStatus.NOT_FOUND, "Pay link does not exist.")
|
# abort(HTTPStatus.NOT_FOUND, "Pay link does not exist.")
|
||||||
ctx = {"request": request, "lnurl":link.lnurl(req=request)}
|
ctx = {"request": request, "lnurl": link.lnurl(req=request)}
|
||||||
return lnurlp_renderer().TemplateResponse("lnurlp/display.html", ctx)
|
return lnurlp_renderer().TemplateResponse("lnurlp/display.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
@lnurlp_ext.get("/print/{link_id}", response_class=HTMLResponse)
|
@lnurlp_ext.get("/print/{link_id}", response_class=HTMLResponse)
|
||||||
async def print_qr(request: Request,link_id):
|
async def print_qr(request: Request, link_id):
|
||||||
link = await get_pay_link(link_id)
|
link = await get_pay_link(link_id)
|
||||||
if not link:
|
if not link:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.NOT_FOUND,
|
status_code=HTTPStatus.NOT_FOUND, detail="Pay link does not exist."
|
||||||
detail="Pay link does not exist."
|
|
||||||
)
|
)
|
||||||
# abort(HTTPStatus.NOT_FOUND, "Pay link does not exist.")
|
# abort(HTTPStatus.NOT_FOUND, "Pay link does not exist.")
|
||||||
ctx = {"request": request, "lnurl":link.lnurl(req=request)}
|
ctx = {"request": request, "lnurl": link.lnurl(req=request)}
|
||||||
return lnurlp_renderer().TemplateResponse("lnurlp/print_qr.html", ctx)
|
return lnurlp_renderer().TemplateResponse("lnurlp/print_qr.html", ctx)
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ from .crud import (
|
||||||
delete_pay_link,
|
delete_pay_link,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@lnurlp_ext.get("/api/v1/currencies")
|
@lnurlp_ext.get("/api/v1/currencies")
|
||||||
async def api_list_currencies_available():
|
async def api_list_currencies_available():
|
||||||
return list(currencies.keys())
|
return list(currencies.keys())
|
||||||
|
|
@ -30,14 +31,21 @@ async def api_list_currencies_available():
|
||||||
|
|
||||||
@lnurlp_ext.get("/api/v1/links", status_code=HTTPStatus.OK)
|
@lnurlp_ext.get("/api/v1/links", status_code=HTTPStatus.OK)
|
||||||
# @api_check_wallet_key("invoice")
|
# @api_check_wallet_key("invoice")
|
||||||
async def api_links(req: Request, wallet: WalletTypeInfo = Depends(get_key_type), all_wallets: bool = Query(False)):
|
async def api_links(
|
||||||
|
req: Request,
|
||||||
|
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||||
|
all_wallets: bool = Query(False),
|
||||||
|
):
|
||||||
wallet_ids = [wallet.wallet.id]
|
wallet_ids = [wallet.wallet.id]
|
||||||
|
|
||||||
if all_wallets:
|
if all_wallets:
|
||||||
wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids
|
wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return [{**link.dict(), "lnurl": link.lnurl(req)} for link in await get_pay_links(wallet_ids)]
|
return [
|
||||||
|
{**link.dict(), "lnurl": link.lnurl(req)}
|
||||||
|
for link in await get_pay_links(wallet_ids)
|
||||||
|
]
|
||||||
# return [
|
# return [
|
||||||
# {**link.dict(), "lnurl": link.lnurl}
|
# {**link.dict(), "lnurl": link.lnurl}
|
||||||
# for link in await get_pay_links(wallet_ids)
|
# for link in await get_pay_links(wallet_ids)
|
||||||
|
|
@ -58,20 +66,20 @@ async def api_links(req: Request, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||||
|
|
||||||
@lnurlp_ext.get("/api/v1/links/{link_id}", status_code=HTTPStatus.OK)
|
@lnurlp_ext.get("/api/v1/links/{link_id}", status_code=HTTPStatus.OK)
|
||||||
# @api_check_wallet_key("invoice")
|
# @api_check_wallet_key("invoice")
|
||||||
async def api_link_retrieve(r: Request, link_id, wallet: WalletTypeInfo = Depends(get_key_type)):
|
async def api_link_retrieve(
|
||||||
|
r: Request, link_id, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||||
|
):
|
||||||
link = await get_pay_link(link_id)
|
link = await get_pay_link(link_id)
|
||||||
|
|
||||||
if not link:
|
if not link:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
detail="Pay link does not exist.",
|
detail="Pay link does not exist.", status_code=HTTPStatus.NOT_FOUND
|
||||||
status_code=HTTPStatus.NOT_FOUND
|
|
||||||
)
|
)
|
||||||
# return {"message": "Pay link does not exist."}, HTTPStatus.NOT_FOUND
|
# return {"message": "Pay link does not exist."}, HTTPStatus.NOT_FOUND
|
||||||
|
|
||||||
if link.wallet != wallet.wallet.id:
|
if link.wallet != wallet.wallet.id:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
detail="Not your pay link.",
|
detail="Not your pay link.", status_code=HTTPStatus.FORBIDDEN
|
||||||
status_code=HTTPStatus.FORBIDDEN
|
|
||||||
)
|
)
|
||||||
# return {"message": "Not your pay link."}, HTTPStatus.FORBIDDEN
|
# return {"message": "Not your pay link."}, HTTPStatus.FORBIDDEN
|
||||||
|
|
||||||
|
|
@ -81,11 +89,14 @@ async def api_link_retrieve(r: Request, link_id, wallet: WalletTypeInfo = Depend
|
||||||
@lnurlp_ext.post("/api/v1/links", status_code=HTTPStatus.CREATED)
|
@lnurlp_ext.post("/api/v1/links", status_code=HTTPStatus.CREATED)
|
||||||
@lnurlp_ext.put("/api/v1/links/{link_id}", status_code=HTTPStatus.OK)
|
@lnurlp_ext.put("/api/v1/links/{link_id}", status_code=HTTPStatus.OK)
|
||||||
# @api_check_wallet_key("invoice")
|
# @api_check_wallet_key("invoice")
|
||||||
async def api_link_create_or_update(data: CreatePayLinkData, link_id=None, wallet: WalletTypeInfo = Depends(get_key_type)):
|
async def api_link_create_or_update(
|
||||||
|
data: CreatePayLinkData,
|
||||||
|
link_id=None,
|
||||||
|
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||||
|
):
|
||||||
if data.min > data.max:
|
if data.min > data.max:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
detail="Min is greater than max.",
|
detail="Min is greater than max.", status_code=HTTPStatus.BAD_REQUEST
|
||||||
status_code=HTTPStatus.BAD_REQUEST
|
|
||||||
)
|
)
|
||||||
# return {"message": "Min is greater than max."}, HTTPStatus.BAD_REQUEST
|
# return {"message": "Min is greater than max."}, HTTPStatus.BAD_REQUEST
|
||||||
|
|
||||||
|
|
@ -93,15 +104,14 @@ async def api_link_create_or_update(data: CreatePayLinkData, link_id=None, walle
|
||||||
round(data.min) != data.min or round(data.max) != data.max
|
round(data.min) != data.min or round(data.max) != data.max
|
||||||
):
|
):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
detail="Must use full satoshis.",
|
detail="Must use full satoshis.", status_code=HTTPStatus.BAD_REQUEST
|
||||||
status_code=HTTPStatus.BAD_REQUEST
|
|
||||||
)
|
)
|
||||||
# return {"message": "Must use full satoshis."}, HTTPStatus.BAD_REQUEST
|
# return {"message": "Must use full satoshis."}, HTTPStatus.BAD_REQUEST
|
||||||
|
|
||||||
if "success_url" in data and data.success_url[:8] != "https://":
|
if "success_url" in data and data.success_url[:8] != "https://":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
detail="Success URL must be secure https://...",
|
detail="Success URL must be secure https://...",
|
||||||
status_code=HTTPStatus.BAD_REQUEST
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
)
|
)
|
||||||
# return (
|
# return (
|
||||||
# {"message": "Success URL must be secure https://..."},
|
# {"message": "Success URL must be secure https://..."},
|
||||||
|
|
@ -113,8 +123,7 @@ async def api_link_create_or_update(data: CreatePayLinkData, link_id=None, walle
|
||||||
|
|
||||||
if not link:
|
if not link:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
detail="Pay link does not exist.",
|
detail="Pay link does not exist.", status_code=HTTPStatus.NOT_FOUND
|
||||||
status_code=HTTPStatus.NOT_FOUND
|
|
||||||
)
|
)
|
||||||
# return (
|
# return (
|
||||||
# {"message": "Pay link does not exist."},
|
# {"message": "Pay link does not exist."},
|
||||||
|
|
@ -123,12 +132,11 @@ async def api_link_create_or_update(data: CreatePayLinkData, link_id=None, walle
|
||||||
|
|
||||||
if link.wallet != wallet.wallet.id:
|
if link.wallet != wallet.wallet.id:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
detail="Not your pay link.",
|
detail="Not your pay link.", status_code=HTTPStatus.FORBIDDEN
|
||||||
status_code=HTTPStatus.FORBIDDEN
|
|
||||||
)
|
)
|
||||||
# return {"message": "Not your pay link."}, HTTPStatus.FORBIDDEN
|
# return {"message": "Not your pay link."}, HTTPStatus.FORBIDDEN
|
||||||
|
|
||||||
link = await update_pay_link(link_id, data)
|
link = await update_pay_link(data, link_id=link_id)
|
||||||
else:
|
else:
|
||||||
link = await create_pay_link(data, wallet_id=wallet.wallet.id)
|
link = await create_pay_link(data, wallet_id=wallet.wallet.id)
|
||||||
print("LINK", link)
|
print("LINK", link)
|
||||||
|
|
@ -142,15 +150,13 @@ async def api_link_delete(link_id, wallet: WalletTypeInfo = Depends(get_key_type
|
||||||
|
|
||||||
if not link:
|
if not link:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
detail="Pay link does not exist.",
|
detail="Pay link does not exist.", status_code=HTTPStatus.NOT_FOUND
|
||||||
status_code=HTTPStatus.NOT_FOUND
|
|
||||||
)
|
)
|
||||||
# return {"message": "Pay link does not exist."}, HTTPStatus.NOT_FOUND
|
# return {"message": "Pay link does not exist."}, HTTPStatus.NOT_FOUND
|
||||||
|
|
||||||
if link.wallet != wallet.wallet.id:
|
if link.wallet != wallet.wallet.id:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
detail="Not your pay link.",
|
detail="Not your pay link.", status_code=HTTPStatus.FORBIDDEN
|
||||||
status_code=HTTPStatus.FORBIDDEN
|
|
||||||
)
|
)
|
||||||
# return {"message": "Not your pay link."}, HTTPStatus.FORBIDDEN
|
# return {"message": "Not your pay link."}, HTTPStatus.FORBIDDEN
|
||||||
|
|
||||||
|
|
|
||||||
3
lnbits/extensions/lnurlpos/README.md
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# LNURLPoS
|
||||||
|
|
||||||
|
For offline LNURL PoS devices
|
||||||
20
lnbits/extensions/lnurlpos/__init__.py
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import asyncio
|
||||||
|
from fastapi import APIRouter, FastAPI
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from starlette.routing import Mount
|
||||||
|
from lnbits.db import Database
|
||||||
|
from lnbits.helpers import template_renderer
|
||||||
|
from lnbits.tasks import catch_everything_and_restart
|
||||||
|
|
||||||
|
db = Database("ext_lnurlpos")
|
||||||
|
|
||||||
|
lnurlpos_ext: APIRouter = APIRouter(prefix="/lnurlpos", tags=["lnurlpos"])
|
||||||
|
|
||||||
|
|
||||||
|
def lnurlpos_renderer():
|
||||||
|
return template_renderer(["lnbits/extensions/lnurlpos/templates"])
|
||||||
|
|
||||||
|
|
||||||
|
from .views_api import * # noqa
|
||||||
|
from .views import * # noqa
|
||||||
|
from .lnurl import * # noqa
|
||||||
6
lnbits/extensions/lnurlpos/config.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"name": "LNURLPoS",
|
||||||
|
"short_description": "For offline LNURL PoS systems",
|
||||||
|
"icon": "point_of_sale",
|
||||||
|
"contributors": ["arcbtc"]
|
||||||
|
}
|
||||||
113
lnbits/extensions/lnurlpos/crud.py
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Optional, Union
|
||||||
|
from lnbits.helpers import urlsafe_short_hash
|
||||||
|
from typing import List, Optional
|
||||||
|
from . import db
|
||||||
|
from .models import lnurlposs, lnurlpospayment, createLnurlpos
|
||||||
|
|
||||||
|
###############lnurlposS##########################
|
||||||
|
|
||||||
|
|
||||||
|
async def create_lnurlpos(
|
||||||
|
data: createLnurlpos,
|
||||||
|
) -> lnurlposs:
|
||||||
|
print(data)
|
||||||
|
lnurlpos_id = urlsafe_short_hash()
|
||||||
|
lnurlpos_key = urlsafe_short_hash()
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO lnurlpos.lnurlposs (
|
||||||
|
id,
|
||||||
|
key,
|
||||||
|
title,
|
||||||
|
wallet,
|
||||||
|
currency
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(lnurlpos_id, lnurlpos_key, data.title, data.wallet, data.currency),
|
||||||
|
)
|
||||||
|
return await get_lnurlpos(lnurlpos_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_lnurlpos(lnurlpos_id: str, **kwargs) -> Optional[lnurlposs]:
|
||||||
|
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
||||||
|
await db.execute(
|
||||||
|
f"UPDATE lnurlpos.lnurlposs SET {q} WHERE id = ?",
|
||||||
|
(*kwargs.values(), lnurlpos_id),
|
||||||
|
)
|
||||||
|
row = await db.fetchone(
|
||||||
|
"SELECT * FROM lnurlpos.lnurlposs WHERE id = ?", (lnurlpos_id,)
|
||||||
|
)
|
||||||
|
return lnurlposs.from_row(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_lnurlpos(lnurlpos_id: str) -> lnurlposs:
|
||||||
|
row = await db.fetchone(
|
||||||
|
"SELECT * FROM lnurlpos.lnurlposs WHERE id = ?", (lnurlpos_id,)
|
||||||
|
)
|
||||||
|
return lnurlposs.from_row(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_lnurlposs(wallet_ids: Union[str, List[str]]) -> List[lnurlposs]:
|
||||||
|
wallet_ids = [wallet_ids]
|
||||||
|
q = ",".join(["?"] * len(wallet_ids[0]))
|
||||||
|
rows = await db.fetchall(
|
||||||
|
f"""
|
||||||
|
SELECT * FROM lnurlpos.lnurlposs WHERE wallet IN ({q})
|
||||||
|
ORDER BY id
|
||||||
|
""",
|
||||||
|
(*wallet_ids,),
|
||||||
|
)
|
||||||
|
|
||||||
|
return [lnurlposs.from_row(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_lnurlpos(lnurlpos_id: str) -> None:
|
||||||
|
await db.execute("DELETE FROM lnurlpos.lnurlposs WHERE id = ?", (lnurlpos_id,))
|
||||||
|
|
||||||
|
########################lnulpos payments###########################
|
||||||
|
|
||||||
|
|
||||||
|
async def create_lnurlpospayment(
|
||||||
|
posid: str,
|
||||||
|
payload: Optional[str] = None,
|
||||||
|
pin: Optional[str] = None,
|
||||||
|
sats: Optional[int] = 0,
|
||||||
|
) -> lnurlpospayment:
|
||||||
|
lnurlpospayment_id = urlsafe_short_hash()
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO lnurlpos.lnurlpospayment (
|
||||||
|
id,
|
||||||
|
posid,
|
||||||
|
payload,
|
||||||
|
pin,
|
||||||
|
sats
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(lnurlpospayment_id, posid, payload, pin, sats),
|
||||||
|
)
|
||||||
|
return await get_lnurlpospayment(lnurlpospayment_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_lnurlpospayment(
|
||||||
|
lnurlpospayment_id: str, **kwargs
|
||||||
|
) -> Optional[lnurlpospayment]:
|
||||||
|
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
||||||
|
await db.execute(
|
||||||
|
f"UPDATE lnurlpos.lnurlpospayment SET {q} WHERE id = ?",
|
||||||
|
(*kwargs.values(), lnurlpospayment_id),
|
||||||
|
)
|
||||||
|
row = await db.fetchone(
|
||||||
|
"SELECT * FROM lnurlpos.lnurlpospayment WHERE id = ?", (lnurlpospayment_id,)
|
||||||
|
)
|
||||||
|
return lnurlpospayment.from_row(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_lnurlpospayment(lnurlpospayment_id: str) -> lnurlpospayment:
|
||||||
|
row = await db.fetchone(
|
||||||
|
"SELECT * FROM lnurlpos.lnurlpospayment WHERE id = ?", (lnurlpospayment_id,)
|
||||||
|
)
|
||||||
|
return lnurlpospayment.from_row(row) if row else None
|
||||||
108
lnbits/extensions/lnurlpos/lnurl.py
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
import json
|
||||||
|
import hashlib
|
||||||
|
import math
|
||||||
|
from lnurl import LnurlPayResponse, LnurlPayActionResponse, LnurlErrorResponse # type: ignore
|
||||||
|
from lnurl.types import LnurlPayMetadata
|
||||||
|
from lnbits.core.services import create_invoice
|
||||||
|
from hashlib import md5
|
||||||
|
from fastapi import Request
|
||||||
|
from fastapi.param_functions import Query
|
||||||
|
from . import lnurlpos_ext
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from starlette.exceptions import HTTPException
|
||||||
|
from starlette.responses import HTMLResponse
|
||||||
|
from http import HTTPStatus
|
||||||
|
from fastapi.params import Depends
|
||||||
|
from fastapi.param_functions import Query
|
||||||
|
from .crud import (
|
||||||
|
get_lnurlpos,
|
||||||
|
create_lnurlpospayment,
|
||||||
|
get_lnurlpospayment,
|
||||||
|
update_lnurlpospayment,
|
||||||
|
)
|
||||||
|
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
|
||||||
|
|
||||||
|
|
||||||
|
@lnurlpos_ext.get("/api/v1/lnurl/{nonce}/{payload}/{pos_id}")
|
||||||
|
async def lnurl_response(
|
||||||
|
request: Request,
|
||||||
|
nonce: str = Query(None),
|
||||||
|
pos_id: str = Query(None),
|
||||||
|
payload: str = Query(None),
|
||||||
|
):
|
||||||
|
pos = await get_lnurlpos(pos_id)
|
||||||
|
if not pos:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="lnurlpos not found."
|
||||||
|
)
|
||||||
|
nonce1 = bytes.fromhex(nonce)
|
||||||
|
payload1 = bytes.fromhex(payload)
|
||||||
|
h = hashlib.sha256(nonce1)
|
||||||
|
h.update(pos.key.encode())
|
||||||
|
s = h.digest()
|
||||||
|
res = bytearray(payload1)
|
||||||
|
for i in range(len(res)):
|
||||||
|
res[i] = res[i] ^ s[i]
|
||||||
|
decryptedAmount = float(int.from_bytes(res[2:6], "little") / 100)
|
||||||
|
decryptedPin = int.from_bytes(res[:2], "little")
|
||||||
|
if type(decryptedAmount) != float:
|
||||||
|
|
||||||
|
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not an amount.")
|
||||||
|
price_msat = (
|
||||||
|
await fiat_amount_as_satoshis(decryptedAmount, pos.currency)
|
||||||
|
if pos.currency != "sat"
|
||||||
|
else pos.currency
|
||||||
|
) * 1000
|
||||||
|
|
||||||
|
lnurlpospayment = await create_lnurlpospayment(
|
||||||
|
posid=pos.id, payload=payload, sats=price_msat, pin=decryptedPin
|
||||||
|
)
|
||||||
|
if not lnurlpospayment:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.FORBIDDEN, detail="Could not create payment"
|
||||||
|
)
|
||||||
|
|
||||||
|
payResponse = {
|
||||||
|
"tag": "payRequest",
|
||||||
|
"callback": request.url_for(
|
||||||
|
"lnurlpos.lnurl_callback",
|
||||||
|
paymentid=lnurlpospayment.id,
|
||||||
|
),
|
||||||
|
"metadata": LnurlPayMetadata(json.dumps([["text/plain", str(pos.title)]])),
|
||||||
|
"minSendable": price_msat,
|
||||||
|
"maxSendable": price_msat,
|
||||||
|
}
|
||||||
|
return json.dumps(payResponse)
|
||||||
|
|
||||||
|
|
||||||
|
@lnurlpos_ext.get("/api/v1/lnurl/cb/{paymentid}")
|
||||||
|
async def lnurl_callback(paymentid: str = Query(None)):
|
||||||
|
lnurlpospayment = await get_lnurlpospayment(paymentid)
|
||||||
|
pos = await get_lnurlpos(lnurlpospayment.posid)
|
||||||
|
if not pos:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.FORBIDDEN, detail="lnurlpos not found."
|
||||||
|
)
|
||||||
|
payment_hash, payment_request = await create_invoice(
|
||||||
|
wallet_id=pos.wallet,
|
||||||
|
amount=int(lnurlpospayment.sats / 1000),
|
||||||
|
memo=pos.title,
|
||||||
|
description_hash=hashlib.sha256(
|
||||||
|
(LnurlPayMetadata(json.dumps([["text/plain", str(pos.title)]]))).encode(
|
||||||
|
"utf-8"
|
||||||
|
)
|
||||||
|
).digest(),
|
||||||
|
extra={"tag": "lnurlpos"},
|
||||||
|
)
|
||||||
|
lnurlpospayment = await update_lnurlpospayment(
|
||||||
|
lnurlpospayment_id=paymentid, payhash=payment_hash
|
||||||
|
)
|
||||||
|
success_action = pos.success_action(paymentid)
|
||||||
|
|
||||||
|
payResponse = {
|
||||||
|
"pr": payment_request,
|
||||||
|
"success_action": success_action,
|
||||||
|
"disposable": False,
|
||||||
|
"routes": [],
|
||||||
|
}
|
||||||
|
return json.dumps(payResponse)
|
||||||
30
lnbits/extensions/lnurlpos/migrations.py
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
async def m001_initial(db):
|
||||||
|
"""
|
||||||
|
Initial lnurlpos table.
|
||||||
|
"""
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
f"""
|
||||||
|
CREATE TABLE lnurlpos.lnurlposs (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
wallet TEXT NOT NULL,
|
||||||
|
currency TEXT NOT NULL,
|
||||||
|
timestamp TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
await db.execute(
|
||||||
|
f"""
|
||||||
|
CREATE TABLE lnurlpos.lnurlpospayment (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
posid TEXT NOT NULL,
|
||||||
|
payhash TEXT,
|
||||||
|
payload TEXT NOT NULL,
|
||||||
|
pin INT,
|
||||||
|
sats INT,
|
||||||
|
timestamp TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
65
lnbits/extensions/lnurlpos/models.py
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import json
|
||||||
|
from lnurl import Lnurl, LnurlWithdrawResponse, encode as lnurl_encode # type: ignore
|
||||||
|
from urllib.parse import urlparse, urlunparse, parse_qs, urlencode, ParseResult
|
||||||
|
from lnurl.types import LnurlPayMetadata # type: ignore
|
||||||
|
from sqlite3 import Row
|
||||||
|
from typing import NamedTuple, Optional, Dict
|
||||||
|
import shortuuid # type: ignore
|
||||||
|
from fastapi.param_functions import Query
|
||||||
|
from pydantic.main import BaseModel
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
|
||||||
|
|
||||||
|
class createLnurlpos(BaseModel):
|
||||||
|
title: str
|
||||||
|
wallet: str
|
||||||
|
currency: str
|
||||||
|
|
||||||
|
|
||||||
|
class lnurlposs(BaseModel):
|
||||||
|
id: str
|
||||||
|
key: str
|
||||||
|
title: str
|
||||||
|
wallet: str
|
||||||
|
currency: str
|
||||||
|
timestamp: str
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_row(cls, row: Row) -> "lnurlposs":
|
||||||
|
return cls(**dict(row))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def lnurl(self) -> Lnurl:
|
||||||
|
url = url_for("lnurlpos.lnurl_response", pos_id=self.id, _external=True)
|
||||||
|
return lnurl_encode(url)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def lnurlpay_metadata(self) -> LnurlPayMetadata:
|
||||||
|
return LnurlPayMetadata(json.dumps([["text/plain", self.title]]))
|
||||||
|
|
||||||
|
def success_action(self, paymentid: str, req: Request) -> Optional[Dict]:
|
||||||
|
url = url_for(
|
||||||
|
"lnurlpos.displaypin",
|
||||||
|
paymentid=paymentid,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"tag": "url",
|
||||||
|
"description": "Check the attached link",
|
||||||
|
"url": url,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class lnurlpospayment(BaseModel):
|
||||||
|
id: str
|
||||||
|
posid: str
|
||||||
|
payhash: str
|
||||||
|
payload: str
|
||||||
|
pin: int
|
||||||
|
sats: int
|
||||||
|
timestamp: str
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_row(cls, row: Row) -> "lnurlpospayment":
|
||||||
|
return cls(**dict(row))
|
||||||
158
lnbits/extensions/lnurlpos/templates/lnurlpos/_api_docs.html
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<p>
|
||||||
|
Register LNURLPoS devices to recieve payments in your LNbits wallet.<br />
|
||||||
|
Build your own here
|
||||||
|
<a href="https://github.com/arcbtc/LNURLPoS"
|
||||||
|
>https://github.com/arcbtc/LNURLPoS</a
|
||||||
|
><br />
|
||||||
|
<small>
|
||||||
|
Created by, <a href="https://github.com/benarc">Ben Arc</a></small
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
</q-card-section>
|
||||||
|
<q-expansion-item
|
||||||
|
group="extras"
|
||||||
|
icon="swap_vertical_circle"
|
||||||
|
label="API info"
|
||||||
|
:content-inset-level="0.5"
|
||||||
|
>
|
||||||
|
<q-expansion-item
|
||||||
|
group="api"
|
||||||
|
dense
|
||||||
|
expand-separator
|
||||||
|
label="Create lnurlpos"
|
||||||
|
>
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<code
|
||||||
|
><span class="text-blue">POST</span> /lnurlpos/api/v1/lnurlpos</code
|
||||||
|
>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||||
|
<code>{"X-Api-Key": <admin_key>}</code><br />
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||||
|
Body (application/json)
|
||||||
|
</h5>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||||
|
Returns 200 OK (application/json)
|
||||||
|
</h5>
|
||||||
|
<code>[<lnurlpos_object>, ...]</code>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
|
<code
|
||||||
|
>curl -X POST {{ request.url_root }}api/v1/lnurlpos -d '{"title":
|
||||||
|
<string>, "message":<string>, "currency":
|
||||||
|
<integer>}' -H "Content-type: application/json" -H "X-Api-Key:
|
||||||
|
{{user.wallets[0].adminkey }}"
|
||||||
|
</code>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
<q-expansion-item
|
||||||
|
group="api"
|
||||||
|
dense
|
||||||
|
expand-separator
|
||||||
|
label="Update lnurlpos"
|
||||||
|
>
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<code
|
||||||
|
><span class="text-blue">PUT</span>
|
||||||
|
/lnurlpos/api/v1/lnurlpos/<lnurlpos_id></code
|
||||||
|
>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||||
|
<code>{"X-Api-Key": <admin_key>}</code><br />
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||||
|
Body (application/json)
|
||||||
|
</h5>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||||
|
Returns 200 OK (application/json)
|
||||||
|
</h5>
|
||||||
|
<code>[<lnurlpos_object>, ...]</code>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
|
<code
|
||||||
|
>curl -X POST {{ request.url_root
|
||||||
|
}}api/v1/lnurlpos/<lnurlpos_id> -d ''{"title": <string>,
|
||||||
|
"message":<string>, "currency": <integer>} -H
|
||||||
|
"Content-type: application/json" -H "X-Api-Key:
|
||||||
|
{{user.wallets[0].adminkey }}"
|
||||||
|
</code>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
|
||||||
|
<q-expansion-item group="api" dense expand-separator label="Get lnurlpos">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<code
|
||||||
|
><span class="text-blue">GET</span>
|
||||||
|
/lnurlpos/api/v1/lnurlpos/<lnurlpos_id></code
|
||||||
|
>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||||
|
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||||
|
Body (application/json)
|
||||||
|
</h5>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||||
|
Returns 200 OK (application/json)
|
||||||
|
</h5>
|
||||||
|
<code>[<lnurlpos_object>, ...]</code>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
|
<code
|
||||||
|
>curl -X GET {{ request.url_root
|
||||||
|
}}api/v1/lnurlpos/<lnurlpos_id> -H "X-Api-Key: {{
|
||||||
|
user.wallets[0].inkey }}"
|
||||||
|
</code>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
<q-expansion-item group="api" dense expand-separator label="Get lnurlposs">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<code
|
||||||
|
><span class="text-blue">GET</span> /lnurlpos/api/v1/lnurlposs</code
|
||||||
|
>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||||
|
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||||
|
Body (application/json)
|
||||||
|
</h5>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||||
|
Returns 200 OK (application/json)
|
||||||
|
</h5>
|
||||||
|
<code>[<lnurlpos_object>, ...]</code>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
|
<code
|
||||||
|
>curl -X GET {{ request.url_root }}api/v1/lnurlposs -H "X-Api-Key:
|
||||||
|
{{ user.wallets[0].inkey }}"
|
||||||
|
</code>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
<q-expansion-item
|
||||||
|
group="api"
|
||||||
|
dense
|
||||||
|
expand-separator
|
||||||
|
label="Delete a pay link"
|
||||||
|
class="q-pb-md"
|
||||||
|
>
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<code
|
||||||
|
><span class="text-pink">DELETE</span>
|
||||||
|
/lnurlpos/api/v1/lnurlpos/<lnurlpos_id></code
|
||||||
|
>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||||
|
<code>{"X-Api-Key": <admin_key>}</code><br />
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Returns 204 NO CONTENT</h5>
|
||||||
|
<code></code>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
|
<code
|
||||||
|
>curl -X DELETE {{ request.url_root
|
||||||
|
}}api/v1/lnurlpos/<lnurlpos_id> -H "X-Api-Key: {{
|
||||||
|
user.wallets[0].adminkey }}"
|
||||||
|
</code>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
</q-expansion-item>
|
||||||
|
</q-card>
|
||||||
34
lnbits/extensions/lnurlpos/templates/lnurlpos/error.html
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
{% 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">LNURL-pay not paid</h3>
|
||||||
|
<br />
|
||||||
|
<q-icon
|
||||||
|
name="warning"
|
||||||
|
class="text-grey"
|
||||||
|
style="font-size: 20rem"
|
||||||
|
></q-icon>
|
||||||
|
|
||||||
|
<br />
|
||||||
|
</center>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %} {% block scripts %}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
new Vue({
|
||||||
|
el: '#vue',
|
||||||
|
mixins: [windowMixin],
|
||||||
|
data: function () {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||