diff --git a/Pipfile b/Pipfile index af3e4174..47718583 100644 --- a/Pipfile +++ b/Pipfile @@ -28,6 +28,7 @@ fastapi = "*" uvicorn = {extras = ["standard"], version = "*"} sse-starlette = "*" jinja2 = "3.0.1" +pyngrok = "*" [dev-packages] black = "==20.8b1" diff --git a/Pipfile.lock b/Pipfile.lock index 907c539e..25091725 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "97473b3cb250742ebabd8c3a71d4e4c42f8feeaff49dd4542cae24429f096535" + "sha256": "9c0e70708a7767ec1f6c4b3df1a0926184220014ab67ff82d4f352c634918085" }, "pipfile-spec": 6, "requires": { @@ -26,11 +26,11 @@ }, "anyio": { "hashes": [ - "sha256:85913b4e2fec030e8c72a8f9f98092eeb9e25847a6e00d567751b77e34f856fe", - "sha256:d7c604dd491eca70e19c78664d685d5e4337612d574419d503e76f5d7d1590bd" + "sha256:4fd09a25ab7fa01d34512b7249e366cd10358cdafc95022c7ff8c8f8a5026d66", + "sha256:67da67b5b21f96b9d3d65daa6ea99f5d5282cb09f50eb4456f8fb51dffefc3ff" ], "markers": "python_full_version >= '3.6.2'", - "version": "==3.3.1" + "version": "==3.3.4" }, "asgiref": { "hashes": [ @@ -63,7 +63,7 @@ "sha256:7d6db8214603bd7871fcfa6c0826ef68b85b0abd90fa21c285a9c5e21d2bd899", "sha256:990dc8e5a5e4feabbdf55207b5315fdd9b73db40be294a19b3752cde9e79d981" ], - "markers": "python_version >= '3.5'", + "markers": "python_full_version >= '3.5.0'", "version": "==1.2.0" }, "bitstring": { @@ -84,26 +84,26 @@ }, "certifi": { "hashes": [ - "sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee", - "sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8" + "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872", + "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569" ], - "version": "==2021.5.30" + "version": "==2021.10.8" }, "charset-normalizer": { "hashes": [ - "sha256:5d209c0a931f215cee683b6445e2d77677e7e75e159f78def0db09d68fafcaa6", - "sha256:5ec46d183433dcbd0ab716f2d7f29d8dee50505b3fdb40c6b985c7c4f5a3591f" + "sha256:e019de665e2bcf9c2b64e2e5aa025fa991da8720daa3c1138cadd2fd1856aed0", + "sha256:f7af805c321bfa1ce6714c51f254e0d5bb5e5834039bc17db7ebe3a4cec9492b" ], - "markers": "python_version >= '3.5'", - "version": "==2.0.6" + "markers": "python_full_version >= '3.5.0'", + "version": "==2.0.7" }, "click": { "hashes": [ - "sha256:8c04c11192119b1ef78ea049e0a6f0463e4c48ef00a30160c704337586f3ad7a", - "sha256:fba402a4a47334742d782209a7c79bc448911afe1149d07bdabdf480b3e2f4b6" + "sha256:353f466495adaeb40b6b5f592f9f91cb22372351c84caeb068132442a4518ef3", + "sha256:410e932b050f5eed773c4cda94de75971c89cdb3155a72a0831139a79e5ecb5b" ], "markers": "python_version >= '3.6'", - "version": "==8.0.1" + "version": "==8.0.3" }, "ecdsa": { "hashes": [ @@ -115,26 +115,26 @@ }, "embit": { "hashes": [ - "sha256:992332bd89af6e2d027e26fe437eb14aa33997db08c882c49064d49c3e6f4ab9" + "sha256:f6484bc495b45da27f3eb7fbe21a24c00cd72c0ab83c6e195660cf17db5cb5e2" ], "index": "pypi", - "version": "==0.4.9" + "version": "==0.4.10" }, "environs": { "hashes": [ - "sha256:72b867ff7b553076cdd90f3ee01ecc1cf854987639c9c459f0ed0d3d44ae490c", - "sha256:ee5466156b50fe03aa9fec6e720feea577b5bf515d7f21b2c46608272557ba26" + "sha256:7412eca2996027a0a1eafd89bbfec872568e7b4ca75fc980817bfd7788cb5a1f", + "sha256:eecf57fb1b91f1166a8a16344a3fd12ea55b7a0f233c906d86506bdb40738a0f" ], "index": "pypi", - "version": "==9.3.3" + "version": "==9.3.4" }, "fastapi": { "hashes": [ - "sha256:644bb815bae326575c4b2842469fb83053a4b974b82fa792ff9283d17fbbd99d", - "sha256:94d2820906c36b9b8303796fb7271337ec89c74223229e3cfcf056b5a7d59e23" + "sha256:66da43cfe5185ea1df99552acffd201f1832c6b364e0f4136c0a99f933466ced", + "sha256:a36d5f2fad931aa3575c07a3472c784e81f3e664e3bb5c8b9c88d0ec1104f59c" ], "index": "pypi", - "version": "==0.68.1" + "version": "==0.70.0" }, "h11": { "hashes": [ @@ -174,34 +174,26 @@ }, "httpx": { "hashes": [ - "sha256:92ecd2c00c688b529eda11cedb15161eaf02dee9116712f621c70d9a40b2cdd0", - "sha256:9bd728a6c5ec0a9e243932a9983d57d3cc4a87bb4f554e1360fce407f78f9435" + "sha256:09606d630f070d07f9ff28104fbcea429ea0014c1e89ac90b4d8de8286c40e7b", + "sha256:33af5aad9bdc82ef1fc89219c1e36f5693bf9cd0ebe330884df563445682c0f8" ], "index": "pypi", - "version": "==0.19.0" + "version": "==0.20.0" }, "idna": { "hashes": [ - "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a", - "sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3" + "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff", + "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d" ], - "version": "==3.2" - }, - "importlib-metadata": { - "hashes": [ - "sha256:b618b6d2d5ffa2f16add5697cf57a46c76a56229b0ed1c438322e4e95645bd15", - "sha256:f284b3e11256ad1e5d03ab86bb2ccd6f5339688ff17a4d797a0fe7df326f23b1" - ], - "markers": "python_version < '3.8'", - "version": "==4.8.1" + "version": "==3.3" }, "jinja2": { "hashes": [ - "sha256:1f06f2da51e7b56b8f238affdd6b4e2c61e39598a378cc49345bc1bd42a978a4", - "sha256:703f484b47a6af502e743c9122595cc812b0271f661722403114f71a79d0f5a4" + "sha256:827a0e32839ab1600d4eb1c4c33ec5a8edfbc5cb42dafa13b81f182f97784b45", + "sha256:8569982d3f0889eed11dd620c706d39b60c36d6d25843961f33f77fb6bc6b20c" ], "index": "pypi", - "version": "==3.0.1" + "version": "==3.0.2" }, "lnurl": { "hashes": [ @@ -273,11 +265,11 @@ }, "marshmallow": { "hashes": [ - "sha256:c67929438fd73a2be92128caa0325b1b5ed8b626d91a094d2f7f2771bf1f1c0e", - "sha256:dd4724335d3c2b870b641ffe4a2f8728a1380cd2e7e2312756715ffeaa82b842" + "sha256:6d00e42d6d6289f8cd3e77618a01689d57a078fe324ee579b00fa206d32e9b07", + "sha256:bba1a940985c052c5cc7849f97da196ebc81f3b85ec10c56ef1f3228aa9cbe74" ], - "markers": "python_version >= '3.5'", - "version": "==3.13.0" + "markers": "python_version >= '3.6'", + "version": "==3.14.0" }, "outcome": { "hashes": [ @@ -297,12 +289,16 @@ "sha256:1e3a362790edc0a365385b1ac4cc0acc429a0c0d662d829a50b6ce743ae61b5a", "sha256:1e85b74cbbb3056e3656f1cc4781294df03383127a8114cbc6531e8b8367bf1e", "sha256:20f1ab44d8c352074e2d7ca67dc00843067788791be373e67a0911998787ce7d", + "sha256:24b0b6688b9f31a911f2361fe818492650795c9e5d3a1bc647acbd7440142a4f", "sha256:2f62c207d1740b0bde5c4e949f857b044818f734a3d57f1d0d0edc65050532ed", "sha256:3242b9619de955ab44581a03a64bdd7d5e470cc4183e8fcadd85ab9d3756ce7a", "sha256:35c4310f8febe41f442d3c65066ca93cccefd75013df3d8c736c5b93ec288140", "sha256:4235f9d5ddcab0b8dbd723dca56ea2922b485ea00e1dafacf33b0c7e840b3d32", + "sha256:542875f62bc56e91c6eac05a0deadeae20e1730be4c6334d8f04c944fcd99759", "sha256:5ced67f1e34e1a450cdb48eb53ca73b60aa0af21c46b9b35ac3e581cf9f00e31", + "sha256:661509f51531ec125e52357a489ea3806640d0ca37d9dada461ffc69ee1e7b6e", "sha256:7360647ea04db2e7dff1648d1da825c8cf68dc5fbd80b8fb5b3ee9f068dcd21a", + "sha256:736b8797b58febabb85494142c627bd182b50d2a7ec65322983e71065ad3034c", "sha256:8c13d72ed6af7fd2c8acbd95661cf9477f94e381fce0792c04981a8283b52917", "sha256:988b47ac70d204aed01589ed342303da7c4d84b56c2f4c4b8b00deda123372bf", "sha256:995fc41ebda5a7a663a254a1dcac52638c3e847f48307b5416ee373da15075d7", @@ -314,6 +310,7 @@ "sha256:c250a7ec489b652c892e4f0a5d122cc14c3780f9f643e1a326754aedf82d9a76", "sha256:ca86db5b561b894f9e5f115d6a159fff2a2570a652e07889d8a383b5fae66eb4", "sha256:cfc523edecddaef56f6740d7de1ce24a2fdf94fd5e704091856a201872e37f9f", + "sha256:d92272c7c16e105788efe2cfa5d680f07e34e0c29b03c1908f8636f55d5f915a", "sha256:da113b70f6ec40e7d81b43d1b139b9db6a05727ab8be1ee559f3a69854a69d34", "sha256:f6fac64a38f6768e7bc7b035b9e10d8a538a9fadce06b983fb3e6fa55ac5f5ce", "sha256:f8559617b1fcf59a9aedba2c9838b5b6aa211ffedecabca412b92a1ff75aac1a", @@ -350,6 +347,13 @@ "markers": "python_full_version >= '3.6.1'", "version": "==1.8.2" }, + "pyngrok": { + "hashes": [ + "sha256:4d03f44a69c3cbc168b17377956a9edcf723e77dbc864eba34c272db15da443c" + ], + "index": "pypi", + "version": "==5.1.0" + }, "pypng": { "hashes": [ "sha256:76f8a1539ec56451da7ab7121f12a361969fe0f2d48d703d198ce2a99d6c5afd" @@ -374,45 +378,49 @@ }, "python-dotenv": { "hashes": [ - "sha256:aae25dc1ebe97c420f50b81fb0e5c949659af713f31fdb63c749ca68748f34b1", - "sha256:f521bc2ac9a8e03c736f62911605c5d83970021e3fa95b37d769e2bbbe9b6172" + "sha256:14f8185cc8d494662683e6914addcb7e95374771e707601dfc70166946b4c4b8", + "sha256:bbd3da593fc49c249397cbfbcc449cf36cb02e75afc8157fcc6a81df6fb7750a" ], - "markers": "python_version >= '3.5'", - "version": "==0.19.0" + "markers": "python_full_version >= '3.5.0'", + "version": "==0.19.1" }, "pyyaml": { "hashes": [ - "sha256:08682f6b72c722394747bddaf0aa62277e02557c0fd1c42cb853016a38f8dedf", - "sha256:0f5f5786c0e09baddcd8b4b45f20a7b5d61a7e7e99846e3c799b05c7c53fa696", - "sha256:129def1b7c1bf22faffd67b8f3724645203b79d8f4cc81f674654d9902cb4393", - "sha256:294db365efa064d00b8d1ef65d8ea2c3426ac366c0c4368d930bf1c5fb497f77", - "sha256:3b2b1824fe7112845700f815ff6a489360226a5609b96ec2190a45e62a9fc922", - "sha256:3bd0e463264cf257d1ffd2e40223b197271046d09dadf73a0fe82b9c1fc385a5", - "sha256:4465124ef1b18d9ace298060f4eccc64b0850899ac4ac53294547536533800c8", - "sha256:49d4cdd9065b9b6e206d0595fee27a96b5dd22618e7520c33204a4a3239d5b10", - "sha256:4e0583d24c881e14342eaf4ec5fbc97f934b999a6828693a99157fde912540cc", - "sha256:5accb17103e43963b80e6f837831f38d314a0495500067cb25afab2e8d7a4018", - "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e", - "sha256:6c78645d400265a062508ae399b60b8c167bf003db364ecb26dcab2bda048253", - "sha256:72a01f726a9c7851ca9bfad6fd09ca4e090a023c00945ea05ba1638c09dc3347", - "sha256:74c1485f7707cf707a7aef42ef6322b8f97921bd89be2ab6317fd782c2d53183", - "sha256:895f61ef02e8fed38159bb70f7e100e00f471eae2bc838cd0f4ebb21e28f8541", - "sha256:8c1be557ee92a20f184922c7b6424e8ab6691788e6d86137c5d93c1a6ec1b8fb", - "sha256:bb4191dfc9306777bc594117aee052446b3fa88737cd13b7188d0e7aa8162185", - "sha256:bfb51918d4ff3d77c1c856a9699f8492c612cde32fd3bcd344af9be34999bfdc", - "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db", - "sha256:cb333c16912324fd5f769fff6bc5de372e9e7a202247b48870bc251ed40239aa", - "sha256:d2d9808ea7b4af864f35ea216be506ecec180628aced0704e34aca0b040ffe46", - "sha256:d483ad4e639292c90170eb6f7783ad19490e7a8defb3e46f97dfe4bacae89122", - "sha256:dd5de0646207f053eb0d6c74ae45ba98c3395a571a2891858e87df7c9b9bd51b", - "sha256:e1d4970ea66be07ae37a3c2e48b5ec63f7ba6804bdddfdbd3cfd954d25a82e63", - "sha256:e4fac90784481d221a8e4b1162afa7c47ed953be40d31ab4629ae917510051df", - "sha256:fa5ae20527d8e831e8230cbffd9f8fe952815b2b7dae6ffec25318803a7528fc", - "sha256:fd7f6999a8070df521b6384004ef42833b9bd62cfee11a09bda1079b4b704247", - "sha256:fdc842473cd33f45ff6bce46aea678a54e3d21f1b61a7750ce3c498eedfe25d6", - "sha256:fe69978f3f768926cfa37b867e3843918e012cf83f680806599ddce33c2c68b0" + "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293", + "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b", + "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57", + "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b", + "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4", + "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07", + "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba", + "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9", + "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287", + "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513", + "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0", + "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0", + "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92", + "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f", + "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2", + "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc", + "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c", + "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86", + "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4", + "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c", + "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34", + "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b", + "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c", + "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb", + "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737", + "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3", + "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d", + "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53", + "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78", + "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803", + "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a", + "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174", + "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5" ], - "version": "==5.4.1" + "version": "==6.0" }, "represent": { "hashes": [ @@ -453,7 +461,7 @@ "sha256:471b71698eac1c2112a40ce2752bb2f4a4814c22a54a3eed3676bc0f5ca9f663", "sha256:c4666eecec1d3f50960c6bdf61ab7bc350648da6c126e3cf6898d8cd4ddcd3de" ], - "markers": "python_version >= '3.5'", + "markers": "python_full_version >= '3.5.0'", "version": "==1.2.0" }, "sqlalchemy": { @@ -510,18 +518,19 @@ }, "sse-starlette": { "hashes": [ - "sha256:1c0cc62cc7d021a386dc06a16a9ddc3e2861d19da6bc2e654e65cc111e820456" + "sha256:8adc5bfe8c6ede3cf8f16dc741db813c580a13fd8510ec06d6d3e27987e972d2", + "sha256:bd572df6a74779090a1060759a8c3b94e1aa54240173d76c7d830f03e991875f" ], "index": "pypi", - "version": "==0.6.2" + "version": "==0.9.0" }, "starlette": { "hashes": [ - "sha256:3c8e48e52736b3161e34c9f0e8153b4f32ec5d8995a3ee1d59410d92f75162ed", - "sha256:7d49f4a27f8742262ef1470608c59ddbc66baf37c148e938c7038e6bc7a998aa" + "sha256:38eb24bf705a2c317e15868e384c1b8a12ca396e5a3c3a003db7e667c43f939f", + "sha256:e1904b5d0007aee24bdd3c43994be9b3b729f4f58e740200de1d623f8c3a8870" ], "markers": "python_version >= '3.6'", - "version": "==0.14.2" + "version": "==0.16.0" }, "typing-extensions": { "hashes": [ @@ -600,14 +609,6 @@ "sha256:ff59c6bdb87b31f7e2d596f09353d5a38c8c8ff571b0e2238e8ee2d55ad68465" ], "version": "==10.0" - }, - "zipp": { - "hashes": [ - "sha256:957cfda87797e389580cb8b9e3870841ca991e2125350677b2ca83a0e99390a3", - "sha256:f5812b1e007e48cff63449a5e9f4e7ebea716b4111f9c4f9a645f91d579bf0c4" - ], - "markers": "python_version >= '3.6'", - "version": "==3.5.0" } }, "develop": { @@ -635,77 +636,53 @@ }, "click": { "hashes": [ - "sha256:8c04c11192119b1ef78ea049e0a6f0463e4c48ef00a30160c704337586f3ad7a", - "sha256:fba402a4a47334742d782209a7c79bc448911afe1149d07bdabdf480b3e2f4b6" + "sha256:353f466495adaeb40b6b5f592f9f91cb22372351c84caeb068132442a4518ef3", + "sha256:410e932b050f5eed773c4cda94de75971c89cdb3155a72a0831139a79e5ecb5b" ], "markers": "python_version >= '3.6'", - "version": "==8.0.1" + "version": "==8.0.3" }, "coverage": { - "hashes": [ - "sha256:004d1880bed2d97151facef49f08e255a20ceb6f9432df75f4eef018fdd5a78c", - "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" + "extras": [ + "toml" ], - "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": [ - "sha256:b618b6d2d5ffa2f16add5697cf57a46c76a56229b0ed1c438322e4e95645bd15", - "sha256:f284b3e11256ad1e5d03ab86bb2ccd6f5339688ff17a4d797a0fe7df326f23b1" + "sha256:04560539c19ec26995ecfb3d9307ff154fbb9a172cb57e3b3cfc4ced673103d1", + "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'", - "version": "==4.8.1" + "markers": "python_version >= '3.6'", + "version": "==6.0.2" }, "iniconfig": { "hashes": [ @@ -799,57 +776,63 @@ }, "pytest-cov": { "hashes": [ - "sha256:261bb9e47e65bd099c89c3edf92972865210c36813f80ede5277dceb77a4a62a", - "sha256:261ceeb8c227b726249b376b8526b600f38667ee314f910353fa318caa01f4d7" + "sha256:578d5d15ac4a25e5f961c938b85a05b09fdaae9deef3bb6de9a6e766622ca7a6", + "sha256:e7f0f5b1617d2210a2cabc266dfe2f4c75a8d32fb89eafb7ad9d06f6d076d470" ], "index": "pypi", - "version": "==2.12.1" + "version": "==3.0.0" }, "regex": { "hashes": [ - "sha256:04f6b9749e335bb0d2f68c707f23bb1773c3fb6ecd10edf0f04df12a8920d468", - "sha256:08d74bfaa4c7731b8dac0a992c63673a2782758f7cfad34cf9c1b9184f911354", - "sha256:0fc1f8f06977c2d4f5e3d3f0d4a08089be783973fc6b6e278bde01f0544ff308", - "sha256:121f4b3185feaade3f85f70294aef3f777199e9b5c0c0245c774ae884b110a2d", - "sha256:1413b5022ed6ac0d504ba425ef02549a57d0f4276de58e3ab7e82437892704fc", - "sha256:1743345e30917e8c574f273f51679c294effba6ad372db1967852f12c76759d8", - "sha256:28fc475f560d8f67cc8767b94db4c9440210f6958495aeae70fac8faec631797", - "sha256:31a99a4796bf5aefc8351e98507b09e1b09115574f7c9dbb9cf2111f7220d2e2", - "sha256:328a1fad67445550b982caa2a2a850da5989fd6595e858f02d04636e7f8b0b13", - "sha256:473858730ef6d6ff7f7d5f19452184cd0caa062a20047f6d6f3e135a4648865d", - "sha256:4cde065ab33bcaab774d84096fae266d9301d1a2f5519d7bd58fc55274afbf7a", - "sha256:5f6a808044faae658f546dd5f525e921de9fa409de7a5570865467f03a626fc0", - "sha256:610b690b406653c84b7cb6091facb3033500ee81089867ee7d59e675f9ca2b73", - "sha256:66256b6391c057305e5ae9209941ef63c33a476b73772ca967d4a2df70520ec1", - "sha256:6eebf512aa90751d5ef6a7c2ac9d60113f32e86e5687326a50d7686e309f66ed", - "sha256:79aef6b5cd41feff359acaf98e040844613ff5298d0d19c455b3d9ae0bc8c35a", - "sha256:808ee5834e06f57978da3e003ad9d6292de69d2bf6263662a1a8ae30788e080b", - "sha256:8e44769068d33e0ea6ccdf4b84d80c5afffe5207aa4d1881a629cf0ef3ec398f", - "sha256:999ad08220467b6ad4bd3dd34e65329dd5d0df9b31e47106105e407954965256", - "sha256:9b006628fe43aa69259ec04ca258d88ed19b64791693df59c422b607b6ece8bb", - "sha256:9d05ad5367c90814099000442b2125535e9d77581855b9bee8780f1b41f2b1a2", - "sha256:a577a21de2ef8059b58f79ff76a4da81c45a75fe0bfb09bc8b7bb4293fa18983", - "sha256:a617593aeacc7a691cc4af4a4410031654f2909053bd8c8e7db837f179a630eb", - "sha256:abb48494d88e8a82601af905143e0de838c776c1241d92021e9256d5515b3645", - "sha256:ac88856a8cbccfc14f1b2d0b829af354cc1743cb375e7f04251ae73b2af6adf8", - "sha256:b4c220a1fe0d2c622493b0a1fd48f8f991998fb447d3cd368033a4b86cf1127a", - "sha256:b844fb09bd9936ed158ff9df0ab601e2045b316b17aa8b931857365ea8586906", - "sha256:bdc178caebd0f338d57ae445ef8e9b737ddf8fbc3ea187603f65aec5b041248f", - "sha256:c206587c83e795d417ed3adc8453a791f6d36b67c81416676cad053b4104152c", - "sha256:c61dcc1cf9fd165127a2853e2c31eb4fb961a4f26b394ac9fe5669c7a6592892", - "sha256:c7cb4c512d2d3b0870e00fbbac2f291d4b4bf2634d59a31176a87afe2777c6f0", - "sha256:d4a332404baa6665b54e5d283b4262f41f2103c255897084ec8f5487ce7b9e8e", - "sha256:d5111d4c843d80202e62b4fdbb4920db1dcee4f9366d6b03294f45ed7b18b42e", - "sha256:e1e8406b895aba6caa63d9fd1b6b1700d7e4825f78ccb1e5260551d168db38ed", - "sha256:e8690ed94481f219a7a967c118abaf71ccc440f69acd583cab721b90eeedb77c", - "sha256:ed283ab3a01d8b53de3a05bfdf4473ae24e43caee7dcb5584e86f3f3e5ab4374", - "sha256:ed4b50355b066796dacdd1cf538f2ce57275d001838f9b132fab80b75e8c84dd", - "sha256:ee329d0387b5b41a5dddbb6243a21cb7896587a651bebb957e2d2bb8b63c0791", - "sha256:f3bf1bc02bc421047bfec3343729c4bbbea42605bcfd6d6bfe2c07ade8b12d2a", - "sha256:f585cbbeecb35f35609edccb95efd95a3e35824cd7752b586503f7e6087303f1", - "sha256:f60667673ff9c249709160529ab39667d1ae9fd38634e006bec95611f632e759" + "sha256:094a905e87a4171508c2a0e10217795f83c636ccc05ddf86e7272c26e14056ae", + "sha256:09e1031e2059abd91177c302da392a7b6859ceda038be9e015b522a182c89e4f", + "sha256:176796cb7f82a7098b0c436d6daac82f57b9101bb17b8e8119c36eecf06a60a3", + "sha256:19b8f6d23b2dc93e8e1e7e288d3010e58fafed323474cf7f27ab9451635136d9", + "sha256:1abbd95cbe9e2467cac65c77b6abd9223df717c7ae91a628502de67c73bf6838", + "sha256:1ce02f420a7ec3b2480fe6746d756530f69769292eca363218c2291d0b116a01", + "sha256:1f51926db492440e66c89cd2be042f2396cf91e5b05383acd7372b8cb7da373f", + "sha256:26895d7c9bbda5c52b3635ce5991caa90fbb1ddfac9c9ff1c7ce505e2282fb2a", + "sha256:2efd47704bbb016136fe34dfb74c805b1ef5c7313aef3ce6dcb5ff844299f432", + "sha256:36c98b013273e9da5790ff6002ab326e3f81072b4616fd95f06c8fa733d2745f", + "sha256:39079ebf54156be6e6902f5c70c078f453350616cfe7bfd2dd15bdb3eac20ccc", + "sha256:3d52c5e089edbdb6083391faffbe70329b804652a53c2fdca3533e99ab0580d9", + "sha256:45cb0f7ff782ef51bc79e227a87e4e8f24bc68192f8de4f18aae60b1d60bc152", + "sha256:4786dae85c1f0624ac77cb3813ed99267c9adb72e59fdc7297e1cf4d6036d493", + "sha256:51feefd58ac38eb91a21921b047da8644155e5678e9066af7bcb30ee0dca7361", + "sha256:55ef044899706c10bc0aa052f2fc2e58551e2510694d6aae13f37c50f3f6ff61", + "sha256:5e5796d2f36d3c48875514c5cd9e4325a1ca172fc6c78b469faa8ddd3d770593", + "sha256:5f199419a81c1016e0560c39773c12f0bd924c37715bffc64b97140d2c314354", + "sha256:5f55c4804797ef7381518e683249310f7f9646da271b71cb6b3552416c7894ee", + "sha256:6dcf53d35850ce938b4f044a43b33015ebde292840cef3af2c8eb4c860730fff", + "sha256:74e55f8d66f1b41d44bc44c891bcf2c7fad252f8f323ee86fba99d71fd1ad5e3", + "sha256:7f125fce0a0ae4fd5c3388d369d7a7d78f185f904c90dd235f7ecf8fe13fa741", + "sha256:82cfb97a36b1a53de32b642482c6c46b6ce80803854445e19bc49993655ebf3b", + "sha256:88dc3c1acd3f0ecfde5f95c32fcb9beda709dbdf5012acdcf66acbc4794468eb", + "sha256:924079d5590979c0e961681507eb1773a142553564ccae18d36f1de7324e71ca", + "sha256:951be934dc25d8779d92b530e922de44dda3c82a509cdb5d619f3a0b1491fafa", + "sha256:973499dac63625a5ef9dfa4c791aa33a502ddb7615d992bdc89cf2cc2285daa3", + "sha256:981c786293a3115bc14c103086ae54e5ee50ca57f4c02ce7cf1b60318d1e8072", + "sha256:9c070d5895ac6aeb665bd3cd79f673775caf8d33a0b569e98ac434617ecea57d", + "sha256:9e3e2cea8f1993f476a6833ef157f5d9e8c75a59a8d8b0395a9a6887a097243b", + "sha256:9e527ab1c4c7cf2643d93406c04e1d289a9d12966529381ce8163c4d2abe4faf", + "sha256:a37305eb3199d8f0d8125ec2fb143ba94ff6d6d92554c4b8d4a8435795a6eccd", + "sha256:aa0ab3530a279a3b7f50f852f1bab41bc304f098350b03e30a3876b7dd89840e", + "sha256:b04e512eb628ea82ed86eb31c0f7fc6842b46bf2601b66b1356a7008327f7700", + "sha256:b09d3904bf312d11308d9a2867427479d277365b1617e48ad09696fa7dfcdf59", + "sha256:b0f2f874c6a157c91708ac352470cb3bef8e8814f5325e3c5c7a0533064c6a24", + "sha256:b8b6ee6555b6fbae578f1468b3f685cdfe7940a65675611365a7ea1f8d724991", + "sha256:b9b5c215f3870aa9b011c00daeb7be7e1ae4ecd628e9beb6d7e6107e07d81287", + "sha256:c6569ba7b948c3d61d27f04e2b08ebee24fec9ff8e9ea154d8d1e975b175bfa7", + "sha256:e2ec1c106d3f754444abf63b31e5c4f9b5d272272a491fa4320475aba9e8157c", + "sha256:e4204708fa116dd03436a337e8e84261bc8051d058221ec63535c9403a1582a1", + "sha256:ea8de658d7db5987b11097445f2b1f134400e2232cb40e614e5f7b6f5428710e", + "sha256:f540f153c4f5617bc4ba6433534f8916d96366a08797cbbe4132c37b70403e92", + "sha256:fab3ab8aedfb443abb36729410403f0fe7f60ad860c19a979d47fb3eb98ef820", + "sha256:fb2baff66b7d2267e07ef71e17d01283b55b3cc51a81b54cc385e721ae172ba4", + "sha256:fe6ce4f3d3c48f9f402da1ceb571548133d3322003ce01b20d960a82251695d2", + "sha256:ff24897f6b2001c38a805d53b6ae72267025878d35ea225aa24675fbff2dba7f" ], - "version": "==2021.8.28" + "version": "==2021.10.8" }, "toml": { "hashes": [ @@ -859,6 +842,13 @@ "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.10.2" }, + "tomli": { + "hashes": [ + "sha256:8dd0e9524d6f386271a36b41dbf6c57d8e32fd96fd22b6584679dc569d20899f", + "sha256:a5b75cb6f3968abb47af1b40c1819dc519ea82bcc065776a866e8d74c5ca9442" + ], + "version": "==1.2.1" + }, "typed-ast": { "hashes": [ "sha256:01ae5f73431d21eead5015997ab41afa53aa1fbe252f9da060be5dad2c730ace", @@ -903,14 +893,6 @@ ], "index": "pypi", "version": "==3.10.0.2" - }, - "zipp": { - "hashes": [ - "sha256:957cfda87797e389580cb8b9e3870841ca991e2125350677b2ca83a0e99390a3", - "sha256:f5812b1e007e48cff63449a5e9f4e7ebea716b4111f9c4f9a645f91d579bf0c4" - ], - "markers": "python_version >= '3.6'", - "version": "==3.5.0" } } } diff --git a/lnbits/__main__.py b/lnbits/__main__.py index e9c43cda..8461eb42 100644 --- a/lnbits/__main__.py +++ b/lnbits/__main__.py @@ -4,8 +4,15 @@ import uvloop from starlette.requests import Request from .commands import bundle_vendored, migrate_databases, transpile_scss -from .settings import (DEBUG, LNBITS_COMMIT, LNBITS_DATA_FOLDER, - LNBITS_SITE_TITLE, PORT, SERVICE_FEE, WALLET) +from .settings import ( + DEBUG, + LNBITS_COMMIT, + LNBITS_DATA_FOLDER, + LNBITS_SITE_TITLE, + PORT, + SERVICE_FEE, + WALLET, +) uvloop.install() diff --git a/lnbits/app.py b/lnbits/app.py index 8ffe4318..f85a009f 100644 --- a/lnbits/app.py +++ b/lnbits/app.py @@ -16,12 +16,23 @@ import lnbits.settings from .commands import db_migrate, handle_assets from .core import core_app from .core.views.generic import core_html_routes -from .helpers import (get_css_vendored, get_js_vendored, get_valid_extensions, - template_renderer, url_for_vendored) +from .helpers import ( + get_css_vendored, + get_js_vendored, + get_valid_extensions, + template_renderer, + url_for_vendored, +) from .requestvars import g from .settings import WALLET -from .tasks import (catch_everything_and_restart, check_pending_payments, internal_invoice_listener, - invoice_listener, run_deferred_async, webhook_handler) +from .tasks import ( + 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: @@ -30,12 +41,11 @@ def create_app(config_object="lnbits.settings") -> FastAPI: """ app = FastAPI() 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 = [ - "http://localhost", - "http://localhost:5000", - ] + origins = ["http://localhost", "http://localhost:5000"] app.add_middleware( CORSMiddleware, @@ -44,14 +54,19 @@ def create_app(config_object="lnbits.settings") -> FastAPI: allow_methods=["*"], allow_headers=["*"], ) - + g().config = lnbits.settings g().base_url = f"http://{lnbits.settings.HOST}:{lnbits.settings.PORT}" @app.exception_handler(RequestValidationError) - async def validation_exception_handler(request: Request, exc: RequestValidationError): - return template_renderer().TemplateResponse("error.html", {"request": request, "err": f"`{exc.errors()}` is not a valid UUID."}) - + async def validation_exception_handler( + request: Request, exc: RequestValidationError + ): + return template_renderer().TemplateResponse( + "error.html", + {"request": request, "err": f"`{exc.errors()}` is not a valid UUID."}, + ) + # return HTMLResponse( # status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, # content=jsonable_encoder({"detail": exc.errors(), "body": exc.body}), @@ -69,6 +84,7 @@ def create_app(config_object="lnbits.settings") -> FastAPI: return app + def check_funding_source(app: FastAPI) -> None: @app.on_event("startup") async def check_wallet_status(): @@ -95,7 +111,7 @@ def register_routes(app: FastAPI) -> None: try: ext_module = importlib.import_module(f"lnbits.extensions.{ext.code}") ext_route = getattr(ext_module, f"{ext.code}_ext") - + if hasattr(ext_module, f"{ext.code}_start"): ext_start_func = getattr(ext_module, f"{ext.code}_start") ext_start_func() @@ -150,6 +166,7 @@ def register_async_tasks(app): async def stop_listeners(): pass + def register_exception_handlers(app: FastAPI): @app.exception_handler(Exception) async def basic_error(request: Request, err): @@ -157,5 +174,6 @@ def register_exception_handlers(app: FastAPI): etype, _, tb = sys.exc_info() traceback.print_exception(etype, err, tb) exc = traceback.format_exc() - return template_renderer().TemplateResponse("error.html", {"request": request, "err": err}) - + return template_renderer().TemplateResponse( + "error.html", {"request": request, "err": err} + ) diff --git a/lnbits/auth_bearer.py b/lnbits/auth_bearer.py index 81b93427..163785dd 100644 --- a/lnbits/auth_bearer.py +++ b/lnbits/auth_bearer.py @@ -8,7 +8,6 @@ from fastapi.security.api_key import APIKeyQuery, APIKeyCookie, APIKeyHeader, AP from fastapi.security.base import SecurityBase - API_KEY = "usr" API_KEY_NAME = "X-API-key" @@ -16,12 +15,11 @@ api_key_query = APIKeyQuery(name=API_KEY_NAME, auto_error=False) api_key_header = APIKeyHeader(name=API_KEY_NAME, auto_error=False) - class AuthBearer(SecurityBase): def __init__(self, scheme_name: str = None, auto_error: bool = True): self.scheme_name = scheme_name or self.__class__.__name__ self.auto_error = auto_error - + async def __call__(self, request: Request): key = await self.get_api_key() print(key) @@ -37,7 +35,9 @@ class AuthBearer(SecurityBase): # else: # raise HTTPException( # 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_header: str = Security(api_key_header), ): @@ -46,4 +46,6 @@ class AuthBearer(SecurityBase): elif api_key_header == API_KEY: return api_key_header else: - raise HTTPException(status_code=403, detail="Could not validate credentials") \ No newline at end of file + raise HTTPException( + status_code=403, detail="Could not validate credentials" + ) diff --git a/lnbits/bolt11.py b/lnbits/bolt11.py index b9f3270b..7cd7a44c 100644 --- a/lnbits/bolt11.py +++ b/lnbits/bolt11.py @@ -125,12 +125,7 @@ def _unshorten_amount(amount: str) -> int: # * `u` (micro): multiply by 0.000001 # * `n` (nano): multiply by 0.000000001 # * `p` (pico): multiply by 0.000000000001 - units = { - "p": 10 ** 12, - "n": 10 ** 9, - "u": 10 ** 6, - "m": 10 ** 3, - } + units = {"p": 10 ** 12, "n": 10 ** 9, "u": 10 ** 6, "m": 10 ** 3} unit = str(amount)[-1] # BOLT #11: @@ -161,9 +156,9 @@ def _trim_to_bytes(barr): def _readable_scid(short_channel_id: int) -> str: return "{blockheight}x{transactionindex}x{outputindex}".format( - blockheight=((short_channel_id >> 40) & 0xffffff), - transactionindex=((short_channel_id >> 16) & 0xffffff), - outputindex=(short_channel_id & 0xffff), + blockheight=((short_channel_id >> 40) & 0xFFFFFF), + transactionindex=((short_channel_id >> 16) & 0xFFFFFF), + outputindex=(short_channel_id & 0xFFFF), ) diff --git a/lnbits/core/__init__.py b/lnbits/core/__init__.py index e632fba8..85e72d50 100644 --- a/lnbits/core/__init__.py +++ b/lnbits/core/__init__.py @@ -9,5 +9,3 @@ core_app: APIRouter = APIRouter() from .views.api import * # noqa from .views.generic import * # noqa from .views.public_api import * # noqa - - diff --git a/lnbits/core/crud.py b/lnbits/core/crud.py index e981f22c..f69ca95b 100644 --- a/lnbits/core/crud.py +++ b/lnbits/core/crud.py @@ -58,10 +58,11 @@ async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[ return None return User( - id = user['id'], - email = user['email'], - extensions = [e[0] for e in extensions], - wallets = [Wallet(**w) for w in wallets]) + id=user["id"], + email=user["email"], + extensions=[e[0] for e in extensions], + wallets=[Wallet(**w) for w in wallets], + ) async def update_user_extension( @@ -106,6 +107,7 @@ async def create_wallet( return new_wallet + async def update_wallet( wallet_id: str, new_name: str, conn: Optional[Connection] = None ) -> Optional[Wallet]: @@ -115,7 +117,7 @@ async def update_wallet( name = ? 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] -async def delete_expired_invoices( - conn: Optional[Connection] = None, -) -> None: +async def delete_expired_invoices(conn: Optional[Connection] = None,) -> None: # first we delete all invoices older than one month await (conn or db).execute( f""" @@ -367,31 +367,22 @@ async def create_payment( async def update_payment_status( - checking_id: str, - pending: bool, - conn: Optional[Connection] = None, + checking_id: str, pending: bool, conn: Optional[Connection] = None ) -> None: await (conn or db).execute( "UPDATE apipayments SET pending = ? WHERE checking_id = ?", - ( - pending, - checking_id, - ), + (pending, checking_id), ) -async def delete_payment( - checking_id: str, - conn: Optional[Connection] = None, -) -> None: +async def delete_payment(checking_id: str, conn: Optional[Connection] = None) -> None: await (conn or db).execute( "DELETE FROM apipayments WHERE checking_id = ?", (checking_id,) ) async def check_internal( - payment_hash: str, - conn: Optional[Connection] = None, + payment_hash: str, conn: Optional[Connection] = None ) -> Optional[str]: row = await (conn or db).fetchone( """ @@ -411,9 +402,7 @@ async def check_internal( async def save_balance_check( - wallet_id: str, - url: str, - conn: Optional[Connection] = None, + wallet_id: str, url: str, conn: Optional[Connection] = None ): domain = urlparse(url).netloc @@ -427,9 +416,7 @@ async def save_balance_check( async def get_balance_check( - wallet_id: str, - domain: str, - conn: Optional[Connection] = None, + wallet_id: str, domain: str, conn: Optional[Connection] = None ) -> Optional[BalanceCheck]: 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( - wallet_id: str, - url: str, - conn: Optional[Connection] = None, + wallet_id: str, url: str, conn: Optional[Connection] = None ): await (conn or db).execute( """ @@ -466,8 +451,7 @@ async def save_balance_notify( async def get_balance_notify( - wallet_id: str, - conn: Optional[Connection] = None, + wallet_id: str, conn: Optional[Connection] = None ) -> Optional[str]: row = await (conn or db).fetchone( """ diff --git a/lnbits/core/models.py b/lnbits/core/models.py index 672a252c..c72c9dbe 100644 --- a/lnbits/core/models.py +++ b/lnbits/core/models.py @@ -30,13 +30,8 @@ class Wallet(BaseModel): @property def lnurlwithdraw_full(self) -> str: - - url = url_for( - "/withdraw", - external=True, - usr=self.user, - wal=self.id, - ) + + url = url_for("/withdraw", external=True, usr=self.user, wal=self.id) try: return lnurl_encode(url) except: @@ -47,9 +42,7 @@ class Wallet(BaseModel): linking_key = hmac.digest(hashing_key, domain.encode("utf-8"), "sha256") return SigningKey.from_string( - linking_key, - curve=SECP256k1, - hashfunc=hashlib.sha256, + linking_key, curve=SECP256k1, hashfunc=hashlib.sha256 ) async def get_payment(self, payment_hash: str) -> Optional["Payment"]: diff --git a/lnbits/core/services.py b/lnbits/core/services.py index ee83b338..02f14fdc 100644 --- a/lnbits/core/services.py +++ b/lnbits/core/services.py @@ -1,34 +1,36 @@ import asyncio import json -import httpx -from io import BytesIO from binascii import unhexlify -from typing import Optional, Tuple, Dict -from urllib.parse import urlparse, parse_qs -from lnurl import LnurlErrorResponse, decode as decode_lnurl # type: ignore +from io import BytesIO +from typing import Dict, Optional, Tuple +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: from typing import TypedDict # type: ignore except ImportError: # pragma: nocover 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): pass @@ -49,7 +51,7 @@ async def create_invoice( conn: Optional[Connection] = None, ) -> Tuple[str, str]: 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( 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 # the payer has enough to deduct from await update_payment_status( - checking_id=internal_checking_id, - pending=False, - conn=conn, + checking_id=internal_checking_id, pending=False, conn=conn ) # 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: # actually pay the external invoice payment: PaymentResponse = await WALLET.pay_invoice(payment_request) @@ -213,10 +214,7 @@ async def redeem_lnurl_withdraw( if wait_seconds: await asyncio.sleep(wait_seconds) - params = { - "k1": res["k1"], - "pr": payment_request, - } + params = {"k1": res["k1"], "pr": payment_request} try: params["balanceNotify"] = url_for( @@ -235,8 +233,7 @@ async def redeem_lnurl_withdraw( async def perform_lnurlauth( - callback: str, - conn: Optional[Connection] = None, + callback: str, conn: Optional[Connection] = None ) -> Optional[LnurlErrorResponse]: cb = urlparse(callback) @@ -304,7 +301,7 @@ async def perform_lnurlauth( return LnurlErrorResponse(reason=resp["reason"]) except (KeyError, json.decoder.JSONDecodeError): 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) if not payment: return PaymentStatus(None) - - return await WALLET.get_invoice_status(payment.checking_id) + status = 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: diff --git a/lnbits/core/tasks.py b/lnbits/core/tasks.py index c75fbac8..9b5adcea 100644 --- a/lnbits/core/tasks.py +++ b/lnbits/core/tasks.py @@ -33,10 +33,7 @@ async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue): if url: async with httpx.AsyncClient() as client: try: - r = await client.post( - url, - timeout=4, - ) + r = await client.post(url, timeout=4) await mark_webhook_sent(payment, r.status_code) except (httpx.ConnectError, httpx.RequestError): pass @@ -55,11 +52,7 @@ async def dispatch_webhook(payment: Payment): async with httpx.AsyncClient() as client: data = payment._asdict() try: - r = await client.post( - payment.webhook, - json=data, - timeout=40, - ) + r = await client.post(payment.webhook, json=data, timeout=40) await mark_webhook_sent(payment, r.status_code) except (httpx.ConnectError, httpx.RequestError): await mark_webhook_sent(payment, -1) diff --git a/lnbits/core/templates/core/_api_docs.html b/lnbits/core/templates/core/_api_docs.html index 4bb38fad..2acadc76 100644 --- a/lnbits/core/templates/core/_api_docs.html +++ b/lnbits/core/templates/core/_api_docs.html @@ -19,7 +19,7 @@ GET /api/v1/wallet
Headers
- {"X-Api-Key": "{{ wallet.adminkey }}"}
+ {"X-Api-Key": "{{ wallet.inkey }}"}
Returns 200 OK (application/json)
@@ -94,6 +94,35 @@
+ + + + + POST + /api/v1/payments/decode +
Headers
+ {"X-Api-Key": "{{ wallet.inkey }}"}
+
Body (application/json)
+ {"invoice": <string>} +
+ Returns 200 (application/json) +
+
Curl example
+ curl -X POST {{ request.url_root }}api/v1/payments/decode -d + '{"data": <bolt11/lnurl, string>}' -H "X-Api-Key: + {{ wallet.inkey }}" -H "Content-type: application/json" +
+
+
2: raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail="Key is invalid") if invoiceData.out is True and wallet.wallet_type == 0: if not invoiceData.bolt11: - raise HTTPException(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 + raise HTTPException( + 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 + class CreateLNURLData(BaseModel): - description_hash: str - callback: str - amount: int - comment: Optional[str] = None - description: Optional[str] = None + description_hash: str + callback: str + amount: int + comment: Optional[str] = None + description: Optional[str] = None + @core_app.post("/api/v1/payments/lnurl") -async def api_payments_pay_lnurl(data: CreateLNURLData, - wallet: WalletTypeInfo = Depends(get_key_type)): +async def api_payments_pay_lnurl( + data: CreateLNURLData, wallet: WalletTypeInfo = Depends(get_key_type) +): domain = urlparse(data.callback).netloc async with httpx.AsyncClient() as client: @@ -207,30 +230,28 @@ async def api_payments_pay_lnurl(data: CreateLNURLData, except (httpx.ConnectError, httpx.RequestError): raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, - detail=f"Failed to connect to {domain}." + detail=f"Failed to connect to {domain}.", ) params = json.loads(r.text) if params.get("status") == "ERROR": raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail=f"{domain} said: '{params.get('reason', '')}'" + status_code=HTTPStatus.BAD_REQUEST, + detail=f"{domain} said: '{params.get('reason', '')}'", ) - invoice = bolt11.decode(params["pr"]) if invoice.amount_msat != data.amount: raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail=f"{domain} returned an invalid invoice. Expected {data['amount']} msat, got {invoice.amount_msat}." + status_code=HTTPStatus.BAD_REQUEST, + detail=f"{domain} returned an invalid invoice. Expected {data['amount']} msat, got {invoice.amount_msat}.", ) - + if invoice.description_hash != data.description_hash: raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail=f"{domain} returned an invalid invoice. Expected description_hash == {data['description_hash']}, got {invoice.description_hash}." + status_code=HTTPStatus.BAD_REQUEST, + detail=f"{domain} returned an invalid invoice. Expected description_hash == {data['description_hash']}, got {invoice.description_hash}.", ) - extra = {} @@ -252,7 +273,7 @@ async def api_payments_pay_lnurl(data: CreateLNURLData, # maintain backwards compatibility with API clients: "checking_id": payment_hash, } - + async def subscribe(request: Request, wallet: Wallet): this_wallet_id = wallet.wallet.id @@ -278,7 +299,7 @@ async def subscribe(request: Request, wallet: Wallet): if data: jdata = json.dumps(dict(data.dict(), pending=False)) - + # yield dict(id=1, event="this", data="1234") # await asyncio.sleep(2) yield dict(data=jdata, event=typ) @@ -288,8 +309,12 @@ async def subscribe(request: Request, wallet: Wallet): @core_app.get("/api/v1/payments/sse") -async def api_payments_sse(request: Request, wallet: WalletTypeInfo = Depends(get_key_type)): - return EventSourceResponse(subscribe(request, wallet), ping=20, media_type="text/event-stream") +async def api_payments_sse( + 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}") @@ -307,9 +332,11 @@ async def api_payment(payment_hash, wallet: WalletTypeInfo = Depends(get_key_typ return {"paid": False} 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): try: url = lnurl.decode(code) @@ -327,7 +354,9 @@ async def api_lnurlscan(code: str): ) # will proceed with these values 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: Dict = {"domain": domain} @@ -343,24 +372,31 @@ async def api_lnurlscan(code: str): r = await client.get(url, timeout=5) if r.is_error: raise HTTPException( - status_code=HTTPStatus.SERVICE_UNAVAILABLE, - detail={"domain": domain, "message": "failed to get parameters"} + status_code=HTTPStatus.SERVICE_UNAVAILABLE, + detail={"domain": domain, "message": "failed to get parameters"}, ) try: data = json.loads(r.text) except json.decoder.JSONDecodeError: raise HTTPException( - status_code=HTTPStatus.SERVICE_UNAVAILABLE, - detail={"domain": domain, "message": f"got invalid response '{r.text[:200]}'"} + status_code=HTTPStatus.SERVICE_UNAVAILABLE, + detail={ + "domain": domain, + "message": f"got invalid response '{r.text[:200]}'", + }, ) try: tag = data["tag"] if tag == "channelRequest": raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail={"domain": domain, "kind": "channel", "message": "unsupported"} + status_code=HTTPStatus.BAD_REQUEST, + detail={ + "domain": domain, + "kind": "channel", + "message": "unsupported", + }, ) params.update(**data) @@ -410,17 +446,44 @@ async def api_lnurlscan(code: str): detail={ "domain": domain, "message": f"lnurl JSON response invalid: {exc}", - }) - + }, + ) + 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())]) async def api_perform_lnurlauth(callback: str): err = await perform_lnurlauth(callback) if err: - raise HTTPException(status_code=HTTPStatus.SERVICE_UNAVAILABLE, detail=err.reason) - + raise HTTPException( + status_code=HTTPStatus.SERVICE_UNAVAILABLE, detail=err.reason + ) + return "" diff --git a/lnbits/core/views/generic.py b/lnbits/core/views/generic.py index b0055af8..173e41ee 100644 --- a/lnbits/core/views/generic.py +++ b/lnbits/core/views/generic.py @@ -17,38 +17,48 @@ from lnbits.helpers import template_renderer, url_for from lnbits.requestvars import g from lnbits.core.models import User from lnbits.decorators import check_user_exists -from lnbits.settings import (LNBITS_ALLOWED_USERS, LNBITS_SITE_TITLE, - SERVICE_FEE) +from lnbits.settings import LNBITS_ALLOWED_USERS, LNBITS_SITE_TITLE, SERVICE_FEE -from ..crud import (create_account, create_wallet, delete_wallet, - get_balance_check, get_user, save_balance_notify, - update_user_extension) +from ..crud import ( + create_account, + create_wallet, + delete_wallet, + get_balance_check, + get_user, + save_balance_notify, + update_user_extension, +) from ..services import pay_invoice, redeem_lnurl_withdraw core_html_routes: APIRouter = APIRouter(tags=["Core NON-API Website Routes"]) + @core_html_routes.get("/favicon.ico") async def favicon(): return FileResponse("lnbits/core/static/favicon.ico") - + @core_html_routes.get("/", response_class=HTMLResponse) 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") async def extensions( - request: Request, + request: Request, user: User = Depends(check_user_exists), - enable: str= Query(None), - disable: str = Query(None) - ): + enable: str = Query(None), + disable: str = Query(None), +): extension_to_enable = enable extension_to_disable = 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: await update_user_extension( @@ -63,14 +73,20 @@ async def extensions( if extension_to_enable or extension_to_disable: 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) -#Not sure how to validate +# Not sure how to validate # @validate_uuids(["usr", "nme"]) -async def wallet(request: Request = Query(None), nme: Optional[str] = Query(None), - usr: Optional[UUID4] = Query(None), wal: Optional[UUID4] = Query(None)): +async def wallet( + 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 wallet_id = wal.hex if wal else None wallet_name = nme @@ -87,23 +103,38 @@ async def wallet(request: Request = Query(None), nme: Optional[str] = Query(None else: user = await get_user(user_id) 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: - 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 user.wallets and not wallet_name: wallet = user.wallets[0] else: 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) 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( - "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,22 +147,17 @@ async def lnurl_full_withdraw(request: Request): wallet = user.get_wallet(request.args.get("wal")) if not wallet: - return{"status": "ERROR", "reason": "Wallet does not exist."} + return {"status": "ERROR", "reason": "Wallet does not exist."} return { - "tag": "withdrawRequest", - "callback": url_for( - "/withdraw/cb", - external=True, - usr=user.id, - wal=wallet.id, - ), - "k1": "0", - "minWithdrawable": 1000 if wallet.withdrawable_balance else 0, - "maxWithdrawable": wallet.withdrawable_balance, - "defaultDescription": f"{LNBITS_SITE_TITLE} balance withdraw from {wallet.id[0:5]}", - "balanceCheck": url_for("/withdraw", external=True, usr=user.id, wal=wallet.id), - } + "tag": "withdrawRequest", + "callback": url_for("/withdraw/cb", external=True, usr=user.id, wal=wallet.id), + "k1": "0", + "minWithdrawable": 1000 if wallet.withdrawable_balance else 0, + "maxWithdrawable": wallet.withdrawable_balance, + "defaultDescription": f"{LNBITS_SITE_TITLE} balance withdraw from {wallet.id[0:5]}", + "balanceCheck": url_for("/withdraw", external=True, usr=user.id, wal=wallet.id), + } @core_html_routes.get("/withdraw/cb") @@ -176,10 +202,14 @@ async def deletewallet(request: Request): user_wallet_ids.remove(wallet_id) if user_wallet_ids: - return RedirectResponse(url_for("/wallet", usr=g().user.id, wal=user_wallet_ids[0]), - status_code=status.HTTP_307_TEMPORARY_REDIRECT) + return RedirectResponse( + 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}") @@ -203,11 +233,14 @@ async def lnurlwallet(request: Request): request.args.get("lightning"), "LNbits initial funding: voucher redeem.", {"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") @@ -217,27 +250,28 @@ async def manifest(usr: str): raise HTTPException(status_code=HTTPStatus.NOT_FOUND) return { - "short_name": "LNbits", - "name": "LNbits Wallet", - "icons": [ - { - "src": "https://cdn.jsdelivr.net/gh/lnbits/lnbits@0.3.0/docs/logos/lnbits.png", - "type": "image/png", - "sizes": "900x900", - } - ], - "start_url": "/wallet?usr=" + usr, - "background_color": "#3367D6", - "description": "Weather forecast information", - "display": "standalone", - "scope": "/", - "theme_color": "#3367D6", - "shortcuts": [ - { - "name": wallet.name, - "short_name": wallet.name, - "description": wallet.name, - "url": "/wallet?usr=" + usr + "&wal=" + wallet.id, - } - for wallet in user.wallets - ],} + "short_name": "LNbits", + "name": "LNbits Wallet", + "icons": [ + { + "src": "https://cdn.jsdelivr.net/gh/lnbits/lnbits@0.3.0/docs/logos/lnbits.png", + "type": "image/png", + "sizes": "900x900", + } + ], + "start_url": "/wallet?usr=" + usr, + "background_color": "#3367D6", + "description": "Weather forecast information", + "display": "standalone", + "scope": "/", + "theme_color": "#3367D6", + "shortcuts": [ + { + "name": wallet.name, + "short_name": wallet.name, + "description": wallet.name, + "url": "/wallet?usr=" + usr + "&wal=" + wallet.id, + } + for wallet in user.wallets + ], + } diff --git a/lnbits/core/views/public_api.py b/lnbits/core/views/public_api.py index 70f949dc..d9213d6c 100644 --- a/lnbits/core/views/public_api.py +++ b/lnbits/core/views/public_api.py @@ -15,8 +15,7 @@ async def api_public_payment_longpolling(payment_hash): if not payment: raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail="Payment does not exist." + status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist." ) elif not payment.pending: return {"status": "paid"} @@ -28,8 +27,7 @@ async def api_public_payment_longpolling(payment_hash): return {"status": "expired"} except: raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail="Invalid bolt11 invoice." + status_code=HTTPStatus.BAD_REQUEST, detail="Invalid bolt11 invoice." ) payment_queue = asyncio.Queue(0) @@ -50,14 +48,10 @@ async def api_public_payment_longpolling(payment_hash): await asyncio.sleep(45) cancel_scope.cancel() - asyncio.create_task(payment_info_receiver()) asyncio.create_task(timeouter()) if response: return response else: - raise HTTPException( - status_code=HTTPStatus.REQUEST_TIMEOUT, - detail="timeout" - ) + raise HTTPException(status_code=HTTPStatus.REQUEST_TIMEOUT, detail="timeout") diff --git a/lnbits/decorators.py b/lnbits/decorators.py index 440f8fa1..04f0e220 100644 --- a/lnbits/decorators.py +++ b/lnbits/decorators.py @@ -1,80 +1,99 @@ -from functools import wraps 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 fastapi import status from fastapi.exceptions import HTTPException from fastapi.openapi.models import APIKey, APIKeyIn from fastapi.params import Security from fastapi.security.api_key import APIKeyHeader, APIKeyQuery from fastapi.security.base import SecurityBase +from pydantic.types import UUID4 from starlette.requests import Request 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.settings import LNBITS_ALLOWED_USERS 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.auto_error = auto_error self._key_type = "invoice" self._api_key = api_key if api_key: - self.model: APIKey= APIKey( - **{"in": APIKeyIn.query}, name="X-API-KEY", description="Wallet API Key - QUERY" + self.model: APIKey = APIKey( + **{"in": APIKeyIn.query}, + name="X-API-KEY", + description="Wallet API Key - QUERY", ) else: - self.model: APIKey= APIKey( - **{"in": APIKeyIn.header}, name="X-API-KEY", description="Wallet API Key - HEADER" + self.model: APIKey = APIKey( + **{"in": APIKeyIn.header}, + name="X-API-KEY", + description="Wallet API Key - HEADER", ) self.wallet = None async def __call__(self, request: Request) -> Wallet: 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. # Also, we should not return the wallet here - thats silly. # Possibly store it in a Redis DB self.wallet = await get_wallet_for_key(key_value, self._key_type) 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: - raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, - detail="`X-API-KEY` header missing.") + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, detail="`X-API-KEY` header missing." + ) + class WalletInvoiceKeyChecker(KeyChecker): """ WalletInvoiceKeyChecker will ensure that the provided invoice - wallet key is correct and populate g().wallet with the wallet + wallet key is correct and populate g().wallet with the wallet for the key in `X-API-key`. 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) self._key_type = "invoice" + class WalletAdminKeyChecker(KeyChecker): """ WalletAdminKeyChecker will ensure that the provided admin - wallet key is correct and populate g().wallet with the wallet + wallet key is correct and populate g().wallet with the wallet for the key in `X-API-key`. 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) self._key_type = "admin" -class WalletTypeInfo(): + +class WalletTypeInfo: wallet_type: int wallet: Wallet @@ -83,16 +102,34 @@ class WalletTypeInfo(): 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_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_query: str = Security(api_key_query)) -> WalletTypeInfo: +api_key_header = APIKeyHeader( + name="X-API-KEY", + 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_query: str = Security(api_key_query), +) -> WalletTypeInfo: # 0: admin # 1: invoice # 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: - checker = WalletAdminKeyChecker(api_key=api_key_query) + checker = WalletAdminKeyChecker(api_key=token) await checker.__call__(r) return WalletTypeInfo(0, checker.wallet) except HTTPException as e: @@ -104,7 +141,7 @@ async def get_key_type(r: Request, raise try: - checker = WalletInvoiceKeyChecker() + checker = WalletInvoiceKeyChecker(api_key=token) await checker.__call__(r) return WalletTypeInfo(1, checker.wallet) except HTTPException as e: @@ -115,46 +152,36 @@ async def get_key_type(r: Request, except: raise -def api_validate_post_request(*, schema: dict): - def wrap(view): - @wraps(view) - async def wrapped_view(**kwargs): - if "application/json" not in request.headers["Content-Type"]: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail=jsonify({"message": "Content-Type must be `application/json`."}) - ) - v = Validator(schema) - data = await request.get_json() - g().data = {key: data[key] for key in schema.keys() if key in data} +async def require_admin_key( + r: Request, + api_key_header: str = Security(api_key_header), + api_key_query: str = Security(api_key_query), +): + token = api_key_header if api_key_header else api_key_query - if not v.validate(g().data): - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail=jsonify({"message": f"Errors in request data: {v.errors}"}) - ) - + wallet = await get_key_type(r, token) - return await view(**kwargs) - - return wrapped_view - - return wrap + 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 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: raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail="User does not exist." + status_code=HTTPStatus.NOT_FOUND, detail="User does not exist." ) if LNBITS_ALLOWED_USERS and g().user.id not in LNBITS_ALLOWED_USERS: raise HTTPException( - status_code=HTTPStatus.UNAUTHORIZED, - detail="User not authorized." + status_code=HTTPStatus.UNAUTHORIZED, detail="User not authorized." ) return g().user diff --git a/lnbits/extensions/copilot/README.md b/lnbits/extensions/copilot/README.md new file mode 100644 index 00000000..323aeddc --- /dev/null +++ b/lnbits/extensions/copilot/README.md @@ -0,0 +1,3 @@ +# StreamerCopilot + +Tool to help streamers accept sats for tips diff --git a/lnbits/extensions/copilot/__init__.py b/lnbits/extensions/copilot/__init__.py new file mode 100644 index 00000000..4252eddc --- /dev/null +++ b/lnbits/extensions/copilot/__init__.py @@ -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)) diff --git a/lnbits/extensions/copilot/config.json b/lnbits/extensions/copilot/config.json new file mode 100644 index 00000000..a4ecb3b5 --- /dev/null +++ b/lnbits/extensions/copilot/config.json @@ -0,0 +1,8 @@ +{ + "name": "Streamer Copilot", + "short_description": "Video tips/animations/webhooks", + "icon": "face", + "contributors": [ + "arcbtc" + ] +} diff --git a/lnbits/extensions/copilot/crud.py b/lnbits/extensions/copilot/crud.py new file mode 100644 index 00000000..955561e4 --- /dev/null +++ b/lnbits/extensions/copilot/crud.py @@ -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,)) diff --git a/lnbits/extensions/copilot/lnurl.py b/lnbits/extensions/copilot/lnurl.py new file mode 100644 index 00000000..6e57e403 --- /dev/null +++ b/lnbits/extensions/copilot/lnurl.py @@ -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) diff --git a/lnbits/extensions/copilot/migrations.py b/lnbits/extensions/copilot/migrations.py new file mode 100644 index 00000000..7b23c936 --- /dev/null +++ b/lnbits/extensions/copilot/migrations.py @@ -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;") diff --git a/lnbits/extensions/copilot/models.py b/lnbits/extensions/copilot/models.py new file mode 100644 index 00000000..a279879d --- /dev/null +++ b/lnbits/extensions/copilot/models.py @@ -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) diff --git a/lnbits/extensions/copilot/static/bitcoin.gif b/lnbits/extensions/copilot/static/bitcoin.gif new file mode 100644 index 00000000..ef8c2ecd Binary files /dev/null and b/lnbits/extensions/copilot/static/bitcoin.gif differ diff --git a/lnbits/extensions/copilot/static/confetti.gif b/lnbits/extensions/copilot/static/confetti.gif new file mode 100644 index 00000000..a3fec971 Binary files /dev/null and b/lnbits/extensions/copilot/static/confetti.gif differ diff --git a/lnbits/extensions/copilot/static/face.gif b/lnbits/extensions/copilot/static/face.gif new file mode 100644 index 00000000..3e70d779 Binary files /dev/null and b/lnbits/extensions/copilot/static/face.gif differ diff --git a/lnbits/extensions/copilot/static/lnurl.png b/lnbits/extensions/copilot/static/lnurl.png new file mode 100644 index 00000000..ad2c9715 Binary files /dev/null and b/lnbits/extensions/copilot/static/lnurl.png differ diff --git a/lnbits/extensions/copilot/static/martijn.gif b/lnbits/extensions/copilot/static/martijn.gif new file mode 100644 index 00000000..e410677d Binary files /dev/null and b/lnbits/extensions/copilot/static/martijn.gif differ diff --git a/lnbits/extensions/copilot/static/rick.gif b/lnbits/extensions/copilot/static/rick.gif new file mode 100644 index 00000000..c36c7e19 Binary files /dev/null and b/lnbits/extensions/copilot/static/rick.gif differ diff --git a/lnbits/extensions/copilot/static/rocket.gif b/lnbits/extensions/copilot/static/rocket.gif new file mode 100644 index 00000000..6f19597d Binary files /dev/null and b/lnbits/extensions/copilot/static/rocket.gif differ diff --git a/lnbits/extensions/copilot/tasks.py b/lnbits/extensions/copilot/tasks.py new file mode 100644 index 00000000..351eb24b --- /dev/null +++ b/lnbits/extensions/copilot/tasks.py @@ -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), + ) diff --git a/lnbits/extensions/copilot/templates/copilot/_api_docs.html b/lnbits/extensions/copilot/templates/copilot/_api_docs.html new file mode 100644 index 00000000..9cbb99cb --- /dev/null +++ b/lnbits/extensions/copilot/templates/copilot/_api_docs.html @@ -0,0 +1,172 @@ + + +

+ StreamerCopilot: get tips via static QR (lnurl-pay) and show an + animation
+ + Created by, Ben Arc +

+
+ + + + + POST /copilot/api/v1/copilot +
Headers
+ {"X-Api-Key": <admin_key>}
+
+ Body (application/json) +
+
+ Returns 200 OK (application/json) +
+ [<copilot_object>, ...] +
Curl example
+ 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 }}" + +
+
+
+ + + + PUT + /copilot/api/v1/copilot/<copilot_id> +
Headers
+ {"X-Api-Key": <admin_key>}
+
+ Body (application/json) +
+
+ Returns 200 OK (application/json) +
+ [<copilot_object>, ...] +
Curl example
+ 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 }}" + +
+
+
+ + + + + GET + /copilot/api/v1/copilot/<copilot_id> +
Headers
+ {"X-Api-Key": <invoice_key>}
+
+ Body (application/json) +
+
+ Returns 200 OK (application/json) +
+ [<copilot_object>, ...] +
Curl example
+ curl -X GET {{ request.url_root }}api/v1/copilot/<copilot_id> + -H "X-Api-Key: {{ user.wallets[0].inkey }}" + +
+
+
+ + + + GET /copilot/api/v1/copilots +
Headers
+ {"X-Api-Key": <invoice_key>}
+
+ Body (application/json) +
+
+ Returns 200 OK (application/json) +
+ [<copilot_object>, ...] +
Curl example
+ curl -X GET {{ request.url_root }}api/v1/copilots -H "X-Api-Key: {{ + user.wallets[0].inkey }}" + +
+
+
+ + + + DELETE + /copilot/api/v1/copilot/<copilot_id> +
Headers
+ {"X-Api-Key": <admin_key>}
+
Returns 204 NO CONTENT
+ +
Curl example
+ curl -X DELETE {{ request.url_root + }}api/v1/copilot/<copilot_id> -H "X-Api-Key: {{ + user.wallets[0].adminkey }}" + +
+
+
+ + + + GET + /api/v1/copilot/ws/<copilot_id>/<comment>/<data> +
Headers
+ {"X-Api-Key": <admin_key>}
+
Returns 200
+ +
Curl example
+ 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 }}" + +
+
+
+
+
diff --git a/lnbits/extensions/copilot/templates/copilot/compose.html b/lnbits/extensions/copilot/templates/copilot/compose.html new file mode 100644 index 00000000..b4022ee0 --- /dev/null +++ b/lnbits/extensions/copilot/templates/copilot/compose.html @@ -0,0 +1,287 @@ +{% extends "public.html" %} {% block page %} + + + + +
+
+ +
+ {% raw %}{{ copilot.lnurl_title }}{% endraw %} +
+
+
+ +

+ {% raw %}{{ price }}{% endraw %} +

+

+ Powered by LNbits/StreamerCopilot +

+
+{% endblock %} {% block scripts %} + + +{% endblock %} diff --git a/lnbits/extensions/copilot/templates/copilot/index.html b/lnbits/extensions/copilot/templates/copilot/index.html new file mode 100644 index 00000000..95c08bae --- /dev/null +++ b/lnbits/extensions/copilot/templates/copilot/index.html @@ -0,0 +1,660 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} + +
+
+ + + {% raw %} + New copilot instance + + + + + + +
+
+
Copilots
+
+ +
+ + + + Export to CSV +
+
+ + + + + {% endraw %} + +
+
+
+ +
+ + +
+ {{SITE_TITLE}} StreamCopilot Extension +
+
+ + + {% include "copilot/_api_docs.html" %} + +
+
+ + + + +
+ +
+ +
+ + + + + + + +
+
+ +
+ +
+ + +
+
+ + +
+
+
+
+
+ + + + +
+
+ +
+ +
+ + +
+
+ + +
+
+
+
+
+ + + + +
+
+ +
+ +
+ + +
+
+ + +
+
+
+
+
+ + + +
+ +
+ +
+
+
+ +
+
+
+ Update Copilot + Create Copilot + Cancel +
+
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/lnbits/extensions/copilot/templates/copilot/panel.html b/lnbits/extensions/copilot/templates/copilot/panel.html new file mode 100644 index 00000000..f17bf34c --- /dev/null +++ b/lnbits/extensions/copilot/templates/copilot/panel.html @@ -0,0 +1,156 @@ +{% extends "public.html" %} {% block page %} +
+ +
+
+
+ +
+
+
+
+ Title: {% raw %} {{ copilot.title }} {% endraw %} +
+
+ +
+
+
+ + +
+
+
+
+ +
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+ +
+
+
+
+
+
+ +{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/lnbits/extensions/copilot/views.py b/lnbits/extensions/copilot/views.py new file mode 100644 index 00000000..17e05081 --- /dev/null +++ b/lnbits/extensions/copilot/views.py @@ -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) diff --git a/lnbits/extensions/copilot/views_api.py b/lnbits/extensions/copilot/views_api.py new file mode 100644 index 00000000..d9342a30 --- /dev/null +++ b/lnbits/extensions/copilot/views_api.py @@ -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 "" diff --git a/lnbits/extensions/events/README.md b/lnbits/extensions/events/README.md new file mode 100644 index 00000000..11b62fec --- /dev/null +++ b/lnbits/extensions/events/README.md @@ -0,0 +1,33 @@ +# Events + +## Sell tickets for events and use the built-in scanner for registering attendants + +Events alows you to make tickets for an event. Each ticket is in the form of a uniqque QR code. After registering, and paying for ticket, the user gets a QR code to present at registration/entrance. + +Events includes a shareable ticket scanner, which can be used to register attendees. + +## Usage + +1. Create an event\ + ![create event](https://i.imgur.com/dadK1dp.jpg) +2. Fill out the event information: + + - event name + - wallet (normally there's only one) + - event information + - closing date for event registration + - begin and end date of the event + + ![event info](https://imgur.com/KAv68Yr.jpg) + +3. Share the event registration link\ + ![event ticket](https://imgur.com/AQWUOBY.jpg) + + - ticket example\ + ![ticket example](https://i.imgur.com/trAVSLd.jpg) + + - QR code ticket, presented after invoice paid, to present at registration\ + ![event ticket](https://i.imgur.com/M0ROM82.jpg) + +4. Use the built-in ticket scanner to validate registered, and paid, attendees\ + ![ticket scanner](https://i.imgur.com/zrm9202.jpg) diff --git a/lnbits/extensions/events/__init__.py b/lnbits/extensions/events/__init__.py new file mode 100644 index 00000000..da29358b --- /dev/null +++ b/lnbits/extensions/events/__init__.py @@ -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 + diff --git a/lnbits/extensions/events/config.json b/lnbits/extensions/events/config.json new file mode 100644 index 00000000..6bc144ab --- /dev/null +++ b/lnbits/extensions/events/config.json @@ -0,0 +1,6 @@ +{ + "name": "Events", + "short_description": "Sell and register event tickets", + "icon": "local_activity", + "contributors": ["benarc"] +} diff --git a/lnbits/extensions/events/crud.py b/lnbits/extensions/events/crud.py new file mode 100644 index 00000000..4a24b797 --- /dev/null +++ b/lnbits/extensions/events/crud.py @@ -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] diff --git a/lnbits/extensions/events/migrations.py b/lnbits/extensions/events/migrations.py new file mode 100644 index 00000000..d8f3d94e --- /dev/null +++ b/lnbits/extensions/events/migrations.py @@ -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") diff --git a/lnbits/extensions/events/models.py b/lnbits/extensions/events/models.py new file mode 100644 index 00000000..c775382f --- /dev/null +++ b/lnbits/extensions/events/models.py @@ -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 diff --git a/lnbits/extensions/events/templates/events/_api_docs.html b/lnbits/extensions/events/templates/events/_api_docs.html new file mode 100644 index 00000000..a5c82174 --- /dev/null +++ b/lnbits/extensions/events/templates/events/_api_docs.html @@ -0,0 +1,23 @@ + + + +
+ Events: Sell and register ticket waves for an event +
+

+ 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.
+ + Created by, Ben Arc + +

+
+
+
diff --git a/lnbits/extensions/events/templates/events/display.html b/lnbits/extensions/events/templates/events/display.html new file mode 100644 index 00000000..4c1f557f --- /dev/null +++ b/lnbits/extensions/events/templates/events/display.html @@ -0,0 +1,207 @@ +{% extends "public.html" %} {% block page %} +
+
+ + +

{{ event_name }}

+
+
{{ event_info }}
+
+ + + + +
+ Submit + Cancel +
+
+
+
+ + +
+ Link to your ticket! +

+

You'll be redirected in a few moments...

+
+
+
+ + + + + + +
+ Copy invoice + Close +
+
+
+
+ +{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/lnbits/extensions/events/templates/events/error.html b/lnbits/extensions/events/templates/events/error.html new file mode 100644 index 00000000..f231177b --- /dev/null +++ b/lnbits/extensions/events/templates/events/error.html @@ -0,0 +1,35 @@ +{% extends "public.html" %} {% block page %} +
+
+ + +
+

{{ event_name }} error

+
+ + +
{{ event_error }}
+
+
+
+
+
+ + {% endblock %} {% block scripts %} + + + + {% endblock %} +
diff --git a/lnbits/extensions/events/templates/events/index.html b/lnbits/extensions/events/templates/events/index.html new file mode 100644 index 00000000..c2d81960 --- /dev/null +++ b/lnbits/extensions/events/templates/events/index.html @@ -0,0 +1,538 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + + New Event + + + + + +
+
+
Events
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
+ + + +
+
+
Tickets
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
+
+
+ + +
+ {{SITE_TITLE}} Events extension +
+
+ + + {% include "events/_api_docs.html" %} + +
+
+ + + + +
+
+ +
+
+ + +
+
+ + +
+
Ticket closing date
+
+ +
+
+ +
+
Event begins
+
+ +
+
+ +
+
Event ends
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ Update Event + Create Event + Cancel +
+
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/lnbits/extensions/events/templates/events/register.html b/lnbits/extensions/events/templates/events/register.html new file mode 100644 index 00000000..4dff9afb --- /dev/null +++ b/lnbits/extensions/events/templates/events/register.html @@ -0,0 +1,173 @@ +{% extends "public.html" %} {% block page %} + +
+
+ + +
+

{{ event_name }} Registration

+
+ +
+ + Scan ticket +
+
+
+ + + + + {% raw %} + + + {% endraw %} + + + +
+ + + +
+ +
+
+ Cancel +
+
+
+
+{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/lnbits/extensions/events/templates/events/ticket.html b/lnbits/extensions/events/templates/events/ticket.html new file mode 100644 index 00000000..a53f834f --- /dev/null +++ b/lnbits/extensions/events/templates/events/ticket.html @@ -0,0 +1,45 @@ +{% extends "public.html" %} {% block page %} +
+
+ + +
+

{{ ticket_name }} Ticket

+
+
+ Bookmark, print or screenshot this page,
+ and present it for registration! +
+
+ + +
+ + Print +
+
+
+
+
+{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/lnbits/extensions/events/views.py b/lnbits/extensions/events/views.py new file mode 100644 index 00000000..a80f7806 --- /dev/null +++ b/lnbits/extensions/events/views.py @@ -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, + } + ) diff --git a/lnbits/extensions/events/views_api.py b/lnbits/extensions/events/views_api.py new file mode 100644 index 00000000..5dae31e1 --- /dev/null +++ b/lnbits/extensions/events/views_api.py @@ -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)] diff --git a/lnbits/extensions/jukebox/README.md b/lnbits/extensions/jukebox/README.md new file mode 100644 index 00000000..c761db44 --- /dev/null +++ b/lnbits/extensions/jukebox/README.md @@ -0,0 +1,36 @@ +# Jukebox + +## An actual Jukebox where users pay sats to play their favourite music from your playlists + +**Note:** To use this extension you need a Premium Spotify subscription. + +## Usage + +1. Click on "ADD SPOTIFY JUKEBOX"\ + ![add jukebox](https://i.imgur.com/NdVoKXd.png) +2. Follow the steps required on the form\ + + - give your jukebox a name + - select a wallet to receive payment + - define the price a user must pay to select a song\ + ![pick wallet price](https://i.imgur.com/4bJ8mb9.png) + - follow the steps to get your Spotify App and get the client ID and secret key\ + ![spotify keys](https://i.imgur.com/w2EzFtB.png) + - paste the codes in the form\ + ![api keys](https://i.imgur.com/6b9xauo.png) + - copy the _Redirect URL_ presented on the form\ + ![redirect url](https://i.imgur.com/GMzl0lG.png) + - on Spotify click the "EDIT SETTINGS" button and paste the copied link in the _Redirect URI's_ prompt + ![spotify app setting](https://i.imgur.com/vb0x4Tl.png) + - back on LNBits, click "AUTORIZE ACCESS" and "Agree" on the page that will open + - choose on which device the LNBits Jukebox extensions will stream to, you may have to be logged in in order to select the device (browser, smartphone app, etc...) + - and select what playlist will be available for users to choose songs (you need to have already playlist on Spotify)\ + ![select playlists](https://i.imgur.com/g4dbtED.png) + +3. After Jukebox is created, click the icon to open the dialog with the shareable QR, open the Jukebox page, etc...\ + ![shareable jukebox](https://i.imgur.com/EAh9PI0.png) +4. The users will see the Jukebox page and choose a song from the selected playlist\ + ![select song](https://i.imgur.com/YYjeQAs.png) +5. After selecting a song they'd like to hear next a dialog will show presenting the music\ + ![play for sats](https://i.imgur.com/eEHl3o8.png) +6. After payment, the song will automatically start playing on the device selected or enter the queue if some other music is already playing diff --git a/lnbits/extensions/jukebox/__init__.py b/lnbits/extensions/jukebox/__init__.py new file mode 100644 index 00000000..0d4524a7 --- /dev/null +++ b/lnbits/extensions/jukebox/__init__.py @@ -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)) diff --git a/lnbits/extensions/jukebox/config.json b/lnbits/extensions/jukebox/config.json new file mode 100644 index 00000000..6b57bec4 --- /dev/null +++ b/lnbits/extensions/jukebox/config.json @@ -0,0 +1,6 @@ +{ + "name": "Spotify Jukebox", + "short_description": "Spotify jukebox middleware", + "icon": "radio", + "contributors": ["benarc"] +} diff --git a/lnbits/extensions/jukebox/crud.py b/lnbits/extensions/jukebox/crud.py new file mode 100644 index 00000000..3f8ce96c --- /dev/null +++ b/lnbits/extensions/jukebox/crud.py @@ -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 diff --git a/lnbits/extensions/jukebox/migrations.py b/lnbits/extensions/jukebox/migrations.py new file mode 100644 index 00000000..a0a3bd28 --- /dev/null +++ b/lnbits/extensions/jukebox/migrations.py @@ -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 + ); + """ + ) diff --git a/lnbits/extensions/jukebox/models.py b/lnbits/extensions/jukebox/models.py new file mode 100644 index 00000000..093961e4 --- /dev/null +++ b/lnbits/extensions/jukebox/models.py @@ -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) diff --git a/lnbits/extensions/jukebox/static/js/index.js b/lnbits/extensions/jukebox/static/js/index.js new file mode 100644 index 00000000..049b600e --- /dev/null +++ b/lnbits/extensions/jukebox/static/js/index.js @@ -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 + } +}) diff --git a/lnbits/extensions/jukebox/static/spotapi.gif b/lnbits/extensions/jukebox/static/spotapi.gif new file mode 100644 index 00000000..023efc9a Binary files /dev/null and b/lnbits/extensions/jukebox/static/spotapi.gif differ diff --git a/lnbits/extensions/jukebox/static/spotapi1.gif b/lnbits/extensions/jukebox/static/spotapi1.gif new file mode 100644 index 00000000..478032c5 Binary files /dev/null and b/lnbits/extensions/jukebox/static/spotapi1.gif differ diff --git a/lnbits/extensions/jukebox/tasks.py b/lnbits/extensions/jukebox/tasks.py new file mode 100644 index 00000000..52366bea --- /dev/null +++ b/lnbits/extensions/jukebox/tasks.py @@ -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) diff --git a/lnbits/extensions/jukebox/templates/jukebox/_api_docs.html b/lnbits/extensions/jukebox/templates/jukebox/_api_docs.html new file mode 100644 index 00000000..b1968b48 --- /dev/null +++ b/lnbits/extensions/jukebox/templates/jukebox/_api_docs.html @@ -0,0 +1,125 @@ + + To use this extension you need a Spotify client ID and client secret. You get + these by creating an app in the Spotify developers dashboard + here + +

Select the playlists you want people to be able to pay for, share + the frontend page, profit :)

+ Made by, + benarc. + Inspired by, + pirosb3. +
+ + + + + + GET /jukebox/api/v1/jukebox +
Headers
+ {"X-Api-Key": <admin_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ [<jukebox_object>, ...] +
Curl example
+ curl -X GET {{ request.url_root }}api/v1/jukebox -H "X-Api-Key: {{ + user.wallets[0].adminkey }}" + +
+
+
+ + + + GET + /jukebox/api/v1/jukebox/<juke_id> +
Headers
+ {"X-Api-Key": <admin_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ <jukebox_object> +
Curl example
+ curl -X GET {{ request.url_root }}api/v1/jukebox/<juke_id> -H + "X-Api-Key: {{ user.wallets[0].adminkey }}" + +
+
+
+ + + + POST/PUT + /jukebox/api/v1/jukebox/ +
Headers
+ {"X-Api-Key": <admin_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ <jukbox_object> +
Curl example
+ 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 }}" + +
+
+
+ + + + DELETE + /jukebox/api/v1/jukebox/<juke_id> +
Headers
+ {"X-Api-Key": <admin_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ <jukebox_object> +
Curl example
+ curl -X DELETE {{ request.url_root }}api/v1/jukebox/<juke_id> + -H "X-Api-Key: {{ user.wallets[0].adminkey }}" + +
+
+
diff --git a/lnbits/extensions/jukebox/templates/jukebox/error.html b/lnbits/extensions/jukebox/templates/jukebox/error.html new file mode 100644 index 00000000..f6f7fd58 --- /dev/null +++ b/lnbits/extensions/jukebox/templates/jukebox/error.html @@ -0,0 +1,37 @@ +{% extends "public.html" %} {% block page %} +
+
+ + +
+

Jukebox error

+
+ + +
+ Ask the host to turn on the device and launch spotify +
+
+
+
+
+
+ + {% endblock %} {% block scripts %} + + + + {% endblock %} +
diff --git a/lnbits/extensions/jukebox/templates/jukebox/index.html b/lnbits/extensions/jukebox/templates/jukebox/index.html new file mode 100644 index 00000000..9b4efbd5 --- /dev/null +++ b/lnbits/extensions/jukebox/templates/jukebox/index.html @@ -0,0 +1,368 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + + Add Spotify Jukebox + + {% raw %} + + + + + + + {% endraw %} + + +
+ +
+ + +
+ {{SITE_TITLE}} jukebox extension +
+
+ + + {% include "jukebox/_api_docs.html" %} + +
+
+ + + + + + + + + +
+
+ Continue + Continue +
+
+ Cancel +
+
+ +
+
+ + + + To use this extension you need a Spotify client ID and client secret. + You get these by creating an app in the Spotify developers dashboard + here. + + + + + + + +
+
+ Submit keys + Submit keys +
+
+ Cancel +
+
+ +
+
+ + + + In the app go to edit-settings, set the redirect URI to this link +
+ {% raw %}{{ locationcb }}{{ jukeboxDialog.data.sp_id }}{% endraw + %} Click to copy URL + +
+ Settings can be found + here. + +
+
+ Authorise access + Authorise access +
+
+ Cancel +
+
+ +
+
+ + + + +
+
+ Create Jukebox + Create Jukebox +
+
+ Cancel +
+
+
+
+
+
+ + + +
+
Shareable Jukebox QR
+
+ + + +
+ + Copy jukebox link + Open jukebox + Close +
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + + +{% endblock %} diff --git a/lnbits/extensions/jukebox/templates/jukebox/jukebox.html b/lnbits/extensions/jukebox/templates/jukebox/jukebox.html new file mode 100644 index 00000000..2791f09e --- /dev/null +++ b/lnbits/extensions/jukebox/templates/jukebox/jukebox.html @@ -0,0 +1,281 @@ +{% extends "public.html" %} {% block page %} +
+
+ + +

Currently playing

+
+
+ +
+
+ {% raw %} + {{ currentPlay.name }}
+ {{ currentPlay.artist }} +
+ {% endraw %} +
+
+
+ + + +

Pick a song

+ + +
+ + + + + + +
+ + + + +
+
+ +
+
+ {% raw %} + {{ receive.name }}
+ {{ receive.artist }} +
+
+
+
+
+ Play for {% endraw %}{{ price }}sats + +
+
+
+ + + + + +
+ Copy invoice +
+
+
+
+
+{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/lnbits/extensions/jukebox/views.py b/lnbits/extensions/jukebox/views.py new file mode 100644 index 00000000..dec65eb9 --- /dev/null +++ b/lnbits/extensions/jukebox/views.py @@ -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)}, + ) diff --git a/lnbits/extensions/jukebox/views_api.py b/lnbits/extensions/jukebox/views_api.py new file mode 100644 index 00000000..e6403d00 --- /dev/null +++ b/lnbits/extensions/jukebox/views_api.py @@ -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 "

Success!

You can close this window

" + + +@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" + ) diff --git a/lnbits/extensions/lndhub/README.md b/lnbits/extensions/lndhub/README.md new file mode 100644 index 00000000..f567d549 --- /dev/null +++ b/lnbits/extensions/lndhub/README.md @@ -0,0 +1,6 @@ +

lndhub Extension

+

*connect to your lnbits wallet from BlueWallet or Zeus*

+ +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. diff --git a/lnbits/extensions/lndhub/__init__.py b/lnbits/extensions/lndhub/__init__.py new file mode 100644 index 00000000..5980ab0d --- /dev/null +++ b/lnbits/extensions/lndhub/__init__.py @@ -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 diff --git a/lnbits/extensions/lndhub/config.json b/lnbits/extensions/lndhub/config.json new file mode 100644 index 00000000..6285ff80 --- /dev/null +++ b/lnbits/extensions/lndhub/config.json @@ -0,0 +1,6 @@ +{ + "name": "LndHub", + "short_description": "Access lnbits from BlueWallet or Zeus", + "icon": "navigation", + "contributors": ["fiatjaf"] +} diff --git a/lnbits/extensions/lndhub/decorators.py b/lnbits/extensions/lndhub/decorators.py new file mode 100644 index 00000000..14931164 --- /dev/null +++ b/lnbits/extensions/lndhub/decorators.py @@ -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 diff --git a/lnbits/extensions/lndhub/migrations.py b/lnbits/extensions/lndhub/migrations.py new file mode 100644 index 00000000..d6ea5fde --- /dev/null +++ b/lnbits/extensions/lndhub/migrations.py @@ -0,0 +1,2 @@ +async def migrate(): + pass diff --git a/lnbits/extensions/lndhub/templates/lndhub/_instructions.html b/lnbits/extensions/lndhub/templates/lndhub/_instructions.html new file mode 100644 index 00000000..4db79aba --- /dev/null +++ b/lnbits/extensions/lndhub/templates/lndhub/_instructions.html @@ -0,0 +1,35 @@ + + + + To access an LNbits wallet from a mobile phone, +
    +
  1. + Install either Zeus or + BlueWallet; +
  2. +
  3. + Go to Add a wallet / Import wallet on BlueWallet or + Settings / Add a new node on Zeus. +
  4. +
  5. Select the desired wallet on this page;
  6. +
  7. Scan one of the two QR codes from the mobile wallet.
  8. +
+
    +
  • + Invoice URLs mean the mobile wallet will only have the + authorization to read your payments and invoices and generate new + invoices. +
  • +
  • + Admin URLs mean the mobile wallet will be able to pay + invoices.. +
  • +
+
+
+
diff --git a/lnbits/extensions/lndhub/templates/lndhub/_lndhub.html b/lnbits/extensions/lndhub/templates/lndhub/_lndhub.html new file mode 100644 index 00000000..a15cab8f --- /dev/null +++ b/lnbits/extensions/lndhub/templates/lndhub/_lndhub.html @@ -0,0 +1,19 @@ + + + +

+ LndHub is a protocol invented by + BlueWallet 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. +

+

+ 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 Zeus and + BlueWallet. +

+
+
+
diff --git a/lnbits/extensions/lndhub/templates/lndhub/index.html b/lnbits/extensions/lndhub/templates/lndhub/index.html new file mode 100644 index 00000000..2c282e59 --- /dev/null +++ b/lnbits/extensions/lndhub/templates/lndhub/index.html @@ -0,0 +1,94 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} {% raw %} +
+
+
+ + + +
+ Copy LndHub {{type}} URL +
+
+
+
+ + + + + + + +
+ + {% endraw %} + +
+ + +
+ {{SITE_TITLE}} LndHub extension +
+
+ + + + {% include "lndhub/_instructions.html" %} + + {% include "lndhub/_lndhub.html" %} + + +
+
+
+ +{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/lnbits/extensions/lndhub/utils.py b/lnbits/extensions/lndhub/utils.py new file mode 100644 index 00000000..3db6317a --- /dev/null +++ b/lnbits/extensions/lndhub/utils.py @@ -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": "", + } diff --git a/lnbits/extensions/lndhub/views.py b/lnbits/extensions/lndhub/views.py new file mode 100644 index 00000000..4b015c09 --- /dev/null +++ b/lnbits/extensions/lndhub/views.py @@ -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()} + ) diff --git a/lnbits/extensions/lndhub/views_api.py b/lnbits/extensions/lndhub/views_api.py new file mode 100644 index 00000000..2df4141d --- /dev/null +++ b/lnbits/extensions/lndhub/views_api.py @@ -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 diff --git a/lnbits/extensions/lnticket/__init__.py b/lnbits/extensions/lnticket/__init__.py index 9394a1c9..c1f382eb 100644 --- a/lnbits/extensions/lnticket/__init__.py +++ b/lnbits/extensions/lnticket/__init__.py @@ -14,12 +14,9 @@ lnticket_ext: APIRouter = APIRouter( # "lnticket", __name__, static_folder="static", template_folder="templates" ) + def lnticket_renderer(): - return template_renderer( - [ - "lnbits/extensions/lnticket/templates", - ] - ) + return template_renderer(["lnbits/extensions/lnticket/templates"]) from .views_api import * # noqa @@ -30,4 +27,3 @@ from .tasks import wait_for_paid_invoices def lnticket_start(): loop = asyncio.get_event_loop() loop.create_task(catch_everything_and_restart(wait_for_paid_invoices)) - diff --git a/lnbits/extensions/lnticket/crud.py b/lnbits/extensions/lnticket/crud.py index e391abb5..8fe17090 100644 --- a/lnbits/extensions/lnticket/crud.py +++ b/lnbits/extensions/lnticket/crud.py @@ -9,16 +9,23 @@ import httpx async def create_ticket( - payment_hash: str, - wallet: str, - data: CreateTicketData + payment_hash: str, wallet: str, data: CreateTicketData ) -> Tickets: await db.execute( """ INSERT INTO lnticket.ticket (id, form, email, ltext, name, wallet, sats, paid) 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) @@ -99,17 +106,23 @@ async def delete_ticket(ticket_id: str) -> None: # FORMS -async def create_form( - data: CreateFormData, - wallet: Wallet, -) -> Forms: +async def create_form(data: CreateFormData, wallet: Wallet) -> Forms: form_id = urlsafe_short_hash() await db.execute( """ INSERT INTO lnticket.form2 (id, wallet, name, webhook, description, flatrate, amount, amountmade) 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) diff --git a/lnbits/extensions/lnticket/migrations.py b/lnbits/extensions/lnticket/migrations.py index abcd5c7f..37fbdc86 100644 --- a/lnbits/extensions/lnticket/migrations.py +++ b/lnbits/extensions/lnticket/migrations.py @@ -79,16 +79,7 @@ async def m002_changed(db): ) 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") @@ -134,15 +125,7 @@ async def m003_changed(db): ) 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") @@ -189,14 +172,6 @@ async def m004_changed(db): ) 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") diff --git a/lnbits/extensions/lnticket/models.py b/lnbits/extensions/lnticket/models.py index 6fac90c2..50ffc1e1 100644 --- a/lnbits/extensions/lnticket/models.py +++ b/lnbits/extensions/lnticket/models.py @@ -2,6 +2,7 @@ from typing import Optional from fastapi.param_functions import Query from pydantic import BaseModel + class CreateFormData(BaseModel): name: str = Query(...) webhook: str = Query(None) @@ -9,6 +10,7 @@ class CreateFormData(BaseModel): amount: int = Query(..., ge=0) flatrate: int = Query(...) + class CreateTicketData(BaseModel): form: str = Query(...) name: str = Query(...) @@ -16,6 +18,7 @@ class CreateTicketData(BaseModel): ltext: str = Query(...) sats: int = Query(..., ge=0) + class Forms(BaseModel): id: str wallet: str diff --git a/lnbits/extensions/lnticket/views.py b/lnbits/extensions/lnticket/views.py index f7f50e37..85be27b5 100644 --- a/lnbits/extensions/lnticket/views.py +++ b/lnbits/extensions/lnticket/views.py @@ -14,13 +14,16 @@ from fastapi.templating import Jinja2Templates templates = Jinja2Templates(directory="templates") + @lnticket_ext.get("/", response_class=HTMLResponse) # not needed as we automatically get the user with the given ID # If no user with this ID is found, an error is raised # @validate_uuids(["usr"], required=True) # @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}") @@ -28,8 +31,7 @@ async def display(request: Request, form_id): form = await get_form(form_id) if not form: raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail="LNTicket does not exist." + status_code=HTTPStatus.NOT_FOUND, detail="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( "lnticket/display.html", - {"request": request, - "form_id":form.id, - "form_name":form.name, - "form_desc":form.description, - "form_amount":form.amount, - "form_flatrate":form.flatrate, - "form_wallet":wallet.inkey} + { + "request": request, + "form_id": form.id, + "form_name": form.name, + "form_desc": form.description, + "form_amount": form.amount, + "form_flatrate": form.flatrate, + "form_wallet": wallet.inkey, + }, ) diff --git a/lnbits/extensions/lnticket/views_api.py b/lnbits/extensions/lnticket/views_api.py index 1474518b..e78a4c71 100644 --- a/lnbits/extensions/lnticket/views_api.py +++ b/lnbits/extensions/lnticket/views_api.py @@ -34,7 +34,11 @@ from .crud import ( @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] 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)] + @lnticket_ext.post("/api/v1/forms", status_code=HTTPStatus.CREATED) @lnticket_ext.put("/api/v1/forms/{form_id}") # @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}, # } # ) -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: form = await get_form(form_id) if not form: raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail=f"Form does not exist." + status_code=HTTPStatus.NOT_FOUND, detail=f"Form does not exist." ) # return {"message": "Form does not exist."}, HTTPStatus.NOT_FOUND if form.wallet != wallet.wallet.id: 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 @@ -86,16 +91,12 @@ async def api_form_delete(form_id, wallet: WalletTypeInfo = Depends(get_key_type if not form: raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail=f"Form does not exist." + status_code=HTTPStatus.NOT_FOUND, detail=f"Form does not exist." ) # return {"message": "Form does not exist."}, HTTPStatus.NOT_FOUND if form.wallet != wallet.wallet.id: - raise HTTPException( - status_code=HTTPStatus.FORBIDDEN, - detail=f"Not your form." - ) + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail=f"Not your form.") # return {"message": "Not your form."}, HTTPStatus.FORBIDDEN 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") # @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] 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)] + @lnticket_ext.post("/api/v1/tickets/{form_id}", status_code=HTTPStatus.CREATED) # @api_validate_post_request( # schema={ @@ -131,8 +135,7 @@ async def api_ticket_make_ticket(data: CreateTicketData, form_id): form = await get_form(form_id) if not form: raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail=f"LNTicket does not exist." + status_code=HTTPStatus.NOT_FOUND, detail=f"LNTicket does not exist." ) # 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"}, ) except Exception as e: - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail=str(e) - ) + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e)) # return {"message": str(e)}, HTTPStatus.INTERNAL_SERVER_ERROR ticket = await create_ticket( @@ -158,18 +158,14 @@ async def api_ticket_make_ticket(data: CreateTicketData, form_id): if not ticket: raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail="LNTicket could not be fetched." + status_code=HTTPStatus.NOT_FOUND, detail="LNTicket could not be fetched." ) # return ( # {"message": "LNTicket could not be fetched."}, # HTTPStatus.NOT_FOUND, # ) - return { - "payment_hash": payment_hash, - "payment_request": payment_request - } + return {"payment_hash": payment_hash, "payment_request": payment_request} @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: raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail=f"LNTicket does not exist." + status_code=HTTPStatus.NOT_FOUND, detail=f"LNTicket does not exist." ) # return {"message": "Paywall does not exist."}, HTTPStatus.NOT_FOUND if ticket.wallet != wallet.wallet.id: - raise HTTPException( - status_code=HTTPStatus.FORBIDDEN, - detail="Not your ticket." - ) + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your ticket.") # return {"message": "Not your ticket."}, HTTPStatus.FORBIDDEN await delete_ticket(ticket_id) diff --git a/lnbits/extensions/lnurlp/__init__.py b/lnbits/extensions/lnurlp/__init__.py index af12d57f..ea8e509a 100644 --- a/lnbits/extensions/lnurlp/__init__.py +++ b/lnbits/extensions/lnurlp/__init__.py @@ -24,12 +24,9 @@ lnurlp_ext: APIRouter = APIRouter( # "lnurlp", __name__, static_folder="static", template_folder="templates" ) + def lnurlp_renderer(): - return template_renderer( - [ - "lnbits/extensions/lnurlp/templates", - ] - ) + return template_renderer(["lnbits/extensions/lnurlp/templates"]) from .views_api import * # noqa @@ -37,13 +34,12 @@ from .views import * # noqa from .tasks import wait_for_paid_invoices from .lnurl import * # noqa + def lnurlp_start(): loop = asyncio.get_event_loop() loop.create_task(catch_everything_and_restart(wait_for_paid_invoices)) - - # from lnbits.tasks import record_async # lnurlp_ext.record(record_async(register_listeners)) diff --git a/lnbits/extensions/lnurlp/crud.py b/lnbits/extensions/lnurlp/crud.py index 892d9e98..4215faf6 100644 --- a/lnbits/extensions/lnurlp/crud.py +++ b/lnbits/extensions/lnurlp/crud.py @@ -5,10 +5,7 @@ from . import db from .models import PayLink, CreatePayLinkData -async def create_pay_link( - data: CreatePayLinkData, - wallet_id: str -) -> PayLink: +async def create_pay_link(data: CreatePayLinkData, wallet_id: str) -> PayLink: returning = "" if db.type == SQLITE else "RETURNING ID" method = db.execute if db.type == SQLITE else db.fetchone diff --git a/lnbits/extensions/lnurlp/lnurl.py b/lnbits/extensions/lnurlp/lnurl.py index ca17646f..f7a615a4 100644 --- a/lnbits/extensions/lnurlp/lnurl.py +++ b/lnbits/extensions/lnurlp/lnurl.py @@ -2,7 +2,12 @@ import hashlib import math from http import HTTPStatus 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.utils.exchange_rates import get_fiat_rate_satoshis @@ -11,13 +16,16 @@ from . import lnurlp_ext 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): link = await increment_pay_link(link_id, served_meta=1) if not link: raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail="Pay link does not exist." + status_code=HTTPStatus.NOT_FOUND, detail="Pay link does not exist." ) 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 -@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): link = await increment_pay_link(link_id, served_pr=1) if not link: raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail="Pay link does not exist." + status_code=HTTPStatus.NOT_FOUND, detail="Pay link does not exist." ) min, max = link.min, link.max rate = await get_fiat_rate_satoshis(link.currency) if link.currency else 1 @@ -54,23 +65,22 @@ async def api_lnurl_callback(request: Request, link_id): min = link.min * 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: return LnurlErrorResponse( - reason=f"Amount {amount_received} is smaller than minimum {min}." - ).dict() + reason=f"Amount {amount_received} is smaller than minimum {min}." + ).dict() elif amount_received > max: return LnurlErrorResponse( - reason=f"Amount {amount_received} is greater than maximum {max}." - ).dict() - + reason=f"Amount {amount_received} is greater than maximum {max}." + ).dict() comment = request.query_params.get("comment") if len(comment or "") > link.comment_chars: return LnurlErrorResponse( - reason=f"Got a comment with {len(comment)} characters, but can only accept {link.comment_chars}" - ).dict() + reason=f"Got a comment with {len(comment)} characters, but can only accept {link.comment_chars}" + ).dict() payment_hash, payment_request = await create_invoice( wallet_id=link.wallet, @@ -79,20 +89,20 @@ async def api_lnurl_callback(request: Request, link_id): description_hash=hashlib.sha256( link.lnurlpay_metadata.encode("utf-8") ).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) if success_action: resp = LnurlPayActionResponse( - pr=payment_request, - success_action=success_action, - routes=[], + pr=payment_request, success_action=success_action, routes=[] ) else: - resp = LnurlPayActionResponse( - pr=payment_request, - routes=[], - ) + resp = LnurlPayActionResponse(pr=payment_request, routes=[]) return resp.dict() diff --git a/lnbits/extensions/lnurlp/models.py b/lnbits/extensions/lnurlp/models.py index 4dfef94a..6fc9cc1a 100644 --- a/lnbits/extensions/lnurlp/models.py +++ b/lnbits/extensions/lnurlp/models.py @@ -8,15 +8,17 @@ from lnurl.types import LnurlPayMetadata # type: ignore from sqlite3 import Row from pydantic import BaseModel + class CreatePayLinkData(BaseModel): - description: str - min: int = Query(0.01, ge=0.01) - max: int = Query(0.01, ge=0.01) - currency: str = Query(None) - comment_chars: int = Query(0, ge=0, lt=800) - webhook_url: str = Query(None) - success_text: str = Query(None) - success_url: str = Query(None) + description: str + min: int = Query(0.01, ge=0.01) + max: int = Query(0.01, ge=0.01) + currency: str = Query(None) + comment_chars: int = Query(0, ge=0, lt=800) + webhook_url: str = Query(None) + success_text: str = Query(None) + success_url: str = Query(None) + class PayLink(BaseModel): id: int @@ -37,7 +39,6 @@ class PayLink(BaseModel): data = dict(row) return cls(**data) - def lnurl(self, req: Request) -> str: url = req.url_for("lnurlp.api_lnurl_response", link_id=self.id) return lnurl_encode(url) @@ -58,9 +59,6 @@ class PayLink(BaseModel): "url": urlunparse(url), } elif self.success_text: - return { - "tag": "message", - "message": self.success_text, - } + return {"tag": "message", "message": self.success_text} else: return None diff --git a/lnbits/extensions/lnurlp/tasks.py b/lnbits/extensions/lnurlp/tasks.py index 470fdea9..b632fa13 100644 --- a/lnbits/extensions/lnurlp/tasks.py +++ b/lnbits/extensions/lnurlp/tasks.py @@ -17,6 +17,7 @@ async def wait_for_paid_invoices(): payment = await invoice_queue.get() await on_invoice_paid(payment) + async def on_invoice_paid(payment: Payment) -> None: if "lnurlp" != payment.extra.get("tag"): # not an lnurlp invoice diff --git a/lnbits/extensions/lnurlp/views.py b/lnbits/extensions/lnurlp/views.py index 74cbd87b..d39a5ebf 100644 --- a/lnbits/extensions/lnurlp/views.py +++ b/lnbits/extensions/lnurlp/views.py @@ -14,34 +14,35 @@ from lnbits.core.models import User templates = Jinja2Templates(directory="templates") + @lnurlp_ext.get("/", response_class=HTMLResponse) # @validate_uuids(["usr"], required=True) # @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) -async def display(request: Request,link_id): +async def display(request: Request, link_id): link = await get_pay_link(link_id) if not link: raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail="Pay link does not exist." + status_code=HTTPStatus.NOT_FOUND, detail="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) @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) if not link: raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail="Pay link does not exist." + status_code=HTTPStatus.NOT_FOUND, detail="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) diff --git a/lnbits/extensions/lnurlp/views_api.py b/lnbits/extensions/lnurlp/views_api.py index f895f05b..7558006f 100644 --- a/lnbits/extensions/lnurlp/views_api.py +++ b/lnbits/extensions/lnurlp/views_api.py @@ -23,6 +23,7 @@ from .crud import ( delete_pay_link, ) + @lnurlp_ext.get("/api/v1/currencies") async def api_list_currencies_available(): return list(currencies.keys()) @@ -30,14 +31,21 @@ async def api_list_currencies_available(): @lnurlp_ext.get("/api/v1/links", status_code=HTTPStatus.OK) # @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] if all_wallets: wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids 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 [ # {**link.dict(), "lnurl": link.lnurl} # 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) # @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) if not link: raise HTTPException( - detail="Pay link does not exist.", - status_code=HTTPStatus.NOT_FOUND + detail="Pay link does not exist.", status_code=HTTPStatus.NOT_FOUND ) # return {"message": "Pay link does not exist."}, HTTPStatus.NOT_FOUND if link.wallet != wallet.wallet.id: raise HTTPException( - detail="Not your pay link.", - status_code=HTTPStatus.FORBIDDEN + detail="Not your pay link.", status_code=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.put("/api/v1/links/{link_id}", status_code=HTTPStatus.OK) # @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: raise HTTPException( - detail="Min is greater than max.", - status_code=HTTPStatus.BAD_REQUEST + detail="Min is greater than max.", status_code=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 ): raise HTTPException( - detail="Must use full satoshis.", - status_code=HTTPStatus.BAD_REQUEST + detail="Must use full satoshis.", status_code=HTTPStatus.BAD_REQUEST ) # return {"message": "Must use full satoshis."}, HTTPStatus.BAD_REQUEST if "success_url" in data and data.success_url[:8] != "https://": raise HTTPException( detail="Success URL must be secure https://...", - status_code=HTTPStatus.BAD_REQUEST + status_code=HTTPStatus.BAD_REQUEST, ) # return ( # {"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: raise HTTPException( - detail="Pay link does not exist.", - status_code=HTTPStatus.NOT_FOUND + detail="Pay link does not exist.", status_code=HTTPStatus.NOT_FOUND ) # return ( # {"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: raise HTTPException( - detail="Not your pay link.", - status_code=HTTPStatus.FORBIDDEN + detail="Not your pay link.", status_code=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: link = await create_pay_link(data, wallet_id=wallet.wallet.id) print("LINK", link) @@ -142,15 +150,13 @@ async def api_link_delete(link_id, wallet: WalletTypeInfo = Depends(get_key_type if not link: raise HTTPException( - detail="Pay link does not exist.", - status_code=HTTPStatus.NOT_FOUND + detail="Pay link does not exist.", status_code=HTTPStatus.NOT_FOUND ) # return {"message": "Pay link does not exist."}, HTTPStatus.NOT_FOUND if link.wallet != wallet.wallet.id: raise HTTPException( - detail="Not your pay link.", - status_code=HTTPStatus.FORBIDDEN + detail="Not your pay link.", status_code=HTTPStatus.FORBIDDEN ) # return {"message": "Not your pay link."}, HTTPStatus.FORBIDDEN diff --git a/lnbits/extensions/lnurlpos/README.md b/lnbits/extensions/lnurlpos/README.md new file mode 100644 index 00000000..e7713055 --- /dev/null +++ b/lnbits/extensions/lnurlpos/README.md @@ -0,0 +1,3 @@ +# LNURLPoS + +For offline LNURL PoS devices diff --git a/lnbits/extensions/lnurlpos/__init__.py b/lnbits/extensions/lnurlpos/__init__.py new file mode 100644 index 00000000..4c86c827 --- /dev/null +++ b/lnbits/extensions/lnurlpos/__init__.py @@ -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 diff --git a/lnbits/extensions/lnurlpos/config.json b/lnbits/extensions/lnurlpos/config.json new file mode 100644 index 00000000..2688e5a5 --- /dev/null +++ b/lnbits/extensions/lnurlpos/config.json @@ -0,0 +1,6 @@ +{ + "name": "LNURLPoS", + "short_description": "For offline LNURL PoS systems", + "icon": "point_of_sale", + "contributors": ["arcbtc"] +} diff --git a/lnbits/extensions/lnurlpos/crud.py b/lnbits/extensions/lnurlpos/crud.py new file mode 100644 index 00000000..5a85fa33 --- /dev/null +++ b/lnbits/extensions/lnurlpos/crud.py @@ -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 diff --git a/lnbits/extensions/lnurlpos/lnurl.py b/lnbits/extensions/lnurlpos/lnurl.py new file mode 100644 index 00000000..2cfbc4ba --- /dev/null +++ b/lnbits/extensions/lnurlpos/lnurl.py @@ -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) diff --git a/lnbits/extensions/lnurlpos/migrations.py b/lnbits/extensions/lnurlpos/migrations.py new file mode 100644 index 00000000..011cb4a3 --- /dev/null +++ b/lnbits/extensions/lnurlpos/migrations.py @@ -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} + ); + """ + ) diff --git a/lnbits/extensions/lnurlpos/models.py b/lnbits/extensions/lnurlpos/models.py new file mode 100644 index 00000000..b6924593 --- /dev/null +++ b/lnbits/extensions/lnurlpos/models.py @@ -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)) diff --git a/lnbits/extensions/lnurlpos/templates/lnurlpos/_api_docs.html b/lnbits/extensions/lnurlpos/templates/lnurlpos/_api_docs.html new file mode 100644 index 00000000..071d6d6c --- /dev/null +++ b/lnbits/extensions/lnurlpos/templates/lnurlpos/_api_docs.html @@ -0,0 +1,158 @@ + + +

+ Register LNURLPoS devices to recieve payments in your LNbits wallet.
+ Build your own here + https://github.com/arcbtc/LNURLPoS
+ + Created by, Ben Arc +

+
+ + + + + POST /lnurlpos/api/v1/lnurlpos +
Headers
+ {"X-Api-Key": <admin_key>}
+
+ Body (application/json) +
+
+ Returns 200 OK (application/json) +
+ [<lnurlpos_object>, ...] +
Curl example
+ 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 }}" + +
+
+
+ + + + PUT + /lnurlpos/api/v1/lnurlpos/<lnurlpos_id> +
Headers
+ {"X-Api-Key": <admin_key>}
+
+ Body (application/json) +
+
+ Returns 200 OK (application/json) +
+ [<lnurlpos_object>, ...] +
Curl example
+ 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 }}" + +
+
+
+ + + + + GET + /lnurlpos/api/v1/lnurlpos/<lnurlpos_id> +
Headers
+ {"X-Api-Key": <invoice_key>}
+
+ Body (application/json) +
+
+ Returns 200 OK (application/json) +
+ [<lnurlpos_object>, ...] +
Curl example
+ curl -X GET {{ request.url_root + }}api/v1/lnurlpos/<lnurlpos_id> -H "X-Api-Key: {{ + user.wallets[0].inkey }}" + +
+
+
+ + + + GET /lnurlpos/api/v1/lnurlposs +
Headers
+ {"X-Api-Key": <invoice_key>}
+
+ Body (application/json) +
+
+ Returns 200 OK (application/json) +
+ [<lnurlpos_object>, ...] +
Curl example
+ curl -X GET {{ request.url_root }}api/v1/lnurlposs -H "X-Api-Key: + {{ user.wallets[0].inkey }}" + +
+
+
+ + + + DELETE + /lnurlpos/api/v1/lnurlpos/<lnurlpos_id> +
Headers
+ {"X-Api-Key": <admin_key>}
+
Returns 204 NO CONTENT
+ +
Curl example
+ curl -X DELETE {{ request.url_root + }}api/v1/lnurlpos/<lnurlpos_id> -H "X-Api-Key: {{ + user.wallets[0].adminkey }}" + +
+
+
+
+
diff --git a/lnbits/extensions/lnurlpos/templates/lnurlpos/error.html b/lnbits/extensions/lnurlpos/templates/lnurlpos/error.html new file mode 100644 index 00000000..d8e41832 --- /dev/null +++ b/lnbits/extensions/lnurlpos/templates/lnurlpos/error.html @@ -0,0 +1,34 @@ +{% extends "public.html" %} {% block page %} +
+
+ + +
+

LNURL-pay not paid

+
+ + +
+
+
+
+
+ + {% endblock %} {% block scripts %} + + + + {% endblock %} +
diff --git a/lnbits/extensions/lnurlpos/templates/lnurlpos/index.html b/lnbits/extensions/lnurlpos/templates/lnurlpos/index.html new file mode 100644 index 00000000..e6d8cd8f --- /dev/null +++ b/lnbits/extensions/lnurlpos/templates/lnurlpos/index.html @@ -0,0 +1,472 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + + {% raw %} + New LNURLPoS instance + + + + + + +
+
+
lNURLPoS
+
+ +
+ + + + Export to CSV +
+
+ + + + + {% endraw %} + +
+
+
+ +
+ + +
+ {{SITE_TITLE}} LNURLPoS Extension +
+
+ + + {% include "lnurlpos/_api_docs.html" %} + +
+
+ + + +
Copy to LNURLPoS device
+
+ {% raw %} String server = "{{location}}";
+ String posId = "{{settingsDialog.data.id}}";
+ String key = "{{settingsDialog.data.key}}";
+ String currency = "{{settingsDialog.data.currency}}";{% endraw %} +
+
+
+ + + + + + + + + + +
+ Update lnurlpos + Create lnurlpos + Cancel +
+
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + + +{% endblock %} diff --git a/lnbits/extensions/lnurlpos/templates/lnurlpos/paid.html b/lnbits/extensions/lnurlpos/templates/lnurlpos/paid.html new file mode 100644 index 00000000..c185ecce --- /dev/null +++ b/lnbits/extensions/lnurlpos/templates/lnurlpos/paid.html @@ -0,0 +1,27 @@ +{% extends "public.html" %} {% block page %} +
+
+ + +
+

{{ pin }}

+
+
+
+
+
+ + {% endblock %} {% block scripts %} + + + + {% endblock %} +
diff --git a/lnbits/extensions/lnurlpos/views.py b/lnbits/extensions/lnurlpos/views.py new file mode 100644 index 00000000..68e4ef06 --- /dev/null +++ b/lnbits/extensions/lnurlpos/views.py @@ -0,0 +1,60 @@ +from http import HTTPStatus +import httpx +from collections import defaultdict +from lnbits.decorators import check_user_exists + +from .crud import get_lnurlpos, get_lnurlpospayment +from functools import wraps +from lnbits.core.crud import get_standalone_payment +import hashlib +from lnbits.core.services import check_invoice_status +from lnbits.core.crud import update_payment_status +from fastapi import FastAPI, Request +from fastapi.templating import Jinja2Templates +from starlette.exceptions import HTTPException +from starlette.responses import HTMLResponse +from fastapi.params import Depends +from fastapi.param_functions import Query +import random + +from datetime import datetime +from http import HTTPStatus +from . import lnurlpos_ext, lnurlpos_renderer +from lnbits.core.models import User, Payment + +templates = Jinja2Templates(directory="templates") + + +@lnurlpos_ext.get("/") +async def index(request: Request, user: User = Depends(check_user_exists)): + return lnurlpos_renderer().TemplateResponse( + "lnurlpos/index.html", {"request": request, "user": user.dict()} + ) + + +@lnurlpos_ext.get("/{paymentid}") +async def displaypin(request: Request, paymentid: str = Query(None)): + lnurlpospayment = await get_lnurlpospayment(paymentid) + if not lnurlpospayment: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="No lmurlpos payment" + ) + pos = await get_lnurlpos(lnurlpospayment.posid) + if not pos: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="lnurlpos not found." + ) + + status = await check_invoice_status(pos.wallet, lnurlpospayment.payhash) + + is_paid = not status.pending + if not is_paid: + return lnurlpos_renderer().TemplateResponse( + "lnurlpos/error.html", + {"request": request, "pin": "filler", "not_paid": True}, + ) + + await update_payment_status(checking_id=lnurlpospayment.payhash, pending=True) + return lnurlpos_renderer().TemplateResponse( + "lnurlpos/paid.html", {"request": request, "pin": lnurlpospayment.pin} + ) diff --git a/lnbits/extensions/lnurlpos/views_api.py b/lnbits/extensions/lnurlpos/views_api.py new file mode 100644 index 00000000..21c8dd12 --- /dev/null +++ b/lnbits/extensions/lnurlpos/views_api.py @@ -0,0 +1,94 @@ +import hashlib +from fastapi import FastAPI, Request +from fastapi.params import Depends +from http import HTTPStatus +from fastapi.templating import Jinja2Templates +from starlette.exceptions import HTTPException +from starlette.responses import HTMLResponse +from fastapi.params import Depends +from fastapi.param_functions import Query +from lnbits.decorators import check_user_exists, WalletTypeInfo, get_key_type +from lnbits.core.crud import get_user +from lnbits.core.models import User, Payment +from . import lnurlpos_ext + +from lnbits.extensions.lnurlpos import lnurlpos_ext +from .crud import ( + create_lnurlpos, + update_lnurlpos, + get_lnurlpos, + get_lnurlposs, + delete_lnurlpos, +) +from lnbits.utils.exchange_rates import currencies +from .models import createLnurlpos + + +@lnurlpos_ext.get("/api/v1/currencies") +async def api_list_currencies_available(): + return list(currencies.keys()) + + +#######################lnurlpos########################## + + +@lnurlpos_ext.post("/api/v1/lnurlpos") +@lnurlpos_ext.put("/api/v1/lnurlpos/{lnurlpos_id}") +async def api_lnurlpos_create_or_update( + request: Request, + data: createLnurlpos, + wallet: WalletTypeInfo = Depends(get_key_type), + lnurlpos_id: str = Query(None), +): + if not lnurlpos_id: + lnurlpos = await create_lnurlpos(data) + print(lnurlpos.dict()) + return lnurlpos.dict() + else: + lnurlpos = await update_lnurlpos(data, lnurlpos_id=lnurlpos_id) + return lnurlpos.dict() + + +@lnurlpos_ext.get("/api/v1/lnurlpos") +async def api_lnurlposs_retrieve( + request: Request, wallet: WalletTypeInfo = Depends(get_key_type) +): + wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids + try: + return [{**lnurlpos.dict()} for lnurlpos in await get_lnurlposs(wallet_ids)] + except: + return "" + + +@lnurlpos_ext.get("/api/v1/lnurlpos/{lnurlpos_id}") +async def api_lnurlpos_retrieve( + request: Request, + wallet: WalletTypeInfo = Depends(get_key_type), + lnurlpos_id: str = Query(None), +): + lnurlpos = await get_lnurlpos(lnurlpos_id) + if not lnurlpos: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="lnurlpos does not exist" + ) + if not lnurlpos.lnurl_toggle: + return {**lnurlpos.dict()} + return {**lnurlpos.dict(), **{"lnurl": lnurlpos.lnurl(request=request)}} + + +@lnurlpos_ext.delete("/api/v1/lnurlpos/{lnurlpos_id}") +async def api_lnurlpos_delete( + request: Request, + wallet: WalletTypeInfo = Depends(get_key_type), + lnurlpos_id: str = Query(None), +): + lnurlpos = await get_lnurlpos(lnurlpos_id) + + if not lnurlpos: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Wallet link does not exist." + ) + + await delete_lnurlpos(lnurlpos_id) + + return "", HTTPStatus.NO_CONTENT diff --git a/lnbits/extensions/ngrok/README.md b/lnbits/extensions/ngrok/README.md new file mode 100644 index 00000000..626c788f --- /dev/null +++ b/lnbits/extensions/ngrok/README.md @@ -0,0 +1,24 @@ +

Ngrok

+

Serve lnbits over https for free using ngrok

+ + + +

How it works

+ +When enabled, ngrok creates a tunnel to ngrok.io with https support and tells you the https web address where you can access your lnbits instance. If you are not the first user to enable it, it doesn't create a new one, it just tells you the existing one. Useful for creating/managing/using lnurls, which must be served either via https or via tor. Note that if you restart your device, your device will generate a new url. If anyone is using your old one for wallets, lnurls, etc., whatever they are doing will stop working. + +

Installation

+ +Check the Extensions page on your instance of lnbits. If you have copy of lnbits with ngrok as one of the built in extensions, click Enable -- that's the only thing you need to do to install it. + +If your copy of lnbits does not have ngrok as one of the built in extensions, stop lnbits, create go into your lnbits folder, and run this command: ./venv/bin/pip install pyngrok. Then go into the lnbits subdirectory and the extensions subdirectory within that. (So lnbits > lnbits > extensions.) Create a new subdirectory in there called freetunnel, download this repository as a zip file, and unzip it in the freetunnel directory. If your unzipper creates a new "freetunnel" subdirectory, take everything out of there and put it in the freetunnel directory you created. Then go back to the top level lnbits directory and run these commands: + +``` +./venv/bin/quart assets +./venv/bin/quart migrate +./venv/bin/hypercorn -k trio --bind 0.0.0.0:5000 'lnbits.app:create_app()' +``` + +

Optional: set up an ngrok.com account

+ +The default setup makes a tunnel on a random subdomain, and the session times out after 24h or a certain bandwith limit. You can set up an account at ngrok.com; a free plan removes the timeout, and a paid plan lets you choose a custom subdomain (or even use your own domain). For this, get an auth token from ngrok.com, and then set it up as `NGROK_AUTHTOKEN` environment variable on your `.env` file e.g., if your auth token is xxxx, add a line NGROK_AUTHTOKEN=xxxx. diff --git a/lnbits/extensions/ngrok/__init__.py b/lnbits/extensions/ngrok/__init__.py new file mode 100644 index 00000000..a60414a3 --- /dev/null +++ b/lnbits/extensions/ngrok/__init__.py @@ -0,0 +1,18 @@ +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_ngrok") + +ngrok_ext: APIRouter = APIRouter(prefix="/ngrok", tags=["ngrok"]) + + +def ngrok_renderer(): + return template_renderer(["lnbits/extensions/ngrok/templates"]) + + +from .views import * diff --git a/lnbits/extensions/ngrok/config.json b/lnbits/extensions/ngrok/config.json new file mode 100644 index 00000000..58e9ff8e --- /dev/null +++ b/lnbits/extensions/ngrok/config.json @@ -0,0 +1,6 @@ +{ + "name": "Ngrok", + "short_description": "Serve lnbits over https for free using ngrok", + "icon": "trip_origin", + "contributors": ["supertestnet"] +} diff --git a/lnbits/extensions/ngrok/migrations.py b/lnbits/extensions/ngrok/migrations.py new file mode 100644 index 00000000..f9b8b37d --- /dev/null +++ b/lnbits/extensions/ngrok/migrations.py @@ -0,0 +1,11 @@ +# async def m001_initial(db): + +# await db.execute( +# """ +# CREATE TABLE example.example ( +# id TEXT PRIMARY KEY, +# wallet TEXT NOT NULL, +# time TIMESTAMP NOT NULL DEFAULT """ + db.timestamp_now + """ +# ); +# """ +# ) diff --git a/lnbits/extensions/ngrok/templates/ngrok/index.html b/lnbits/extensions/ngrok/templates/ngrok/index.html new file mode 100644 index 00000000..3af4fa44 --- /dev/null +++ b/lnbits/extensions/ngrok/templates/ngrok/index.html @@ -0,0 +1,53 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} + +
+
+ + +
+ Access this lnbits instance at the following url +
+ +

{{ ngrok }}

+
+
+
+ +
+ + +
Ngrok extension
+
+ + + +

+ Note that if you restart your device, your device will generate a + new url. If anyone is using your old one for wallets, lnurls, + etc., whatever they are doing will stop working. +

+ Created by + Supertestnet. +
+
+
+
+
+
+ +{% endblock %}{% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/lnbits/extensions/ngrok/views.py b/lnbits/extensions/ngrok/views.py new file mode 100644 index 00000000..8b3fe0dd --- /dev/null +++ b/lnbits/extensions/ngrok/views.py @@ -0,0 +1,44 @@ +from http import HTTPStatus + +from lnbits.decorators import check_user_exists + +from . import ngrok_ext, ngrok_renderer +from fastapi import FastAPI, 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 os import getenv +from pyngrok import conf, ngrok + +templates = Jinja2Templates(directory="templates") + + +def log_event_callback(log): + string = str(log) + print(string) + string2 = string[string.find('url="https') : string.find('url="https') + 80] + if string2: + string3 = string2 + string4 = string3[4:] + global string5 + string5 = string4.replace('"', "") + + +conf.get_default().log_event_callback = log_event_callback + +ngrok_authtoken = getenv("NGROK_AUTHTOKEN") +if ngrok_authtoken is not None: + ngrok.set_auth_token(ngrok_authtoken) + +port = getenv("PORT") +ngrok_tunnel = ngrok.connect(port) + + +@ngrok_ext.get("/") +async def index(request: Request, user: User = Depends(check_user_exists)): + return ngrok_renderer().TemplateResponse( + "ngrok/index.html", {"request": request, "ngrok": string5, "user": user.dict()} + ) diff --git a/lnbits/extensions/offlineshop/__init__.py b/lnbits/extensions/offlineshop/__init__.py index d0912a64..0cc3201d 100644 --- a/lnbits/extensions/offlineshop/__init__.py +++ b/lnbits/extensions/offlineshop/__init__.py @@ -29,11 +29,7 @@ offlineshop_ext: APIRouter = APIRouter( def offlineshop_renderer(): - return template_renderer( - [ - "lnbits/extensions/offlineshop/templates", - ] - ) + return template_renderer(["lnbits/extensions/offlineshop/templates"]) from .lnurl import * # noqa diff --git a/lnbits/extensions/offlineshop/crud.py b/lnbits/extensions/offlineshop/crud.py index 2ee931cd..0ecb3d52 100644 --- a/lnbits/extensions/offlineshop/crud.py +++ b/lnbits/extensions/offlineshop/crud.py @@ -51,12 +51,7 @@ async def set_method(shop: int, method: str, wordlist: str = "") -> Optional[Sho async def add_item( - shop: int, - name: str, - description: str, - image: Optional[str], - price: int, - unit: str, + shop: int, name: str, description: str, image: Optional[str], price: int, unit: str ) -> int: result = await db.execute( """ diff --git a/lnbits/extensions/offlineshop/helpers.py b/lnbits/extensions/offlineshop/helpers.py index db2c19cc..6b56cf55 100644 --- a/lnbits/extensions/offlineshop/helpers.py +++ b/lnbits/extensions/offlineshop/helpers.py @@ -8,8 +8,8 @@ def hotp(key, counter, digits=6, digest="sha1"): key = base64.b32decode(key.upper() + "=" * ((8 - len(key)) % 8)) counter = struct.pack(">Q", counter) mac = hmac.new(key, counter, digest).digest() - offset = mac[-1] & 0x0f - binary = struct.unpack(">L", mac[offset : offset + 4])[0] & 0x7fffffff + offset = mac[-1] & 0x0F + binary = struct.unpack(">L", mac[offset : offset + 4])[0] & 0x7FFFFFFF return str(binary)[-digits:].zfill(digits) diff --git a/lnbits/extensions/offlineshop/lnurl.py b/lnbits/extensions/offlineshop/lnurl.py index b04d8512..ea576f1d 100644 --- a/lnbits/extensions/offlineshop/lnurl.py +++ b/lnbits/extensions/offlineshop/lnurl.py @@ -4,7 +4,11 @@ from fastapi.params import Query from starlette.requests import Request from lnbits.helpers import url_for -from lnurl import LnurlPayResponse, LnurlPayActionResponse, LnurlErrorResponse # type: ignore +from lnurl import ( + LnurlPayResponse, + LnurlPayActionResponse, + LnurlErrorResponse, +) # type: ignore from lnbits.core.services import create_invoice from lnbits.utils.exchange_rates import fiat_amount_as_satoshis @@ -15,7 +19,7 @@ from .crud import get_shop, get_item @offlineshop_ext.get("/lnurl/{item_id}", name="offlineshop.lnurl_response") async def lnurl_response(req: Request, item_id: int = Query(...)): - item = await get_item(item_id) # type: Item + item = await get_item(item_id) # type: Item if not item: return {"status": "ERROR", "reason": "Item not found."} @@ -40,7 +44,7 @@ async def lnurl_response(req: Request, item_id: int = Query(...)): @offlineshop_ext.get("/lnurl/cb/{item_id}", name="offlineshop.lnurl_callback") async def lnurl_callback(request: Request, item_id: int): - item = await get_item(item_id) # type: Item + item = await get_item(item_id) # type: Item if not item: return {"status": "ERROR", "reason": "Couldn't find item."} @@ -80,7 +84,9 @@ async def lnurl_callback(request: Request, item_id: int): resp = LnurlPayActionResponse( pr=payment_request, - success_action=item.success_action(shop, payment_hash, request) if shop.method else None, + success_action=item.success_action(shop, payment_hash, request) + if shop.method + else None, routes=[], ) diff --git a/lnbits/extensions/offlineshop/models.py b/lnbits/extensions/offlineshop/models.py index 12616022..06225351 100644 --- a/lnbits/extensions/offlineshop/models.py +++ b/lnbits/extensions/offlineshop/models.py @@ -14,7 +14,7 @@ from .helpers import totp shop_counters: Dict = {} -class ShopCounter(): +class ShopCounter: wordlist: List[str] fulfilled_payments: OrderedDict counter: int @@ -66,7 +66,7 @@ class Shop(BaseModel): def otp_key(self) -> str: return base64.b32encode( hashlib.sha256( - ("otpkey" + str(self.id) + self.wallet).encode("ascii"), + ("otpkey" + str(self.id) + self.wallet).encode("ascii") ).digest() ).decode("ascii") @@ -90,9 +90,7 @@ class Item(BaseModel): unit: str def lnurl(self, req: Request) -> str: - return lnurl_encode( - req.url_for("offlineshop.lnurl_response", item_id=self.id) - ) + return lnurl_encode(req.url_for("offlineshop.lnurl_response", item_id=self.id)) def values(self, req: Request): values = self.dict() @@ -116,8 +114,6 @@ class Item(BaseModel): return None return UrlAction( - url=req.url_for( - "offlineshop.confirmation_code", p=payment_hash - ), + url=req.url_for("offlineshop.confirmation_code", p=payment_hash), description="Open to get the confirmation code for your purchase.", ) diff --git a/lnbits/extensions/offlineshop/views.py b/lnbits/extensions/offlineshop/views.py index e8bea173..748d2024 100644 --- a/lnbits/extensions/offlineshop/views.py +++ b/lnbits/extensions/offlineshop/views.py @@ -18,14 +18,16 @@ from fastapi import Request, HTTPException @offlineshop_ext.get("/", response_class=HTMLResponse) async def index(request: Request, user: User = Depends(check_user_exists)): - return offlineshop_renderer().TemplateResponse("offlineshop/index.html", {"request": request, "user": user.dict()}) + return offlineshop_renderer().TemplateResponse( + "offlineshop/index.html", {"request": request, "user": user.dict()} + ) @offlineshop_ext.get("/print", response_class=HTMLResponse) async def print_qr_codes(request: Request, items: List[int] = None): items = [] for item_id in request.query_params.get("items").split(","): - item = await get_item(item_id) # type: Item + item = await get_item(item_id) # type: Item if item: items.append( { @@ -35,11 +37,16 @@ async def print_qr_codes(request: Request, items: List[int] = None): } ) - return offlineshop_renderer().TemplateResponse("offlineshop/print.html", {"request": request,"items":items}) + return offlineshop_renderer().TemplateResponse( + "offlineshop/print.html", {"request": request, "items": items} + ) -@offlineshop_ext.get("/confirmation/{p}", name="offlineshop.confirmation_code", - response_class=HTMLResponse) +@offlineshop_ext.get( + "/confirmation/{p}", + name="offlineshop.confirmation_code", + response_class=HTMLResponse, +) async def confirmation_code(p: str = Query(...)): style = "" @@ -48,20 +55,20 @@ async def confirmation_code(p: str = Query(...)): if not payment: raise HTTPException( status_code=HTTPStatus.NOT_FOUND, - detail=f"Couldn't find the payment {payment_hash}." + style + detail=f"Couldn't find the payment {payment_hash}." + style, ) if payment.pending: raise HTTPException( status_code=HTTPStatus.PAYMENT_REQUIRED, - detail=f"Payment {payment_hash} wasn't received yet. Please try again in a minute." + style + detail=f"Payment {payment_hash} wasn't received yet. Please try again in a minute." + + style, ) if payment.time + 60 * 15 < time.time(): raise HTTPException( status_code=HTTPStatus.REQUEST_TIMEOUT, - detail="Too much time has passed." + style + detail="Too much time has passed." + style, ) - item = await get_item(payment.extra.get("item")) shop = await get_shop(item.shop) diff --git a/lnbits/extensions/offlineshop/views_api.py b/lnbits/extensions/offlineshop/views_api.py index 58b95935..f3968948 100644 --- a/lnbits/extensions/offlineshop/views_api.py +++ b/lnbits/extensions/offlineshop/views_api.py @@ -33,17 +33,16 @@ async def api_list_currencies_available(): @offlineshop_ext.get("/api/v1/offlineshop") # @api_check_wallet_key("invoice") -async def api_shop_from_wallet(r: Request, wallet: WalletTypeInfo = Depends(get_key_type)): +async def api_shop_from_wallet( + r: Request, wallet: WalletTypeInfo = Depends(get_key_type) +): shop = await get_or_create_shop_by_wallet(wallet.wallet.id) items = await get_items(shop.id) try: return { **shop.dict(), - **{ - "otp_key": shop.otp_key, - "items": [item.values(r) for item in items], - }, + **{"otp_key": shop.otp_key, "items": [item.values(r) for item in items]}, } except LnurlInvalidUrl: raise HTTPException( @@ -63,18 +62,15 @@ class CreateItemsData(BaseModel): @offlineshop_ext.post("/api/v1/offlineshop/items") @offlineshop_ext.put("/api/v1/offlineshop/items/{item_id}") # @api_check_wallet_key("invoice") -async def api_add_or_update_item(data: CreateItemsData, item_id=None, wallet: WalletTypeInfo = Depends(get_key_type)): +async def api_add_or_update_item( + data: CreateItemsData, item_id=None, wallet: WalletTypeInfo = Depends(get_key_type) +): shop = await get_or_create_shop_by_wallet(wallet.wallet.id) if item_id == None: await add_item( - shop.id, - data.name, - data.description, - data.image, - data.price, - data.unit, + shop.id, data.name, data.description, data.image, data.price, data.unit ) - return HTMLResponse(status_code=HTTPStatus.CREATED) + return HTMLResponse(status_code=HTTPStatus.CREATED) else: await update_item( shop.id, @@ -102,7 +98,9 @@ class CreateMethodData(BaseModel): @offlineshop_ext.put("/api/v1/offlineshop/method") # @api_check_wallet_key("invoice") -async def api_set_method(data: CreateMethodData, wallet: WalletTypeInfo = Depends(get_key_type)): +async def api_set_method( + data: CreateMethodData, wallet: WalletTypeInfo = Depends(get_key_type) +): method = data.method wordlist = data.wordlist.split("\n") if data.wordlist else None diff --git a/lnbits/extensions/satsdice/README.md b/lnbits/extensions/satsdice/README.md new file mode 100644 index 00000000..c2419930 --- /dev/null +++ b/lnbits/extensions/satsdice/README.md @@ -0,0 +1,5 @@ +# satsdice + +## Create staic LNURL powered satsdices + +Gambling is dangerous, flip responsibly diff --git a/lnbits/extensions/satsdice/__init__.py b/lnbits/extensions/satsdice/__init__.py new file mode 100644 index 00000000..5a645618 --- /dev/null +++ b/lnbits/extensions/satsdice/__init__.py @@ -0,0 +1,25 @@ +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_satsdice") + +satsdice_ext: APIRouter = APIRouter(prefix="/satsdice", tags=["satsdice"]) + + +def satsdice_renderer(): + return template_renderer(["lnbits/extensions/satsdice/templates"]) + + +from .views_api import * # noqa +from .views import * # noqa +from .lnurl import * # noqa + + +# def satsdice_start(): +# loop = asyncio.get_event_loop() +# loop.create_task(catch_everything_and_restart(wait_for_paid_invoices)) diff --git a/lnbits/extensions/satsdice/config.json b/lnbits/extensions/satsdice/config.json new file mode 100644 index 00000000..e4c2eddb --- /dev/null +++ b/lnbits/extensions/satsdice/config.json @@ -0,0 +1,6 @@ +{ + "name": "Sats Dice", + "short_description": "LNURL Satoshi dice", + "icon": "casino", + "contributors": ["arcbtc"] +} diff --git a/lnbits/extensions/satsdice/crud.py b/lnbits/extensions/satsdice/crud.py new file mode 100644 index 00000000..4fd3c8c2 --- /dev/null +++ b/lnbits/extensions/satsdice/crud.py @@ -0,0 +1,285 @@ +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 ( + satsdiceWithdraw, + HashCheck, + satsdiceLink, + satsdicePayment, + CreateSatsDiceLink, + CreateSatsDicePayment, + CreateSatsDiceWithdraw, +) +from lnbits.helpers import urlsafe_short_hash + + +async def create_satsdice_pay( + data: CreateSatsDiceLink, +) -> satsdiceLink: + satsdice_id = urlsafe_short_hash() + await db.execute( + """ + INSERT INTO satsdice.satsdice_pay ( + id, + wallet, + title, + base_url, + min_bet, + max_bet, + amount, + served_meta, + served_pr, + multiplier, + chance, + haircut, + open_time + ) + VALUES (?, ?, ?, ?, ?, ?, 0, 0, 0, ?, ?, ?, ?) + """, + ( + satsdice_id, + data.wallet_id, + data.title, + data.base_url, + data.min_bet, + data.max_bet, + data.multiplier, + data.chance, + data.haircut, + int(datetime.now().timestamp()), + ), + ) + link = await get_satsdice_pay(satsdice_id) + assert link, "Newly created link couldn't be retrieved" + return link + + +async def get_satsdice_pay(link_id: str) -> Optional[satsdiceLink]: + row = await db.fetchone( + "SELECT * FROM satsdice.satsdice_pay WHERE id = ?", (link_id,) + ) + return satsdiceLink(**row) if row else None + + +async def get_satsdice_pays(wallet_ids: Union[str, List[str]]) -> List[satsdiceLink]: + if isinstance(wallet_ids, str): + wallet_ids = [wallet_ids] + print("wallet_ids") + print(wallet_ids) + print("wallet_ids") + + q = ",".join(["?"] * len(wallet_ids)) + rows = await db.fetchall( + f""" + SELECT * FROM satsdice.satsdice_pay WHERE wallet IN ({q}) + ORDER BY id + """, + (*wallet_ids,), + ) + return [satsdiceLink(**row) for row in rows] + + +async def update_satsdice_pay(link_id: int, **kwargs) -> Optional[satsdiceLink]: + q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) + await db.execute( + f"UPDATE satsdice.satsdice_pay SET {q} WHERE id = ?", + (*kwargs.values(), link_id), + ) + row = await db.fetchone( + "SELECT * FROM satsdice.satsdice_pay WHERE id = ?", (link_id,) + ) + return satsdiceLink(**row) if row else None + + +async def increment_satsdice_pay(link_id: int, **kwargs) -> Optional[satsdiceLink]: + q = ", ".join([f"{field[0]} = {field[0]} + ?" for field in kwargs.items()]) + await db.execute( + f"UPDATE satsdice.satsdice_pay SET {q} WHERE id = ?", + (*kwargs.values(), link_id), + ) + row = await db.fetchone( + "SELECT * FROM satsdice.satsdice_pay WHERE id = ?", (link_id,) + ) + return satsdiceLink(**row) if row else None + + +async def delete_satsdice_pay(link_id: int) -> None: + await db.execute("DELETE FROM satsdice.satsdice_pay WHERE id = ?", (link_id,)) + + +##################SATSDICE PAYMENT LINKS + + +async def create_satsdice_payment(data: CreateSatsDicePayment) -> satsdicePayment: + await db.execute( + """ + INSERT INTO satsdice.satsdice_payment ( + payment_hash, + satsdice_pay, + value, + paid, + lost + ) + VALUES (?, ?, ?, ?, ?) + """, + (data["payment_hash"], data["satsdice_pay"], data["value"], False, False), + ) + payment = await get_satsdice_payment(data["payment_hash"]) + assert payment, "Newly created withdraw couldn't be retrieved" + return payment + + +async def get_satsdice_payment(payment_hash: str) -> Optional[satsdicePayment]: + row = await db.fetchone( + "SELECT * FROM satsdice.satsdice_payment WHERE payment_hash = ?", + (payment_hash,), + ) + return satsdicePayment(**row) if row else None + + +async def update_satsdice_payment( + payment_hash: int, **kwargs +) -> Optional[satsdicePayment]: + q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) + + await db.execute( + f"UPDATE satsdice.satsdice_payment SET {q} WHERE payment_hash = ?", + (bool(*kwargs.values()), payment_hash), + ) + row = await db.fetchone( + "SELECT * FROM satsdice.satsdice_payment WHERE payment_hash = ?", + (payment_hash,), + ) + return satsdicePayment(**row) if row else None + + +##################SATSDICE WITHDRAW LINKS + + +async def create_satsdice_withdraw(data: CreateSatsDiceWithdraw) -> satsdiceWithdraw: + await db.execute( + """ + INSERT INTO satsdice.satsdice_withdraw ( + id, + satsdice_pay, + value, + unique_hash, + k1, + open_time, + used + ) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + data["payment_hash"], + data["satsdice_pay"], + data["value"], + urlsafe_short_hash(), + urlsafe_short_hash(), + int(datetime.now().timestamp()), + data["used"], + ), + ) + withdraw = await get_satsdice_withdraw(data["payment_hash"], 0) + assert withdraw, "Newly created withdraw couldn't be retrieved" + return withdraw + + +async def get_satsdice_withdraw(withdraw_id: str, num=0) -> Optional[satsdiceWithdraw]: + row = await db.fetchone( + "SELECT * FROM satsdice.satsdice_withdraw WHERE id = ?", (withdraw_id,) + ) + if not row: + return None + + withdraw = [] + for item in row: + withdraw.append(item) + withdraw.append(num) + return satsdiceWithdraw(**row) + + +async def get_satsdice_withdraw_by_hash( + unique_hash: str, num=0 +) -> Optional[satsdiceWithdraw]: + row = await db.fetchone( + "SELECT * FROM satsdice.satsdice_withdraw WHERE unique_hash = ?", (unique_hash,) + ) + if not row: + return None + + withdraw = [] + for item in row: + withdraw.append(item) + withdraw.append(num) + return satsdiceWithdraw(**row) + + +async def get_satsdice_withdraws( + wallet_ids: Union[str, List[str]] +) -> List[satsdiceWithdraw]: + if isinstance(wallet_ids, str): + wallet_ids = [wallet_ids] + + q = ",".join(["?"] * len(wallet_ids)) + rows = await db.fetchall( + f"SELECT * FROM satsdice.satsdice_withdraw WHERE wallet IN ({q})", + (*wallet_ids,), + ) + + return [satsdiceWithdraw(**row) for row in rows] + + +async def update_satsdice_withdraw( + withdraw_id: str, **kwargs +) -> Optional[satsdiceWithdraw]: + q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) + await db.execute( + f"UPDATE satsdice.satsdice_withdraw SET {q} WHERE id = ?", + (*kwargs.values(), withdraw_id), + ) + row = await db.fetchone( + "SELECT * FROM satsdice.satsdice_withdraw WHERE id = ?", (withdraw_id,) + ) + return satsdiceWithdraw(**row) if row else None + + +async def delete_satsdice_withdraw(withdraw_id: str) -> None: + await db.execute( + "DELETE FROM satsdice.satsdice_withdraw WHERE id = ?", (withdraw_id,) + ) + + +async def create_withdraw_hash_check(the_hash: str, lnurl_id: str) -> HashCheck: + await db.execute( + """ + INSERT INTO satsdice.hash_checkw ( + id, + lnurl_id + ) + VALUES (?, ?) + """, + (the_hash, lnurl_id), + ) + hashCheck = await get_withdraw_hash_checkw(the_hash, lnurl_id) + return hashCheck + + +async def get_withdraw_hash_checkw(the_hash: str, lnurl_id: str) -> Optional[HashCheck]: + rowid = await db.fetchone( + "SELECT * FROM satsdice.hash_checkw WHERE id = ?", (the_hash,) + ) + rowlnurl = await db.fetchone( + "SELECT * FROM satsdice.hash_checkw WHERE lnurl_id = ?", (lnurl_id,) + ) + if not rowlnurl: + await create_withdraw_hash_check(the_hash, lnurl_id) + return {"lnurl": True, "hash": False} + else: + if not rowid: + await create_withdraw_hash_check(the_hash, lnurl_id) + return {"lnurl": True, "hash": False} + else: + return {"lnurl": True, "hash": True} diff --git a/lnbits/extensions/satsdice/lnurl.py b/lnbits/extensions/satsdice/lnurl.py new file mode 100644 index 00000000..1d46f054 --- /dev/null +++ b/lnbits/extensions/satsdice/lnurl.py @@ -0,0 +1,199 @@ +import shortuuid # type: ignore +import hashlib +import math +import json +from http import HTTPStatus +from datetime import datetime +from lnbits.core.services import pay_invoice, create_invoice +from http import HTTPStatus +from starlette.exceptions import HTTPException +from starlette.responses import HTMLResponse, JSONResponse # type: ignore +from lnbits.utils.exchange_rates import get_fiat_rate_satoshis +from fastapi import FastAPI, Request +from fastapi.params import Depends +from typing import Optional +from fastapi.param_functions import Query +from . import satsdice_ext +from .crud import ( + get_satsdice_withdraw_by_hash, + update_satsdice_withdraw, + get_satsdice_pay, + create_satsdice_payment, +) +from lnurl import ( + LnurlPayResponse, + LnurlPayActionResponse, + LnurlErrorResponse, +) +from .models import CreateSatsDicePayment + + +##############LNURLP STUFF + + +@satsdice_ext.get( + "/api/v1/lnurlp/{link_id}", + response_class=HTMLResponse, + name="satsdice.lnurlp_response", +) +async def api_lnurlp_response(req: Request, link_id: str = Query(None)): + link = await get_satsdice_pay(link_id) + if not link: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="LNURL-pay not found." + ) + payResponse = { + "tag": "payRequest", + "callback": req.url_for("satsdice.api_lnurlp_callback", link_id=link.id), + "metadata": link.lnurlpay_metadata, + "minSendable": math.ceil(link.min_bet * 1) * 1000, + "maxSendable": round(link.max_bet * 1) * 1000, + } + return json.dumps(payResponse) + + +@satsdice_ext.get( + "/api/v1/lnurlp/cb/{link_id}", + response_class=HTMLResponse, + name="satsdice.api_lnurlp_callback", +) +async def api_lnurlp_callback( + req: Request, + link_id: str = Query(None), + amount: str = Query(None), +): + link = await get_satsdice_pay(link_id) + print(link) + if not link: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="LNURL-pay not found." + ) + + min, max = link.min_bet, link.max_bet + min = link.min_bet * 1000 + max = link.max_bet * 1000 + + amount_received = int(amount or 0) + if amount_received < min: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail=f"Amount {amount_received} is smaller than minimum {min}.", + ) + elif amount_received > max: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail=f"Amount {amount_received} is greater than maximum {max}.", + ) + + payment_hash, payment_request = await create_invoice( + wallet_id=link.wallet, + amount=int(amount_received / 1000), + memo="Satsdice bet", + description_hash=hashlib.sha256( + link.lnurlpay_metadata.encode("utf-8") + ).digest(), + extra={"tag": "satsdice", "link": link.id, "comment": "comment"}, + ) + + success_action = link.success_action(payment_hash=payment_hash, req=req) + + data: CreateSatsDicePayment = { + "satsdice_pay": link.id, + "value": amount_received / 1000, + "payment_hash": payment_hash, + } + + await create_satsdice_payment(data) + payResponse = { + "pr": payment_request, + "successAction": success_action, + "routes": [], + } + print(json.dumps(payResponse)) + + return json.dumps(payResponse) + + +##############LNURLW STUFF + + +@satsdice_ext.get( + "/api/v1/lnurlw/{unique_hash}", + response_class=HTMLResponse, + name="satsdice.lnurlw_response", +) +async def api_lnurlw_response(req: Request, unique_hash: str = Query(None)): + link = await get_satsdice_withdraw_by_hash(unique_hash) + + if not link: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="LNURL-satsdice not found." + ) + if link.used: + raise HTTPException(status_code=HTTPStatus.OK, detail="satsdice is spent.") + url = req.url_for("satsdice.api_lnurlw_callback", unique_hash=link.unique_hash) + withdrawResponse = { + "tag": "withdrawRequest", + "callback": url, + "k1": link.k1, + "minWithdrawable": link.value * 1000, + "maxWithdrawable": link.value * 1000, + "defaultDescription": "Satsdice winnings!", + } + return json.dumps(withdrawResponse) + + +# CALLBACK + + +@satsdice_ext.get( + "/api/v1/lnurlw/cb/{unique_hash}", + response_class=HTMLResponse, + name="satsdice.api_lnurlw_callback", +) +async def api_lnurlw_callback( + req: Request, + unique_hash: str = Query(None), + k1: str = Query(None), + pr: str = Query(None), +): + link = await get_satsdice_withdraw_by_hash(unique_hash) + paylink = await get_satsdice_pay(link.satsdice_pay) + payment_request = pr + now = int(datetime.now().timestamp()) + + if not link: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="LNURL-satsdice not found." + ) + + if link.used: + raise HTTPException(status_code=HTTPStatus.OK, detail="satsdice is spent.") + + if link.k1 != k1: + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail="Bad request..") + + if now < link.open_time: + return {"status": "ERROR", "reason": f"Wait {link.open_time - now} seconds."} + + try: + await update_satsdice_withdraw(link.id, used=1) + + await pay_invoice( + wallet_id=paylink.wallet, + payment_request=payment_request, + max_sat=link.value, + extra={"tag": "withdraw"}, + ) + + except ValueError as e: + await update_satsdice_withdraw(link.id, used=1) + return {"status": "ERROR", "reason": str(e)} + except PermissionError: + await update_satsdice_withdraw(link.id, used=1) + return {"status": "ERROR", "reason": "satsdice link is empty."} + except Exception as e: + await update_satsdice_withdraw(link.id, used=1) + return {"status": "ERROR", "reason": str(e)} + + return {"status": "OK"} diff --git a/lnbits/extensions/satsdice/migrations.py b/lnbits/extensions/satsdice/migrations.py new file mode 100644 index 00000000..61298241 --- /dev/null +++ b/lnbits/extensions/satsdice/migrations.py @@ -0,0 +1,73 @@ +async def m001_initial(db): + """ + Creates an improved satsdice table and migrates the existing data. + """ + await db.execute( + """ + CREATE TABLE satsdice.satsdice_pay ( + id TEXT PRIMARY KEY, + wallet TEXT, + title TEXT, + min_bet INTEGER, + max_bet INTEGER, + amount INTEGER DEFAULT 0, + served_meta INTEGER NOT NULL, + served_pr INTEGER NOT NULL, + multiplier FLOAT, + haircut FLOAT, + chance FLOAT, + base_url TEXT, + open_time INTEGER + ); + """ + ) + + +async def m002_initial(db): + """ + Creates an improved satsdice table and migrates the existing data. + """ + await db.execute( + """ + CREATE TABLE satsdice.satsdice_withdraw ( + id TEXT PRIMARY KEY, + satsdice_pay TEXT, + value INTEGER DEFAULT 1, + unique_hash TEXT UNIQUE, + k1 TEXT, + open_time INTEGER, + used INTEGER DEFAULT 0 + ); + """ + ) + + +async def m003_initial(db): + """ + Creates an improved satsdice table and migrates the existing data. + """ + await db.execute( + """ + CREATE TABLE satsdice.satsdice_payment ( + payment_hash TEXT PRIMARY KEY, + satsdice_pay TEXT, + value INTEGER, + paid BOOL DEFAULT FALSE, + lost BOOL DEFAULT FALSE + ); + """ + ) + + +async def m004_make_hash_check(db): + """ + Creates a hash check table. + """ + await db.execute( + """ + CREATE TABLE satsdice.hash_checkw ( + id TEXT PRIMARY KEY, + lnurl_id TEXT + ); + """ + ) diff --git a/lnbits/extensions/satsdice/models.py b/lnbits/extensions/satsdice/models.py new file mode 100644 index 00000000..11c8da69 --- /dev/null +++ b/lnbits/extensions/satsdice/models.py @@ -0,0 +1,137 @@ +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 satsdiceLink(BaseModel): + id: str + wallet: str + title: str + min_bet: int + max_bet: int + amount: int + served_meta: int + served_pr: int + multiplier: float + haircut: float + chance: float + base_url: str + open_time: int + + def lnurl(self, req: Request) -> str: + return lnurl_encode(req.url_for("satsdice.lnurlp_response", link_id=self.id)) + + @classmethod + def from_row(cls, row: Row) -> "satsdiceLink": + data = dict(row) + return cls(**data) + + @property + def lnurlpay_metadata(self) -> LnurlPayMetadata: + return LnurlPayMetadata( + json.dumps( + [ + [ + "text/plain", + f"{self.title} (Chance: {self.chance}%, Multiplier: {self.multiplier})", + ] + ] + ) + ) + + def success_action(self, payment_hash: str, req: Request) -> Optional[Dict]: + url = req.url_for( + "satsdice.displaywin", link_id=self.id, payment_hash=payment_hash + ) + return {"tag": "url", "description": "Check the attached link", "url": url} + + +class satsdicePayment(BaseModel): + payment_hash: str + satsdice_pay: str + value: int + paid: bool + lost: bool + + +class satsdiceWithdraw(BaseModel): + id: str + satsdice_pay: str + value: int + unique_hash: str + k1: str + open_time: int + used: int + + def lnurl(self, req: Request) -> Lnurl: + return lnurl_encode( + req.url_for("satsdice.lnurlw_response", unique_hash=self.unique_hash) + ) + + @property + def is_spent(self) -> bool: + return self.used >= 1 + + @property + def lnurl_response(self, req: Request) -> LnurlWithdrawResponse: + url = req.url_for("satsdice.api_lnurlw_callback", unique_hash=self.unique_hash) + withdrawResponse = { + "tag": "withdrawRequest", + "callback": url, + "k1": self.k1, + "minWithdrawable": self.value * 1000, + "maxWithdrawable": self.value * 1000, + "defaultDescription": "Satsdice winnings!", + } + return withdrawResponse + + +class HashCheck(BaseModel): + id: str + lnurl_id: str + + @classmethod + def from_row(cls, row: Row) -> "Hash": + return cls(**dict(row)) + + +class CreateSatsDiceLink(BaseModel): + wallet_id: str = Query(None) + title: str = Query(None) + base_url: str = Query(None) + min_bet: str = Query(None) + max_bet: str = Query(None) + multiplier: float = Query(0) + chance: float = Query(0) + haircut: int = Query(0) + + +class CreateSatsDicePayment(BaseModel): + satsdice_pay: str = Query(None) + value: int = Query(0) + payment_hash: str = Query(None) + + +class CreateSatsDiceWithdraw(BaseModel): + payment_hash: str = Query(None) + satsdice_pay: str = Query(None) + value: int = Query(0) + used: int = Query(0) + + +class CreateSatsDiceWithdraws(BaseModel): + title: str = Query(None) + min_satsdiceable: int = Query(0) + max_satsdiceable: int = Query(0) + uses: int = Query(0) + wait_time: str = Query(None) + is_unique: bool = Query(False) diff --git a/lnbits/extensions/satsdice/templates/satsdice/_api_docs.html b/lnbits/extensions/satsdice/templates/satsdice/_api_docs.html new file mode 100644 index 00000000..7d73ae7e --- /dev/null +++ b/lnbits/extensions/satsdice/templates/satsdice/_api_docs.html @@ -0,0 +1,194 @@ + + + + + GET /satsdice/api/v1/links +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ [<satsdice_link_object>, ...] +
Curl example
+ curl -X GET {{ request.url_root }}api/v1/links -H "X-Api-Key: {{ + user.wallets[0].inkey }}" + +
+
+
+ + + + GET + /satsdice/api/v1/links/<satsdice_id> +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 201 CREATED (application/json) +
+ {"lnurl": <string>} +
Curl example
+ curl -X GET {{ request.url_root }}api/v1/links/<satsdice_id> -H + "X-Api-Key: {{ user.wallets[0].inkey }}" + +
+
+
+ + + + POST /satsdice/api/v1/links +
Headers
+ {"X-Api-Key": <admin_key>}
+
Body (application/json)
+ {"title": <string>, "min_satsdiceable": <integer>, + "max_satsdiceable": <integer>, "uses": <integer>, + "wait_time": <integer>, "is_unique": <boolean>} +
+ Returns 201 CREATED (application/json) +
+ {"lnurl": <string>} +
Curl example
+ curl -X POST {{ request.url_root }}api/v1/links -d '{"title": + <string>, "min_satsdiceable": <integer>, + "max_satsdiceable": <integer>, "uses": <integer>, + "wait_time": <integer>, "is_unique": <boolean>}' -H + "Content-type: application/json" -H "X-Api-Key: {{ + user.wallets[0].adminkey }}" + +
+
+
+ + + + PUT + /satsdice/api/v1/links/<satsdice_id> +
Headers
+ {"X-Api-Key": <admin_key>}
+
Body (application/json)
+ {"title": <string>, "min_satsdiceable": <integer>, + "max_satsdiceable": <integer>, "uses": <integer>, + "wait_time": <integer>, "is_unique": <boolean>} +
+ Returns 200 OK (application/json) +
+ {"lnurl": <string>} +
Curl example
+ curl -X PUT {{ request.url_root }}api/v1/links/<satsdice_id> -d + '{"title": <string>, "min_satsdiceable": <integer>, + "max_satsdiceable": <integer>, "uses": <integer>, + "wait_time": <integer>, "is_unique": <boolean>}' -H + "Content-type: application/json" -H "X-Api-Key: {{ + user.wallets[0].adminkey }}" + +
+
+
+ + + + DELETE + /satsdice/api/v1/links/<satsdice_id> +
Headers
+ {"X-Api-Key": <admin_key>}
+
Returns 204 NO CONTENT
+ +
Curl example
+ curl -X DELETE {{ request.url_root }}api/v1/links/<satsdice_id> + -H "X-Api-Key: {{ user.wallets[0].adminkey }}" + +
+
+
+ + + + GET + /satsdice/api/v1/links/<the_hash>/<lnurl_id> +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 201 CREATED (application/json) +
+ {"status": <bool>} +
Curl example
+ curl -X GET {{ request.url_root + }}api/v1/links/<the_hash>/<lnurl_id> -H "X-Api-Key: {{ + user.wallets[0].inkey }}" + +
+
+
+ + + + GET + /satsdice/img/<lnurl_id> +
Curl example
+ curl -X GET {{ request.url_root }}/satsdice/img/<lnurl_id>" + +
+
+
+
diff --git a/lnbits/extensions/satsdice/templates/satsdice/_lnurl.html b/lnbits/extensions/satsdice/templates/satsdice/_lnurl.html new file mode 100644 index 00000000..20b67cab --- /dev/null +++ b/lnbits/extensions/satsdice/templates/satsdice/_lnurl.html @@ -0,0 +1,29 @@ + + + +

+ WARNING: LNURL must be used over https or TOR
+ LNURL is a range of lightning-network standards that allow us to use + lightning-network differently. An LNURL satsdice is the permission for + someone to pull a certain amount of funds from a lightning wallet. In + this extension time is also added - an amount can be satsdice over a + period of time. A typical use case for an LNURL satsdice is a faucet, + although it is a very powerful technology, with much further reaching + implications. For example, an LNURL satsdice could be minted to pay for + a subscription service. +

+

+ Exploring LNURL and finding use cases, is really helping inform + lightning protocol development, rather than the protocol dictating how + lightning-network should be engaged with. +

+ Check + Awesome LNURL + for further information. +
+
+
diff --git a/lnbits/extensions/satsdice/templates/satsdice/display.html b/lnbits/extensions/satsdice/templates/satsdice/display.html new file mode 100644 index 00000000..d4238e30 --- /dev/null +++ b/lnbits/extensions/satsdice/templates/satsdice/display.html @@ -0,0 +1,63 @@ +{% extends "public.html" %} {% block page %} +
+
+ + + +
+ Copy Satsdice LNURL +
+
+
+
+
+ + +
+ Chance of winning: {% raw %}{{ chance }}{% endraw %}, Amount + multiplier: {{ multiplier }} +
+

+ Use a LNURL compatible bitcoin wallet to play the satsdice. +

+
+ + + {% include "satsdice/_lnurl.html" %} + +
+
+
+{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/lnbits/extensions/satsdice/templates/satsdice/displaywin.html b/lnbits/extensions/satsdice/templates/satsdice/displaywin.html new file mode 100644 index 00000000..aa4f1375 --- /dev/null +++ b/lnbits/extensions/satsdice/templates/satsdice/displaywin.html @@ -0,0 +1,56 @@ +{% extends "public.html" %} {% block page %} +
+
+ + + +
+ Copy winnings LNURL +
+
+
+
+
+ + +
+ Congrats! You have won {{ value }}sats (you must claim the sats now) +
+

+ Use a LNURL compatible bitcoin wallet to play the satsdice. +

+
+ + + {% include "satsdice/_lnurl.html" %} + +
+
+
+{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/lnbits/extensions/satsdice/templates/satsdice/error.html b/lnbits/extensions/satsdice/templates/satsdice/error.html new file mode 100644 index 00000000..1c8fc618 --- /dev/null +++ b/lnbits/extensions/satsdice/templates/satsdice/error.html @@ -0,0 +1,48 @@ +{% extends "public.html" %} {% from "macros.jinja" import window_vars with +context %}{% block page %} +
+
+ + +
+ {% if lost %} +
+ You lost. Play again? +
+ {% endif %} {% if paid %} +
+ Winnings spent. Play again? +
+ {% endif %} +
+ +
+
+
+
+
+
+{% endblock %} {% block scripts %}{{ window_vars(user) }} + +{% endblock %} diff --git a/lnbits/extensions/satsdice/templates/satsdice/index.html b/lnbits/extensions/satsdice/templates/satsdice/index.html new file mode 100644 index 00000000..a5ec243d --- /dev/null +++ b/lnbits/extensions/satsdice/templates/satsdice/index.html @@ -0,0 +1,526 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + + New satsdice + + + + + +
+
+
satsdices
+
+
+ + {% raw %} + + + {% endraw %} + +
+
+
+ +
+ + +
+ {{SITE_TITLE}} Sats Dice extension +
+
+ + + + {% include "satsdice/_api_docs.html" %} + + {% include "satsdice/_lnurl.html" %} + + +
+
+ + + + + + + {% raw %} + + +
+
+ +
+
+ +
+
+ + +
+ + Multipler: x{{ multiValue }}, Chance of winning: {{ chanceValueCalc + | percent }} + + + +
+ +
+ Update flip link + Create satsdice + Cancel +
+
+
+
+ + + + + + +

+ ID: {{ qrCodeDialog.data.id }}
+ Amount: {{ qrCodeDialog.data.amount }}
+ {{ qrCodeDialog.data.currency }} price: {{ + fiatRates[qrCodeDialog.data.currency] ? + fiatRates[qrCodeDialog.data.currency] + ' sat' : 'Loading...' }}
+ Accepts comments: {{ qrCodeDialog.data.comments }}
+ Dispatches webhook to: {{ qrCodeDialog.data.webhook + }}
+ On success: {{ qrCodeDialog.data.success }}
+

+ {% endraw %} +
+ Copy Satsdice LNURL + Copy shareable link + + Launch shareable link + Print Satsdice + Close +
+
+
+
+ +{% endblock %} {% block scripts %} {{ window_vars(user) }} + + +{% endblock %} diff --git a/lnbits/extensions/satsdice/views.py b/lnbits/extensions/satsdice/views.py new file mode 100644 index 00000000..53c1b420 --- /dev/null +++ b/lnbits/extensions/satsdice/views.py @@ -0,0 +1,175 @@ +from datetime import datetime +from http import HTTPStatus +from lnbits.decorators import check_user_exists, WalletTypeInfo, get_key_type +from . import satsdice_ext, satsdice_renderer +from .crud import ( + get_satsdice_pay, + update_satsdice_payment, + get_satsdice_payment, + create_satsdice_withdraw, + get_satsdice_withdraw, +) +from lnbits.core.crud import ( + get_payments, + get_standalone_payment, + delete_expired_invoices, + get_balance_checks, +) +from lnbits.core.services import check_invoice_status +from fastapi import FastAPI, 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, Payment +from fastapi.params import Depends +from fastapi.param_functions import Query +import random +from .models import CreateSatsDiceWithdraw + +templates = Jinja2Templates(directory="templates") + + +@satsdice_ext.get("/") +async def index(request: Request, user: User = Depends(check_user_exists)): + return satsdice_renderer().TemplateResponse( + "satsdice/index.html", {"request": request, "user": user.dict()} + ) + + +@satsdice_ext.get("/{link_id}") +async def display(request: Request, link_id: str = Query(None)): + link = await get_satsdice_pay(link_id) or abort( + HTTPStatus.NOT_FOUND, "satsdice link does not exist." + ) + return satsdice_renderer().TemplateResponse( + "satsdice/display.html", + { + "request": request, + "chance": link.chance, + "multiplier": link.multiplier, + "lnurl": link.lnurl(request), + "unique": True, + }, + ) + + +@satsdice_ext.get("/win/{link_id}/{payment_hash}", name="satsdice.displaywin") +async def displaywin( + request: Request, link_id: str = Query(None), payment_hash: str = Query(None) +): + satsdicelink = await get_satsdice_pay(link_id) or abort( + HTTPStatus.NOT_FOUND, "satsdice link does not exist." + ) + + status = await check_invoice_status( + wallet_id=satsdicelink.wallet, payment_hash=payment_hash + ) + + withdrawLink = await get_satsdice_withdraw(payment_hash) + if withdrawLink: + return satsdice_renderer().TemplateResponse( + "satsdice/displaywin.html", + { + "request": request, + "value": withdrawLink.value, + "chance": satsdicelink.chance, + "multiplier": satsdicelink.multiplier, + "lnurl": withdrawLink.lnurl(request), + "paid": False, + "lost": False, + }, + ) + + payment = await get_standalone_payment(payment_hash) or abort( + HTTPStatus.NOT_FOUND, "satsdice link does not exist." + ) + + if payment.pending == 1: + await check_invoice_status(payment.wallet_id, payment_hash) + payment = await get_standalone_payment(payment_hash) or abort( + HTTPStatus.NOT_FOUND, "satsdice link does not exist." + ) + if payment.pending == 1: + return satsdice_renderer().TemplateResponse( + "satsdice/error.html", + { + "request": request, + "link": satsdicelink.id, + "paid": False, + "lost": True, + }, + ) + + await update_satsdice_payment(payment_hash, paid=1) + paylink = await get_satsdice_payment(payment_hash) + if not paylink: + + return satsdice_renderer().TemplateResponse( + "satsdice/error.html", + { + "request": request, + "link": satsdicelink.id, + "paid": False, + "lost": True, + }, + ) + rand1 = random.randint(0, 100) + rand2 = random.randint(0, 100) + rand3 = random.randint(0, 100) + rand4 = random.randint(0, 100) + rand = (rand1 + rand2 + rand3 + rand4) / 4 + print(rand) + chance = satsdicelink.chance + if rand > chance: + await update_satsdice_payment(payment_hash, lost=1) + return satsdice_renderer().TemplateResponse( + "satsdice/error.html", + { + "request": request, + "link": satsdicelink.id, + "paid": False, + "lost": True, + }, + ) + + data: CreateSatsDiceWithdraw = { + "satsdice_pay": satsdicelink.id, + "value": paylink.value * satsdicelink.multiplier, + "payment_hash": payment_hash, + "used": 0, + } + + withdrawLink = await create_satsdice_withdraw(data) + return satsdice_renderer().TemplateResponse( + "satsdice/displaywin.html", + { + "request": request, + "value": withdrawLink.value, + "chance": satsdicelink.chance, + "multiplier": satsdicelink.multiplier, + "lnurl": withdrawLink.lnurl(request), + "paid": False, + "lost": False, + }, + ) + + +@satsdice_ext.get("/img/{link_id}") +async def img(link_id): + link = await get_satsdice_pay(link_id) or abort( + HTTPStatus.NOT_FOUND, "satsdice link does not exist." + ) + qr = pyqrcode.create(link.lnurl) + stream = BytesIO() + qr.svg(stream, scale=3) + return ( + stream.getvalue(), + 200, + { + "Content-Type": "image/svg+xml", + "Cache-Control": "no-cache, no-store, must-revalidate", + "Pragma": "no-cache", + "Expires": "0", + }, + ) diff --git a/lnbits/extensions/satsdice/views_api.py b/lnbits/extensions/satsdice/views_api.py new file mode 100644 index 00000000..315d823c --- /dev/null +++ b/lnbits/extensions/satsdice/views_api.py @@ -0,0 +1,246 @@ +from http import HTTPStatus + +from fastapi import Request +from fastapi.param_functions import Query +from fastapi.params import Depends +from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl # type: ignore +from starlette.exceptions import HTTPException + +from lnbits.core.crud import get_user +from lnbits.decorators import WalletTypeInfo, get_key_type + +from . import satsdice_ext +from .crud import ( + create_satsdice_pay, + create_satsdice_withdraw, + delete_satsdice_pay, + delete_satsdice_withdraw, + get_satsdice_pay, + get_satsdice_pays, + get_satsdice_withdraw, + get_satsdice_withdraws, + update_satsdice_pay, + update_satsdice_withdraw, +) +from .models import CreateSatsDiceLink, CreateSatsDiceWithdraws + +################LNURL pay + + +@satsdice_ext.get("/api/v1/links") +async def api_links( + request: Request, + wallet: WalletTypeInfo = Depends(get_key_type), + all_wallets: str = Query(None), +): + wallet_ids = [wallet.wallet.id] + + if all_wallets: + wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids + + try: + links = await get_satsdice_pays(wallet_ids) + + return [{**link.dict(), **{"lnurl": link.lnurl(request)}} for link in links] + except LnurlInvalidUrl: + raise HTTPException( + status_code=HTTPStatus.UPGRADE_REQUIRED, + detail="LNURLs need to be delivered over a publically accessible `https` domain or Tor.", + ) + + +@satsdice_ext.get("/api/v1/links/{link_id}") +async def api_link_retrieve( + data: CreateSatsDiceLink, + link_id: str = Query(None), + wallet: WalletTypeInfo = Depends(get_key_type), +): + link = await get_satsdice_pay(link_id) + + if not link: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Pay link does not exist." + ) + + if link.wallet != wallet.wallet.id: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, detail="Not your pay link." + ) + + return {**link._asdict(), **{"lnurl": link.lnurl}} + + +@satsdice_ext.post("/api/v1/links", status_code=HTTPStatus.CREATED) +@satsdice_ext.put("/api/v1/links/{link_id}", status_code=HTTPStatus.OK) +async def api_link_create_or_update( + data: CreateSatsDiceLink, + wallet: WalletTypeInfo = Depends(get_key_type), + link_id: str = Query(None), +): + if data.min_bet > data.max_bet: + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail="Bad request") + if link_id: + link = await get_satsdice_pay(link_id) + + if not link: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Satsdice does not exist" + ) + + if link.wallet != wallet.wallet.id: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="Come on, seriously, this isn't your satsdice!", + ) + data.link_id = link_id + link = await update_satsdice_pay(data) + else: + data.wallet_id = wallet.wallet.id + link = await create_satsdice_pay(data) + + return {**link.dict(), **{"lnurl": link.lnurl}} + + +@satsdice_ext.delete("/api/v1/links/{link_id}") +async def api_link_delete( + wallet: WalletTypeInfo = Depends(get_key_type), link_id: str = Query(None) +): + link = await get_satsdice_pay(link_id) + + if not link: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Pay link does not exist." + ) + + if link.wallet != g.wallet.id: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, detail="Not your pay link." + ) + + await delete_satsdice_pay(link_id) + + return "", HTTPStatus.NO_CONTENT + + +##########LNURL withdraw + + +@satsdice_ext.get("/api/v1/withdraws") +async def api_withdraws( + wallet: WalletTypeInfo = Depends(get_key_type), all_wallets: str = Query(None) +): + wallet_ids = [wallet.wallet.id] + + if all_wallets: + wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids + try: + return ( + jsonify( + [ + {**withdraw._asdict(), **{"lnurl": withdraw.lnurl}} + for withdraw in await get_satsdice_withdraws(wallet_ids) + ] + ), + HTTPStatus.OK, + ) + except LnurlInvalidUrl: + raise HTTPException( + status_code=HTTPStatus.UPGRADE_REQUIRED, + detail="LNURLs need to be delivered over a publically accessible `https` domain or Tor.", + ) + + +@satsdice_ext.get("/api/v1/withdraws/{withdraw_id}") +async def api_withdraw_retrieve( + wallet: WalletTypeInfo = Depends(get_key_type), withdraw_id: str = Query(None) +): + withdraw = await get_satsdice_withdraw(withdraw_id, 0) + + if not withdraw: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="satsdice withdraw does not exist." + ) + + if withdraw.wallet != wallet.wallet.id: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, detail="Not your satsdice withdraw." + ) + + return {**withdraw._asdict(), **{"lnurl": withdraw.lnurl}}, HTTPStatus.OK + + +@satsdice_ext.post("/api/v1/withdraws", status_code=HTTPStatus.CREATED) +@satsdice_ext.put("/api/v1/withdraws/{withdraw_id}", status_code=HTTPStatus.OK) +async def api_withdraw_create_or_update( + data: CreateSatsDiceWithdraws, + wallet: WalletTypeInfo = Depends(get_key_type), + withdraw_id: str = Query(None), +): + if data.max_satsdiceable < data.min_satsdiceable: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="`max_satsdiceable` needs to be at least `min_satsdiceable`.", + ) + + usescsv = "" + for i in range(data.uses): + if data.is_unique: + usescsv += "," + str(i + 1) + else: + usescsv += "," + str(1) + usescsv = usescsv[1:] + + if withdraw_id: + withdraw = await get_satsdice_withdraw(withdraw_id, 0) + if not withdraw: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="satsdice withdraw does not exist.", + ) + if withdraw.wallet != wallet.wallet.id: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, detail="Not your satsdice withdraw." + ) + + withdraw = await update_satsdice_withdraw( + withdraw_id, **data, usescsv=usescsv, used=0 + ) + else: + withdraw = await create_satsdice_withdraw( + wallet_id=wallet.wallet.id, **data, usescsv=usescsv + ) + + return {**withdraw._asdict(), **{"lnurl": withdraw.lnurl}} + + +@satsdice_ext.delete("/api/v1/withdraws/{withdraw_id}") +async def api_withdraw_delete( + data: CreateSatsDiceWithdraws, + wallet: WalletTypeInfo = Depends(get_key_type), + withdraw_id: str = Query(None), +): + withdraw = await get_satsdice_withdraw(withdraw_id) + + if not withdraw: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="satsdice withdraw does not exist." + ) + + if withdraw.wallet != wallet.wallet.id: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, detail="Not your satsdice withdraw." + ) + + await delete_satsdice_withdraw(withdraw_id) + + return "", HTTPStatus.NO_CONTENT + + +@satsdice_ext.get("/api/v1/withdraws/{the_hash}/{lnurl_id}") +async def api_withdraw_hash_retrieve( + wallet: WalletTypeInfo = Depends(get_key_type), + lnurl_id: str = Query(None), + the_hash: str = Query(None), +): + hashCheck = await get_withdraw_hash_check(the_hash, lnurl_id) + return hashCheck diff --git a/lnbits/extensions/satspay/README.md b/lnbits/extensions/satspay/README.md new file mode 100644 index 00000000..d52547ae --- /dev/null +++ b/lnbits/extensions/satspay/README.md @@ -0,0 +1,27 @@ +# SatsPay Server + +## Create onchain and LN charges. Includes webhooks! + +Easilly create invoices that support Lightning Network and on-chain BTC payment. + +1. Create a "NEW CHARGE"\ + ![new charge](https://i.imgur.com/fUl6p74.png) +2. Fill out the invoice fields + - set a descprition for the payment + - the amount in sats + - the time, in minutes, the invoice is valid for, after this period the invoice can't be payed + - set a webhook that will get the transaction details after a successful payment + - set to where the user should redirect after payment + - set the text for the button that will show after payment (not setting this, will display "NONE" in the button) + - select if you want onchain payment, LN payment or both + - depending on what you select you'll have to choose the respective wallets where to receive your payment\ + ![charge form](https://i.imgur.com/F10yRiW.png) +3. The charge will appear on the _Charges_ section\ + ![charges](https://i.imgur.com/zqHpVxc.png) +4. Your costumer/payee will get the payment page + - they can choose to pay on LN\ + ![offchain payment](https://i.imgur.com/4191SMV.png) + - or pay on chain\ + ![onchain payment](https://i.imgur.com/wzLRR5N.png) +5. You can check the state of your charges in LNBits\ + ![invoice state](https://i.imgur.com/JnBd22p.png) diff --git a/lnbits/extensions/satspay/__init__.py b/lnbits/extensions/satspay/__init__.py new file mode 100644 index 00000000..b9c67e40 --- /dev/null +++ b/lnbits/extensions/satspay/__init__.py @@ -0,0 +1,19 @@ +import asyncio + +from fastapi import APIRouter + +from lnbits.db import Database +from lnbits.helpers import template_renderer + +db = Database("ext_satspay") + + +satspay_ext: APIRouter = APIRouter(prefix="/satspay", tags=["satspay"]) + + +def satspay_renderer(): + return template_renderer(["lnbits/extensions/satspay/templates"]) + + +from .views_api import * # noqa +from .views import * # noqa diff --git a/lnbits/extensions/satspay/config.json b/lnbits/extensions/satspay/config.json new file mode 100644 index 00000000..beb0071c --- /dev/null +++ b/lnbits/extensions/satspay/config.json @@ -0,0 +1,8 @@ +{ + "name": "SatsPay Server", + "short_description": "Create onchain and LN charges", + "icon": "payment", + "contributors": [ + "arcbtc" + ] +} diff --git a/lnbits/extensions/satspay/crud.py b/lnbits/extensions/satspay/crud.py new file mode 100644 index 00000000..50545d9c --- /dev/null +++ b/lnbits/extensions/satspay/crud.py @@ -0,0 +1,119 @@ +from typing import List, Optional, Union + +# from lnbits.db import open_ext_db +from . import db +from .models import Charges, CreateCharge + +from lnbits.helpers import urlsafe_short_hash + +import httpx +from lnbits.core.services import create_invoice, check_invoice_status +from ..watchonly.crud import get_watch_wallet, get_fresh_address, get_mempool + + +###############CHARGES########################## + + +async def create_charge(user: str, data: CreateCharge) -> Charges: + charge_id = urlsafe_short_hash() + if data.onchainwallet: + wallet = await get_watch_wallet(data.onchainwallet) + onchain = await get_fresh_address(data.onchainwallet) + onchainaddress = onchain.address + else: + onchainaddress = None + if data.lnbitswallet: + payment_hash, payment_request = await create_invoice( + wallet_id=data.lnbitswallet, amount=data.amount, memo=charge_id + ) + else: + payment_hash = None + payment_request = None + await db.execute( + """ + INSERT INTO satspay.charges ( + id, + "user", + description, + onchainwallet, + onchainaddress, + lnbitswallet, + payment_request, + payment_hash, + webhook, + completelink, + completelinktext, + time, + amount, + balance + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + charge_id, + user, + data.description, + data.onchainwallet, + onchainaddress, + data.lnbitswallet, + payment_request, + payment_hash, + data.webhook, + data.completelink, + data.completelinktext, + data.time, + data.amount, + 0, + ), + ) + return await get_charge(charge_id) + + +async def update_charge(charge_id: str, **kwargs) -> Optional[Charges]: + q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) + await db.execute( + f"UPDATE satspay.charges SET {q} WHERE id = ?", (*kwargs.values(), charge_id) + ) + row = await db.fetchone("SELECT * FROM satspay.charges WHERE id = ?", (charge_id,)) + return Charges.from_row(row) if row else None + + +async def get_charge(charge_id: str) -> Charges: + row = await db.fetchone("SELECT * FROM satspay.charges WHERE id = ?", (charge_id,)) + return Charges.from_row(row) if row else None + + +async def get_charges(user: str) -> List[Charges]: + rows = await db.fetchall( + """SELECT * FROM satspay.charges WHERE "user" = ?""", (user,) + ) + return [Charges.from_row(row) for row in rows] + + +async def delete_charge(charge_id: str) -> None: + await db.execute("DELETE FROM satspay.charges WHERE id = ?", (charge_id,)) + + +async def check_address_balance(charge_id: str) -> List[Charges]: + charge = await get_charge(charge_id) + if not charge.paid: + if charge.onchainaddress: + mempool = await get_mempool(charge.user) + try: + async with httpx.AsyncClient() as client: + r = await client.get( + mempool.endpoint + "/api/address/" + charge.onchainaddress + ) + respAmount = r.json()["chain_stats"]["funded_txo_sum"] + if respAmount >= charge.balance: + await update_charge(charge_id=charge_id, balance=respAmount) + except Exception: + pass + if charge.lnbitswallet: + invoice_status = await check_invoice_status( + charge.lnbitswallet, charge.payment_hash + ) + if invoice_status.paid: + return await update_charge(charge_id=charge_id, balance=charge.amount) + row = await db.fetchone("SELECT * FROM satspay.charges WHERE id = ?", (charge_id,)) + return Charges.from_row(row) if row else None diff --git a/lnbits/extensions/satspay/migrations.py b/lnbits/extensions/satspay/migrations.py new file mode 100644 index 00000000..87446c80 --- /dev/null +++ b/lnbits/extensions/satspay/migrations.py @@ -0,0 +1,28 @@ +async def m001_initial(db): + """ + Initial wallet table. + """ + + await db.execute( + """ + CREATE TABLE satspay.charges ( + id TEXT NOT NULL PRIMARY KEY, + "user" TEXT, + description TEXT, + onchainwallet TEXT, + onchainaddress TEXT, + lnbitswallet TEXT, + payment_request TEXT, + payment_hash TEXT, + webhook TEXT, + completelink TEXT, + completelinktext TEXT, + time INTEGER, + amount INTEGER, + balance INTEGER DEFAULT 0, + timestamp TIMESTAMP NOT NULL DEFAULT """ + + db.timestamp_now + + """ + ); + """ + ) diff --git a/lnbits/extensions/satspay/models.py b/lnbits/extensions/satspay/models.py new file mode 100644 index 00000000..d67e478a --- /dev/null +++ b/lnbits/extensions/satspay/models.py @@ -0,0 +1,52 @@ +from sqlite3 import Row +from typing import Optional +from fastapi.param_functions import Query +from pydantic import BaseModel +import time + + +class CreateCharge(BaseModel): + onchainwallet: str = Query(None) + lnbitswallet: str = Query(None) + description: str = Query(...) + webhook: str = Query(None) + completelink: str = Query(None) + completelinktext: str = Query(None) + time: int = Query(..., ge=1) + amount: int = Query(..., ge=1) + + +class Charges(BaseModel): + id: str + user: str + description: Optional[str] + onchainwallet: Optional[str] + onchainaddress: Optional[str] + lnbitswallet: Optional[str] + payment_request: str + payment_hash: str + webhook: Optional[str] + completelink: Optional[str] + completelinktext: Optional[str] = "Back to Merchant" + time: int + amount: int + balance: int + timestamp: int + + @classmethod + def from_row(cls, row: Row) -> "Charges": + return cls(**dict(row)) + + @property + def time_elapsed(self): + if (self.timestamp + (self.time * 60)) >= time.time(): + return False + else: + return True + + @property + def paid(self): + if self.balance >= self.amount: + return True + else: + return False diff --git a/lnbits/extensions/satspay/templates/satspay/_api_docs.html b/lnbits/extensions/satspay/templates/satspay/_api_docs.html new file mode 100644 index 00000000..af95cbf2 --- /dev/null +++ b/lnbits/extensions/satspay/templates/satspay/_api_docs.html @@ -0,0 +1,171 @@ + + +

+ SatsPayServer, create Onchain/LN charges.
WARNING: If using with the + WatchOnly extension, we highly reccomend using a fresh extended public Key + specifically for SatsPayServer!
+ + Created by, Ben Arc +

+
+ + + + + POST /satspay/api/v1/charge +
Headers
+ {"X-Api-Key": <admin_key>}
+
+ Body (application/json) +
+
+ Returns 200 OK (application/json) +
+ [<charge_object>, ...] +
Curl example
+ curl -X POST {{ request.url_root }}api/v1/charge -d + '{"onchainwallet": <string, watchonly_wallet_id>, + "description": <string>, "webhook":<string>, "time": + <integer>, "amount": <integer>, "lnbitswallet": + <string, lnbits_wallet_id>}' -H "Content-type: + application/json" -H "X-Api-Key: {{user.wallets[0].adminkey }}" + +
+
+
+ + + + PUT + /satspay/api/v1/charge/<charge_id> +
Headers
+ {"X-Api-Key": <admin_key>}
+
+ Body (application/json) +
+
+ Returns 200 OK (application/json) +
+ [<charge_object>, ...] +
Curl example
+ curl -X POST {{ request.url_root }}api/v1/charge/<charge_id> + -d '{"onchainwallet": <string, watchonly_wallet_id>, + "description": <string>, "webhook":<string>, "time": + <integer>, "amount": <integer>, "lnbitswallet": + <string, lnbits_wallet_id>}' -H "Content-type: + application/json" -H "X-Api-Key: {{user.wallets[0].adminkey }}" + +
+
+
+ + + + + GET + /satspay/api/v1/charge/<charge_id> +
Headers
+ {"X-Api-Key": <invoice_key>}
+
+ Body (application/json) +
+
+ Returns 200 OK (application/json) +
+ [<charge_object>, ...] +
Curl example
+ curl -X GET {{ request.url_root }}api/v1/charge/<charge_id> + -H "X-Api-Key: {{ user.wallets[0].inkey }}" + +
+
+
+ + + + GET /satspay/api/v1/charges +
Headers
+ {"X-Api-Key": <invoice_key>}
+
+ Body (application/json) +
+
+ Returns 200 OK (application/json) +
+ [<charge_object>, ...] +
Curl example
+ curl -X GET {{ request.url_root }}api/v1/charges -H "X-Api-Key: {{ + user.wallets[0].inkey }}" + +
+
+
+ + + + DELETE + /satspay/api/v1/charge/<charge_id> +
Headers
+ {"X-Api-Key": <admin_key>}
+
Returns 204 NO CONTENT
+ +
Curl example
+ curl -X DELETE {{ request.url_root + }}api/v1/charge/<charge_id> -H "X-Api-Key: {{ + user.wallets[0].adminkey }}" + +
+
+
+ + + + GET + /satspay/api/v1/charges/balance/<charge_id> +
+ Body (application/json) +
+
+ Returns 200 OK (application/json) +
+ [<charge_object>, ...] +
Curl example
+ curl -X GET {{ request.url_root + }}api/v1/charges/balance/<charge_id> -H "X-Api-Key: {{ + user.wallets[0].inkey }}" + +
+
+
+
+
diff --git a/lnbits/extensions/satspay/templates/satspay/display.html b/lnbits/extensions/satspay/templates/satspay/display.html new file mode 100644 index 00000000..5b0282b6 --- /dev/null +++ b/lnbits/extensions/satspay/templates/satspay/display.html @@ -0,0 +1,319 @@ +{% extends "public.html" %} {% block page %} +
+ +
+
+
{{ charge.description }}
+
+
+
+
Time elapsed
+
+
+
Charge paid
+
+
+ + + + Awaiting payment... + + {% raw %} {{ newTimeLeft }} {% endraw %} + + + +
+
+
+
+ Charge ID: {{ charge.id }} +
+ {% raw %} Total to pay: {{ charge_amount }}sats
+ Amount paid: {{ charge_balance }}

+ Amount due: {{ charge_amount - charge_balance }}sats {% endraw %} +
+
+ +
+
+
+ + + bitcoin onchain payment method not available + + + + pay with lightning + +
+
+ + + bitcoin lightning payment method not available + + + + pay onchain + +
+
+ +
+
+ + +
+
+ +
+
+ + +
+
+
+ Pay this
+ lightning-network invoice
+
+ + + + + +
+ Copy invoice +
+
+
+
+
+ + +
+
+ +
+
+ + +
+
+
+ Send {{ charge.amount }}sats
+ to this onchain address
+
+ + + + + +
+ Copy address +
+
+
+
+
+
+
+ +{% endblock %} {% block scripts %} + + + +{% endblock %} diff --git a/lnbits/extensions/satspay/templates/satspay/index.html b/lnbits/extensions/satspay/templates/satspay/index.html new file mode 100644 index 00000000..d941e90b --- /dev/null +++ b/lnbits/extensions/satspay/templates/satspay/index.html @@ -0,0 +1,557 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + + {% raw %} + New charge + + + + + + +
+
+
Charges
+
+ +
+ + + + Export to CSV +
+
+ + + + + {% endraw %} + +
+
+
+ +
+ + +
+ {{SITE_TITLE}} satspay Extension +
+
+ + + {% include "satspay/_api_docs.html" %} + +
+
+ + + + + + + + + + + + + + + + + + +
+
+
+ +
+
+ + + Watch-Only extension MUST be activated and have a wallet + + +
+
+
+
+ +
+
+
+ +
+ +
+ + + +
+ Create Charge + Cancel +
+
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + + + +{% endblock %} diff --git a/lnbits/extensions/satspay/views.py b/lnbits/extensions/satspay/views.py new file mode 100644 index 00000000..f6694034 --- /dev/null +++ b/lnbits/extensions/satspay/views.py @@ -0,0 +1,34 @@ +from fastapi.param_functions import Depends +from starlette.exceptions import HTTPException +from starlette.responses import HTMLResponse +from starlette.requests import Request +from lnbits.core.models import User +from lnbits.core.crud import get_wallet +from lnbits.decorators import check_user_exists +from http import HTTPStatus + +from fastapi.templating import Jinja2Templates + +from . import satspay_ext, satspay_renderer +from .crud import get_charge + +templates = Jinja2Templates(directory="templates") + + +@satspay_ext.get("/", response_class=HTMLResponse) +async def index(request: Request, user: User = Depends(check_user_exists)): + return satspay_renderer().TemplateResponse( + "satspay/index.html", {"request": request, "user": user.dict()} + ) + + +@satspay_ext.get("/{charge_id}", response_class=HTMLResponse) +async def display(request: Request, charge_id): + charge = await get_charge(charge_id) + if not charge: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Charge link does not exist." + ) + return satspay_renderer().TemplateResponse( + "satspay/display.html", {"request": request, "charge": charge} + ) diff --git a/lnbits/extensions/satspay/views_api.py b/lnbits/extensions/satspay/views_api.py new file mode 100644 index 00000000..2ca16370 --- /dev/null +++ b/lnbits/extensions/satspay/views_api.py @@ -0,0 +1,144 @@ +import hashlib + +from http import HTTPStatus +import httpx + +from fastapi import Query +from fastapi.params import Depends + +from starlette.exceptions import HTTPException +from starlette.requests import Request +from starlette.responses import HTMLResponse, JSONResponse # type: ignore + + +from lnbits.core.crud import get_user +from lnbits.decorators import WalletTypeInfo, get_key_type + +from lnbits.extensions.satspay import satspay_ext +from .models import CreateCharge +from .crud import ( + create_charge, + update_charge, + get_charge, + get_charges, + delete_charge, + check_address_balance, +) + +#############################CHARGES########################## + + +@satspay_ext.post("/api/v1/charge") +@satspay_ext.put("/api/v1/charge/{charge_id}") +async def api_charge_create_or_update( + data: CreateCharge, wallet: WalletTypeInfo = Depends(get_key_type), charge_id=None +): + if not charge_id: + charge = await create_charge(user=wallet.wallet.user, data=data) + return charge.dict() + else: + charge = await update_charge(charge_id=charge_id, data=data) + return charge.dict() + + +@satspay_ext.get("/api/v1/charges") +async def api_charges_retrieve(wallet: WalletTypeInfo = Depends(get_key_type)): + try: + return [ + { + **charge.dict(), + **{"time_elapsed": charge.time_elapsed}, + **{"paid": charge.paid}, + } + for charge in await get_charges(wallet.wallet.user) + ] + except: + return "" + + +@satspay_ext.get("/api/v1/charge/{charge_id}") +async def api_charge_retrieve( + charge_id, wallet: WalletTypeInfo = Depends(get_key_type) +): + charge = await get_charge(charge_id) + + if not charge: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Charge does not exist." + ) + + return { + **charge.dict(), + **{"time_elapsed": charge.time_elapsed}, + **{"paid": charge.paid}, + } + + +@satspay_ext.delete("/api/v1/charge/{charge_id}") +async def api_charge_delete(charge_id, wallet: WalletTypeInfo = Depends(get_key_type)): + charge = await get_charge(charge_id) + + if not charge: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Charge does not exist." + ) + + await delete_charge(charge_id) + raise HTTPException(status_code=HTTPStatus.NO_CONTENT) + + +#############################BALANCE########################## + + +@satspay_ext.get("/api/v1/charges/balance/{charge_id}") +async def api_charges_balance(charge_id): + + charge = await check_address_balance(charge_id) + + if not charge: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Charge does not exist." + ) + + if charge.paid and charge.webhook: + async with httpx.AsyncClient() as client: + try: + r = await client.post( + charge.webhook, + json={ + "id": charge.id, + "description": charge.description, + "onchainaddress": charge.onchainaddress, + "payment_request": charge.payment_request, + "payment_hash": charge.payment_hash, + "time": charge.time, + "amount": charge.amount, + "balance": charge.balance, + "paid": charge.paid, + "timestamp": charge.timestamp, + "completelink": charge.completelink, + }, + timeout=40, + ) + except AssertionError: + charge.webhook = None + return charge.dict() + + +#############################MEMPOOL########################## + + +@satspay_ext.put("/api/v1/mempool") +async def api_update_mempool( + endpoint: str = Query(...), wallet: WalletTypeInfo = Depends(get_key_type) +): + mempool = await update_mempool(endpoint, user=wallet.wallet.user) + return mempool.dict() + + +@satspay_ext.route("/api/v1/mempool") +async def api_get_mempool(wallet: WalletTypeInfo = Depends(get_key_type)): + mempool = await get_mempool(wallet.wallet.user) + if not mempool: + mempool = await create_mempool(user=wallet.wallet.user) + return mempool.dict() diff --git a/lnbits/extensions/splitpayments/README.md b/lnbits/extensions/splitpayments/README.md new file mode 100644 index 00000000..04576a57 --- /dev/null +++ b/lnbits/extensions/splitpayments/README.md @@ -0,0 +1,34 @@ +# Split Payments + +## Have payments split between multiple wallets + +LNBits Split Payments extension allows for distributing payments across multiple wallets. Set it and forget it. It will keep splitting your payments across wallets forever. + +## Usage + +1. After enabling the extension, choose the source wallet that will receive and distribute the Payments + +![choose wallet](https://i.imgur.com/nPQudqL.png) + +2. Add the wallet or wallets info to split payments to + +![split wallets](https://i.imgur.com/5hCNWpg.png) - get the wallet id, or an invoice key from a different wallet. It can be a completely different user as long as it's under the same LNbits instance/domain. You can get the wallet information on the API Info section on every wallet page\ + ![wallet info](https://i.imgur.com/betqflC.png) - set a wallet _Alias_ for your own identification\ + +- set how much, in percentage, this wallet will receive from every payment sent to the source wallets + +3. When done, click "SAVE TARGETS" to make the splits effective + +4. You can have several wallets to split to, as long as the sum of the percentages is under or equal to 100% + +5. When the source wallet receives a payment, the extension will automatically split the corresponding values to every wallet\ + - on receiving a 20 sats payment\ + ![get 20 sats payment](https://i.imgur.com/BKp0xvy.png) + - source wallet gets 18 sats\ + ![source wallet](https://i.imgur.com/GCxDZ5s.png) + - Ben's wallet (the wallet from the example) instantly, and feeless, gets the corresponding 10%, or 2 sats\ + ![ben wallet](https://i.imgur.com/MfsccNa.png) + +## Sponsored by + +[![](https://cdn.shopify.com/s/files/1/0826/9235/files/cryptograffiti_logo_clear_background.png?v=1504730421)](https://cryptograffiti.com/) diff --git a/lnbits/extensions/splitpayments/__init__.py b/lnbits/extensions/splitpayments/__init__.py new file mode 100644 index 00000000..b1e0fbdf --- /dev/null +++ b/lnbits/extensions/splitpayments/__init__.py @@ -0,0 +1,38 @@ +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_splitpayments") + +splitpayments_static_files = [ + { + "path": "/splitpayments/static", + "app": StaticFiles(directory="lnbits/extensions/splitpayments/static"), + "name": "splitpayments_static", + } +] +splitpayments_ext: APIRouter = APIRouter( + prefix="/splitpayments", tags=["splitpayments"] +) + + +def splitpayments_renderer(): + return template_renderer(["lnbits/extensions/splitpayments/templates"]) + + +# from lnbits.tasks import record_async +# splitpayments_ext.record(record_async(register_listeners)) + + +from .views_api import * # noqa +from .views import * # noqa +from .tasks import wait_for_paid_invoices + + +def splitpayments_start(): + loop = asyncio.get_event_loop() + loop.create_task(catch_everything_and_restart(wait_for_paid_invoices)) diff --git a/lnbits/extensions/splitpayments/config.json b/lnbits/extensions/splitpayments/config.json new file mode 100644 index 00000000..898dcbde --- /dev/null +++ b/lnbits/extensions/splitpayments/config.json @@ -0,0 +1,6 @@ +{ + "name": "Split Payments", + "short_description": "Split incoming payments across wallets", + "icon": "call_split", + "contributors": ["fiatjaf", "cryptograffiti"] +} diff --git a/lnbits/extensions/splitpayments/crud.py b/lnbits/extensions/splitpayments/crud.py new file mode 100644 index 00000000..ef10add4 --- /dev/null +++ b/lnbits/extensions/splitpayments/crud.py @@ -0,0 +1,27 @@ +from typing import List + +from . import db +from .models import Target + + +async def get_targets(source_wallet: str) -> List[Target]: + rows = await db.fetchall( + "SELECT * FROM splitpayments.targets WHERE source = ?", (source_wallet,) + ) + return [Target(**dict(row)) for row in rows] + + +async def set_targets(source_wallet: str, targets: List[Target]): + async with db.connect() as conn: + await conn.execute( + "DELETE FROM splitpayments.targets WHERE source = ?", (source_wallet,) + ) + for target in targets: + await conn.execute( + """ + INSERT INTO splitpayments.targets + (source, wallet, percent, alias) + VALUES (?, ?, ?, ?) + """, + (source_wallet, target.wallet, target.percent, target.alias), + ) diff --git a/lnbits/extensions/splitpayments/migrations.py b/lnbits/extensions/splitpayments/migrations.py new file mode 100644 index 00000000..735afc6c --- /dev/null +++ b/lnbits/extensions/splitpayments/migrations.py @@ -0,0 +1,16 @@ +async def m001_initial(db): + """ + Initial split payment table. + """ + await db.execute( + """ + CREATE TABLE splitpayments.targets ( + wallet TEXT NOT NULL, + source TEXT NOT NULL, + percent INTEGER NOT NULL CHECK (percent >= 0 AND percent <= 100), + alias TEXT, + + UNIQUE (source, wallet) + ); + """ + ) diff --git a/lnbits/extensions/splitpayments/models.py b/lnbits/extensions/splitpayments/models.py new file mode 100644 index 00000000..1c38b37d --- /dev/null +++ b/lnbits/extensions/splitpayments/models.py @@ -0,0 +1,20 @@ +from typing import List, Optional + +from fastapi.param_functions import Query +from pydantic import BaseModel + + +class Target(BaseModel): + wallet: str + source: str + percent: int + alias: Optional[str] + +class TargetPutList(BaseModel): + wallet: str = Query(...) + alias: str = Query("") + percent: int = Query(..., ge=1) + + +class TargetPut(BaseModel): + __root__: List[TargetPutList] diff --git a/lnbits/extensions/splitpayments/static/js/index.js b/lnbits/extensions/splitpayments/static/js/index.js new file mode 100644 index 00000000..dea469e5 --- /dev/null +++ b/lnbits/extensions/splitpayments/static/js/index.js @@ -0,0 +1,143 @@ +/* globals Quasar, Vue, _, VueQrcode, windowMixin, LNbits, LOCALE */ + +Vue.component(VueQrcode.name, VueQrcode) + +function hashTargets(targets) { + return targets + .filter(isTargetComplete) + .map(({wallet, percent, alias}) => `${wallet}${percent}${alias}`) + .join('') +} + +function isTargetComplete(target) { + return target.wallet && target.wallet.trim() !== '' && target.percent > 0 +} + +new Vue({ + el: '#vue', + mixins: [windowMixin], + data() { + return { + selectedWallet: null, + currentHash: '', // a string that must match if the edit data is unchanged + targets: [] + } + }, + computed: { + isDirty() { + return hashTargets(this.targets) !== this.currentHash + } + }, + methods: { + clearTargets() { + this.targets = [{}] + this.$q.notify({ + message: + 'Cleared the form, but not saved. You must click to save manually.', + timeout: 500 + }) + }, + getTargets() { + LNbits.api + .request( + 'GET', + '/splitpayments/api/v1/targets', + this.selectedWallet.adminkey + ) + .catch(err => { + LNbits.utils.notifyApiError(err) + }) + .then(response => { + this.currentHash = hashTargets(response.data) + this.targets = response.data.concat({}) + }) + }, + changedWallet(wallet) { + this.selectedWallet = wallet + this.getTargets() + }, + targetChanged(isPercent, index) { + // fix percent min and max range + if (isPercent) { + if (this.targets[index].percent > 100) this.targets[index].percent = 100 + if (this.targets[index].percent < 0) this.targets[index].percent = 0 + } + + // remove empty lines (except last) + if (this.targets.length >= 2) { + for (let i = this.targets.length - 2; i >= 0; i--) { + let target = this.targets[i] + if ( + (!target.wallet || target.wallet.trim() === '') && + (!target.alias || target.alias.trim() === '') && + !target.percent + ) { + this.targets.splice(i, 1) + } + } + } + + // add a line at the end if the last one is filled + let last = this.targets[this.targets.length - 1] + if (last.wallet && last.wallet.trim() !== '' && last.percent > 0) { + this.targets.push({}) + } + + // sum of all percents + let currentTotal = this.targets.reduce( + (acc, target) => acc + (target.percent || 0), + 0 + ) + + // remove last (unfilled) line if the percent is already 100 + if (currentTotal >= 100) { + let last = this.targets[this.targets.length - 1] + if ( + (!last.wallet || last.wallet.trim() === '') && + (!last.alias || last.alias.trim() === '') && + !last.percent + ) { + this.targets = this.targets.slice(0, -1) + } + } + + // adjust percents of other lines (not this one) + if (currentTotal > 100 && isPercent) { + let diff = (currentTotal - 100) / (100 - this.targets[index].percent) + this.targets.forEach((target, t) => { + if (t !== index) target.percent -= Math.round(diff * target.percent) + }) + } + + // overwrite so changes appear + this.targets = this.targets + }, + saveTargets() { + LNbits.api + .request( + 'PUT', + '/splitpayments/api/v1/targets', + this.selectedWallet.adminkey, + { + "targets": this.targets + .filter(isTargetComplete) + .map(({wallet, percent, alias}) => ({wallet, percent, alias})) + } + ) + .then(response => { + this.$q.notify({ + message: 'Split payments targets set.', + timeout: 700 + }) + this.getTargets() + }) + .catch(err => { + LNbits.utils.notifyApiError(err) + }) + } + }, + created() { + this.selectedWallet = this.g.user.wallets[0] + this.getTargets() + } +}) diff --git a/lnbits/extensions/splitpayments/tasks.py b/lnbits/extensions/splitpayments/tasks.py new file mode 100644 index 00000000..12612782 --- /dev/null +++ b/lnbits/extensions/splitpayments/tasks.py @@ -0,0 +1,82 @@ +import json + +from lnbits.core.models import Payment +from lnbits.core.crud import create_payment +from lnbits.core import db as core_db +from lnbits.tasks import register_invoice_listener, internal_invoice_queue +from lnbits.helpers import urlsafe_short_hash + +from .crud import get_targets + + +import asyncio +import httpx + +from lnbits.core import db as core_db +from lnbits.core.models import 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 "splitpayments" == payment.extra.get("tag") or payment.extra.get("splitted"): + # already splitted, ignore + return + + # now we make some special internal transfers (from no one to the receiver) + targets = await get_targets(payment.wallet_id) + transfers = [ + (target.wallet, int(target.percent * payment.amount / 100)) + for target in targets + ] + transfers = [(wallet, amount) for wallet, amount in transfers if amount > 0] + amount_left = payment.amount - sum([amount for _, amount in transfers]) + + if amount_left < 0: + print("splitpayments failure: amount_left is negative.", payment.payment_hash) + return + + if not targets: + return + + # mark the original payment with one extra key, "splitted" + # (this prevents us from doing this process again and it's informative) + # and reduce it by the amount we're going to send to the producer + await core_db.execute( + """ + UPDATE apipayments + SET extra = ?, amount = ? + WHERE hash = ? + AND checking_id NOT LIKE 'internal_%' + """, + ( + json.dumps(dict(**payment.extra, splitted=True)), + amount_left, + payment.payment_hash, + ), + ) + + # perform the internal transfer using the same payment_hash + for wallet, amount in transfers: + internal_checking_id = f"internal_{urlsafe_short_hash()}" + await create_payment( + wallet_id=wallet, + checking_id=internal_checking_id, + payment_request="", + payment_hash=payment.payment_hash, + amount=amount, + memo=payment.memo, + pending=False, + extra={"tag": "splitpayments"}, + ) + + # manually send this for now + await internal_invoice_queue.put(internal_checking_id) + return diff --git a/lnbits/extensions/splitpayments/templates/splitpayments/_api_docs.html b/lnbits/extensions/splitpayments/templates/splitpayments/_api_docs.html new file mode 100644 index 00000000..116bdd74 --- /dev/null +++ b/lnbits/extensions/splitpayments/templates/splitpayments/_api_docs.html @@ -0,0 +1,90 @@ + + + +

+ Add some wallets to the list of "Target Wallets", each with an + associated percent. After saving, every time any payment + arrives at the "Source Wallet" that payment will be split with the + target wallets according to their percent. +

+

This is valid for every payment, doesn't matter how it was created.

+

Target wallets can be any wallet from this same LNbits instance.

+

+ To remove a wallet from the targets list, just erase its fields and + save. To remove all, click "Clear" then save. +

+
+
+
+ + + + + + GET + /splitpayments/api/v1/targets +
Headers
+ {"X-Api-Key": <admin_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ [{"wallet": <wallet id>, "alias": <chosen name for this + wallet>, "percent": <number between 1 and 100>}, ...] +
Curl example
+ curl -X GET {{ request.url_root }}api/v1/livestream -H "X-Api-Key: {{ + user.wallets[0].inkey }}" + +
+
+
+ + + + PUT + /splitpayments/api/v1/targets +
Headers
+ {"X-Api-Key": <admin_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+
Curl example
+ curl -X PUT {{ request.url_root }}api/v1/splitpayments/targets -H + "X-Api-Key: {{ user.wallets[0].adminkey }}" -H 'Content-Type: + application/json' -d '{"targets": [{"wallet": <wallet id or invoice + key>, "alias": <name to identify this>, "percent": <number + between 1 and 100>}, ...]}' + +
+
+
+
diff --git a/lnbits/extensions/splitpayments/templates/splitpayments/index.html b/lnbits/extensions/splitpayments/templates/splitpayments/index.html new file mode 100644 index 00000000..1aae4e33 --- /dev/null +++ b/lnbits/extensions/splitpayments/templates/splitpayments/index.html @@ -0,0 +1,100 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + + + + + + + + + + +
+
Target Wallets
+
+ + +
+ + + +
+ + + + + Clear + + + + + + Save Targets + + + +
+
+
+
+ +
+ + +
+ {{SITE_TITLE}} SplitPayments extension +
+
+ + + {% include "splitpayments/_api_docs.html" %} + +
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/lnbits/extensions/splitpayments/views.py b/lnbits/extensions/splitpayments/views.py new file mode 100644 index 00000000..056c7563 --- /dev/null +++ b/lnbits/extensions/splitpayments/views.py @@ -0,0 +1,18 @@ +from fastapi import Request +from fastapi.params import Depends +from fastapi.templating import Jinja2Templates +from starlette.responses import HTMLResponse + +from lnbits.core.models import User +from lnbits.decorators import check_user_exists + +from . import splitpayments_ext, splitpayments_renderer + +templates = Jinja2Templates(directory="templates") + + +@splitpayments_ext.get("/", response_class=HTMLResponse) +async def index(request: Request, user: User = Depends(check_user_exists)): + return splitpayments_renderer().TemplateResponse( + "splitpayments/index.html", {"request": request, "user": user.dict()} + ) diff --git a/lnbits/extensions/splitpayments/views_api.py b/lnbits/extensions/splitpayments/views_api.py new file mode 100644 index 00000000..7b532218 --- /dev/null +++ b/lnbits/extensions/splitpayments/views_api.py @@ -0,0 +1,63 @@ +from http import HTTPStatus + +from fastapi import Request +from fastapi.params import Depends +from starlette.exceptions import HTTPException + +from lnbits.core.crud import get_wallet, get_wallet_for_key +from lnbits.decorators import WalletTypeInfo, require_admin_key + +from . import splitpayments_ext +from .crud import get_targets, set_targets +from .models import Target, TargetPut + + +@splitpayments_ext.get("/api/v1/targets") +async def api_targets_get(wallet: WalletTypeInfo = Depends(require_admin_key)): + targets = await get_targets(wallet.wallet.id) + return [target.dict() for target in targets] or [] + + +@splitpayments_ext.put("/api/v1/targets") +async def api_targets_set( + req: Request, wal: WalletTypeInfo = Depends(require_admin_key) +): + body = await req.json() + targets = [] + data = TargetPut.parse_obj(body["targets"]) + for entry in data.__root__: + print("ENTRY", entry) + wallet = await get_wallet(entry.wallet) + if not wallet: + wallet = await get_wallet_for_key(entry.wallet, "invoice") + if not wallet: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"Invalid wallet '{entry.wallet}'.", + ) + + if wallet.id == wal.wallet.id: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Can't split to itself.", + ) + + if entry.percent < 0: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"Invalid percent '{entry.percent}'.", + ) + + targets.append( + Target(wallet=wallet.id, source=wal.wallet.id, percent=entry.percent, alias=entry.alias) + ) + + percent_sum = sum([target.percent for target in targets]) + if percent_sum > 100: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Splitting over 100%.", + ) + + await set_targets(wal.wallet.id, targets) + return "" diff --git a/lnbits/extensions/tpos/__init__.py b/lnbits/extensions/tpos/__init__.py index ea7a0504..6caf88fa 100644 --- a/lnbits/extensions/tpos/__init__.py +++ b/lnbits/extensions/tpos/__init__.py @@ -1,25 +1,18 @@ -import asyncio - from fastapi import APIRouter from lnbits.db import Database from lnbits.helpers import template_renderer -from lnbits.tasks import catch_everything_and_restart db = Database("ext_tpos") tpos_ext: APIRouter = APIRouter( prefix="/tpos", tags=["TPoS"] - # "tpos", __name__, static_folder="static", template_folder="templates" ) + def tpos_renderer(): - return template_renderer( - [ - "lnbits/extensions/tpos/templates", - ] - ) + return template_renderer(["lnbits/extensions/tpos/templates"]) from .views_api import * # noqa diff --git a/lnbits/extensions/tpos/crud.py b/lnbits/extensions/tpos/crud.py index 25a65904..1a198769 100644 --- a/lnbits/extensions/tpos/crud.py +++ b/lnbits/extensions/tpos/crud.py @@ -3,7 +3,7 @@ from typing import List, Optional, Union from lnbits.helpers import urlsafe_short_hash from . import db -from .models import TPoS, CreateTposData +from .models import CreateTposData, TPoS async def create_tpos(wallet_id: str, data: CreateTposData) -> TPoS: diff --git a/lnbits/extensions/tpos/models.py b/lnbits/extensions/tpos/models.py index a474a05d..653a055c 100644 --- a/lnbits/extensions/tpos/models.py +++ b/lnbits/extensions/tpos/models.py @@ -1,5 +1,5 @@ from sqlite3 import Row -from fastapi.param_functions import Query + from pydantic import BaseModel @@ -7,6 +7,7 @@ class CreateTposData(BaseModel): name: str currency: str + class TPoS(BaseModel): id: str wallet: str diff --git a/lnbits/extensions/tpos/templates/tpos/tpos.html b/lnbits/extensions/tpos/templates/tpos/tpos.html index 429c763d..d05fab4e 100644 --- a/lnbits/extensions/tpos/templates/tpos/tpos.html +++ b/lnbits/extensions/tpos/templates/tpos/tpos.html @@ -133,6 +133,15 @@ + + + {% endblock %} {% block styles %} @@ -172,6 +181,9 @@ }, urlDialog: { show: false + }, + complete: { + show: false } } }, @@ -234,11 +246,10 @@ dialog.dismissMsg() dialog.show = false - self.$q.notify({ - type: 'positive', - message: self.fsat + ' sat received!', - icon: null - }) + self.complete.show = true + setTimeout(function () { + self.complete.show = false + }, 5000) } }) }, 3000) diff --git a/lnbits/extensions/tpos/views.py b/lnbits/extensions/tpos/views.py index 1c7fedcd..2d78ecce 100644 --- a/lnbits/extensions/tpos/views.py +++ b/lnbits/extensions/tpos/views.py @@ -1,23 +1,25 @@ +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.core.crud import get_wallet from lnbits.decorators import check_user_exists -from http import HTTPStatus from . import tpos_ext, tpos_renderer from .crud import get_tpos -from fastapi import FastAPI, Request -from fastapi.params import Depends -from fastapi.templating import Jinja2Templates templates = Jinja2Templates(directory="templates") + @tpos_ext.get("/", response_class=HTMLResponse) -# @validate_uuids(["usr"], required=True) -# @check_user_exists() async def index(request: Request, user: User = Depends(check_user_exists)): - return tpos_renderer().TemplateResponse("tpos/index.html", {"request": request,"user": user.dict()}) + return tpos_renderer().TemplateResponse( + "tpos/index.html", {"request": request, "user": user.dict()} + ) @tpos_ext.get("/{tpos_id}") @@ -25,9 +27,9 @@ async def tpos(request: Request, tpos_id): tpos = await get_tpos(tpos_id) if not tpos: raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail="TPoS does not exist." + status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist." ) - # abort(HTTPStatus.NOT_FOUND, "TPoS does not exist.") - return tpos_renderer().TemplateResponse("tpos/tpos.html", {"request": request, "tpos": tpos}) + return tpos_renderer().TemplateResponse( + "tpos/tpos.html", {"request": request, "tpos": tpos} + ) diff --git a/lnbits/extensions/tpos/views_api.py b/lnbits/extensions/tpos/views_api.py index b73dc83e..8d640a8a 100644 --- a/lnbits/extensions/tpos/views_api.py +++ b/lnbits/extensions/tpos/views_api.py @@ -2,35 +2,32 @@ from http import HTTPStatus from fastapi import Query from fastapi.params import Depends - -from pydantic import BaseModel from starlette.exceptions import HTTPException -from starlette.requests import Request -from starlette.responses import HTMLResponse, JSONResponse # type: ignore from lnbits.core.crud import get_user, get_wallet -from lnbits.core.services import create_invoice, check_invoice_status +from lnbits.core.services import check_invoice_status, create_invoice from lnbits.decorators import WalletTypeInfo, get_key_type from . import tpos_ext -from .crud import create_tpos, get_tpos, get_tposs, delete_tpos -from .models import TPoS, CreateTposData +from .crud import create_tpos, delete_tpos, get_tpos, get_tposs +from .models import CreateTposData @tpos_ext.get("/api/v1/tposs", status_code=HTTPStatus.OK) async def api_tposs( - all_wallets: bool = Query(None), - wallet: WalletTypeInfo = Depends(get_key_type) - ): + all_wallets: bool = Query(None), wallet: WalletTypeInfo = Depends(get_key_type) +): wallet_ids = [wallet.wallet.id] if all_wallets: - wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids + wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids return [tpos.dict() for tpos in await get_tposs(wallet_ids)] @tpos_ext.post("/api/v1/tposs", status_code=HTTPStatus.CREATED) -async def api_tpos_create(data: CreateTposData, wallet: WalletTypeInfo = Depends(get_key_type)): +async def api_tpos_create( + data: CreateTposData, wallet: WalletTypeInfo = Depends(get_key_type) +): tpos = await create_tpos(wallet_id=wallet.wallet.id, data=data) return tpos.dict() @@ -41,30 +38,26 @@ async def api_tpos_delete(tpos_id: str, wallet: WalletTypeInfo = Depends(get_key if not tpos: raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail="TPoS does not exist." + status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist." ) # return {"message": "TPoS does not exist."}, HTTPStatus.NOT_FOUND if tpos.wallet != wallet.wallet.id: - raise HTTPException( - status_code=HTTPStatus.FORBIDDEN, - detail="Not your TPoS." - ) + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your TPoS.") # return {"message": "Not your TPoS."}, HTTPStatus.FORBIDDEN await delete_tpos(tpos_id) raise HTTPException(status_code=HTTPStatus.NO_CONTENT) # return "", HTTPStatus.NO_CONTENT + @tpos_ext.post("/api/v1/tposs/{tpos_id}/invoices", status_code=HTTPStatus.CREATED) async def api_tpos_create_invoice(amount: int = Query(..., ge=1), tpos_id: str = None): tpos = await get_tpos(tpos_id) if not tpos: raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail="TPoS does not exist." + status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist." ) # return {"message": "TPoS does not exist."}, HTTPStatus.NOT_FOUND @@ -76,22 +69,20 @@ async def api_tpos_create_invoice(amount: int = Query(..., ge=1), tpos_id: str = extra={"tag": "tpos"}, ) except Exception as e: - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail=str(e) - ) + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e)) # return {"message": str(e)}, HTTPStatus.INTERNAL_SERVER_ERROR return {"payment_hash": payment_hash, "payment_request": payment_request} -@tpos_ext.get("/api/v1/tposs/{tpos_id}/invoices/{payment_hash}", status_code=HTTPStatus.OK) +@tpos_ext.get( + "/api/v1/tposs/{tpos_id}/invoices/{payment_hash}", status_code=HTTPStatus.OK +) async def api_tpos_check_invoice(tpos_id: str, payment_hash: str): tpos = await get_tpos(tpos_id) if not tpos: raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail="TPoS does not exist." + status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist." ) # return {"message": "TPoS does not exist."}, HTTPStatus.NOT_FOUND diff --git a/lnbits/extensions/usermanager/README.md b/lnbits/extensions/usermanager/README.md new file mode 100644 index 00000000..b6f30627 --- /dev/null +++ b/lnbits/extensions/usermanager/README.md @@ -0,0 +1,26 @@ +# User Manager + +## Make and manage users/wallets + +To help developers use LNbits to manage their users, the User Manager extension allows the creation and management of users and wallets. + +For example, a games developer may be developing a game that needs each user to have their own wallet, LNbits can be included in the developers stack as the user and wallet manager. Or someone wanting to manage their family's wallets (wife, children, parents, etc...) or you want to host a community Lightning Network node and want to manage wallets for the users. + +## Usage + +1. Click the button "NEW USER" to create a new user\ + ![new user](https://i.imgur.com/4yZyfJE.png) +2. Fill the user information\ + - username + - the generated wallet name, user can create other wallets later on + - email + - set a password + ![user information](https://i.imgur.com/40du7W5.png) +3. After creating your user, it will appear in the **Users** section, and a user's wallet in the **Wallets** section. +4. Next you can share the wallet with the corresponding user\ + ![user wallet](https://i.imgur.com/gAyajbx.png) +5. If you need to create more wallets for some user, click "NEW WALLET" at the top\ + ![multiple wallets](https://i.imgur.com/wovVnim.png) + - select the existing user you wish to add the wallet + - set a wallet name\ + ![new wallet](https://i.imgur.com/sGwG8dC.png) diff --git a/lnbits/extensions/usermanager/__init__.py b/lnbits/extensions/usermanager/__init__.py new file mode 100644 index 00000000..915948a2 --- /dev/null +++ b/lnbits/extensions/usermanager/__init__.py @@ -0,0 +1,19 @@ +from fastapi import APIRouter + +from lnbits.db import Database +from lnbits.helpers import template_renderer + +db = Database("ext_usermanager") + +usermanager_ext: APIRouter = APIRouter( + prefix="/usermanager", + tags=["usermanager"] +) + + +def usermanager_renderer(): + return template_renderer(["lnbits/extensions/usermanager/templates"]) + + +from .views import * # noqa +from .views_api import * # noqa diff --git a/lnbits/extensions/usermanager/config.json b/lnbits/extensions/usermanager/config.json new file mode 100644 index 00000000..7391ec29 --- /dev/null +++ b/lnbits/extensions/usermanager/config.json @@ -0,0 +1,6 @@ +{ + "name": "User Manager", + "short_description": "Generate users and wallets", + "icon": "person_add", + "contributors": ["benarc"] +} diff --git a/lnbits/extensions/usermanager/crud.py b/lnbits/extensions/usermanager/crud.py new file mode 100644 index 00000000..4272d726 --- /dev/null +++ b/lnbits/extensions/usermanager/crud.py @@ -0,0 +1,118 @@ +from typing import List, Optional + +from lnbits.core.crud import (create_account, create_wallet, delete_wallet, + get_payments, get_user) +from lnbits.core.models import Payment + +from . import db +from .models import CreateUserData, Users, Wallets + +### Users + + +async def create_usermanager_user(data: CreateUserData) -> Users: + account = await create_account() + user = await get_user(account.id) + assert user, "Newly created user couldn't be retrieved" + + wallet = await create_wallet(user_id=user.id, wallet_name=data.wallet_name) + + await db.execute( + """ + INSERT INTO usermanager.users (id, name, admin, email, password) + VALUES (?, ?, ?, ?, ?) + """, + (user.id, data.user_name, data.admin_id, data.email, data.password), + ) + + await db.execute( + """ + INSERT INTO usermanager.wallets (id, admin, name, "user", adminkey, inkey) + VALUES (?, ?, ?, ?, ?, ?) + """, + ( + wallet.id, + data.admin_id, + data.wallet_name, + user.id, + wallet.adminkey, + wallet.inkey, + ), + ) + + user_created = await get_usermanager_user(user.id) + assert user_created, "Newly created user couldn't be retrieved" + return user_created + + +async def get_usermanager_user(user_id: str) -> Optional[Users]: + row = await db.fetchone("SELECT * FROM usermanager.users WHERE id = ?", (user_id,)) + return Users(**row) if row else None + + +async def get_usermanager_users(user_id: str) -> List[Users]: + rows = await db.fetchall( + "SELECT * FROM usermanager.users WHERE admin = ?", (user_id,) + ) + + return [Users(**row) for row in rows] + + +async def delete_usermanager_user(user_id: str) -> None: + wallets = await get_usermanager_wallets(user_id) + for wallet in wallets: + await delete_wallet(user_id=user_id, wallet_id=wallet.id) + + await db.execute("DELETE FROM usermanager.users WHERE id = ?", (user_id,)) + await db.execute("""DELETE FROM usermanager.wallets WHERE "user" = ?""", (user_id,)) + + +### Wallets + + +async def create_usermanager_wallet( + user_id: str, wallet_name: str, admin_id: str +) -> Wallets: + wallet = await create_wallet(user_id=user_id, wallet_name=wallet_name) + await db.execute( + """ + INSERT INTO usermanager.wallets (id, admin, name, "user", adminkey, inkey) + VALUES (?, ?, ?, ?, ?, ?) + """, + (wallet.id, admin_id, wallet_name, user_id, wallet.adminkey, wallet.inkey), + ) + wallet_created = await get_usermanager_wallet(wallet.id) + assert wallet_created, "Newly created wallet couldn't be retrieved" + return wallet_created + + +async def get_usermanager_wallet(wallet_id: str) -> Optional[Wallets]: + row = await db.fetchone( + "SELECT * FROM usermanager.wallets WHERE id = ?", (wallet_id,) + ) + return Wallets(**row) if row else None + + +async def get_usermanager_wallets(admin_id: str) -> Optional[Wallets]: + rows = await db.fetchall( + "SELECT * FROM usermanager.wallets WHERE admin = ?", (admin_id,) + ) + return [Wallets(**row) for row in rows] + + +async def get_usermanager_users_wallets(user_id: str) -> Optional[Wallets]: + rows = await db.fetchall( + """SELECT * FROM usermanager.wallets WHERE "user" = ?""", (user_id,) + ) + return [Wallets(**row) for row in rows] + + +async def get_usermanager_wallet_transactions(wallet_id: str) -> Optional[Payment]: + return await get_payments( + wallet_id=wallet_id, complete=True, pending=False, outgoing=True, incoming=True + ) + + +async def delete_usermanager_wallet(wallet_id: str, user_id: str) -> None: + await delete_wallet(user_id=user_id, wallet_id=wallet_id) + await db.execute("DELETE FROM usermanager.wallets WHERE id = ?", (wallet_id,)) diff --git a/lnbits/extensions/usermanager/migrations.py b/lnbits/extensions/usermanager/migrations.py new file mode 100644 index 00000000..62a21575 --- /dev/null +++ b/lnbits/extensions/usermanager/migrations.py @@ -0,0 +1,31 @@ +async def m001_initial(db): + """ + Initial users table. + """ + await db.execute( + """ + CREATE TABLE usermanager.users ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + admin TEXT NOT NULL, + email TEXT, + password TEXT + ); + """ + ) + + """ + Initial wallets table. + """ + await db.execute( + """ + CREATE TABLE usermanager.wallets ( + id TEXT PRIMARY KEY, + admin TEXT NOT NULL, + name TEXT NOT NULL, + "user" TEXT NOT NULL, + adminkey TEXT NOT NULL, + inkey TEXT NOT NULL + ); + """ + ) diff --git a/lnbits/extensions/usermanager/models.py b/lnbits/extensions/usermanager/models.py new file mode 100644 index 00000000..a0845233 --- /dev/null +++ b/lnbits/extensions/usermanager/models.py @@ -0,0 +1,33 @@ +from sqlite3 import Row + +from fastapi.param_functions import Query +from pydantic import BaseModel + + +class CreateUserData(BaseModel): + user_name: str = Query(...) + wallet_name: str = Query(...) + admin_id: str = Query(...) + email: str = Query("") + password: str = Query("") + + +class Users(BaseModel): + id: str + name: str + admin: str + email: str + password: str + + +class Wallets(BaseModel): + id: str + admin: str + name: str + user: str + adminkey: str + inkey: str + + @classmethod + def from_row(cls, row: Row) -> "Wallets": + return cls(**dict(row)) diff --git a/lnbits/extensions/usermanager/templates/usermanager/_api_docs.html b/lnbits/extensions/usermanager/templates/usermanager/_api_docs.html new file mode 100644 index 00000000..1944416b --- /dev/null +++ b/lnbits/extensions/usermanager/templates/usermanager/_api_docs.html @@ -0,0 +1,264 @@ + + + +
+ User Manager: Make and manager users/wallets +
+

+ To help developers use LNbits to manage their users, the User Manager + extension allows the creation and management of users and wallets. +
For example, a games developer may be developing a game that needs + each user to have their own wallet, LNbits can be included in the + develpoers stack as the user and wallet manager.
+ + Created by, Ben Arc +

+
+
+
+ + + + + GET + /usermanager/api/v1/users +
Body (application/json)
+
+ Returns 201 CREATED (application/json) +
+ JSON list of users +
Curl example
+ curl -X GET {{ request.url_root }}usermanager/api/v1/users -H + "X-Api-Key: {{ user.wallets[0].inkey }}" + +
+
+
+ + + + GET + /usermanager/api/v1/users/<user_id> +
Body (application/json)
+
+ Returns 201 CREATED (application/json) +
+ JSON list of users +
Curl example
+ curl -X GET {{ request.url_root + }}usermanager/api/v1/users/<user_id> -H "X-Api-Key: {{ + user.wallets[0].inkey }}" + +
+
+
+ + + + GET + /usermanager/api/v1/wallets/<user_id> +
Headers
+ {"X-Api-Key": <string>} +
Body (application/json)
+
+ Returns 201 CREATED (application/json) +
+ JSON wallet data +
Curl example
+ curl -X GET {{ request.url_root + }}usermanager/api/v1/wallets/<user_id> -H "X-Api-Key: {{ + user.wallets[0].inkey }}" + +
+
+
+ + + + GET + /usermanager/api/v1/wallets<wallet_id> +
Headers
+ {"X-Api-Key": <string>} +
Body (application/json)
+
+ Returns 201 CREATED (application/json) +
+ JSON a wallets transactions +
Curl example
+ curl -X GET {{ request.url_root + }}usermanager/api/v1/wallets<wallet_id> -H "X-Api-Key: {{ + user.wallets[0].inkey }}" + +
+
+
+ + + + POST + /usermanager/api/v1/users +
Headers
+ {"X-Api-Key": <string>, "Content-type": + "application/json"} +
+ Body (application/json) - "admin_id" is a YOUR user ID +
+ {"admin_id": <string>, "user_name": <string>, + "wallet_name": <string>,"email": <Optional string> + ,"password": <Optional string>} +
+ Returns 201 CREATED (application/json) +
+ {"id": <string>, "name": <string>, "admin": + <string>, "email": <string>, "password": + <string>} +
Curl example
+ curl -X POST {{ request.url_root }}usermanager/api/v1/users -d + '{"admin_id": "{{ user.id }}", "wallet_name": <string>, + "user_name": <string>, "email": <Optional string>, + "password": < Optional string>}' -H "X-Api-Key: {{ + user.wallets[0].inkey }}" -H "Content-type: application/json" + +
+
+
+ + + + POST + /usermanager/api/v1/wallets +
Headers
+ {"X-Api-Key": <string>, "Content-type": + "application/json"} +
+ Body (application/json) - "admin_id" is a YOUR user ID +
+ {"user_id": <string>, "wallet_name": <string>, + "admin_id": <string>} +
+ Returns 201 CREATED (application/json) +
+ {"id": <string>, "admin": <string>, "name": + <string>, "user": <string>, "adminkey": <string>, + "inkey": <string>} +
Curl example
+ curl -X POST {{ request.url_root }}usermanager/api/v1/wallets -d + '{"user_id": <string>, "wallet_name": <string>, + "admin_id": "{{ user.id }}"}' -H "X-Api-Key: {{ user.wallets[0].inkey + }}" -H "Content-type: application/json" + +
+
+
+ + + + DELETE + /usermanager/api/v1/users/<user_id> +
Headers
+ {"X-Api-Key": <string>} +
Curl example
+ curl -X DELETE {{ request.url_root + }}usermanager/api/v1/users/<user_id> -H "X-Api-Key: {{ + user.wallets[0].inkey }}" + +
+
+
+ + + + DELETE + /usermanager/api/v1/wallets/<wallet_id> +
Headers
+ {"X-Api-Key": <string>} +
Curl example
+ curl -X DELETE {{ request.url_root + }}usermanager/api/v1/wallets/<wallet_id> -H "X-Api-Key: {{ + user.wallets[0].inkey }}" + +
+
+
+ + + + POST + /usermanager/api/v1/extensions +
Headers
+ {"X-Api-Key": <string>} +
Curl example
+ curl -X POST {{ request.url_root }}usermanager/api/v1/extensions -d + '{"userid": <string>, "extension": <string>, "active": + <integer>}' -H "X-Api-Key: {{ user.wallets[0].inkey }}" -H + "Content-type: application/json" + +
+
+
+
diff --git a/lnbits/extensions/usermanager/templates/usermanager/index.html b/lnbits/extensions/usermanager/templates/usermanager/index.html new file mode 100644 index 00000000..6fbe9686 --- /dev/null +++ b/lnbits/extensions/usermanager/templates/usermanager/index.html @@ -0,0 +1,474 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + + New User + New Wallet + + + + + + +
+
+
Users
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
+ + + +
+
+
Wallets
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
+
+ +
+ + +
+ {{SITE_TITLE}} User Manager Extension +
+
+ + + {% include "usermanager/_api_docs.html" %} + +
+
+ + + + + + + + + + Create User + Cancel + + + + + + + + + + + Create Wallet + Cancel + + + +
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/lnbits/extensions/usermanager/views.py b/lnbits/extensions/usermanager/views.py new file mode 100644 index 00000000..420669b0 --- /dev/null +++ b/lnbits/extensions/usermanager/views.py @@ -0,0 +1,15 @@ +from fastapi import Request +from fastapi.params import Depends +from starlette.responses import HTMLResponse + +from lnbits.core.models import User +from lnbits.decorators import check_user_exists + +from . import usermanager_ext, usermanager_renderer + + +@usermanager_ext.get("/", response_class=HTMLResponse) +async def index(request: Request, user: User = Depends(check_user_exists)): + return usermanager_renderer().TemplateResponse( + "usermanager/index.html", {"request": request, "user": user.dict()} + ) diff --git a/lnbits/extensions/usermanager/views_api.py b/lnbits/extensions/usermanager/views_api.py new file mode 100644 index 00000000..7bdee8fc --- /dev/null +++ b/lnbits/extensions/usermanager/views_api.py @@ -0,0 +1,138 @@ +from http import HTTPStatus + +from fastapi import Query +from fastapi.params import Depends +from starlette.exceptions import HTTPException + +from lnbits.core import update_user_extension +from lnbits.core.crud import get_user +from lnbits.decorators import WalletTypeInfo, get_key_type + +from . import usermanager_ext +from .crud import ( + create_usermanager_user, + create_usermanager_wallet, + delete_usermanager_user, + delete_usermanager_wallet, + get_usermanager_user, + get_usermanager_users, + get_usermanager_users_wallets, + get_usermanager_wallet, + get_usermanager_wallet_transactions, + get_usermanager_wallets, +) +from .models import CreateUserData + +### Users + + +@usermanager_ext.get("/api/v1/users", status_code=HTTPStatus.OK) +async def api_usermanager_users(wallet: WalletTypeInfo = Depends(get_key_type)): + user_id = wallet.wallet.user + return [user.dict() for user in await get_usermanager_users(user_id)] + + +@usermanager_ext.get("/api/v1/users/{user_id}", status_code=HTTPStatus.OK) +async def api_usermanager_user(user_id, wallet: WalletTypeInfo = Depends(get_key_type)): + user = await get_usermanager_user(user_id) + return user.dict() + + +@usermanager_ext.post("/api/v1/users", status_code=HTTPStatus.CREATED) +# @api_validate_post_request( +# schema={ +# "user_name": {"type": "string", "empty": False, "required": True}, +# "wallet_name": {"type": "string", "empty": False, "required": True}, +# "admin_id": {"type": "string", "empty": False, "required": True}, +# "email": {"type": "string", "required": False}, +# "password": {"type": "string", "required": False}, +# } +# ) +async def api_usermanager_users_create( + data: CreateUserData, wallet: WalletTypeInfo = Depends(get_key_type) +): + user = await create_usermanager_user(data) + full = user.dict() + full["wallets"] = [ + wallet.dict() for wallet in await get_usermanager_users_wallets(user.id) + ] + return full + + +@usermanager_ext.delete("/api/v1/users/{user_id}") +async def api_usermanager_users_delete( + user_id, wallet: WalletTypeInfo = Depends(get_key_type) +): + user = await get_usermanager_user(user_id) + if not user: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="User does not exist." + ) + await delete_usermanager_user(user_id) + raise HTTPException(status_code=HTTPStatus.NO_CONTENT) + + +###Activate Extension + + +@usermanager_ext.post("/api/v1/extensions") +async def api_usermanager_activate_extension( + extension: str = Query(...), userid: str = Query(...), active: bool = Query(...) +): + user = await get_user(userid) + if not user: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="User does not exist." + ) + update_user_extension(user_id=userid, extension=extension, active=active) + return {"extension": "updated"} + + +###Wallets + + +@usermanager_ext.post("/api/v1/wallets") +async def api_usermanager_wallets_create( + wallet: WalletTypeInfo = Depends(get_key_type), + user_id: str = Query(...), + wallet_name: str = Query(...), + admin_id: str = Query(...), +): + user = await create_usermanager_wallet(user_id, wallet_name, admin_id) + return user.dict() + + +@usermanager_ext.get("/api/v1/wallets") +async def api_usermanager_wallets(wallet: WalletTypeInfo = Depends(get_key_type)): + admin_id = wallet.wallet.user + return [wallet.dict() for wallet in await get_usermanager_wallets(admin_id)] + + +@usermanager_ext.get("/api/v1/wallets/{wallet_id}") +async def api_usermanager_wallet_transactions( + wallet_id, wallet: WalletTypeInfo = Depends(get_key_type) +): + return await get_usermanager_wallet_transactions(wallet_id) + + +@usermanager_ext.get("/api/v1/wallets/{user_id}") +async def api_usermanager_users_wallets( + user_id, wallet: WalletTypeInfo = Depends(get_key_type) +): + # wallet = await get_usermanager_users_wallets(user_id) + return [ + s_wallet.dict() for s_wallet in await get_usermanager_users_wallets(user_id) + ] + + +@usermanager_ext.delete("/api/v1/wallets/{wallet_id}") +async def api_usermanager_wallets_delete( + wallet_id, wallet: WalletTypeInfo = Depends(get_key_type) +): + get_wallet = await get_usermanager_wallet(wallet_id) + if not get_wallet: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist." + ) + await delete_usermanager_wallet(wallet_id, get_wallet.user) + raise HTTPException(status_code=HTTPStatus.NO_CONTENT) diff --git a/lnbits/extensions/watchonly/README.md b/lnbits/extensions/watchonly/README.md new file mode 100644 index 00000000..d93f7162 --- /dev/null +++ b/lnbits/extensions/watchonly/README.md @@ -0,0 +1,19 @@ +# Watch Only wallet + +## Monitor an onchain wallet and generate addresses for onchain payments + +Monitor an extended public key and generate deterministic fresh public keys with this simple watch only wallet. Invoice payments can also be generated, both through a publically shareable page and API. + +1. Start by clicking "NEW WALLET"\ + ![new wallet](https://i.imgur.com/vgbAB7c.png) +2. Fill the requested fields: + - give the wallet a name + - paste an Extended Public Key (xpub, ypub, zpub) + - click "CREATE WATCH-ONLY WALLET"\ + ![fill wallet form](https://i.imgur.com/UVoG7LD.png) +3. You can then access your onchain addresses\ + ![get address](https://i.imgur.com/zkxTQ6l.png) +4. You can then generate bitcoin onchain adresses from LNbits\ + ![onchain address](https://i.imgur.com/4KVSSJn.png) + +You can now use this wallet on the LNBits [SatsPayServer](https://github.com/lnbits/lnbits/blob/master/lnbits/extensions/satspay/README.md) extension diff --git a/lnbits/extensions/watchonly/__init__.py b/lnbits/extensions/watchonly/__init__.py new file mode 100644 index 00000000..0c525980 --- /dev/null +++ b/lnbits/extensions/watchonly/__init__.py @@ -0,0 +1,17 @@ +from fastapi import APIRouter + +from lnbits.db import Database +from lnbits.helpers import template_renderer + +db = Database("ext_watchonly") + + +watchonly_ext: APIRouter = APIRouter(prefix="/watchonly", tags=["watchonly"]) + + +def watchonly_renderer(): + return template_renderer(["lnbits/extensions/watchonly/templates"]) + + +from .views import * # noqa +from .views_api import * # noqa diff --git a/lnbits/extensions/watchonly/config.json b/lnbits/extensions/watchonly/config.json new file mode 100644 index 00000000..48c19ef0 --- /dev/null +++ b/lnbits/extensions/watchonly/config.json @@ -0,0 +1,8 @@ +{ + "name": "Watch Only", + "short_description": "Onchain watch only wallets", + "icon": "visibility", + "contributors": [ + "arcbtc" + ] +} diff --git a/lnbits/extensions/watchonly/crud.py b/lnbits/extensions/watchonly/crud.py new file mode 100644 index 00000000..68b59da6 --- /dev/null +++ b/lnbits/extensions/watchonly/crud.py @@ -0,0 +1,213 @@ +from typing import List, Optional + +from embit.descriptor import Descriptor, Key # type: ignore +from embit.descriptor.arguments import AllowedDerivation # type: ignore +from embit.networks import NETWORKS # type: ignore + +from lnbits.helpers import urlsafe_short_hash + +from . import db +from .models import Addresses, Mempool, Wallets + +##########################WALLETS#################### + + +def detect_network(k): + version = k.key.version + for network_name in NETWORKS: + net = NETWORKS[network_name] + # not found in this network + if version in [net["xpub"], net["ypub"], net["zpub"], net["Zpub"], net["Ypub"]]: + return net + + +def parse_key(masterpub: str): + """Parses masterpub or descriptor and returns a tuple: (Descriptor, network) + To create addresses use descriptor.derive(num).address(network=network) + """ + network = None + # probably a single key + if "(" not in masterpub: + k = Key.from_string(masterpub) + if not k.is_extended: + raise ValueError("The key is not a master public key") + if k.is_private: + raise ValueError("Private keys are not allowed") + # check depth + if k.key.depth != 3: + raise ValueError( + "Non-standard depth. Only bip44, bip49 and bip84 are supported with bare xpubs. For custom derivation paths use descriptors." + ) + # if allowed derivation is not provided use default /{0,1}/* + if k.allowed_derivation is None: + k.allowed_derivation = AllowedDerivation.default() + # get version bytes + version = k.key.version + for network_name in NETWORKS: + net = NETWORKS[network_name] + # not found in this network + if version in [net["xpub"], net["ypub"], net["zpub"]]: + network = net + if version == net["xpub"]: + desc = Descriptor.from_string("pkh(%s)" % str(k)) + elif version == net["ypub"]: + desc = Descriptor.from_string("sh(wpkh(%s))" % str(k)) + elif version == net["zpub"]: + desc = Descriptor.from_string("wpkh(%s)" % str(k)) + break + # we didn't find correct version + if network is None: + raise ValueError("Unknown master public key version") + else: + desc = Descriptor.from_string(masterpub) + if not desc.is_wildcard: + raise ValueError("Descriptor should have wildcards") + for k in desc.keys: + if k.is_extended: + net = detect_network(k) + if net is None: + raise ValueError(f"Unknown version: {k}") + if network is not None and network != net: + raise ValueError("Keys from different networks") + network = net + return desc, network + + +async def create_watch_wallet(user: str, masterpub: str, title: str) -> Wallets: + # check the masterpub is fine, it will raise an exception if not + print("PARSE", parse_key(masterpub)) + parse_key(masterpub) + wallet_id = urlsafe_short_hash() + await db.execute( + """ + INSERT INTO watchonly.wallets ( + id, + "user", + masterpub, + title, + address_no, + balance + ) + VALUES (?, ?, ?, ?, ?, ?) + """, + # address_no is -1 so fresh address on empty wallet can get address with index 0 + (wallet_id, user, masterpub, title, -1, 0), + ) + + return await get_watch_wallet(wallet_id) + + +async def get_watch_wallet(wallet_id: str) -> Optional[Wallets]: + row = await db.fetchone( + "SELECT * FROM watchonly.wallets WHERE id = ?", (wallet_id,) + ) + return Wallets.from_row(row) if row else None + + +async def get_watch_wallets(user: str) -> List[Wallets]: + rows = await db.fetchall( + """SELECT * FROM watchonly.wallets WHERE "user" = ?""", (user,) + ) + return [Wallets(**row) for row in rows] + + +async def update_watch_wallet(wallet_id: str, **kwargs) -> Optional[Wallets]: + q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) + + await db.execute( + f"UPDATE watchonly.wallets SET {q} WHERE id = ?", (*kwargs.values(), wallet_id) + ) + row = await db.fetchone( + "SELECT * FROM watchonly.wallets WHERE id = ?", (wallet_id,) + ) + return Wallets.from_row(row) if row else None + + +async def delete_watch_wallet(wallet_id: str) -> None: + await db.execute("DELETE FROM watchonly.wallets WHERE id = ?", (wallet_id,)) + + ########################ADDRESSES####################### + + +async def get_derive_address(wallet_id: str, num: int): + wallet = await get_watch_wallet(wallet_id) + key = wallet.masterpub + desc, network = parse_key(key) + return desc.derive(num).address(network=network) + + +async def get_fresh_address(wallet_id: str) -> Optional[Addresses]: + wallet = await get_watch_wallet(wallet_id) + + if not wallet: + return None + + address = await get_derive_address(wallet_id, wallet.address_no + 1) + + await update_watch_wallet(wallet_id=wallet_id, address_no=wallet.address_no + 1) + masterpub_id = urlsafe_short_hash() + await db.execute( + """ + INSERT INTO watchonly.addresses ( + id, + address, + wallet, + amount + ) + VALUES (?, ?, ?, ?) + """, + (masterpub_id, address, wallet_id, 0), + ) + + return await get_address(address) + + +async def get_address(address: str) -> Optional[Addresses]: + row = await db.fetchone( + "SELECT * FROM watchonly.addresses WHERE address = ?", (address,) + ) + return Addresses.from_row(row) if row else None + + +async def get_addresses(wallet_id: str) -> List[Addresses]: + rows = await db.fetchall( + "SELECT * FROM watchonly.addresses WHERE wallet = ?", (wallet_id,) + ) + return [Addresses(**row) for row in rows] + + +######################MEMPOOL####################### + + +async def create_mempool(user: str) -> Optional[Mempool]: + await db.execute( + """ + INSERT INTO watchonly.mempool ("user",endpoint) + VALUES (?, ?) + """, + (user, "https://mempool.space"), + ) + row = await db.fetchone( + """SELECT * FROM watchonly.mempool WHERE "user" = ?""", (user,) + ) + return Mempool.from_row(row) if row else None + + +async def update_mempool(user: str, **kwargs) -> Optional[Mempool]: + q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) + + await db.execute( + f"""UPDATE watchonly.mempool SET {q} WHERE "user" = ?""", + (*kwargs.values(), user), + ) + row = await db.fetchone( + """SELECT * FROM watchonly.mempool WHERE "user" = ?""", (user,) + ) + return Mempool.from_row(row) if row else None + + +async def get_mempool(user: str) -> Mempool: + row = await db.fetchone( + """SELECT * FROM watchonly.mempool WHERE "user" = ?""", (user,) + ) + return Mempool.from_row(row) if row else None diff --git a/lnbits/extensions/watchonly/migrations.py b/lnbits/extensions/watchonly/migrations.py new file mode 100644 index 00000000..05c229b5 --- /dev/null +++ b/lnbits/extensions/watchonly/migrations.py @@ -0,0 +1,36 @@ +async def m001_initial(db): + """ + Initial wallet table. + """ + await db.execute( + """ + CREATE TABLE watchonly.wallets ( + id TEXT NOT NULL PRIMARY KEY, + "user" TEXT, + masterpub TEXT NOT NULL, + title TEXT NOT NULL, + address_no INTEGER NOT NULL DEFAULT 0, + balance INTEGER NOT NULL + ); + """ + ) + + await db.execute( + """ + CREATE TABLE watchonly.addresses ( + id TEXT NOT NULL PRIMARY KEY, + address TEXT NOT NULL, + wallet TEXT NOT NULL, + amount INTEGER NOT NULL + ); + """ + ) + + await db.execute( + """ + CREATE TABLE watchonly.mempool ( + "user" TEXT NOT NULL, + endpoint TEXT NOT NULL + ); + """ + ) diff --git a/lnbits/extensions/watchonly/models.py b/lnbits/extensions/watchonly/models.py new file mode 100644 index 00000000..d0894097 --- /dev/null +++ b/lnbits/extensions/watchonly/models.py @@ -0,0 +1,42 @@ +from sqlite3 import Row + +from fastapi.param_functions import Query +from pydantic import BaseModel + + +class CreateWallet(BaseModel): + masterpub: str = Query("") + title: str = Query("") + + +class Wallets(BaseModel): + id: str + user: str + masterpub: str + title: str + address_no: int + balance: int + + @classmethod + def from_row(cls, row: Row) -> "Wallets": + return cls(**dict(row)) + + +class Mempool(BaseModel): + user: str + endpoint: str + + @classmethod + def from_row(cls, row: Row) -> "Mempool": + return cls(**dict(row)) + + +class Addresses(BaseModel): + id: str + address: str + wallet: str + amount: int + + @classmethod + def from_row(cls, row: Row) -> "Addresses": + return cls(**dict(row)) diff --git a/lnbits/extensions/watchonly/templates/watchonly/_api_docs.html b/lnbits/extensions/watchonly/templates/watchonly/_api_docs.html new file mode 100644 index 00000000..9d6bb6ac --- /dev/null +++ b/lnbits/extensions/watchonly/templates/watchonly/_api_docs.html @@ -0,0 +1,244 @@ + + +

+ Watch Only extension uses mempool.space
+ For use with "account Extended Public Key" + https://iancoleman.io/bip39/ + +
Created by, + Ben Arc (using, + Embit
) +

+
+ + + + + + GET /watchonly/api/v1/wallet +
Headers
+ {"X-Api-Key": <invoice_key>}
+
+ Body (application/json) +
+
+ Returns 200 OK (application/json) +
+ [<wallets_object>, ...] +
Curl example
+ curl -X GET {{ request.url_root }}api/v1/wallet -H "X-Api-Key: {{ + user.wallets[0].inkey }}" + +
+
+
+ + + + GET + /watchonly/api/v1/wallet/<wallet_id> +
Headers
+ {"X-Api-Key": <invoice_key>}
+
+ Body (application/json) +
+
+ Returns 201 CREATED (application/json) +
+ [<wallet_object>, ...] +
Curl example
+ curl -X GET {{ request.url_root }}api/v1/wallet/<wallet_id> + -H "X-Api-Key: {{ user.wallets[0].inkey }}" + +
+
+
+ + + + POST /watchonly/api/v1/wallet +
Headers
+ {"X-Api-Key": <admin_key>}
+
+ Body (application/json) +
+
+ Returns 201 CREATED (application/json) +
+ [<wallet_object>, ...] +
Curl example
+ curl -X POST {{ request.url_root }}api/v1/wallet -d '{"title": + <string>, "masterpub": <string>}' -H "Content-type: + application/json" -H "X-Api-Key: {{ user.wallets[0].adminkey }}" + +
+
+
+ + + + DELETE + /watchonly/api/v1/wallet/<wallet_id> +
Headers
+ {"X-Api-Key": <admin_key>}
+
Returns 204 NO CONTENT
+ +
Curl example
+ curl -X DELETE {{ request.url_root + }}api/v1/wallet/<wallet_id> -H "X-Api-Key: {{ + user.wallets[0].adminkey }}" + +
+
+
+ + + + + GET + /watchonly/api/v1/addresses/<wallet_id> +
Headers
+ {"X-Api-Key": <invoice_key>}
+
+ Body (application/json) +
+
+ Returns 200 OK (application/json) +
+ [<address_object>, ...] +
Curl example
+ curl -X GET {{ request.url_root + }}api/v1/addresses/<wallet_id> -H "X-Api-Key: {{ + user.wallets[0].inkey }}" + +
+
+
+ + + + + GET + /watchonly/api/v1/address/<wallet_id> +
Headers
+ {"X-Api-Key": <invoice_key>}
+
+ Body (application/json) +
+
+ Returns 200 OK (application/json) +
+ [<address_object>, ...] +
Curl example
+ curl -X GET {{ request.url_root }}api/v1/address/<wallet_id> + -H "X-Api-Key: {{ user.wallets[0].inkey }}" + +
+
+
+ + + + + GET /watchonly/api/v1/mempool +
Headers
+ {"X-Api-Key": <admin_key>}
+
+ Body (application/json) +
+
+ Returns 200 OK (application/json) +
+ [<mempool_object>, ...] +
Curl example
+ curl -X GET {{ request.url_root }}api/v1/mempool -H "X-Api-Key: {{ + user.wallets[0].adminkey }}" + +
+
+
+ + + + + POST + /watchonly/api/v1/mempool +
Headers
+ {"X-Api-Key": <admin_key>}
+
+ Body (application/json) +
+
+ Returns 201 CREATED (application/json) +
+ [<mempool_object>, ...] +
Curl example
+ curl -X PUT {{ request.url_root }}api/v1/mempool -d '{"endpoint": + <string>}' -H "Content-type: application/json" -H "X-Api-Key: + {{ user.wallets[0].adminkey }}" + +
+
+
+
+
diff --git a/lnbits/extensions/watchonly/templates/watchonly/index.html b/lnbits/extensions/watchonly/templates/watchonly/index.html new file mode 100644 index 00000000..e70f8a23 --- /dev/null +++ b/lnbits/extensions/watchonly/templates/watchonly/index.html @@ -0,0 +1,648 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + + {% raw %} + New wallet + + +
+ Point to another Mempool + {{ this.mempool.endpoint }} + + +
+ set + cancel +
+
+
+
+
+
+ + + +
+
+
Wallets
+
+
+ + + +
+
+ + + + +
+
+ + +
+
{{satBtc(utxos.total)}}
+ + {{utxos.sats ? ' sats' : ' BTC'}} +
+
+ + + +
+
+
Transactions
+
+
+ + + +
+
+ + + + +
+
+
+ + {% endraw %} + +
+ + +
+ {{SITE_TITLE}} Watch Only Extension +
+
+ + + {% include "watchonly/_api_docs.html" %} + +
+
+ + + + + + + + +
+ Create Watch-only Wallet + Cancel +
+
+
+
+ + + + {% raw %} +
Addresses
+
+

+ Current: + {{ currentaddress }} + +

+ + + +

+ + + + {{ data.address }} + + + + +

+ +
+ Get fresh address + Close +
+
+
+ {% endraw %} +
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + + + +{% endblock %} diff --git a/lnbits/extensions/watchonly/views.py b/lnbits/extensions/watchonly/views.py new file mode 100644 index 00000000..e0646651 --- /dev/null +++ b/lnbits/extensions/watchonly/views.py @@ -0,0 +1,33 @@ +from fastapi.params import Depends +from fastapi.templating import Jinja2Templates +from starlette.requests import Request +from starlette.responses import HTMLResponse + +from lnbits.core.models import User +from lnbits.decorators import check_user_exists + +from . import watchonly_ext, watchonly_renderer + +# from .crud import get_payment + + +templates = Jinja2Templates(directory="templates") + + +@watchonly_ext.get("/", response_class=HTMLResponse) +async def index(request: Request, user: User = Depends(check_user_exists)): + return watchonly_renderer().TemplateResponse( + "watchonly/index.html", {"request": request, "user": user.dict()} + ) + + +# @watchonly_ext.get("/{charge_id}", response_class=HTMLResponse) +# async def display(request: Request, charge_id): +# link = get_payment(charge_id) +# if not link: +# raise HTTPException( +# status_code=HTTPStatus.NOT_FOUND, +# detail="Charge link does not exist." +# ) +# +# return watchonly_renderer().TemplateResponse("watchonly/display.html", {"request": request,"link": link.dict()}) diff --git a/lnbits/extensions/watchonly/views_api.py b/lnbits/extensions/watchonly/views_api.py new file mode 100644 index 00000000..61978b74 --- /dev/null +++ b/lnbits/extensions/watchonly/views_api.py @@ -0,0 +1,119 @@ +from http import HTTPStatus + +from fastapi import Query +from fastapi.params import Depends +from starlette.exceptions import HTTPException + +from lnbits.core.crud import get_user +from lnbits.decorators import WalletTypeInfo, get_key_type +from lnbits.extensions.watchonly import watchonly_ext + +from .crud import (create_mempool, create_watch_wallet, delete_watch_wallet, + get_addresses, get_fresh_address, get_mempool, + get_watch_wallet, get_watch_wallets, update_mempool) +from .models import CreateWallet + +###################WALLETS############################# + + +@watchonly_ext.get("/api/v1/wallet") +async def api_wallets_retrieve(wallet: WalletTypeInfo = Depends(get_key_type)): + + try: + return [wallet.dict() for wallet in await get_watch_wallets(wallet.wallet.user)] + except: + return "" + + +@watchonly_ext.get("/api/v1/wallet/{wallet_id}") +async def api_wallet_retrieve( + wallet_id, wallet: WalletTypeInfo = Depends(get_key_type) +): + w_wallet = await get_watch_wallet(wallet_id) + + if not w_wallet: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist." + ) + + return w_wallet.dict() + + +@watchonly_ext.post("/api/v1/wallet") +async def api_wallet_create_or_update( + data: CreateWallet, wallet_id=None, w: WalletTypeInfo = Depends(get_key_type) +): + try: + wallet = await create_watch_wallet( + user=w.wallet.user, masterpub=data.masterpub, title=data.title + ) + except Exception as e: + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) + + mempool = await get_mempool(w.wallet.user) + if not mempool: + create_mempool(user=w.wallet.user) + return wallet.dict() + + +@watchonly_ext.delete("/api/v1/wallet/{wallet_id}") +async def api_wallet_delete(wallet_id, w: WalletTypeInfo = Depends(get_key_type)): + wallet = await get_watch_wallet(wallet_id) + + if not wallet: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist." + ) + + await delete_watch_wallet(wallet_id) + + raise HTTPException(status_code=HTTPStatus.NO_CONTENT) + + +#############################ADDRESSES########################## + + +@watchonly_ext.get("/api/v1/address/{wallet_id}") +async def api_fresh_address(wallet_id, w: WalletTypeInfo = Depends(get_key_type)): + await get_fresh_address(wallet_id) + + addresses = await get_addresses(wallet_id) + + return [address.dict() for address in addresses] + + +@watchonly_ext.get("/api/v1/addresses/{wallet_id}") +async def api_get_addresses(wallet_id, w: WalletTypeInfo = Depends(get_key_type)): + wallet = await get_watch_wallet(wallet_id) + + if not wallet: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist." + ) + + addresses = await get_addresses(wallet_id) + + if not addresses: + await get_fresh_address(wallet_id) + addresses = await get_addresses(wallet_id) + + return [address.dict() for address in addresses] + + +#############################MEMPOOL########################## + + +@watchonly_ext.put("/api/v1/mempool") +async def api_update_mempool( + endpoint: str = Query(...), w: WalletTypeInfo = Depends(get_key_type) +): + mempool = await update_mempool(endpoint, user=w.wallet.user) + return mempool.dict() + + +@watchonly_ext.get("/api/v1/mempool") +async def api_get_mempool(w: WalletTypeInfo = Depends(get_key_type)): + mempool = await get_mempool(w.wallet.user) + if not mempool: + mempool = await create_mempool(user=w.wallet.user) + return mempool.dict() diff --git a/lnbits/extensions/withdraw/__init__.py b/lnbits/extensions/withdraw/__init__.py index 184b4f01..b756aeea 100644 --- a/lnbits/extensions/withdraw/__init__.py +++ b/lnbits/extensions/withdraw/__init__.py @@ -21,18 +21,14 @@ withdraw_ext: APIRouter = APIRouter( # "withdraw", __name__, static_folder="static", template_folder="templates" ) + def withdraw_renderer(): - return template_renderer( - [ - "lnbits/extensions/withdraw/templates", - ] - ) + return template_renderer(["lnbits/extensions/withdraw/templates"]) -from .views_api import * # noqa -from .views import * # noqa from .lnurl import * # noqa - +from .views import * # noqa +from .views_api import * # noqa # @withdraw_ext.on_event("startup") # def _do_it(): diff --git a/lnbits/extensions/withdraw/crud.py b/lnbits/extensions/withdraw/crud.py index 183d8629..01a841cf 100644 --- a/lnbits/extensions/withdraw/crud.py +++ b/lnbits/extensions/withdraw/crud.py @@ -1,15 +1,14 @@ from datetime import datetime from typing import List, Optional, Union + from lnbits.helpers import urlsafe_short_hash from . import db -from .models import WithdrawLink, HashCheck, CreateWithdrawData +from .models import CreateWithdrawData, HashCheck, WithdrawLink async def create_withdraw_link( - data: CreateWithdrawData, - wallet_id: str, - usescsv: str, + data: CreateWithdrawData, wallet_id: str, usescsv: str ) -> WithdrawLink: link_id = urlsafe_short_hash() await db.execute( @@ -61,7 +60,7 @@ async def get_withdraw_link(link_id: str, num=0) -> Optional[WithdrawLink]: # for item in row: # link.append(item) # link.append(num) - print("GET_LINK", WithdrawLink.from_row(row)) + # print("GET_LINK", WithdrawLink.from_row(row)) return WithdrawLink.from_row(row) @@ -115,10 +114,7 @@ def chunks(lst, n): yield lst[i : i + n] -async def create_hash_check( - the_hash: str, - lnurl_id: str, -) -> HashCheck: +async def create_hash_check(the_hash: str, lnurl_id: str) -> HashCheck: await db.execute( """ INSERT INTO withdraw.hash_check ( @@ -127,10 +123,7 @@ async def create_hash_check( ) VALUES (?, ?) """, - ( - the_hash, - lnurl_id, - ), + (the_hash, lnurl_id), ) hashCheck = await get_hash_check(the_hash, lnurl_id) return hashCheck diff --git a/lnbits/extensions/withdraw/lnurl.py b/lnbits/extensions/withdraw/lnurl.py index 75339cf7..af3ecff4 100644 --- a/lnbits/extensions/withdraw/lnurl.py +++ b/lnbits/extensions/withdraw/lnurl.py @@ -1,25 +1,30 @@ -import shortuuid # type: ignore -from http import HTTPStatus from datetime import datetime +from http import HTTPStatus + +import shortuuid # type: ignore +from fastapi import HTTPException +from fastapi.param_functions import Query +from starlette.requests import Request from lnbits.core.services import pay_invoice -from starlette.requests import Request from . import withdraw_ext from .crud import get_withdraw_link_by_hash, update_withdraw_link - # FOR LNURLs WHICH ARE NOT UNIQUE -@withdraw_ext.get("/api/v1/lnurl/{unique_hash}", status_code=HTTPStatus.OK, name="withdraw.api_lnurl_response") +@withdraw_ext.get( + "/api/v1/lnurl/{unique_hash}", + status_code=HTTPStatus.OK, + name="withdraw.api_lnurl_response", +) async def api_lnurl_response(request: Request, unique_hash): link = await get_withdraw_link_by_hash(unique_hash) if not link: raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail="Withdraw link does not exist." + status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist." ) # return ({"status": "ERROR", "reason": "LNURL-withdraw not found."}, # HTTPStatus.OK, @@ -37,66 +42,22 @@ async def api_lnurl_response(request: Request, unique_hash): return link.lnurl_response(request).dict() -# FOR LNURLs WHICH ARE UNIQUE - - -@withdraw_ext.get("/api/v1/lnurl/{unique_hash}/{id_unique_hash}", status_code=HTTPStatus.OK, name="withdraw.api_lnurl_multi_response") -async def api_lnurl_multi_response(request: Request, unique_hash, id_unique_hash): - link = await get_withdraw_link_by_hash(unique_hash) - - if not link: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail="LNURL-withdraw not found." - ) - # return ( - # {"status": "ERROR", "reason": "LNURL-withdraw not found."}, - # HTTPStatus.OK, - # ) - - if link.is_spent: - raise HTTPException( - # WHAT STATUS_CODE TO USE?? - detail="Withdraw is spent." - ) - # return ( - # {"status": "ERROR", "reason": "Withdraw is spent."}, - # HTTPStatus.OK, - # ) - - useslist = link.usescsv.split(",") - found = False - for x in useslist: - tohash = link.id + link.unique_hash + str(x) - if id_unique_hash == shortuuid.uuid(name=tohash): - found = True - if not found: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail="LNURL-withdraw not found." - ) - # return ( - # {"status": "ERROR", "reason": "LNURL-withdraw not found."}, - # HTTPStatus.OK, - # ) - - return link.lnurl_response(request).dict() - - # CALLBACK -@withdraw_ext.get("/api/v1/lnurl/cb/{unique_hash}", status_code=HTTPStatus.OK, name="withdraw.api_lnurl_callback") -async def api_lnurl_callback(unique_hash): +@withdraw_ext.get("/api/v1/lnurl/cb/{unique_hash}", name="withdraw.api_lnurl_callback") +async def api_lnurl_callback( + request: Request, + unique_hash: str = Query(...), + k1: str = Query(...), + payment_request: str = Query(..., alias="pr"), +): link = await get_withdraw_link_by_hash(unique_hash) - k1 = request.query_params['k1'] - payment_request = request.query_params['pr'] now = int(datetime.now().timestamp()) if not link: raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail="LNURL-withdraw not found." + status_code=HTTPStatus.NOT_FOUND, detail="LNURL-withdraw not found." ) # return ( # {"status": "ERROR", "reason": "LNURL-withdraw not found."}, @@ -114,10 +75,7 @@ async def api_lnurl_callback(unique_hash): # ) if link.k1 != k1: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail="Bad request." - ) + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail="Bad request.") # return {"status": "ERROR", "reason": "Bad request."}, HTTPStatus.OK if now < link.open_time: @@ -163,3 +121,51 @@ async def api_lnurl_callback(unique_hash): return {"status": "ERROR", "reason": str(e)} return {"status": "OK"} + + +# FOR LNURLs WHICH ARE UNIQUE + + +@withdraw_ext.get( + "/api/v1/lnurl/{unique_hash}/{id_unique_hash}", + status_code=HTTPStatus.OK, + name="withdraw.api_lnurl_multi_response", +) +async def api_lnurl_multi_response(request: Request, unique_hash, id_unique_hash): + link = await get_withdraw_link_by_hash(unique_hash) + + if not link: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="LNURL-withdraw not found." + ) + # return ( + # {"status": "ERROR", "reason": "LNURL-withdraw not found."}, + # HTTPStatus.OK, + # ) + + if link.is_spent: + raise HTTPException( + # WHAT STATUS_CODE TO USE?? + detail="Withdraw is spent." + ) + # return ( + # {"status": "ERROR", "reason": "Withdraw is spent."}, + # HTTPStatus.OK, + # ) + + useslist = link.usescsv.split(",") + found = False + for x in useslist: + tohash = link.id + link.unique_hash + str(x) + if id_unique_hash == shortuuid.uuid(name=tohash): + found = True + if not found: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="LNURL-withdraw not found." + ) + # return ( + # {"status": "ERROR", "reason": "LNURL-withdraw not found."}, + # HTTPStatus.OK, + # ) + + return link.lnurl_response(request).dict() diff --git a/lnbits/extensions/withdraw/models.py b/lnbits/extensions/withdraw/models.py index 55b234e3..8de38e38 100644 --- a/lnbits/extensions/withdraw/models.py +++ b/lnbits/extensions/withdraw/models.py @@ -1,17 +1,20 @@ -from starlette.requests import Request -from fastapi.param_functions import Query -from lnurl import Lnurl, LnurlWithdrawResponse, encode as lnurl_encode # type: ignore from sqlite3 import Row -from pydantic import BaseModel + import shortuuid # type: ignore +from fastapi.param_functions import Query +from lnurl import Lnurl, LnurlWithdrawResponse +from lnurl import encode as lnurl_encode # type: ignore +from pydantic import BaseModel +from starlette.requests import Request + class CreateWithdrawData(BaseModel): - title: str = Query(...) - min_withdrawable: int = Query(..., ge=1) - max_withdrawable: int = Query(..., ge=1) - uses: int = Query(..., ge=1) - wait_time: int = Query(..., ge=1) - is_unique: bool + title: str = Query(...) + min_withdrawable: int = Query(..., ge=1) + max_withdrawable: int = Query(..., ge=1) + uses: int = Query(..., ge=1) + wait_time: int = Query(..., ge=1) + is_unique: bool class WithdrawLink(BaseModel): @@ -49,20 +52,18 @@ class WithdrawLink(BaseModel): url = req.url_for( "withdraw.api_lnurl_multi_response", unique_hash=self.unique_hash, - id_unique_hash=multihash + id_unique_hash=multihash, ) else: url = req.url_for( - "withdraw.api_lnurl_response", - unique_hash=self.unique_hash + "withdraw.api_lnurl_response", unique_hash=self.unique_hash ) return lnurl_encode(url) - def lnurl_response(self, req: Request) -> LnurlWithdrawResponse: url = req.url_for( - "withdraw.api_lnurl_callback", unique_hash=self.unique_hash, _external=True + name="withdraw.api_lnurl_callback", unique_hash=self.unique_hash ) return LnurlWithdrawResponse( callback=url, diff --git a/lnbits/extensions/withdraw/templates/withdraw/display.html b/lnbits/extensions/withdraw/templates/withdraw/display.html index f4d6ef9d..245b3ed1 100644 --- a/lnbits/extensions/withdraw/templates/withdraw/display.html +++ b/lnbits/extensions/withdraw/templates/withdraw/display.html @@ -7,10 +7,10 @@ {% if link.is_spent %} Withdraw is spent. {% endif %} - + @@ -18,7 +18,7 @@
- Copy LNURL
diff --git a/lnbits/extensions/withdraw/views.py b/lnbits/extensions/withdraw/views.py index 9bdaee45..6329ce64 100644 --- a/lnbits/extensions/withdraw/views.py +++ b/lnbits/extensions/withdraw/views.py @@ -1,25 +1,29 @@ from http import HTTPStatus -import pyqrcode from io import BytesIO + +import pyqrcode +from fastapi import Request +from fastapi.params import Depends +from fastapi.templating import Jinja2Templates +from starlette.exceptions import HTTPException +from starlette.responses import HTMLResponse, StreamingResponse + +from lnbits.core.models import User from lnbits.decorators import check_user_exists from . import withdraw_ext, withdraw_renderer -from .crud import get_withdraw_link, chunks -from fastapi import FastAPI, 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 .crud import chunks, get_withdraw_link templates = Jinja2Templates(directory="templates") + @withdraw_ext.get("/", response_class=HTMLResponse) # @validate_uuids(["usr"], required=True) # @check_user_exists() async def index(request: Request, user: User = Depends(check_user_exists)): - return withdraw_renderer().TemplateResponse("withdraw/index.html", {"request":request,"user": user.dict()}) + return withdraw_renderer().TemplateResponse( + "withdraw/index.html", {"request": request, "user": user.dict()} + ) @withdraw_ext.get("/{link_id}", response_class=HTMLResponse) @@ -28,21 +32,28 @@ async def display(request: Request, link_id): if not link: raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail="Withdraw link does not exist." + status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist." ) # response.status_code = HTTPStatus.NOT_FOUND # return "Withdraw link does not exist." #probably here is where we should return the 404?? - return withdraw_renderer().TemplateResponse("withdraw/display.html", {"request":request,"link":{**link.dict(), "lnurl": link.lnurl(request)}, "unique":True}) + print("LINK", link) + return withdraw_renderer().TemplateResponse( + "withdraw/display.html", + { + "request": request, + "link": link.dict(), + "lnurl": link.lnurl(req=request), + "unique": True, + }, + ) -@withdraw_ext.get("/img/{link_id}", response_class=HTMLResponse) +@withdraw_ext.get("/img/{link_id}", response_class=StreamingResponse) async def img(request: Request, link_id): link = await get_withdraw_link(link_id, 0) if not link: raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail="Withdraw link does not exist." + status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist." ) # response.status_code = HTTPStatus.NOT_FOUND # return "Withdraw link does not exist." @@ -50,10 +61,14 @@ async def img(request: Request, link_id): print(qr) stream = BytesIO() qr.svg(stream, scale=3) - return ( - stream.getvalue(), - 200, - { + stream.seek(0) + + async def _generator(stream: BytesIO): + yield stream.getvalue() + + return StreamingResponse( + _generator(stream), + headers={ "Content-Type": "image/svg+xml", "Cache-Control": "no-cache, no-store, must-revalidate", "Pragma": "no-cache", @@ -67,15 +82,17 @@ async def print_qr(request: Request, link_id): link = await get_withdraw_link(link_id) if not link: raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail="Withdraw link does not exist." + status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist." ) # response.status_code = HTTPStatus.NOT_FOUND # return "Withdraw link does not exist." if link.uses == 0: - return withdraw_renderer().TemplateResponse("withdraw/print_qr.html", {"request":request,"link":link.dict(), unique:False}) + return withdraw_renderer().TemplateResponse( + "withdraw/print_qr.html", + {"request": request, "link": link.dict(), unique: False}, + ) links = [] count = 0 @@ -83,8 +100,7 @@ async def print_qr(request: Request, link_id): linkk = await get_withdraw_link(link_id, count) if not linkk: raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail="Withdraw link does not exist." + status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist." ) # response.status_code = HTTPStatus.NOT_FOUND # return "Withdraw link does not exist." @@ -93,4 +109,6 @@ async def print_qr(request: Request, link_id): page_link = list(chunks(links, 2)) linked = list(chunks(page_link, 5)) print("LINKED", linked) - return withdraw_renderer().TemplateResponse("withdraw/print_qr.html", {"request":request,"link":linked, "unique":True}) + return withdraw_renderer().TemplateResponse( + "withdraw/print_qr.html", {"request": request, "link": linked, "unique": True} + ) diff --git a/lnbits/extensions/withdraw/views_api.py b/lnbits/extensions/withdraw/views_api.py index 93b498ef..678346f9 100644 --- a/lnbits/extensions/withdraw/views_api.py +++ b/lnbits/extensions/withdraw/views_api.py @@ -1,34 +1,31 @@ -from fastapi.params import Depends -from fastapi.param_functions import Query -from pydantic.main import BaseModel - from http import HTTPStatus + +from fastapi.param_functions import Query +from fastapi.params import Depends from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl # type: ignore from starlette.exceptions import HTTPException from starlette.requests import Request -from starlette.responses import HTMLResponse, JSONResponse # type: ignore from lnbits.core.crud import get_user from lnbits.decorators import WalletTypeInfo, get_key_type + +from . import withdraw_ext +from .crud import (create_withdraw_link, + delete_withdraw_link, get_hash_check, get_withdraw_link, + get_withdraw_links, update_withdraw_link) from .models import CreateWithdrawData # from fastapi import FastAPI, Query, Response -from . import withdraw_ext -from .crud import ( - create_withdraw_link, - get_withdraw_link, - get_withdraw_links, - update_withdraw_link, - delete_withdraw_link, - create_hash_check, - get_hash_check, -) @withdraw_ext.get("/api/v1/links", status_code=HTTPStatus.OK) # @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] if all_wallets: @@ -36,12 +33,9 @@ async def api_links(req: Request, wallet: WalletTypeInfo = Depends(get_key_type) try: return [ - { - **link.dict(), - **{"lnurl": link.lnurl(req)}, - } - for link in await get_withdraw_links(wallet_ids) - ] + {**link.dict(), **{"lnurl": link.lnurl(req)}} + for link in await get_withdraw_links(wallet_ids) + ] except LnurlInvalidUrl: raise HTTPException( @@ -59,21 +53,20 @@ async def api_link_retrieve(link_id, wallet: WalletTypeInfo = Depends(get_key_ty if not link: raise HTTPException( - detail="Withdraw link does not exist.", - status_code=HTTPStatus.NOT_FOUND + detail="Withdraw link does not exist.", status_code=HTTPStatus.NOT_FOUND ) # response.status_code = HTTPStatus.NOT_FOUND # return {"message": "Withdraw link does not exist."} if link.wallet != wallet.wallet.id: raise HTTPException( - detail="Not your withdraw link.", - status_code=HTTPStatus.FORBIDDEN + detail="Not your withdraw link.", status_code=HTTPStatus.FORBIDDEN ) # response.status_code = HTTPStatus.FORBIDDEN # return {"message": "Not your withdraw link."} return {**link, **{"lnurl": link.lnurl(request)}} + # class CreateData(BaseModel): # title: str = Query(...) # min_withdrawable: int = Query(..., ge=1) @@ -82,14 +75,20 @@ async def api_link_retrieve(link_id, wallet: WalletTypeInfo = Depends(get_key_ty # wait_time: int = Query(..., ge=1) # is_unique: bool + @withdraw_ext.post("/api/v1/links", status_code=HTTPStatus.CREATED) @withdraw_ext.put("/api/v1/links/{link_id}", status_code=HTTPStatus.OK) # @api_check_wallet_key("admin") -async def api_link_create_or_update(req: Request, data: CreateWithdrawData, link_id: str = None, wallet: WalletTypeInfo = Depends(get_key_type)): +async def api_link_create_or_update( + req: Request, + data: CreateWithdrawData, + link_id: str = None, + wallet: WalletTypeInfo = Depends(get_key_type), +): if data.max_withdrawable < data.min_withdrawable: raise HTTPException( detail="`max_withdrawable` needs to be at least `min_withdrawable`.", - status_code=HTTPStatus.BAD_REQUEST + status_code=HTTPStatus.BAD_REQUEST, ) # response.status_code = HTTPStatus.BAD_REQUEST # return { @@ -108,15 +107,13 @@ async def api_link_create_or_update(req: Request, data: CreateWithdrawData, link link = await get_withdraw_link(link_id, 0) if not link: raise HTTPException( - detail="Withdraw link does not exist.", - status_code=HTTPStatus.NOT_FOUND + detail="Withdraw link does not exist.", status_code=HTTPStatus.NOT_FOUND ) # response.status_code = HTTPStatus.NOT_FOUND # return {"message": "Withdraw link does not exist."} if link.wallet != wallet.wallet.id: raise HTTPException( - detail="Not your withdraw link.", - status_code=HTTPStatus.FORBIDDEN + detail="Not your withdraw link.", status_code=HTTPStatus.FORBIDDEN ) # response.status_code = HTTPStatus.FORBIDDEN # return {"message": "Not your withdraw link."} @@ -137,16 +134,14 @@ async def api_link_delete(link_id, wallet: WalletTypeInfo = Depends(get_key_type if not link: raise HTTPException( - detail="Withdraw link does not exist.", - status_code=HTTPStatus.NOT_FOUND + detail="Withdraw link does not exist.", status_code=HTTPStatus.NOT_FOUND ) # response.status_code = HTTPStatus.NOT_FOUND # return {"message": "Withdraw link does not exist."} if link.wallet != wallet.wallet.id: raise HTTPException( - detail="Not your withdraw link.", - status_code=HTTPStatus.FORBIDDEN + detail="Not your withdraw link.", status_code=HTTPStatus.FORBIDDEN ) # response.status_code = HTTPStatus.FORBIDDEN # return {"message": "Not your withdraw link."} @@ -158,6 +153,8 @@ async def api_link_delete(link_id, wallet: WalletTypeInfo = Depends(get_key_type @withdraw_ext.get("/api/v1/links/{the_hash}/{lnurl_id}", status_code=HTTPStatus.OK) # @api_check_wallet_key("invoice") -async def api_hash_retrieve(the_hash, lnurl_id, wallet: WalletTypeInfo = Depends(get_key_type)): +async def api_hash_retrieve( + the_hash, lnurl_id, wallet: WalletTypeInfo = Depends(get_key_type) +): hashCheck = await get_hash_check(the_hash, lnurl_id) return hashCheck diff --git a/lnbits/helpers.py b/lnbits/helpers.py index dbb060a5..a1396411 100644 --- a/lnbits/helpers.py +++ b/lnbits/helpers.py @@ -41,7 +41,9 @@ class ExtensionManager: ]: try: with open( - os.path.join(settings.LNBITS_PATH, "extensions", extension, "config.json") + os.path.join( + settings.LNBITS_PATH, "extensions", extension, "config.json" + ) ) as json_file: config = json.load(json_file) is_valid = True @@ -137,11 +139,8 @@ def get_vendored(ext: str, prefer_minified: bool = False) -> List[str]: def url_for_vendored(abspath: str) -> str: return "/" + os.path.relpath(abspath, settings.LNBITS_PATH) -def url_for( - endpoint: str, - external: Optional[bool] = False, - **params: Any, -) -> str: + +def url_for(endpoint: str, external: Optional[bool] = False, **params: Any) -> str: base = g().base_url if external else "" url_params = "?" for key in params: @@ -149,9 +148,12 @@ def url_for( url = f"{base}{endpoint}{url_params}" return url + def template_renderer(additional_folders: List = []) -> Jinja2Templates: t = Jinja2Templates( - loader=jinja2.FileSystemLoader(["lnbits/templates", "lnbits/core/templates", *additional_folders]), + loader=jinja2.FileSystemLoader( + ["lnbits/templates", "lnbits/core/templates", *additional_folders] + ) ) t.env.globals["SITE_TITLE"] = settings.LNBITS_SITE_TITLE t.env.globals["SITE_TAGLINE"] = settings.LNBITS_SITE_TAGLINE @@ -159,7 +161,7 @@ def template_renderer(additional_folders: List = []) -> Jinja2Templates: t.env.globals["LNBITS_THEME_OPTIONS"] = settings.LNBITS_THEME_OPTIONS t.env.globals["LNBITS_VERSION"] = settings.LNBITS_COMMIT t.env.globals["EXTENSIONS"] = get_valid_extensions() - + if settings.DEBUG: t.env.globals["VENDORED_JS"] = map(url_for_vendored, get_js_vendored()) t.env.globals["VENDORED_CSS"] = map(url_for_vendored, get_css_vendored()) diff --git a/lnbits/jinja2_templating.py b/lnbits/jinja2_templating.py index 5e3ceba2..f74f05c0 100644 --- a/lnbits/jinja2_templating.py +++ b/lnbits/jinja2_templating.py @@ -1,4 +1,4 @@ -# Borrowed from the excellent accent-starlette +# Borrowed from the excellent accent-starlette # https://github.com/accent-starlette/starlette-core/blob/master/starlette_core/templating.py import typing @@ -23,7 +23,7 @@ class Jinja2Templates(templating.Jinja2Templates): def get_environment(self, loader: "jinja2.BaseLoader") -> "jinja2.Environment": @jinja2.contextfunction def url_for(context: dict, name: str, **path_params: typing.Any) -> str: - request: Request = context["request"] # type: starlette.requests.Request + request: Request = context["request"] # type: starlette.requests.Request return request.app.url_path_for(name, **path_params) def url_params_update(init: QueryParams, **new: typing.Any) -> QueryParams: diff --git a/lnbits/requestvars.py b/lnbits/requestvars.py index 7dcf9203..2f7139d8 100644 --- a/lnbits/requestvars.py +++ b/lnbits/requestvars.py @@ -1,8 +1,9 @@ import contextvars import types -request_global = contextvars.ContextVar("request_global", - default=types.SimpleNamespace()) +request_global = contextvars.ContextVar( + "request_global", default=types.SimpleNamespace() +) def g() -> types.SimpleNamespace: diff --git a/lnbits/settings.py b/lnbits/settings.py index a351b5f1..475f5f47 100644 --- a/lnbits/settings.py +++ b/lnbits/settings.py @@ -52,8 +52,7 @@ SERVICE_FEE = env.float("LNBITS_SERVICE_FEE", default=0.0) try: LNBITS_COMMIT = ( subprocess.check_output( - ["git", "-C", LNBITS_PATH, "rev-parse", "HEAD"], - stderr=subprocess.DEVNULL, + ["git", "-C", LNBITS_PATH, "rev-parse", "HEAD"], stderr=subprocess.DEVNULL ) .strip() .decode("ascii") diff --git a/lnbits/static/js/base.js b/lnbits/static/js/base.js index fec75796..13f68388 100644 --- a/lnbits/static/js/base.js +++ b/lnbits/static/js/base.js @@ -225,7 +225,7 @@ window.LNbits = { Quasar.plugins.Notify.create({ timeout: 5000, type: types[error.response.status] || 'warning', - message: error.response.data.message || null, + message: error.response.data.message || error.response.data.detail || null, caption: [error.response.status, ' ', error.response.statusText] .join('') diff --git a/lnbits/wallets/clightning.py b/lnbits/wallets/clightning.py index 0780776f..c1b10220 100644 --- a/lnbits/wallets/clightning.py +++ b/lnbits/wallets/clightning.py @@ -35,7 +35,7 @@ class CLightningWallet(Wallet): try: answer = self.ln.help("invoicewithdescriptionhash") if answer["help"][0]["command"].startswith( - "invoicewithdescriptionhash msatoshi label description_hash", + "invoicewithdescriptionhash msatoshi label description_hash" ): self.supports_description_hash = True except: @@ -53,8 +53,7 @@ class CLightningWallet(Wallet): try: funds = self.ln.listfunds() return StatusResponse( - None, - sum([ch["channel_sat"] * 1000 for ch in funds["channels"]]), + None, sum([ch["channel_sat"] * 1000 for ch in funds["channels"]]) ) except RpcError as exc: error_message = f"lightningd '{exc.method}' failed with '{exc.error}'." @@ -121,11 +120,7 @@ class CLightningWallet(Wallet): i = 0 while True: call = json.dumps( - { - "method": "waitanyinvoice", - "id": 0, - "params": [self.last_pay_index], - } + {"method": "waitanyinvoice", "id": 0, "params": [self.last_pay_index]} ) await stream.send_all(call.encode("utf-8")) diff --git a/lnbits/wallets/lnbits.py b/lnbits/wallets/lnbits.py index b262cb1e..638fb7c1 100644 --- a/lnbits/wallets/lnbits.py +++ b/lnbits/wallets/lnbits.py @@ -30,9 +30,7 @@ class LNbitsWallet(Wallet): async with httpx.AsyncClient() as client: try: r = await client.get( - url=f"{self.endpoint}/api/v1/wallet", - headers=self.key, - timeout=15, + url=f"{self.endpoint}/api/v1/wallet", headers=self.key, timeout=15 ) except Exception as exc: return StatusResponse( @@ -65,9 +63,7 @@ class LNbitsWallet(Wallet): async with httpx.AsyncClient() as client: r = await client.post( - url=f"{self.endpoint}/api/v1/payments", - headers=self.key, - json=data, + url=f"{self.endpoint}/api/v1/payments", headers=self.key, json=data ) ok, checking_id, payment_request, error_message = ( not r.is_error, diff --git a/lnbits/wallets/lndgrpc.py b/lnbits/wallets/lndgrpc.py index 99ff3637..89cbded7 100644 --- a/lnbits/wallets/lndgrpc.py +++ b/lnbits/wallets/lndgrpc.py @@ -64,19 +64,11 @@ def load_macaroon(macaroon_path: str): def parse_checking_id(checking_id: str) -> bytes: - return base64.b64decode( - checking_id.replace("_", "/"), - ) + return base64.b64decode(checking_id.replace("_", "/")) def stringify_checking_id(r_hash: bytes) -> str: - return ( - base64.b64encode( - r_hash, - ) - .decode("utf-8") - .replace("/", "_") - ) + return base64.b64encode(r_hash).decode("utf-8").replace("/", "_") class LndWallet(Wallet): @@ -177,28 +169,23 @@ class LndWallet(Wallet): async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: async with purerpc.secure_channel( - self.endpoint, - self.port, - get_ssl_context(self.cert_path), + self.endpoint, self.port, get_ssl_context(self.cert_path) ) as channel: client = purerpc.Client("lnrpc.Lightning", channel) subscribe_invoices = client.get_method_stub( "SubscribeInvoices", purerpc.RPCSignature( - purerpc.Cardinality.UNARY_STREAM, - ln.InvoiceSubscription, - ln.Invoice, + purerpc.Cardinality.UNARY_STREAM, ln.InvoiceSubscription, ln.Invoice ), ) - if self.macaroon_path.split('.')[-1] == 'macaroon': + if self.macaroon_path.split(".")[-1] == "macaroon": macaroon = load_macaroon(self.macaroon_path) else: macaroon = self.macaroon_path async for inv in subscribe_invoices( - ln.InvoiceSubscription(), - metadata=[("macaroon", macaroon)], + ln.InvoiceSubscription(), metadata=[("macaroon", macaroon)] ): if not inv.settled: continue diff --git a/lnbits/wallets/lndrest.py b/lnbits/wallets/lndrest.py index 4b31c726..f0824dac 100644 --- a/lnbits/wallets/lndrest.py +++ b/lnbits/wallets/lndrest.py @@ -39,8 +39,7 @@ class LndRestWallet(Wallet): try: async with httpx.AsyncClient(verify=self.cert) as client: r = await client.get( - f"{self.endpoint}/v1/balance/channels", - headers=self.auth, + f"{self.endpoint}/v1/balance/channels", headers=self.auth ) except (httpx.ConnectError, httpx.RequestError): return StatusResponse(f"Unable to connect to {self.endpoint}.", 0) @@ -60,10 +59,7 @@ class LndRestWallet(Wallet): memo: Optional[str] = None, description_hash: Optional[bytes] = None, ) -> InvoiceResponse: - data: Dict = { - "value": amount, - "private": True, - } + data: Dict = {"value": amount, "private": True} if description_hash: data["description_hash"] = base64.b64encode(description_hash).decode( "ascii" @@ -73,9 +69,7 @@ class LndRestWallet(Wallet): async with httpx.AsyncClient(verify=self.cert) as client: r = await client.post( - url=f"{self.endpoint}/v1/invoices", - headers=self.auth, - json=data, + url=f"{self.endpoint}/v1/invoices", headers=self.auth, json=data ) if r.is_error: @@ -117,8 +111,7 @@ class LndRestWallet(Wallet): async with httpx.AsyncClient(verify=self.cert) as client: r = await client.get( - url=f"{self.endpoint}/v1/invoice/{checking_id}", - headers=self.auth, + url=f"{self.endpoint}/v1/invoice/{checking_id}", headers=self.auth ) if r.is_error or not r.json().get("settled"): @@ -164,9 +157,7 @@ class LndRestWallet(Wallet): while True: try: async with httpx.AsyncClient( - timeout=None, - headers=self.auth, - verify=self.cert, + timeout=None, headers=self.auth, verify=self.cert ) as client: async with client.stream("GET", url) as r: async for line in r.aiter_lines(): diff --git a/lnbits/wallets/lnpay.py b/lnbits/wallets/lnpay.py index ab8e0d81..98610a79 100644 --- a/lnbits/wallets/lnpay.py +++ b/lnbits/wallets/lnpay.py @@ -139,12 +139,10 @@ class LNPayWallet(Wallet): lntx_id = data["data"]["wtx"]["lnTx"]["id"] async with httpx.AsyncClient() as client: r = await client.get( - f"{self.endpoint}/lntx/{lntx_id}?fields=settled", - headers=self.auth, + f"{self.endpoint}/lntx/{lntx_id}?fields=settled", headers=self.auth ) data = r.json() if data["settled"]: await self.queue.put(lntx_id) raise HTTPException(status_code=HTTPStatus.NO_CONTENT) - diff --git a/lnbits/wallets/lntxbot.py b/lnbits/wallets/lntxbot.py index c804542a..190336a3 100644 --- a/lnbits/wallets/lntxbot.py +++ b/lnbits/wallets/lntxbot.py @@ -30,9 +30,7 @@ class LntxbotWallet(Wallet): async def status(self) -> StatusResponse: async with httpx.AsyncClient() as client: r = await client.get( - f"{self.endpoint}/balance", - headers=self.auth, - timeout=40, + f"{self.endpoint}/balance", headers=self.auth, timeout=40 ) try: data = r.json() @@ -60,10 +58,7 @@ class LntxbotWallet(Wallet): async with httpx.AsyncClient() as client: r = await client.post( - f"{self.endpoint}/addinvoice", - headers=self.auth, - json=data, - timeout=40, + f"{self.endpoint}/addinvoice", headers=self.auth, json=data, timeout=40 ) if r.is_error: @@ -123,8 +118,7 @@ class LntxbotWallet(Wallet): async def get_payment_status(self, checking_id: str) -> PaymentStatus: async with httpx.AsyncClient() as client: r = await client.post( - url=f"{self.endpoint}/paymentstatus/{checking_id}", - headers=self.auth, + url=f"{self.endpoint}/paymentstatus/{checking_id}", headers=self.auth ) data = r.json() diff --git a/lnbits/wallets/opennode.py b/lnbits/wallets/opennode.py index ddc2849e..965b6d66 100644 --- a/lnbits/wallets/opennode.py +++ b/lnbits/wallets/opennode.py @@ -36,9 +36,7 @@ class OpenNodeWallet(Wallet): try: async with httpx.AsyncClient() as client: r = await client.get( - f"{self.endpoint}/v1/account/balance", - headers=self.auth, - timeout=40, + f"{self.endpoint}/v1/account/balance", headers=self.auth, timeout=40 ) except (httpx.ConnectError, httpx.RequestError): return StatusResponse(f"Unable to connect to '{self.endpoint}'", 0) @@ -137,7 +135,6 @@ class OpenNodeWallet(Wallet): if "status" not in data or data["status"] != "paid": raise HTTPException(status_code=HTTPStatus.NO_CONTENT) - charge_id = data["id"] x = hmac.new(self.auth["Authorization"].encode("ascii"), digestmod="sha256") x.update(charge_id.encode("ascii")) @@ -147,4 +144,3 @@ class OpenNodeWallet(Wallet): await self.queue.put(charge_id) raise HTTPException(status_code=HTTPStatus.NO_CONTENT) - diff --git a/lnbits/wallets/spark.py b/lnbits/wallets/spark.py index ca8f6efe..a97ad310 100644 --- a/lnbits/wallets/spark.py +++ b/lnbits/wallets/spark.py @@ -75,8 +75,7 @@ class SparkWallet(Wallet): return StatusResponse(str(e), 0) return StatusResponse( - None, - sum([ch["channel_sat"] * 1000 for ch in funds["channels"]]), + None, sum([ch["channel_sat"] * 1000 for ch in funds["channels"]]) ) async def create_invoice(