diff --git a/Pipfile b/Pipfile index 6909f2f23..5d7343ee3 100644 --- a/Pipfile +++ b/Pipfile @@ -24,11 +24,16 @@ quart-trio = "*" trio = "==0.16.0" hypercorn = {extras = ["trio"], version = "*"} sqlalchemy-aio = "*" +<<<<<<< HEAD embit = "*" +======= +pyqrcode = "*" +pypng = "*" +>>>>>>> master [dev-packages] black = "==20.8b1" pytest = "*" pytest-cov = "*" -mypy = "==0.761" +mypy = "latest" pytest-trio = "*" diff --git a/Pipfile.lock b/Pipfile.lock index 90518ea38..7f843c6fa 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,11 @@ { "_meta": { "hash": { +<<<<<<< HEAD "sha256": "9e5dd461dc1a7d645f089c7e7a67fb7bfaf47273eaf2ebadbdb60234bfb34710" +======= + "sha256": "f98f5cc03179f57291aeeca8e0e117ef4f38806176c9d2c0f984f501a5806338" +>>>>>>> master }, "pipfile-spec": 6, "requires": { @@ -78,6 +82,7 @@ "sha256:6b2ae9f5f67f89aade1fab0f7fd8f2832501311c363a21579d02defa844d9296", "sha256:6c772d6c0a79ac0f414a9f8947cc407e119b8598de7621f39cacadae3cf57d12", "sha256:7cb81373984cc0e4682f31bc3d6be9026006d96eecd07ea49aafb06897746452", + "sha256:854c33dad5ba0fbd6ab69185fec8dab89e13cda6b7d191ba111987df74f38761", "sha256:88c63a1b55f352b02c6ffd24b15ead9fc0e8bf781dbe070213039324922a2eea", "sha256:93130612b837103e15ac3f9cbacb4613f9e348b58b3aad53721d92e57f96d46a", "sha256:97f715cf371b16ac88b8c19da00029804e20e25f30d80203417255d239f228b5", @@ -86,6 +91,7 @@ "sha256:b663f1e02de5d0573610756398e44c130add0eb9a3fc912a09665332942a2efb", "sha256:c2415d9d082152460f2bd4e382a1e85aed233abc92db5a3880da2257dc7daf7b", "sha256:c83aa123d56f2e060644427a882a36b3c12db93727ad7a7b9efd7d7f3e9cc2c4", + "sha256:cfc391f4429ee0a9370aa93d812a52e1fee0f37a81861f4fdd1f4fb28e8547c3", "sha256:db844eb158a87ccab83e868a762ea8024ae27337fc7ddcbfcddd157f841fdfe7", "sha256:defed7ea5f218a9f2336301e6fd379f55c655bea65ba2476346340a0ce6f74a1", "sha256:f909bbbc433048b499cb9db9e713b5d8d949e8c109a2a548502fb9aa8630f0b1" @@ -101,10 +107,10 @@ }, "certifi": { "hashes": [ - "sha256:1f422849db327d534e3d0c5f02a263458c3955ec0aae4ff09b95f195c59f4edd", - "sha256:f05def092c44fbf25834a51509ef6e631dc19765ab8a57b4e7ab85531f0a9cf4" + "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c", + "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830" ], - "version": "==2020.11.8" + "version": "==2020.12.5" }, "click": { "hashes": [ @@ -130,18 +136,68 @@ }, "environs": { "hashes": [ - "sha256:10dca340bff9c912e99d237905909390365e32723c2785a9f3afa6ef426c53bc", - "sha256:36081033ab34a725c2414f48ee7ec7f7c57e498d8c9255d61fbc7f2d4bf60865" + "sha256:2da44b7c30114415aa858577fa6396ee326fc76a0a60f0f15e8260ba554f19dc", + "sha256:3f6def554abb5455141b540e6e0b72fda3853404f2b0d31658aab1bf95410db3" ], "index": "pypi", - "version": "==9.2.0" + "version": "==9.3.1" + }, + "greenlet": { + "hashes": [ + "sha256:0a77691f0080c9da8dfc81e23f4e3cffa5accf0f5b56478951016d7cfead9196", + "sha256:0ddd77586553e3daf439aa88b6642c5f252f7ef79a39271c25b1d4bf1b7cbb85", + "sha256:111cfd92d78f2af0bc7317452bd93a477128af6327332ebf3c2be7df99566683", + "sha256:122c63ba795fdba4fc19c744df6277d9cfd913ed53d1a286f12189a0265316dd", + "sha256:181300f826625b7fd1182205b830642926f52bd8cdb08b34574c9d5b2b1813f7", + "sha256:1a1ada42a1fd2607d232ae11a7b3195735edaa49ea787a6d9e6a53afaf6f3476", + "sha256:1bb80c71de788b36cefb0c3bb6bfab306ba75073dbde2829c858dc3ad70f867c", + "sha256:1d1d4473ecb1c1d31ce8fd8d91e4da1b1f64d425c1dc965edc4ed2a63cfa67b2", + "sha256:292e801fcb3a0b3a12d8c603c7cf340659ea27fd73c98683e75800d9fd8f704c", + "sha256:2c65320774a8cd5fdb6e117c13afa91c4707548282464a18cf80243cf976b3e6", + "sha256:4365eccd68e72564c776418c53ce3c5af402bc526fe0653722bc89efd85bf12d", + "sha256:5352c15c1d91d22902582e891f27728d8dac3bd5e0ee565b6a9f575355e6d92f", + "sha256:58ca0f078d1c135ecf1879d50711f925ee238fe773dfe44e206d7d126f5bc664", + "sha256:5d4030b04061fdf4cbc446008e238e44936d77a04b2b32f804688ad64197953c", + "sha256:5d69bbd9547d3bc49f8a545db7a0bd69f407badd2ff0f6e1a163680b5841d2b0", + "sha256:5f297cb343114b33a13755032ecf7109b07b9a0020e841d1c3cedff6602cc139", + "sha256:62afad6e5fd70f34d773ffcbb7c22657e1d46d7fd7c95a43361de979f0a45aef", + "sha256:647ba1df86d025f5a34043451d7c4a9f05f240bee06277a524daad11f997d1e7", + "sha256:719e169c79255816cdcf6dccd9ed2d089a72a9f6c42273aae12d55e8d35bdcf8", + "sha256:7cd5a237f241f2764324396e06298b5dee0df580cf06ef4ada0ff9bff851286c", + "sha256:875d4c60a6299f55df1c3bb870ebe6dcb7db28c165ab9ea6cdc5d5af36bb33ce", + "sha256:90b6a25841488cf2cb1c8623a53e6879573010a669455046df5f029d93db51b7", + "sha256:94620ed996a7632723a424bccb84b07e7b861ab7bb06a5aeb041c111dd723d36", + "sha256:b5f1b333015d53d4b381745f5de842f19fe59728b65f0fbb662dafbe2018c3a5", + "sha256:c5b22b31c947ad8b6964d4ed66776bcae986f73669ba50620162ba7c832a6b6a", + "sha256:c93d1a71c3fe222308939b2e516c07f35a849c5047f0197442a4d6fbcb4128ee", + "sha256:cdb90267650c1edb54459cdb51dab865f6c6594c3a47ebd441bc493360c7af70", + "sha256:cfd06e0f0cc8db2a854137bd79154b61ecd940dce96fad0cba23fe31de0b793c", + "sha256:d3789c1c394944084b5e57c192889985a9f23bd985f6d15728c745d380318128", + "sha256:da7d09ad0f24270b20f77d56934e196e982af0d0a2446120cb772be4e060e1a2", + "sha256:df3e83323268594fa9755480a442cabfe8d82b21aba815a71acf1bb6c1776218", + "sha256:df8053867c831b2643b2c489fe1d62049a98566b1646b194cc815f13e27b90df", + "sha256:e1128e022d8dce375362e063754e129750323b67454cac5600008aad9f54139e", + "sha256:e6e9fdaf6c90d02b95e6b0709aeb1aba5affbbb9ccaea5502f8638e4323206be", + "sha256:eac8803c9ad1817ce3d8d15d1bb82c2da3feda6bee1153eec5c58fa6e5d3f770", + "sha256:eb333b90036358a0e2c57373f72e7648d7207b76ef0bd00a4f7daad1f79f5203", + "sha256:ed1d1351f05e795a527abc04a0d82e9aecd3bdf9f46662c36ff47b0b00ecaf06", + "sha256:f3dc68272990849132d6698f7dc6df2ab62a88b0d36e54702a8fd16c0490e44f", + "sha256:f59eded163d9752fd49978e0bab7a1ff21b1b8d25c05f0995d140cc08ac83379", + "sha256:f5e2d36c86c7b03c94b8459c3bd2c9fe2c7dab4b258b8885617d44a22e453fb7", + "sha256:f6f65bf54215e4ebf6b01e4bb94c49180a589573df643735107056f7a910275b", + "sha256:f8450d5ef759dbe59f84f2c9f77491bb3d3c44bc1a573746daf086e70b14c243", + "sha256:f97d83049715fd9dec7911860ecf0e17b48d8725de01e45de07d8ac0bd5bc378" + ], + "markers": "python_version >= '3'", + "version": "==1.0.0" }, "h11": { "hashes": [ - "sha256:3c6c61d69c6f13d41f1b80ab0322f1872702a3ba26e12aa864c928f6a43fbaab", - "sha256:ab6c335e1b6ef34b205d5ca3e228c9299cc7218b049819ec84a388c2525e5d87" + "sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6", + "sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042" ], - "version": "==0.11.0" + "markers": "python_version >= '3.6'", + "version": "==0.12.0" }, "h2": { "hashes": [ @@ -159,26 +215,31 @@ }, "httpcore": { "hashes": [ - "sha256:420700af11db658c782f7e8fda34f9dcd95e3ee93944dd97d78cb70247e0cd06", - "sha256:dd1d762d4f7c2702149d06be2597c35fb154c5eff9789a8c5823fbcf4d2978d6" + "sha256:37ae835fb370049b2030c3290e12ed298bf1473c41bb72ca4aa78681eba9b7c9", + "sha256:93e822cd16c32016b414b789aeff4e855d0ccbfc51df563ee34d4dbadbb3bcdc" ], +<<<<<<< HEAD "version": "==0.12.2" +======= + "markers": "python_version >= '3.6'", + "version": "==0.12.3" +>>>>>>> master }, "httpx": { "hashes": [ - "sha256:126424c279c842738805974687e0518a94c7ae8d140cd65b9c4f77ac46ffa537", - "sha256:9cffb8ba31fac6536f2c8cde30df859013f59e4bcc5b8d43901cb3654a8e0a5b" + "sha256:cc2a55188e4b25272d2bcd46379d300f632045de4377682aa98a8a6069d55967", + "sha256:d379653bd457e8257eb0df99cb94557e4aac441b7ba948e333be969298cac272" ], "index": "pypi", - "version": "==0.16.1" + "version": "==0.17.1" }, "hypercorn": { "hashes": [ - "sha256:81c69dd84a87b8e8b3ebf06ef5dd92836a8238f0ac65ded3d86befb8ba9acfeb", - "sha256:e3f317d6d64d15ce589f49e4f5057947259fa35332d169e62cb060e9997189e4" + "sha256:5ba1e719c521080abd698ff5781a2331e34ef50fc1c89a50960538115a896a9a", + "sha256:8007c10f81566920f8ae12c0e26e146f94ca70506da964b5a727ad610aa1d821" ], "index": "pypi", - "version": "==0.11.1" + "version": "==0.11.2" }, "hyperframe": { "hashes": [ @@ -189,10 +250,10 @@ }, "idna": { "hashes": [ - "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", - "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" + "sha256:5205d03e7bcbb919cc9c19885f9920d622ca52448306f2377daede5cf3faac16", + "sha256:c5b02147e01ea9920e6b0a3f1f7bb833612d507592c837a6c49552768f4054e1" ], - "version": "==2.10" + "version": "==3.1" }, "itsdangerous": { "hashes": [ @@ -203,10 +264,15 @@ }, "jinja2": { "hashes": [ - "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0", - "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035" + "sha256:03e47ad063331dd6a3f04a43eddca8a966a26ba0c5b7207a9a9e4e08f1b29419", + "sha256:a6d58433de0ae800347cab1fa3043cebbabe8baa9d29e668f1c768cb87a333c6" ], +<<<<<<< HEAD "version": "==2.11.2" +======= + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==2.11.3" +>>>>>>> master }, "lnurl": { "hashes": [ @@ -222,8 +288,12 @@ "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235", "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5", "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42", + "sha256:195d7d2c4fbb0ee8139a6cf67194f3973a6b3042d742ebe0a9ed36d8b6f0c07f", + "sha256:22c178a091fc6630d0d045bdb5992d2dfe14e3259760e713c490da5323866c39", "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff", "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b", + "sha256:2beec1e0de6924ea551859edb9e7679da6e4870d32cb766240ce17e0a0ba2014", + "sha256:3b8a6499709d29c2e2399569d96719a1b21dcd94410a586a18526b143ec8470f", "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1", "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e", "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183", @@ -232,33 +302,53 @@ "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1", "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15", "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1", + "sha256:6f1e273a344928347c1290119b493a1f0303c52f5a5eae5f16d74f48c15d4a85", + "sha256:6fffc775d90dcc9aed1b89219549b329a9250d918fd0b8fa8d93d154918422e1", "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e", "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b", "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905", + "sha256:7fed13866cf14bba33e7176717346713881f56d9d2bcebab207f7a036f41b850", + "sha256:84dee80c15f1b560d55bcfe6d47b27d070b4681c699c572af2e3c7cc90a3b8e0", "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735", "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d", + "sha256:98bae9582248d6cf62321dcb52aaf5d9adf0bad3b40582925ef7c7f0ed85fceb", "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e", "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d", "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c", + "sha256:a6a744282b7718a2a62d2ed9d993cad6f5f585605ad352c11de459f4108df0a1", + "sha256:acf08ac40292838b3cbbb06cfe9b2cb9ec78fce8baca31ddb87aaac2e2dc3bc2", "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21", "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2", "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5", + "sha256:b1dba4527182c95a0db8b6060cc98ac49b9e2f5e64320e2b56e47cb2831978c7", "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b", + "sha256:b7d644ddb4dbd407d31ffb699f1d140bc35478da613b441c582aeb7c43838dd8", "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6", + "sha256:bf5aa3cbcfdf57fa2ee9cd1822c862ef23037f5c832ad09cfea57fa846dec193", "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f", + "sha256:caabedc8323f1e93231b52fc32bdcde6db817623d33e100708d9a68e1f53b26b", "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f", "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2", + "sha256:d53bc011414228441014aa71dbec320c66468c1030aae3a6e29778a3382d96e5", + "sha256:d73a845f227b0bfe8a7455ee623525ee656a9e2e749e4742706d80a6065d5e2c", + "sha256:d9be0ba6c527163cbed5e0857c451fcd092ce83947944d6c14bc95441203f032", "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7", - "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be" + "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be", + "sha256:feb7b34d6325451ef96bc0e36e1a6c0c1c64bc1fbec4b854f4529e51887b1621" ], "version": "==1.1.1" }, "marshmallow": { "hashes": [ - "sha256:73facc37462dfc0b27f571bdaffbef7709e19f7a616beb3802ea425b07843f4e", - "sha256:e26763201474b588d144dae9a32bdd945cd26a06c943bc746a6882e850475378" + "sha256:4ab2fdb7f36eb61c3665da67a7ce281c8900db08d72ba6bf0e695828253581f7", + "sha256:eca81d53aa4aafbc0e20566973d0d2e50ce8bf0ee15165bb799bec0df1e50177" ], +<<<<<<< HEAD "version": "==3.9.1" +======= + "markers": "python_version >= '3.5'", + "version": "==3.10.0" +>>>>>>> master }, "outcome": { "hashes": [ @@ -276,6 +366,7 @@ }, "pydantic": { "hashes": [ +<<<<<<< HEAD "sha256:025bf13ce27990acc059d0c5be46f416fc9b293f45363b3d19855165fee1874f", "sha256:185e18134bec5ef43351149fe34fda4758e53d05bb8ea4d5928f0720997b79ef", "sha256:213125b7e9e64713d16d988d10997dabc6a1f73f3991e1ff8e35ebb1409c7dc9", @@ -300,6 +391,48 @@ "sha256:ffd180ebd5dd2a9ac0da4e8b995c9c99e7c74c31f985ba090ee01d681b1c4b95" ], "version": "==1.7.3" +======= + "sha256:0c40162796fc8d0aa744875b60e4dc36834db9f2a25dbf9ba9664b1915a23850", + "sha256:20d42f1be7c7acc352b3d09b0cf505a9fab9deb93125061b376fbe1f06a5459f", + "sha256:2287ebff0018eec3cc69b1d09d4b7cebf277726fa1bd96b45806283c1d808683", + "sha256:258576f2d997ee4573469633592e8b99aa13bda182fcc28e875f866016c8e07e", + "sha256:26cf3cb2e68ec6c0cfcb6293e69fb3450c5fd1ace87f46b64f678b0d29eac4c3", + "sha256:2f2736d9a996b976cfdfe52455ad27462308c9d3d0ae21a2aa8b4cd1a78f47b9", + "sha256:3114d74329873af0a0e8004627f5389f3bb27f956b965ddd3e355fe984a1789c", + "sha256:3bbd023c981cbe26e6e21c8d2ce78485f85c2e77f7bab5ec15b7d2a1f491918f", + "sha256:3bcb9d7e1f9849a6bdbd027aabb3a06414abd6068cb3b21c49427956cce5038a", + "sha256:4bbc47cf7925c86a345d03b07086696ed916c7663cb76aa409edaa54546e53e2", + "sha256:6388ef4ef1435364c8cc9a8192238aed030595e873d8462447ccef2e17387125", + "sha256:830ef1a148012b640186bf4d9789a206c56071ff38f2460a32ae67ca21880eb8", + "sha256:8fbb677e4e89c8ab3d450df7b1d9caed23f254072e8597c33279460eeae59b99", + "sha256:c17a0b35c854049e67c68b48d55e026c84f35593c66d69b278b8b49e2484346f", + "sha256:dd4888b300769ecec194ca8f2699415f5f7760365ddbe243d4fd6581485fa5f0", + "sha256:dde4ca368e82791de97c2ec019681ffb437728090c0ff0c3852708cf923e0c7d", + "sha256:e3f8790c47ac42549dc8b045a67b0ca371c7f66e73040d0197ce6172b385e520", + "sha256:e8bc082afef97c5fd3903d05c6f7bb3a6af9fc18631b4cc9fedeb4720efb0c58", + "sha256:eb8ccf12295113ce0de38f80b25f736d62f0a8d87c6b88aca645f168f9c78771", + "sha256:fb77f7a7e111db1832ae3f8f44203691e15b1fa7e5a1cb9691d4e2659aee41c4", + "sha256:fbfb608febde1afd4743c6822c19060a8dbdd3eb30f98e36061ba4973308059e", + "sha256:fff29fe54ec419338c522b908154a2efabeee4f483e48990f87e189661f31ce3" + ], + "markers": "python_full_version >= '3.6.1'", + "version": "==1.8.1" + }, + "pypng": { + "hashes": [ + "sha256:1032833440c91bafee38a42c38c02d00431b24c42927feb3e63b104d8550170b" + ], + "index": "pypi", + "version": "==0.0.20" + }, + "pyqrcode": { + "hashes": [ + "sha256:1b2812775fa6ff5c527977c4cd2ccb07051ca7d0bc0aecf937a43864abe5eff6", + "sha256:fdbf7634733e56b72e27f9bce46e4550b75a3a2c420414035cae9d9d26b234d5" + ], + "index": "pypi", + "version": "==1.2.1" +>>>>>>> master }, "pyscss": { "hashes": [ @@ -310,18 +443,18 @@ }, "python-dotenv": { "hashes": [ - "sha256:0c8d1b80d1a1e91717ea7d526178e3882732420b03f08afea0406db6402e220e", - "sha256:587825ed60b1711daea4832cf37524dfd404325b7db5e25ebe88c495c9f807a0" + "sha256:31d752f5b748f4e292448c9a0cac6a08ed5e6f4cefab85044462dcad56905cec", + "sha256:9fa413c37d4652d3fa02fea0ff465c384f5db75eab259c4fc5d0c5b8bf20edd4" ], - "version": "==0.15.0" + "version": "==0.16.0" }, "quart": { "hashes": [ - "sha256:9c634e4c1e4b21b824003c676de1583581258c72b0ac4d2ba747db846e97ff56", - "sha256:d885d782edd9d5dcfd2c4a56e020db3b82493d4c3950f91c221b7d88d239ac93" + "sha256:429c5b4ff27e1d2f9ca0aacc38f6aba0ff49b38b815448bf24b613d3de12ea02", + "sha256:7b13786e07541cc9ce1466fdc6a6ccd5f36eb39118edd25a42d617593cd17707" ], "index": "pypi", - "version": "==0.13.1" + "version": "==0.14.1" }, "quart-compress": { "hashes": [ @@ -333,26 +466,27 @@ }, "quart-cors": { "hashes": [ - "sha256:020a17d504264db86cada3c1335ef174af28b33f57cee321ddc46d69c33d5c8e", - "sha256:c08bdb326219b6c186d19ed6a97a7fd02de8fe36c7856af889494c69b525c53c" + "sha256:0ea23ea8db2c21835f6698b91a09d99ab59f98f8d90a2a739475ef0409591573", + "sha256:e526e9929934ad31301853efe357a3bd2e08c3282aff37184fa8671ed854f052" ], "index": "pypi", - "version": "==0.3.0" + "version": "==0.4.0" }, "quart-trio": { "hashes": [ - "sha256:8262e82d01ff63a1e74f9a95e5980f9658bfd5facf119d99e11c7bfe23427d69", - "sha256:ce63f8b21c6795579f0206138ee67487259359d8e9341b2924fa635f7672de32" + "sha256:1e7fce0df41afc3038bf0431b20614f90984de50341b19f9d4d3b9ba1ac7574a", + "sha256:933e3c18e232ece30ccbac7579fdc5f62f2f9c79c3273d6c341f5a1686791eb1" ], "index": "pypi", - "version": "==0.6.0" + "version": "==0.7.0" }, "represent": { "hashes": [ - "sha256:293dfec8b2e9e2150a21a49bfec2cd009ecb600c8c04f9186d2ad222c3cef78a", - "sha256:6000c24f317dbf8b57a116ce4d7e4459fc5900af6a2915c9a2d74456bcc33d3c" + "sha256:026c0de2ee8385d1255b9c2426cd4f03fe9177ac94c09979bc601946c8493aa0", + "sha256:99142650756ef1998ce0661568f54a47dac8c638fb27e3816c02536575dbba8c" ], - "version": "==1.6.0" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.6.0.post0" }, "rfc3986": { "hashes": [ @@ -382,6 +516,10 @@ "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" ], +<<<<<<< HEAD +======= + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", +>>>>>>> master "version": "==1.15.0" }, "sniffio": { @@ -400,6 +538,7 @@ }, "sqlalchemy": { "hashes": [ +<<<<<<< HEAD "sha256:009e8388d4d551a2107632921320886650b46332f61dc935e70c8bcf37d8e0d6", "sha256:0157c269701d88f5faf1fa0e4560e4d814f210c01a5b55df3cab95e9346a8bcc", "sha256:0a92745bb1ebbcb3985ed7bda379b94627f0edbc6c82e9e4bac4fb5647ae609a", @@ -440,6 +579,45 @@ "sha256:fcdb3755a7c355bc29df1b5e6fb8226d5c8b90551d202d69d0076a8a5649d68b" ], "version": "==1.3.20" +======= + "sha256:0096305b3e0912c59d8308f55d17544b3e5c1787f5ad8ef9cd75084136bcba9c", + "sha256:106fd3da313390dffe6ca156e5b7244293d6f4bfd389bcb315771c7addb5f3b3", + "sha256:1b30b71ea7c0f854d1b31549816694d8c435c9b5cce44da140b473544bb48a6b", + "sha256:228fe0cc700748ccc7a9a430896a77dfaa8a1035874e540961589e31f31cabe1", + "sha256:22faab9884c46ea2c00d5457a6a23375e0b4ab5257b72a27c8b979d4f677d4cf", + "sha256:3280c283e85e5c7b95c7be75b2df765d4bb13a01be36552826557bb4177d2bdf", + "sha256:33a2b756bd8f7022c24a16228071dea39cf6f21f62732a5307b6ebcef084bf16", + "sha256:409f3cd35f99592d0ceb1ee2e13c24b3083109e0f80096aae36000e5988aa24a", + "sha256:41a6dc66714c7dddf210dc8652d19bb2a55364c21038ca77312500014271aa67", + "sha256:4aa4de9bd3ae5e46f7395aa769303722e7174795ae83dd78302d849fcbc7513d", + "sha256:52c2512914bdeb3ce7957e2597b6a9d4a3dd3b3177c32ccf481908d6e59384ae", + "sha256:5da7f97893631d060c4590e531784f5eaf64bb3e6002804ee8a96d9c91cd1885", + "sha256:655b35725f1478bb6f797336acc803ddbb4c693816b3663a6fd94ad454eb056a", + "sha256:755aed46915e20c0b317a4124251f31682dcc7a43984d771352f6863ea11cd9c", + "sha256:81920161039cca14dac30378713c472a0ac5e783b2077984d6f8ec6f2d824356", + "sha256:83e65d8826bc649f8af556588555b744d4b9cfc0fcda8f3ddd08fe43c656e459", + "sha256:8761759028eb7754b76ca153e613bdea0fb6f8107557e57c60616a7212e2a297", + "sha256:8cf28097524b7fff3526df9154abcdbed0c4e434d4c4e6787e3d4fc33e7deb6a", + "sha256:92cca0c8757ac9a8a53cc800ea0f04a4f6c346376bc4cb878e4a6aed6f19d18d", + "sha256:96e68231f7115f5acb1bb51ccec26351bc155fedf835d7625fa203a43c8a3762", + "sha256:9852a7b4feee4c7de4b7541fa8a72ab36a5dad7942c58006e76ffe59c0f8efec", + "sha256:a719b80b41a900bbcec3cc248616394ebd134043ce5e62185270d785d8a184d3", + "sha256:a91fa4189f66af9644fde50740c5134689dc01c6c5edf04af6eafa3225ae110c", + "sha256:aa529647a3770293f392dff40466344a5d142fe66a2bbec465247a05d695eced", + "sha256:ad2d9fc0ffba476cf069cea558527bc23e1ced24ec6c8badab8aa63cbde56b07", + "sha256:b445fe8f043288178bc7d4adda49a505de86641864d50493d3fad10e0711cbff", + "sha256:b529f285b04094d458e811147a320019397909265eef1d1aa9dc6ecac0ad240c", + "sha256:bce476fd66aeaeb1a155f97838233d95fccd2c611da4d6b1cb4b6205435e5326", + "sha256:be1df71f9a06730b2a7213b68d6c465130a82305789462e375cf87037b181af3", + "sha256:c063277efd89c7f755480ff80f87828c9a68afb0fdc6d79462b9e474301fded3", + "sha256:d157a87dcd861eae04cd9b19cac535451719397fcae7b6f870688d8fb69d84f9", + "sha256:d34afc46b9fe3025b8db7ade6876bf80668918c5cdcdae067aaec348b5daa821", + "sha256:e337983564e09857a7a687dfa7adfaf85f59ed9e885d30081e13aea792d6abf7", + "sha256:e8b24bb68e981b6a2a8845d6d0f85891564d38562fc338170338ef90a221241e" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", + "version": "==1.4.3" +>>>>>>> master }, "sqlalchemy-aio": { "hashes": [ @@ -454,6 +632,10 @@ "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" ], +<<<<<<< HEAD +======= + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", +>>>>>>> master "version": "==0.10.2" }, "trio": { @@ -526,49 +708,72 @@ }, "coverage": { "hashes": [ - "sha256:0203acd33d2298e19b57451ebb0bed0ab0c602e5cf5a818591b4918b1f97d516", - "sha256:0f313707cdecd5cd3e217fc68c78a960b616604b559e9ea60cc16795c4304259", - "sha256:1c6703094c81fa55b816f5ae542c6ffc625fec769f22b053adb42ad712d086c9", - "sha256:1d44bb3a652fed01f1f2c10d5477956116e9b391320c94d36c6bf13b088a1097", - "sha256:280baa8ec489c4f542f8940f9c4c2181f0306a8ee1a54eceba071a449fb870a0", - "sha256:29a6272fec10623fcbe158fdf9abc7a5fa032048ac1d8631f14b50fbfc10d17f", - "sha256:2b31f46bf7b31e6aa690d4c7a3d51bb262438c6dcb0d528adde446531d0d3bb7", - "sha256:2d43af2be93ffbad25dd959899b5b809618a496926146ce98ee0b23683f8c51c", - "sha256:381ead10b9b9af5f64646cd27107fb27b614ee7040bb1226f9c07ba96625cbb5", - "sha256:47a11bdbd8ada9b7ee628596f9d97fbd3851bd9999d398e9436bd67376dbece7", - "sha256:4d6a42744139a7fa5b46a264874a781e8694bb32f1d76d8137b68138686f1729", - "sha256:50691e744714856f03a86df3e2bff847c2acede4c191f9a1da38f088df342978", - "sha256:530cc8aaf11cc2ac7430f3614b04645662ef20c348dce4167c22d99bec3480e9", - "sha256:582ddfbe712025448206a5bc45855d16c2e491c2dd102ee9a2841418ac1c629f", - "sha256:63808c30b41f3bbf65e29f7280bf793c79f54fb807057de7e5238ffc7cc4d7b9", - "sha256:71b69bd716698fa62cd97137d6f2fdf49f534decb23a2c6fc80813e8b7be6822", - "sha256:7858847f2d84bf6e64c7f66498e851c54de8ea06a6f96a32a1d192d846734418", - "sha256:78e93cc3571fd928a39c0b26767c986188a4118edc67bc0695bc7a284da22e82", - "sha256:7f43286f13d91a34fadf61ae252a51a130223c52bfefb50310d5b2deb062cf0f", - "sha256:86e9f8cd4b0cdd57b4ae71a9c186717daa4c5a99f3238a8723f416256e0b064d", - "sha256:8f264ba2701b8c9f815b272ad568d555ef98dfe1576802ab3149c3629a9f2221", - "sha256:9342dd70a1e151684727c9c91ea003b2fb33523bf19385d4554f7897ca0141d4", - "sha256:9361de40701666b034c59ad9e317bae95c973b9ff92513dd0eced11c6adf2e21", - "sha256:9669179786254a2e7e57f0ecf224e978471491d660aaca833f845b72a2df3709", - "sha256:aac1ba0a253e17889550ddb1b60a2063f7474155465577caa2a3b131224cfd54", - "sha256:aef72eae10b5e3116bac6957de1df4d75909fc76d1499a53fb6387434b6bcd8d", - "sha256:bd3166bb3b111e76a4f8e2980fa1addf2920a4ca9b2b8ca36a3bc3dedc618270", - "sha256:c1b78fb9700fc961f53386ad2fd86d87091e06ede5d118b8a50dea285a071c24", - "sha256:c3888a051226e676e383de03bf49eb633cd39fc829516e5334e69b8d81aae751", - "sha256:c5f17ad25d2c1286436761b462e22b5020d83316f8e8fcb5deb2b3151f8f1d3a", - "sha256:c851b35fc078389bc16b915a0a7c1d5923e12e2c5aeec58c52f4aa8085ac8237", - "sha256:cb7df71de0af56000115eafd000b867d1261f786b5eebd88a0ca6360cccfaca7", - "sha256:cedb2f9e1f990918ea061f28a0f0077a07702e3819602d3507e2ff98c8d20636", - "sha256:e8caf961e1b1a945db76f1b5fa9c91498d15f545ac0ababbe575cfab185d3bd8" + "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" ], +<<<<<<< HEAD "version": "==5.3" +======= + "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" +>>>>>>> master }, "idna": { "hashes": [ - "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", - "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" + "sha256:5205d03e7bcbb919cc9c19885f9920d622ca52448306f2377daede5cf3faac16", + "sha256:c5b02147e01ea9920e6b0a3f1f7bb833612d507592c837a6c49552768f4054e1" ], - "version": "==2.10" + "version": "==3.1" }, "iniconfig": { "hashes": [ @@ -579,23 +784,31 @@ }, "mypy": { "hashes": [ - "sha256:0a9a45157e532da06fe56adcfef8a74629566b607fa2c1ac0122d1ff995c748a", - "sha256:2c35cae79ceb20d47facfad51f952df16c2ae9f45db6cb38405a3da1cf8fc0a7", - "sha256:4b9365ade157794cef9685791032521233729cb00ce76b0ddc78749abea463d2", - "sha256:53ea810ae3f83f9c9b452582261ea859828a9ed666f2e1ca840300b69322c474", - "sha256:634aef60b4ff0f650d3e59d4374626ca6153fcaff96ec075b215b568e6ee3cb0", - "sha256:7e396ce53cacd5596ff6d191b47ab0ea18f8e0ec04e15d69728d530e86d4c217", - "sha256:7eadc91af8270455e0d73565b8964da1642fe226665dd5c9560067cd64d56749", - "sha256:7f672d02fffcbace4db2b05369142e0506cdcde20cea0e07c7c2171c4fd11dd6", - "sha256:85baab8d74ec601e86134afe2bcccd87820f79d2f8d5798c889507d1088287bf", - "sha256:87c556fb85d709dacd4b4cb6167eecc5bbb4f0a9864b69136a0d4640fdc76a36", - "sha256:a6bd44efee4dc8c3324c13785a9dc3519b3ee3a92cada42d2b57762b7053b49b", - "sha256:c6d27bd20c3ba60d5b02f20bd28e20091d6286a699174dfad515636cb09b5a72", - "sha256:e2bb577d10d09a2d8822a042a23b8d62bc3b269667c9eb8e60a6edfa000211b1", - "sha256:f97a605d7c8bc2c6d1172c2f0d5a65b24142e11a58de689046e62c2d632ca8c1" + "sha256:0d0a87c0e7e3a9becdfbe936c981d32e5ee0ccda3e0f07e1ef2c3d1a817cf73e", + "sha256:25adde9b862f8f9aac9d2d11971f226bd4c8fbaa89fb76bdadb267ef22d10064", + "sha256:28fb5479c494b1bab244620685e2eb3c3f988d71fd5d64cc753195e8ed53df7c", + "sha256:2f9b3407c58347a452fc0736861593e105139b905cca7d097e413453a1d650b4", + "sha256:33f159443db0829d16f0a8d83d94df3109bb6dd801975fe86bacb9bf71628e97", + "sha256:3f2aca7f68580dc2508289c729bd49ee929a436208d2b2b6aab15745a70a57df", + "sha256:499c798053cdebcaa916eef8cd733e5584b5909f789de856b482cd7d069bdad8", + "sha256:4eec37370483331d13514c3f55f446fc5248d6373e7029a29ecb7b7494851e7a", + "sha256:552a815579aa1e995f39fd05dde6cd378e191b063f031f2acfe73ce9fb7f9e56", + "sha256:5873888fff1c7cf5b71efbe80e0e73153fe9212fafdf8e44adfe4c20ec9f82d7", + "sha256:61a3d5b97955422964be6b3baf05ff2ce7f26f52c85dd88db11d5e03e146a3a6", + "sha256:674e822aa665b9fd75130c6c5f5ed9564a38c6cea6a6432ce47eafb68ee578c5", + "sha256:7ce3175801d0ae5fdfa79b4f0cfed08807af4d075b402b7e294e6aa72af9aa2a", + "sha256:9743c91088d396c1a5a3c9978354b61b0382b4e3c440ce83cf77994a43e8c521", + "sha256:9f94aac67a2045ec719ffe6111df543bac7874cee01f41928f6969756e030564", + "sha256:a26f8ec704e5a7423c8824d425086705e381b4f1dfdef6e3a1edab7ba174ec49", + "sha256:abf7e0c3cf117c44d9285cc6128856106183938c68fd4944763003decdcfeb66", + "sha256:b09669bcda124e83708f34a94606e01b614fa71931d356c1f1a5297ba11f110a", + "sha256:cd07039aa5df222037005b08fbbfd69b3ab0b0bd7a07d7906de75ae52c4e3119", + "sha256:d23e0ea196702d918b60c8288561e722bf437d82cb7ef2edcd98cfa38905d506", + "sha256:d65cc1df038ef55a99e617431f0553cd77763869eebdf9042403e16089fe746c", + "sha256:d7da2e1d5f558c37d6e8c1246f1aec1e7349e4913d8fb3cb289a35de573fe2eb" ], "index": "pypi", - "version": "==0.761" + "version": "==0.812" }, "mypy-extensions": { "hashes": [ @@ -613,10 +826,18 @@ }, "packaging": { "hashes": [ +<<<<<<< HEAD "sha256:05af3bb85d320377db281cf254ab050e1a7ebcbf5410685a9a407e18a1f81236", "sha256:eb41423378682dadb7166144a4926e443093863024de508ca5c9737d6bc08376" ], "version": "==20.7" +======= + "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5", + "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==20.9" +>>>>>>> master }, "pathspec": { "hashes": [ @@ -634,33 +855,42 @@ }, "py": { "hashes": [ - "sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2", - "sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342" + "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3", + "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a" ], +<<<<<<< HEAD "version": "==1.9.0" +======= + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.10.0" +>>>>>>> master }, "pyparsing": { "hashes": [ "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" ], +<<<<<<< HEAD +======= + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", +>>>>>>> master "version": "==2.4.7" }, "pytest": { "hashes": [ - "sha256:4288fed0d9153d9646bfcdf0c0428197dba1ecb27a33bb6e031d002fa88653fe", - "sha256:c0a7e94a8cdbc5422a51ccdad8e6f1024795939cc89159a0ae7f0b316ad3823e" + "sha256:9d1edf9e7d0b84d72ea3dbcdfd22b35fb543a5e8f2a60092dd578936bf63d7f9", + "sha256:b574b57423e818210672e07ca1fa90aaf194a4f63f3ab909a2c67ebb22913839" ], "index": "pypi", - "version": "==6.1.2" + "version": "==6.2.2" }, "pytest-cov": { "hashes": [ - "sha256:45ec2d5182f89a81fc3eb29e3d1ed3113b9e9a873bcddb2a71faaab066110191", - "sha256:47bd0ce14056fdd79f93e1713f88fad7bdcc583dcd7783da86ef2f085a0bb88e" + "sha256:359952d9d39b9f822d9d29324483e7ba04a3a17dd7d05aa6beb7ea01e359e5f7", + "sha256:bdb9fdb0b85a7cc825269a4c56b48ccaa5c7e365054b6038772c32ddcdc969da" ], "index": "pypi", - "version": "==2.10.1" + "version": "==2.11.1" }, "pytest-trio": { "hashes": [ @@ -671,6 +901,7 @@ }, "regex": { "hashes": [ +<<<<<<< HEAD "sha256:02951b7dacb123d8ea6da44fe45ddd084aa6777d4b2454fa0da61d569c6fa538", "sha256:0d08e71e70c0237883d0bef12cad5145b84c3705e9c6a588b2a9c7080e5af2a4", "sha256:1862a9d9194fae76a7aaf0150d5f2a8ec1da89e8b55890b1786b8f88a0f619dc", @@ -714,6 +945,51 @@ "sha256:f8f295db00ef5f8bae530fc39af0b40486ca6068733fb860b42115052206466f" ], "version": "==2020.11.13" +======= + "sha256:07ef35301b4484bce843831e7039a84e19d8d33b3f8b2f9aab86c376813d0139", + "sha256:13f50969028e81765ed2a1c5fcfdc246c245cf8d47986d5172e82ab1a0c42ee5", + "sha256:14de88eda0976020528efc92d0a1f8830e2fb0de2ae6005a6fc4e062553031fa", + "sha256:159fac1a4731409c830d32913f13f68346d6b8e39650ed5d704a9ce2f9ef9cb3", + "sha256:18e25e0afe1cf0f62781a150c1454b2113785401ba285c745acf10c8ca8917df", + "sha256:201e2619a77b21a7780580ab7b5ce43835e242d3e20fef50f66a8df0542e437f", + "sha256:360a01b5fa2ad35b3113ae0c07fb544ad180603fa3b1f074f52d98c1096fa15e", + "sha256:39c44532d0e4f1639a89e52355b949573e1e2c5116106a395642cbbae0ff9bcd", + "sha256:3d9356add82cff75413bec360c1eca3e58db4a9f5dafa1f19650958a81e3249d", + "sha256:3d9a7e215e02bd7646a91fb8bcba30bc55fd42a719d6b35cf80e5bae31d9134e", + "sha256:4651f839dbde0816798e698626af6a2469eee6d9964824bb5386091255a1694f", + "sha256:486a5f8e11e1f5bbfcad87f7c7745eb14796642323e7e1829a331f87a713daaa", + "sha256:4b8a1fb724904139149a43e172850f35aa6ea97fb0545244dc0b805e0154ed68", + "sha256:4c0788010a93ace8a174d73e7c6c9d3e6e3b7ad99a453c8ee8c975ddd9965643", + "sha256:4c2e364491406b7888c2ad4428245fc56c327e34a5dfe58fd40df272b3c3dab3", + "sha256:575a832e09d237ae5fedb825a7a5bc6a116090dd57d6417d4f3b75121c73e3be", + "sha256:5770a51180d85ea468234bc7987f5597803a4c3d7463e7323322fe4a1b181578", + "sha256:633497504e2a485a70a3268d4fc403fe3063a50a50eed1039083e9471ad0101c", + "sha256:63f3ca8451e5ff7133ffbec9eda641aeab2001be1a01878990f6c87e3c44b9d5", + "sha256:709f65bb2fa9825f09892617d01246002097f8f9b6dde8d1bb4083cf554701ba", + "sha256:808404898e9a765e4058bf3d7607d0629000e0a14a6782ccbb089296b76fa8fe", + "sha256:882f53afe31ef0425b405a3f601c0009b44206ea7f55ee1c606aad3cc213a52c", + "sha256:8bd4f91f3fb1c9b1380d6894bd5b4a519409135bec14c0c80151e58394a4e88a", + "sha256:8e65e3e4c6feadf6770e2ad89ad3deb524bcb03d8dc679f381d0568c024e0deb", + "sha256:976a54d44fd043d958a69b18705a910a8376196c6b6ee5f2596ffc11bff4420d", + "sha256:a0d04128e005142260de3733591ddf476e4902c0c23c1af237d9acf3c96e1b38", + "sha256:a0df9a0ad2aad49ea3c7f65edd2ffb3d5c59589b85992a6006354f6fb109bb18", + "sha256:a2ee026f4156789df8644d23ef423e6194fad0bc53575534101bb1de5d67e8ce", + "sha256:a59a2ee329b3de764b21495d78c92ab00b4ea79acef0f7ae8c1067f773570afa", + "sha256:b97ec5d299c10d96617cc851b2e0f81ba5d9d6248413cd374ef7f3a8871ee4a6", + "sha256:b98bc9db003f1079caf07b610377ed1ac2e2c11acc2bea4892e28cc5b509d8d5", + "sha256:b9d8d286c53fe0cbc6d20bf3d583cabcd1499d89034524e3b94c93a5ab85ca90", + "sha256:bcd945175c29a672f13fce13a11893556cd440e37c1b643d6eeab1988c8b209c", + "sha256:c66221e947d7207457f8b6f42b12f613b09efa9669f65a587a2a71f6a0e4d106", + "sha256:c782da0e45aff131f0bed6e66fbcfa589ff2862fc719b83a88640daa01a5aff7", + "sha256:cb4ee827857a5ad9b8ae34d3c8cc51151cb4a3fe082c12ec20ec73e63cc7c6f0", + "sha256:d47d359545b0ccad29d572ecd52c9da945de7cd6cf9c0cfcb0269f76d3555689", + "sha256:dc9963aacb7da5177e40874585d7407c0f93fb9d7518ec58b86e562f633f36cd", + "sha256:ea2f41445852c660ba7c3ebf7d70b3779b20d9ca8ba54485a17740db49f46932", + "sha256:f5d0c921c99297354cecc5a416ee4280bd3f20fd81b9fb671ca6be71499c3fdf", + "sha256:f85d6f41e34f6a2d1607e312820971872944f1661a73d33e1e82d35ea3305e14" + ], + "version": "==2021.3.17" +>>>>>>> master }, "sniffio": { "hashes": [ @@ -734,6 +1010,10 @@ "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" ], +<<<<<<< HEAD +======= + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", +>>>>>>> master "version": "==0.10.2" }, "trio": { @@ -746,38 +1026,38 @@ }, "typed-ast": { "hashes": [ - "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355", - "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919", - "sha256:0d8110d78a5736e16e26213114a38ca35cb15b6515d535413b090bd50951556d", - "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa", - "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652", - "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75", - "sha256:3742b32cf1c6ef124d57f95be609c473d7ec4c14d0090e5a5e05a15269fb4d0c", - "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01", - "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d", - "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1", - "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907", - "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c", - "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3", - "sha256:7e4c9d7658aaa1fc80018593abdf8598bf91325af6af5cce4ce7c73bc45ea53d", - "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b", - "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614", - "sha256:92c325624e304ebf0e025d1224b77dd4e6393f18aab8d829b5b7e04afe9b7a2c", - "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb", - "sha256:b52ccf7cfe4ce2a1064b18594381bccf4179c2ecf7f513134ec2f993dd4ab395", - "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b", - "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41", - "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6", - "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34", - "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe", - "sha256:d648b8e3bf2fe648745c8ffcee3db3ff903d0817a01a12dd6a6ea7a8f4889072", - "sha256:f208eb7aff048f6bea9586e61af041ddf7f9ade7caed625742af423f6bae3298", - "sha256:fac11badff8313e23717f3dada86a15389d0708275bddf766cca67a84ead3e91", - "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4", - "sha256:fcf135e17cc74dbfbc05894ebca928ffeb23d9790b3167a674921db19082401f", - "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7" + "sha256:07d49388d5bf7e863f7fa2f124b1b1d89d8aa0e2f7812faff0a5658c01c59aa1", + "sha256:14bf1522cdee369e8f5581238edac09150c765ec1cb33615855889cf33dcb92d", + "sha256:240296b27397e4e37874abb1df2a608a92df85cf3e2a04d0d4d61055c8305ba6", + "sha256:36d829b31ab67d6fcb30e185ec996e1f72b892255a745d3a82138c97d21ed1cd", + "sha256:37f48d46d733d57cc70fd5f30572d11ab8ed92da6e6b28e024e4a3edfb456e37", + "sha256:4c790331247081ea7c632a76d5b2a265e6d325ecd3179d06e9cf8d46d90dd151", + "sha256:5dcfc2e264bd8a1db8b11a892bd1647154ce03eeba94b461effe68790d8b8e07", + "sha256:7147e2a76c75f0f64c4319886e7639e490fee87c9d25cb1d4faef1d8cf83a440", + "sha256:7703620125e4fb79b64aa52427ec192822e9f45d37d4b6625ab37ef403e1df70", + "sha256:8368f83e93c7156ccd40e49a783a6a6850ca25b556c0fa0240ed0f659d2fe496", + "sha256:84aa6223d71012c68d577c83f4e7db50d11d6b1399a9c779046d75e24bed74ea", + "sha256:85f95aa97a35bdb2f2f7d10ec5bbdac0aeb9dafdaf88e17492da0504de2e6400", + "sha256:8db0e856712f79c45956da0c9a40ca4246abc3485ae0d7ecc86a20f5e4c09abc", + "sha256:9044ef2df88d7f33692ae3f18d3be63dec69c4fb1b5a4a9ac950f9b4ba571606", + "sha256:963c80b583b0661918718b095e02303d8078950b26cc00b5e5ea9ababe0de1fc", + "sha256:987f15737aba2ab5f3928c617ccf1ce412e2e321c77ab16ca5a293e7bbffd581", + "sha256:9ec45db0c766f196ae629e509f059ff05fc3148f9ffd28f3cfe75d4afb485412", + "sha256:9fc0b3cb5d1720e7141d103cf4819aea239f7d136acf9ee4a69b047b7986175a", + "sha256:a2c927c49f2029291fbabd673d51a2180038f8cd5a5b2f290f78c4516be48be2", + "sha256:a38878a223bdd37c9709d07cd357bb79f4c760b29210e14ad0fb395294583787", + "sha256:b4fcdcfa302538f70929eb7b392f536a237cbe2ed9cba88e3bf5027b39f5f77f", + "sha256:c0c74e5579af4b977c8b932f40a5464764b2f86681327410aa028a22d2f54937", + "sha256:c1c876fd795b36126f773db9cbb393f19808edd2637e00fd6caba0e25f2c7b64", + "sha256:c9aadc4924d4b5799112837b226160428524a9a45f830e0d0f184b19e4090487", + "sha256:cc7b98bf58167b7f2db91a4327da24fb93368838eb84a44c472283778fc2446b", + "sha256:cf54cfa843f297991b7388c281cb3855d911137223c6b6d2dd82a47ae5125a41", + "sha256:d003156bb6a59cda9050e983441b7fa2487f7800d76bdc065566b7d728b4581a", + "sha256:d175297e9533d8d37437abc14e8a83cbc68af93cc9c1c59c2c292ec59a0697a3", + "sha256:d746a437cdbca200622385305aedd9aef68e8a645e385cc483bdc5e488f07166", + "sha256:e683e409e5c45d5c9082dc1daf13f6374300806240719f95dc783d1fc942af10" ], - "version": "==1.4.1" + "version": "==1.4.2" }, "typing-extensions": { "hashes": [ diff --git a/lnbits/__main__.py b/lnbits/__main__.py index 89fc6163a..fa75231c4 100644 --- a/lnbits/__main__.py +++ b/lnbits/__main__.py @@ -10,7 +10,14 @@ from .app import create_app app = create_app() -from .settings import LNBITS_SITE_TITLE, SERVICE_FEE, DEBUG, LNBITS_DATA_FOLDER, WALLET, LNBITS_COMMIT +from .settings import ( + LNBITS_SITE_TITLE, + SERVICE_FEE, + DEBUG, + LNBITS_DATA_FOLDER, + WALLET, + LNBITS_COMMIT, +) print( f"""Starting LNbits with diff --git a/lnbits/app.py b/lnbits/app.py index b1562f629..cd700f5c9 100644 --- a/lnbits/app.py +++ b/lnbits/app.py @@ -1,3 +1,4 @@ +import sys import importlib import warnings @@ -9,12 +10,24 @@ from secure import SecureHeaders # type: ignore from .commands import db_migrate, handle_assets from .core import core_app -from .helpers import get_valid_extensions, get_js_vendored, get_css_vendored, url_for_vendored +from .helpers import ( + get_valid_extensions, + get_js_vendored, + get_css_vendored, + url_for_vendored, +) from .proxy_fix import ASGIProxyFix -from .tasks import run_deferred_async, invoice_listener, internal_invoice_listener, webhook_handler, grab_app_for_later +from .tasks import ( + run_deferred_async, + check_pending_payments, + invoice_listener, + internal_invoice_listener, + webhook_handler, + grab_app_for_later, +) from .settings import WALLET -secure_headers = SecureHeaders(hsts=False) +secure_headers = SecureHeaders(hsts=False, xfo=False) def create_app(config_object="lnbits.settings") -> QuartTrio: @@ -43,14 +56,18 @@ def create_app(config_object="lnbits.settings") -> QuartTrio: def check_funding_source(app: QuartTrio) -> None: @app.before_serving async def check_wallet_status(): - error_message, balance = WALLET.status() + error_message, balance = await WALLET.status() if error_message: warnings.warn( f" × The backend for {WALLET.__class__.__name__} isn't working properly: '{error_message}'", RuntimeWarning, ) + + sys.exit(4) else: - print(f" ✔️ {WALLET.__class__.__name__} seems to be connected and with a balance of {balance} msat.") + print( + f" ✔️ {WALLET.__class__.__name__} seems to be connected and with a balance of {balance} msat." + ) def register_blueprints(app: QuartTrio) -> None: @@ -62,13 +79,11 @@ def register_blueprints(app: QuartTrio) -> None: ext_module = importlib.import_module(f"lnbits.extensions.{ext.code}") bp = getattr(ext_module, f"{ext.code}_ext") - @bp.teardown_request - async def after_request(exc): - await ext_module.db.close_session() - app.register_blueprint(bp, url_prefix=f"/{ext.code}") except Exception: - raise ImportError(f"Please make sure that the extension `{ext.code}` follows conventions.") + raise ImportError( + f"Please make sure that the extension `{ext.code}` follows conventions." + ) def register_commands(app: QuartTrio): @@ -103,12 +118,6 @@ def register_request_hooks(app: QuartTrio): async def before_request(): g.nursery = app.nursery - @app.teardown_request - async def after_request(exc): - from lnbits.core import db - - await db.close_session() - @app.after_request async def set_secure_headers(response): secure_headers.quart(response) @@ -123,8 +132,9 @@ def register_async_tasks(app): @app.before_serving async def listeners(): run_deferred_async(app.nursery) - app.nursery.start_soon(invoice_listener) - app.nursery.start_soon(internal_invoice_listener) + app.nursery.start_soon(check_pending_payments) + app.nursery.start_soon(invoice_listener, app.nursery) + app.nursery.start_soon(internal_invoice_listener, app.nursery) @app.after_serving async def stop_listeners(): diff --git a/lnbits/bolt11.py b/lnbits/bolt11.py index 1be351be9..6acc6db75 100644 --- a/lnbits/bolt11.py +++ b/lnbits/bolt11.py @@ -106,7 +106,9 @@ def decode(pr: str) -> Invoice: key = VerifyingKey.from_string(unhexlify(invoice.payee), curve=SECP256k1) key.verify(sig, message, hashlib.sha256, sigdecode=sigdecode_string) else: - keys = VerifyingKey.from_public_key_recovery(sig, message, SECP256k1, hashlib.sha256) + keys = VerifyingKey.from_public_key_recovery( + sig, message, SECP256k1, hashlib.sha256 + ) signaling_byte = signature[64] key = keys[int(signaling_byte)] invoice.payee = key.to_string("compressed").hex() diff --git a/lnbits/commands.py b/lnbits/commands.py index 8236766e9..2be04d127 100644 --- a/lnbits/commands.py +++ b/lnbits/commands.py @@ -7,7 +7,12 @@ import os from sqlalchemy.exc import OperationalError # type: ignore from .core import db as core_db, migrations as core_migrations -from .helpers import get_valid_extensions, get_css_vendored, get_js_vendored, url_for_vendored +from .helpers import ( + get_valid_extensions, + get_css_vendored, + get_js_vendored, + url_for_vendored, +) from .settings import LNBITS_PATH @@ -48,41 +53,41 @@ def bundle_vendored(): async def migrate_databases(): """Creates the necessary databases if they don't exist already; or migrates them.""" - core_conn = await core_db.connect() - core_txn = await core_conn.begin() - - try: - rows = await (await core_conn.execute("SELECT * FROM dbversions")).fetchall() - except OperationalError: - # migration 3 wasn't ran - await core_migrations.m000_create_migrations_table(core_conn) - rows = await (await core_conn.execute("SELECT * FROM dbversions")).fetchall() - - current_versions = {row["db"]: row["version"] for row in rows} - matcher = re.compile(r"^m(\d\d\d)_") - - async def run_migration(db, migrations_module): - db_name = migrations_module.__name__.split(".")[-2] - for key, migrate in migrations_module.__dict__.items(): - match = match = matcher.match(key) - if match: - version = int(match.group(1)) - if version > current_versions.get(db_name, 0): - print(f"running migration {db_name}.{version}") - await migrate(db) - await core_conn.execute( - "INSERT OR REPLACE INTO dbversions (db, version) VALUES (?, ?)", (db_name, version) - ) - - await run_migration(core_conn, core_migrations) - - for ext in get_valid_extensions(): + async with core_db.connect() as conn: try: - ext_migrations = importlib.import_module(f"lnbits.extensions.{ext.code}.migrations") - ext_db = importlib.import_module(f"lnbits.extensions.{ext.code}").db - await run_migration(ext_db, ext_migrations) - except ImportError: - raise ImportError(f"Please make sure that the extension `{ext.code}` has a migrations file.") + rows = await (await conn.execute("SELECT * FROM dbversions")).fetchall() + except OperationalError: + # migration 3 wasn't ran + await core_migrations.m000_create_migrations_table(conn) + rows = await (await conn.execute("SELECT * FROM dbversions")).fetchall() - await core_txn.commit() - await core_conn.close() + current_versions = {row["db"]: row["version"] for row in rows} + matcher = re.compile(r"^m(\d\d\d)_") + + async def run_migration(db, migrations_module): + db_name = migrations_module.__name__.split(".")[-2] + for key, migrate in migrations_module.__dict__.items(): + match = match = matcher.match(key) + if match: + version = int(match.group(1)) + if version > current_versions.get(db_name, 0): + print(f"running migration {db_name}.{version}") + await migrate(db) + await conn.execute( + "INSERT OR REPLACE INTO dbversions (db, version) VALUES (?, ?)", + (db_name, version), + ) + + await run_migration(conn, core_migrations) + + for ext in get_valid_extensions(): + try: + ext_migrations = importlib.import_module( + f"lnbits.extensions.{ext.code}.migrations" + ) + ext_db = importlib.import_module(f"lnbits.extensions.{ext.code}").db + await run_migration(ext_db, ext_migrations) + except ImportError: + raise ImportError( + f"Please make sure that the extension `{ext.code}` has a migrations file." + ) diff --git a/lnbits/core/__init__.py b/lnbits/core/__init__.py index a2ea1ddf5..ca0959a89 100644 --- a/lnbits/core/__init__.py +++ b/lnbits/core/__init__.py @@ -4,7 +4,11 @@ from lnbits.db import Database db = Database("database") core_app: Blueprint = Blueprint( - "core", __name__, template_folder="templates", static_folder="static", static_url_path="/core/static" + "core", + __name__, + template_folder="templates", + static_folder="static", + static_url_path="/core/static", ) diff --git a/lnbits/core/crud.py b/lnbits/core/crud.py index 5b0d572c5..87d4972ae 100644 --- a/lnbits/core/crud.py +++ b/lnbits/core/crud.py @@ -1,9 +1,10 @@ import json import datetime from uuid import uuid4 -from typing import List, Optional, Dict +from typing import List, Optional, Dict, Any from lnbits import bolt11 +from lnbits.db import Connection from lnbits.settings import DEFAULT_WALLET_NAME from . import db @@ -14,28 +15,36 @@ from .models import User, Wallet, Payment # -------- -async def create_account() -> User: +async def create_account(conn: Optional[Connection] = None) -> User: user_id = uuid4().hex - await db.execute("INSERT INTO accounts (id) VALUES (?)", (user_id,)) + await (conn or db).execute("INSERT INTO accounts (id) VALUES (?)", (user_id,)) - new_account = await get_account(user_id=user_id) + new_account = await get_account(user_id=user_id, conn=conn) assert new_account, "Newly created account couldn't be retrieved" return new_account -async def get_account(user_id: str) -> Optional[User]: - row = await db.fetchone("SELECT id, email, pass as password FROM accounts WHERE id = ?", (user_id,)) +async def get_account( + user_id: str, conn: Optional[Connection] = None +) -> Optional[User]: + row = await (conn or db).fetchone( + "SELECT id, email, pass as password FROM accounts WHERE id = ?", (user_id,) + ) return User(**row) if row else None -async def get_user(user_id: str) -> Optional[User]: - user = await db.fetchone("SELECT id, email FROM accounts WHERE id = ?", (user_id,)) +async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[User]: + user = await (conn or db).fetchone( + "SELECT id, email FROM accounts WHERE id = ?", (user_id,) + ) if user: - extensions = await db.fetchall("SELECT extension FROM extensions WHERE user = ? AND active = 1", (user_id,)) - wallets = await db.fetchall( + extensions = await (conn or db).fetchall( + "SELECT extension FROM extensions WHERE user = ? AND active = 1", (user_id,) + ) + wallets = await (conn or db).fetchall( """ SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0) AS balance_msat FROM wallets @@ -45,14 +54,24 @@ async def get_user(user_id: str) -> Optional[User]: ) return ( - User(**{**user, **{"extensions": [e[0] for e in extensions], "wallets": [Wallet(**w) for w in wallets]}}) + User( + **{ + **user, + **{ + "extensions": [e[0] for e in extensions], + "wallets": [Wallet(**w) for w in wallets], + }, + } + ) if user else None ) -async def update_user_extension(*, user_id: str, extension: str, active: int) -> None: - await db.execute( +async def update_user_extension( + *, user_id: str, extension: str, active: int, conn: Optional[Connection] = None +) -> None: + await (conn or db).execute( """ INSERT OR REPLACE INTO extensions (user, extension, active) VALUES (?, ?, ?) @@ -65,24 +84,37 @@ async def update_user_extension(*, user_id: str, extension: str, active: int) -> # ------- -async def create_wallet(*, user_id: str, wallet_name: Optional[str] = None) -> Wallet: +async def create_wallet( + *, + user_id: str, + wallet_name: Optional[str] = None, + conn: Optional[Connection] = None, +) -> Wallet: wallet_id = uuid4().hex - await db.execute( + await (conn or db).execute( """ INSERT INTO wallets (id, name, user, adminkey, inkey) VALUES (?, ?, ?, ?, ?) """, - (wallet_id, wallet_name or DEFAULT_WALLET_NAME, user_id, uuid4().hex, uuid4().hex), + ( + wallet_id, + wallet_name or DEFAULT_WALLET_NAME, + user_id, + uuid4().hex, + uuid4().hex, + ), ) - new_wallet = await get_wallet(wallet_id=wallet_id) + new_wallet = await get_wallet(wallet_id=wallet_id, conn=conn) assert new_wallet, "Newly created wallet couldn't be retrieved" return new_wallet -async def delete_wallet(*, user_id: str, wallet_id: str) -> None: - await db.execute( +async def delete_wallet( + *, user_id: str, wallet_id: str, conn: Optional[Connection] = None +) -> None: + await (conn or db).execute( """ UPDATE wallets AS w SET @@ -95,8 +127,10 @@ async def delete_wallet(*, user_id: str, wallet_id: str) -> None: ) -async def get_wallet(wallet_id: str) -> Optional[Wallet]: - row = await db.fetchone( +async def get_wallet( + wallet_id: str, conn: Optional[Connection] = None +) -> Optional[Wallet]: + row = await (conn or db).fetchone( """ SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0) AS balance_msat FROM wallets @@ -108,8 +142,10 @@ async def get_wallet(wallet_id: str) -> Optional[Wallet]: return Wallet(**row) if row else None -async def get_wallet_for_key(key: str, key_type: str = "invoice") -> Optional[Wallet]: - row = await db.fetchone( +async def get_wallet_for_key( + key: str, key_type: str = "invoice", conn: Optional[Connection] = None +) -> Optional[Wallet]: + row = await (conn or db).fetchone( """ SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0) AS balance_msat FROM wallets @@ -131,21 +167,26 @@ async def get_wallet_for_key(key: str, key_type: str = "invoice") -> Optional[Wa # --------------- -async def get_standalone_payment(checking_id: str) -> Optional[Payment]: - row = await db.fetchone( +async def get_standalone_payment( + checking_id_or_hash: str, conn: Optional[Connection] = None +) -> Optional[Payment]: + row = await (conn or db).fetchone( """ SELECT * FROM apipayments - WHERE checking_id = ? + WHERE checking_id = ? OR hash = ? + LIMIT 1 """, - (checking_id,), + (checking_id_or_hash, checking_id_or_hash), ) return Payment.from_row(row) if row else None -async def get_wallet_payment(wallet_id: str, payment_hash: str) -> Optional[Payment]: - row = await db.fetchone( +async def get_wallet_payment( + wallet_id: str, payment_hash: str, conn: Optional[Connection] = None +) -> Optional[Payment]: + row = await (conn or db).fetchone( """ SELECT * FROM apipayments @@ -157,61 +198,75 @@ async def get_wallet_payment(wallet_id: str, payment_hash: str) -> Optional[Paym return Payment.from_row(row) if row else None -async def get_wallet_payments( - wallet_id: str, +async def get_payments( *, + wallet_id: Optional[str] = None, complete: bool = False, pending: bool = False, outgoing: bool = False, incoming: bool = False, + since: Optional[int] = None, exclude_uncheckable: bool = False, + conn: Optional[Connection] = None, ) -> List[Payment]: """ Filters payments to be returned by complete | pending | outgoing | incoming. """ - clause = "" - if complete and pending: - clause += "" - elif complete: - clause += "AND ((amount > 0 AND pending = 0) OR amount < 0)" - elif pending: - clause += "AND pending = 1" - else: - raise TypeError("at least one of [complete, pending] must be True.") + args: List[Any] = [] + clause: List[str] = [] - clause += " " + if since != None: + clause.append("time > ?") + args.append(since) + + if wallet_id: + clause.append("wallet = ?") + args.append(wallet_id) + + if complete and pending: + pass + elif complete: + clause.append("((amount > 0 AND pending = 0) OR amount < 0)") + elif pending: + clause.append("pending = 1") + else: + pass if outgoing and incoming: - clause += "" + pass elif outgoing: - clause += "AND amount < 0" + clause.append("amount < 0") elif incoming: - clause += "AND amount > 0" + clause.append("amount > 0") else: - raise TypeError("at least one of [outgoing, incoming] must be True.") - - clause += " " + pass if exclude_uncheckable: # checkable means it has a checking_id that isn't internal - clause += "AND checking_id NOT LIKE 'temp_%' " - clause += "AND checking_id NOT LIKE 'internal_%' " + clause.append("checking_id NOT LIKE 'temp_%'") + clause.append("checking_id NOT LIKE 'internal_%'") - rows = await db.fetchall( + where = "" + if clause: + where = f"WHERE {' AND '.join(clause)}" + + rows = await (conn or db).fetchall( f""" SELECT * FROM apipayments - WHERE wallet = ? {clause} + {where} ORDER BY time DESC """, - (wallet_id,), + tuple(args), ) return [Payment.from_row(row) for row in rows] -async def delete_expired_invoices() -> None: - rows = await db.fetchall( +async def delete_expired_invoices( + conn: Optional[Connection] = None, +) -> None: + rows = await (conn or db).fetchall( """ SELECT bolt11 FROM apipayments @@ -228,7 +283,7 @@ async def delete_expired_invoices() -> None: if expiration_date > datetime.datetime.utcnow(): continue - await db.execute( + await (conn or db).execute( """ DELETE FROM apipayments WHERE pending = 1 AND hash = ? @@ -254,8 +309,9 @@ async def create_payment( pending: bool = True, extra: Optional[Dict] = None, webhook: Optional[str] = None, + conn: Optional[Connection] = None, ) -> Payment: - await db.execute( + await (conn or db).execute( """ INSERT INTO apipayments (wallet, checking_id, bolt11, hash, preimage, @@ -272,19 +328,25 @@ async def create_payment( int(pending), memo, fee, - json.dumps(extra) if extra and extra != {} and type(extra) is dict else None, + json.dumps(extra) + if extra and extra != {} and type(extra) is dict + else None, webhook, ), ) - new_payment = await get_wallet_payment(wallet_id, payment_hash) + new_payment = await get_wallet_payment(wallet_id, payment_hash, conn=conn) assert new_payment, "Newly created payment couldn't be retrieved" return new_payment -async def update_payment_status(checking_id: str, pending: bool) -> None: - await db.execute( +async def update_payment_status( + checking_id: str, + pending: bool, + conn: Optional[Connection] = None, +) -> None: + await (conn or db).execute( "UPDATE apipayments SET pending = ? WHERE checking_id = ?", ( int(pending), @@ -293,12 +355,20 @@ async def update_payment_status(checking_id: str, pending: bool) -> None: ) -async def delete_payment(checking_id: str) -> None: - await db.execute("DELETE FROM apipayments WHERE checking_id = ?", (checking_id,)) +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) -> Optional[str]: - row = await db.fetchone( +async def check_internal( + payment_hash: str, + conn: Optional[Connection] = None, +) -> Optional[str]: + row = await (conn or db).fetchone( """ SELECT checking_id FROM apipayments WHERE hash = ? AND pending AND amount > 0 diff --git a/lnbits/core/migrations.py b/lnbits/core/migrations.py index d04963228..890bf51fe 100644 --- a/lnbits/core/migrations.py +++ b/lnbits/core/migrations.py @@ -111,7 +111,12 @@ async def m002_add_fields_to_apipayments(db): UPDATE apipayments SET extra = ?, memo = ? WHERE checking_id = ? AND memo = ? """, - (json.dumps({"tag": ext}), new, row["checking_id"], row["memo"]), + ( + json.dumps({"tag": ext}), + new, + row["checking_id"], + row["memo"], + ), ) break except OperationalError: diff --git a/lnbits/core/models.py b/lnbits/core/models.py index 9e37baa17..9f7b53a32 100644 --- a/lnbits/core/models.py +++ b/lnbits/core/models.py @@ -58,12 +58,12 @@ class Wallet(NamedTuple): pending: bool = False, outgoing: bool = True, incoming: bool = True, - exclude_uncheckable: bool = False + exclude_uncheckable: bool = False, ) -> List["Payment"]: - from .crud import get_wallet_payments + from .crud import get_payments - return await get_wallet_payments( - self.id, + return await get_payments( + wallet_id=self.id, complete=complete, pending=pending, outgoing=outgoing, @@ -127,7 +127,9 @@ class Payment(NamedTuple): @property def is_uncheckable(self) -> bool: - return self.checking_id.startswith("temp_") or self.checking_id.startswith("internal_") + return self.checking_id.startswith("temp_") or self.checking_id.startswith( + "internal_" + ) async def set_pending(self, pending: bool) -> None: from .crud import update_payment_status @@ -139,11 +141,18 @@ class Payment(NamedTuple): return if self.is_out: - pending = WALLET.get_payment_status(self.checking_id) + status = await WALLET.get_payment_status(self.checking_id) else: - pending = WALLET.get_invoice_status(self.checking_id) + status = await WALLET.get_invoice_status(self.checking_id) - await self.set_pending(pending.pending) + print( + f" - checking '{'in' if self.is_in else 'out'}' {self.checking_id}: {status}" + ) + + if self.is_out and status.failed: + await self.delete() + elif not status.pending: + await self.set_pending(status.pending) async def delete(self) -> None: from .crud import delete_payment diff --git a/lnbits/core/services.py b/lnbits/core/services.py index 266e36a84..d623b1183 100644 --- a/lnbits/core/services.py +++ b/lnbits/core/services.py @@ -13,12 +13,20 @@ except ImportError: # pragma: nocover from typing_extensions import TypedDict from lnbits import bolt11 +from lnbits.db import Connection from lnbits.helpers import urlsafe_short_hash from lnbits.settings import WALLET from lnbits.wallets.base import PaymentStatus, PaymentResponse from . import db -from .crud import get_wallet, create_payment, delete_payment, check_internal, update_payment_status, get_wallet_payment +from .crud import ( + get_wallet, + create_payment, + delete_payment, + check_internal, + update_payment_status, + get_wallet_payment, +) async def create_invoice( @@ -29,12 +37,12 @@ async def create_invoice( description_hash: Optional[bytes] = None, extra: Optional[Dict] = None, webhook: Optional[str] = None, + conn: Optional[Connection] = None, ) -> Tuple[str, str]: - await db.begin() invoice_memo = None if description_hash else memo storeable_memo = memo - ok, checking_id, payment_request, error_message = WALLET.create_invoice( + ok, checking_id, payment_request, error_message = await WALLET.create_invoice( amount=amount, memo=invoice_memo, description_hash=description_hash ) if not ok: @@ -52,9 +60,9 @@ async def create_invoice( memo=storeable_memo, extra=extra, webhook=webhook, + conn=conn, ) - await db.commit() return invoice.payment_hash, payment_request @@ -65,105 +73,130 @@ async def pay_invoice( max_sat: Optional[int] = None, extra: Optional[Dict] = None, description: str = "", + conn: Optional[Connection] = None, ) -> str: - await db.begin() - temp_id = f"temp_{urlsafe_short_hash()}" - internal_id = f"internal_{urlsafe_short_hash()}" + async with (db.reuse_conn(conn) if conn else db.connect()) as conn: + temp_id = f"temp_{urlsafe_short_hash()}" + internal_id = f"internal_{urlsafe_short_hash()}" - invoice = bolt11.decode(payment_request) - if invoice.amount_msat == 0: - raise ValueError("Amountless invoices not supported.") - if max_sat and invoice.amount_msat > max_sat * 1000: - raise ValueError("Amount in invoice is too high.") + invoice = bolt11.decode(payment_request) + if invoice.amount_msat == 0: + raise ValueError("Amountless invoices not supported.") + if max_sat and invoice.amount_msat > max_sat * 1000: + raise ValueError("Amount in invoice is too high.") - # put all parameters that don't change here - PaymentKwargs = TypedDict( - "PaymentKwargs", - { - "wallet_id": str, - "payment_request": str, - "payment_hash": str, - "amount": int, - "memo": str, - "extra": Optional[Dict], - }, - ) - payment_kwargs: PaymentKwargs = dict( - wallet_id=wallet_id, - payment_request=payment_request, - payment_hash=invoice.payment_hash, - amount=-invoice.amount_msat, - memo=description or invoice.description or "", - extra=extra, - ) + # put all parameters that don't change here + PaymentKwargs = TypedDict( + "PaymentKwargs", + { + "wallet_id": str, + "payment_request": str, + "payment_hash": str, + "amount": int, + "memo": str, + "extra": Optional[Dict], + }, + ) + payment_kwargs: PaymentKwargs = dict( + wallet_id=wallet_id, + payment_request=payment_request, + payment_hash=invoice.payment_hash, + amount=-invoice.amount_msat, + memo=description or invoice.description or "", + extra=extra, + ) - # check_internal() returns the checking_id of the invoice we're waiting for - internal_checking_id = await check_internal(invoice.payment_hash) - if internal_checking_id: - # create a new payment from this wallet - await create_payment(checking_id=internal_id, fee=0, pending=False, **payment_kwargs) - else: - # create a temporary payment here so we can check if - # the balance is enough in the next step - fee_reserve = max(1000, int(invoice.amount_msat * 0.01)) - await create_payment(checking_id=temp_id, fee=-fee_reserve, **payment_kwargs) - - # do the balance check - wallet = await get_wallet(wallet_id) - assert wallet - if wallet.balance_msat < 0: - await db.rollback() - raise PermissionError("Insufficient balance.") - else: - await db.commit() - await db.begin() - - if internal_checking_id: - # mark the invoice from the other side as not pending anymore - # 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) - - # notify receiver asynchronously - from lnbits.tasks import internal_invoice_paid - - await internal_invoice_paid.send(internal_checking_id) - else: - # actually pay the external invoice - payment: PaymentResponse = WALLET.pay_invoice(payment_request) - if payment.ok and payment.checking_id: + # check_internal() returns the checking_id of the invoice we're waiting for + internal_checking_id = await check_internal(invoice.payment_hash, conn=conn) + if internal_checking_id: + # create a new payment from this wallet await create_payment( - checking_id=payment.checking_id, - fee=payment.fee_msat, - preimage=payment.preimage, + checking_id=internal_id, + fee=0, + pending=False, + conn=conn, **payment_kwargs, ) - await delete_payment(temp_id) - await db.commit() else: - await delete_payment(temp_id) - await db.commit() - raise Exception(payment.error_message or "Failed to pay_invoice on backend.") + # create a temporary payment here so we can check if + # the balance is enough in the next step + fee_reserve = max(1000, int(invoice.amount_msat * 0.01)) + await create_payment( + checking_id=temp_id, + fee=-fee_reserve, + conn=conn, + **payment_kwargs, + ) - return invoice.payment_hash + # do the balance check + wallet = await get_wallet(wallet_id, conn=conn) + assert wallet + if wallet.balance_msat < 0: + raise PermissionError("Insufficient balance.") + + if internal_checking_id: + # mark the invoice from the other side as not pending anymore + # 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, + ) + + # notify receiver asynchronously + from lnbits.tasks import internal_invoice_paid + + await internal_invoice_paid.send(internal_checking_id) + else: + # actually pay the external invoice + payment: PaymentResponse = await WALLET.pay_invoice(payment_request) + if payment.checking_id: + await create_payment( + checking_id=payment.checking_id, + fee=payment.fee_msat, + preimage=payment.preimage, + pending=payment.ok == None, + conn=conn, + **payment_kwargs, + ) + await delete_payment(temp_id, conn=conn) + else: + raise Exception( + payment.error_message or "Failed to pay_invoice on backend." + ) + + return invoice.payment_hash -async def redeem_lnurl_withdraw(wallet_id: str, res: LnurlWithdrawResponse, memo: Optional[str] = None) -> None: +async def redeem_lnurl_withdraw( + wallet_id: str, + res: LnurlWithdrawResponse, + memo: Optional[str] = None, + conn: Optional[Connection] = None, +) -> None: _, payment_request = await create_invoice( wallet_id=wallet_id, amount=res.max_sats, memo=memo or res.default_description or "", extra={"tag": "lnurlwallet"}, + conn=conn, ) async with httpx.AsyncClient() as client: await client.get( res.callback.base, - params={**res.callback.query_params, **{"k1": res.k1, "pr": payment_request}}, + params={ + **res.callback.query_params, + **{"k1": res.k1, "pr": payment_request}, + }, ) -async def perform_lnurlauth(callback: str) -> Optional[LnurlErrorResponse]: +async def perform_lnurlauth( + callback: str, + conn: Optional[Connection] = None, +) -> Optional[LnurlErrorResponse]: cb = urlparse(callback) k1 = unhexlify(parse_qs(cb.query)["k1"][0]) @@ -234,9 +267,13 @@ async def perform_lnurlauth(callback: str) -> Optional[LnurlErrorResponse]: ) -async def check_invoice_status(wallet_id: str, payment_hash: str) -> PaymentStatus: - payment = await get_wallet_payment(wallet_id, payment_hash) +async def check_invoice_status( + wallet_id: str, + payment_hash: str, + conn: Optional[Connection] = None, +) -> PaymentStatus: + payment = await get_wallet_payment(wallet_id, payment_hash, conn=conn) if not payment: return PaymentStatus(None) - return WALLET.get_invoice_status(payment.checking_id) + return await WALLET.get_invoice_status(payment.checking_id) diff --git a/lnbits/core/static/js/wallet.js b/lnbits/core/static/js/wallet.js index 097a78195..bfed347b6 100644 --- a/lnbits/core/static/js/wallet.js +++ b/lnbits/core/static/js/wallet.js @@ -584,18 +584,16 @@ new Vue({ LNbits.href.deleteWallet(walletId, user) }) }, - fetchPayments: function (checkPending) { - return LNbits.api - .getPayments(this.g.wallet, checkPending) - .then(response => { - this.payments = response.data - .map(obj => { - return LNbits.map.payment(obj) - }) - .sort((a, b) => { - return b.time - a.time - }) - }) + fetchPayments: function () { + return LNbits.api.getPayments(this.g.wallet).then(response => { + this.payments = response.data + .map(obj => { + return LNbits.map.payment(obj) + }) + .sort((a, b) => { + return b.time - a.time + }) + }) }, fetchBalance: function () { LNbits.api.getWallet(this.g.wallet).then(response => { @@ -606,16 +604,6 @@ new Vue({ ]) }) }, - checkPendingPayments: function () { - var dismissMsg = this.$q.notify({ - timeout: 0, - message: 'Checking pending transactions...' - }) - - this.fetchPayments(true).then(() => { - dismissMsg() - }) - }, exportCSV: function () { LNbits.utils.exportCSV(this.paymentsTable.columns, this.payments) } @@ -628,7 +616,6 @@ new Vue({ created: function () { this.fetchBalance() this.fetchPayments() - setTimeout(this.checkPendingPayments(), 1200) }, mounted: function () { // show disclaimer diff --git a/lnbits/core/templates/core/wallet.html b/lnbits/core/templates/core/wallet.html index a313ce26c..2fe30ec3e 100644 --- a/lnbits/core/templates/core/wallet.html +++ b/lnbits/core/templates/core/wallet.html @@ -438,7 +438,7 @@ list: + result = await self.conn.execute(query, values) + return await result.fetchall() + + async def fetchone(self, query: str, values: tuple = ()): + result = await self.conn.execute(query, values) + row = await result.fetchone() + await result.close() + return row + + async def execute(self, query: str, values: tuple = ()): + return await self.conn.execute(query, values) + + class Database: def __init__(self, db_name: str): self.db_name = db_name db_path = os.path.join(LNBITS_DATA_FOLDER, f"{db_name}.sqlite3") self.engine = create_engine(f"sqlite:///{db_path}", strategy=TRIO_STRATEGY) + self.lock = trio.StrictFIFOLock() - def connect(self): - return self.engine.connect() - - def session_connection(self) -> Tuple[Optional[Any], Optional[Any]]: + @asynccontextmanager + async def connect(self): + await self.lock.acquire() try: - return getattr(g, f"{self.db_name}_conn", None), getattr(g, f"{self.db_name}_txn", None) - except RuntimeError: - return None, None - - async def begin(self): - conn, _ = self.session_connection() - if conn: - return - - conn = await self.engine.connect() - setattr(g, f"{self.db_name}_conn", conn) - txn = await conn.begin() - setattr(g, f"{self.db_name}_txn", txn) + async with self.engine.connect() as conn: + async with conn.begin(): + yield Connection(conn) + finally: + self.lock.release() async def fetchall(self, query: str, values: tuple = ()) -> list: - conn, _ = self.session_connection() - if conn: - result = await conn.execute(query, values) - return await result.fetchall() - async with self.connect() as conn: result = await conn.execute(query, values) return await result.fetchall() async def fetchone(self, query: str, values: tuple = ()): - conn, _ = self.session_connection() - if conn: - result = await conn.execute(query, values) - row = await result.fetchone() - await result.close() - return row - async with self.connect() as conn: result = await conn.execute(query, values) row = await result.fetchone() @@ -57,29 +56,9 @@ class Database: return row async def execute(self, query: str, values: tuple = ()): - conn, _ = self.session_connection() - if conn: - return await conn.execute(query, values) - async with self.connect() as conn: return await conn.execute(query, values) - async def commit(self): - conn, txn = self.session_connection() - if conn and txn: - await txn.commit() - await self.close_session() - - async def rollback(self): - conn, txn = self.session_connection() - if conn and txn: - await txn.rollback() - await self.close_session() - - async def close_session(self): - conn, txn = self.session_connection() - if conn and txn: - await txn.close() - await conn.close() - delattr(g, f"{self.db_name}_conn") - delattr(g, f"{self.db_name}_txn") + @asynccontextmanager + async def reuse_conn(self, conn: Connection): + yield conn diff --git a/lnbits/decorators.py b/lnbits/decorators.py index 1e659e09c..5d923c352 100644 --- a/lnbits/decorators.py +++ b/lnbits/decorators.py @@ -77,11 +77,15 @@ def check_user_exists(param: str = "usr"): return wrap -def validate_uuids(params: List[str], *, required: Union[bool, List[str]] = False, version: int = 4): +def validate_uuids( + params: List[str], *, required: Union[bool, List[str]] = False, version: int = 4 +): def wrap(view): @wraps(view) async def wrapped_view(**kwargs): - query_params = {param: request.args.get(param, type=str) for param in params} + query_params = { + param: request.args.get(param, type=str) for param in params + } for param, value in query_params.items(): if not value and (required is True or (required and param in required)): diff --git a/lnbits/extensions/amilk/__init__.py b/lnbits/extensions/amilk/__init__.py index 9aa7047c8..0cdd8727f 100644 --- a/lnbits/extensions/amilk/__init__.py +++ b/lnbits/extensions/amilk/__init__.py @@ -3,7 +3,9 @@ from lnbits.db import Database db = Database("ext_amilk") -amilk_ext: Blueprint = Blueprint("amilk", __name__, static_folder="static", template_folder="templates") +amilk_ext: Blueprint = Blueprint( + "amilk", __name__, static_folder="static", template_folder="templates" +) from .views_api import * # noqa diff --git a/lnbits/extensions/amilk/crud.py b/lnbits/extensions/amilk/crud.py index 5170ca1fb..773caa42b 100644 --- a/lnbits/extensions/amilk/crud.py +++ b/lnbits/extensions/amilk/crud.py @@ -31,7 +31,9 @@ async def get_amilks(wallet_ids: Union[str, List[str]]) -> List[AMilk]: wallet_ids = [wallet_ids] q = ",".join(["?"] * len(wallet_ids)) - rows = await db.fetchall(f"SELECT * FROM amilks WHERE wallet IN ({q})", (*wallet_ids,)) + rows = await db.fetchall( + f"SELECT * FROM amilks WHERE wallet IN ({q})", (*wallet_ids,) + ) return [AMilk(**row) for row in rows] diff --git a/lnbits/extensions/amilk/views_api.py b/lnbits/extensions/amilk/views_api.py index 8ffaa4db5..1ebfe02cb 100644 --- a/lnbits/extensions/amilk/views_api.py +++ b/lnbits/extensions/amilk/views_api.py @@ -21,7 +21,10 @@ async def api_amilks(): if "all_wallets" in request.args: wallet_ids = (await get_user(g.wallet.user)).wallet_ids - return jsonify([amilk._asdict() for amilk in await get_amilks(wallet_ids)]), HTTPStatus.OK + return ( + jsonify([amilk._asdict() for amilk in await get_amilks(wallet_ids)]), + HTTPStatus.OK, + ) @amilk_ext.route("/api/v1/amilk/milk/", methods=["GET"]) @@ -35,12 +38,18 @@ async def api_amilkit(amilk_id): abort(HTTPStatus.INTERNAL_SERVER_ERROR, "Could not process withdraw LNURL.") payment_hash, payment_request = await create_invoice( - wallet_id=milk.wallet, amount=withdraw_res.max_sats, memo=memo, extra={"tag": "amilk"} + wallet_id=milk.wallet, + amount=withdraw_res.max_sats, + memo=memo, + extra={"tag": "amilk"}, ) r = httpx.get( withdraw_res.callback.base, - params={**withdraw_res.callback.query_params, **{"k1": withdraw_res.k1, "pr": payment_request}}, + params={ + **withdraw_res.callback.query_params, + **{"k1": withdraw_res.k1, "pr": payment_request}, + }, ) if r.is_error: @@ -68,7 +77,10 @@ async def api_amilkit(amilk_id): ) async def api_amilk_create(): amilk = await create_amilk( - wallet_id=g.wallet.id, lnurl=g.data["lnurl"], atime=g.data["atime"], amount=g.data["amount"] + wallet_id=g.wallet.id, + lnurl=g.data["lnurl"], + atime=g.data["atime"], + amount=g.data["amount"], ) return jsonify(amilk._asdict()), HTTPStatus.CREATED diff --git a/lnbits/extensions/bleskomat/README.md b/lnbits/extensions/bleskomat/README.md new file mode 100644 index 000000000..97c70700a --- /dev/null +++ b/lnbits/extensions/bleskomat/README.md @@ -0,0 +1,21 @@ +# Bleskomat Extension for lnbits + +This extension allows you to connect a Bleskomat ATM to an lnbits wallet. It will work with both the [open-source DIY Bleskomat ATM project](https://github.com/samotari/bleskomat) as well as the [commercial Bleskomat ATM](https://www.bleskomat.com/). + + +## Connect Your Bleskomat ATM + +* Click the "Add Bleskomat" button on this page to begin. +* Choose a wallet. This will be the wallet that is used to pay satoshis to your ATM customers. +* Choose the fiat currency. This should match the fiat currency that your ATM accepts. +* Pick an exchange rate provider. This is the API that will be used to query the fiat to satoshi exchange rate at the time your customer attempts to withdraw their funds. +* Set your ATM's fee percentage. +* Click the "Done" button. +* Find the new Bleskomat in the list and then click the export icon to download a new configuration file for your ATM. +* Copy the configuration file ("bleskomat.conf") to your ATM's SD card. +* Restart Your Bleskomat ATM. It should automatically reload the configurations from the SD card. + + +## How Does It Work? + +Since the Bleskomat ATMs are designed to be offline, a cryptographic signing scheme is used to verify that the URL was generated by an authorized device. When one of your customers inserts fiat money into the device, a signed URL (lnurl-withdraw) is created and displayed as a QR code. Your customer scans the QR code with their lnurl-supporting mobile app, their mobile app communicates with the web API of lnbits to verify the signature, the fiat currency amount is converted to sats, the customer accepts the withdrawal, and finally lnbits will pay the customer from your lnbits wallet. diff --git a/lnbits/extensions/bleskomat/__init__.py b/lnbits/extensions/bleskomat/__init__.py new file mode 100644 index 000000000..42f9bb460 --- /dev/null +++ b/lnbits/extensions/bleskomat/__init__.py @@ -0,0 +1,12 @@ +from quart import Blueprint +from lnbits.db import Database + +db = Database("ext_bleskomat") + +bleskomat_ext: Blueprint = Blueprint( + "bleskomat", __name__, static_folder="static", template_folder="templates" +) + +from .lnurl_api import * # noqa +from .views_api import * # noqa +from .views import * # noqa diff --git a/lnbits/extensions/bleskomat/config.json b/lnbits/extensions/bleskomat/config.json new file mode 100644 index 000000000..99244df14 --- /dev/null +++ b/lnbits/extensions/bleskomat/config.json @@ -0,0 +1,6 @@ +{ + "name": "Bleskomat", + "short_description": "Connect a Bleskomat ATM to an lnbits", + "icon": "money", + "contributors": ["chill117"] +} diff --git a/lnbits/extensions/bleskomat/crud.py b/lnbits/extensions/bleskomat/crud.py new file mode 100644 index 000000000..b85477b4d --- /dev/null +++ b/lnbits/extensions/bleskomat/crud.py @@ -0,0 +1,112 @@ +import secrets +import time +from uuid import uuid4 +from typing import List, Optional, Union +from . import db +from .models import Bleskomat, BleskomatLnurl +from .helpers import generate_bleskomat_lnurl_hash + + +async def create_bleskomat( + *, + wallet_id: str, + name: str, + fiat_currency: str, + exchange_rate_provider: str, + fee: str, +) -> Bleskomat: + bleskomat_id = uuid4().hex + api_key_id = secrets.token_hex(8) + api_key_secret = secrets.token_hex(32) + api_key_encoding = "hex" + await db.execute( + """ + INSERT INTO bleskomats (id, wallet, api_key_id, api_key_secret, api_key_encoding, name, fiat_currency, exchange_rate_provider, fee) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + bleskomat_id, + wallet_id, + api_key_id, + api_key_secret, + api_key_encoding, + name, + fiat_currency, + exchange_rate_provider, + fee, + ), + ) + bleskomat = await get_bleskomat(bleskomat_id) + assert bleskomat, "Newly created bleskomat couldn't be retrieved" + return bleskomat + + +async def get_bleskomat(bleskomat_id: str) -> Optional[Bleskomat]: + row = await db.fetchone("SELECT * FROM bleskomats WHERE id = ?", (bleskomat_id,)) + return Bleskomat(**row) if row else None + + +async def get_bleskomat_by_api_key_id(api_key_id: str) -> Optional[Bleskomat]: + row = await db.fetchone( + "SELECT * FROM bleskomats WHERE api_key_id = ?", (api_key_id,) + ) + return Bleskomat(**row) if row else None + + +async def get_bleskomats(wallet_ids: Union[str, List[str]]) -> List[Bleskomat]: + if isinstance(wallet_ids, str): + wallet_ids = [wallet_ids] + q = ",".join(["?"] * len(wallet_ids)) + rows = await db.fetchall( + f"SELECT * FROM bleskomats WHERE wallet IN ({q})", (*wallet_ids,) + ) + return [Bleskomat(**row) for row in rows] + + +async def update_bleskomat(bleskomat_id: str, **kwargs) -> Optional[Bleskomat]: + q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) + await db.execute( + f"UPDATE bleskomats SET {q} WHERE id = ?", (*kwargs.values(), bleskomat_id) + ) + row = await db.fetchone("SELECT * FROM bleskomats WHERE id = ?", (bleskomat_id,)) + return Bleskomat(**row) if row else None + + +async def delete_bleskomat(bleskomat_id: str) -> None: + await db.execute("DELETE FROM bleskomats WHERE id = ?", (bleskomat_id,)) + + +async def create_bleskomat_lnurl( + *, bleskomat: Bleskomat, secret: str, tag: str, params: str, uses: int = 1 +) -> BleskomatLnurl: + bleskomat_lnurl_id = uuid4().hex + hash = generate_bleskomat_lnurl_hash(secret) + now = int(time.time()) + await db.execute( + """ + INSERT INTO bleskomat_lnurls (id, bleskomat, wallet, hash, tag, params, api_key_id, initial_uses, remaining_uses, created_time, updated_time) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + bleskomat_lnurl_id, + bleskomat.id, + bleskomat.wallet, + hash, + tag, + params, + bleskomat.api_key_id, + uses, + uses, + now, + now, + ), + ) + bleskomat_lnurl = await get_bleskomat_lnurl(secret) + assert bleskomat_lnurl, "Newly created bleskomat LNURL couldn't be retrieved" + return bleskomat_lnurl + + +async def get_bleskomat_lnurl(secret: str) -> BleskomatLnurl: + hash = generate_bleskomat_lnurl_hash(secret) + row = await db.fetchone("SELECT * FROM bleskomat_lnurls WHERE hash = ?", (hash,)) + return BleskomatLnurl(**row) if row else None diff --git a/lnbits/extensions/bleskomat/exchange_rates.py b/lnbits/extensions/bleskomat/exchange_rates.py new file mode 100644 index 000000000..928a28231 --- /dev/null +++ b/lnbits/extensions/bleskomat/exchange_rates.py @@ -0,0 +1,79 @@ +import httpx +import json +import os + +fiat_currencies = json.load( + open( + os.path.join( + os.path.dirname(os.path.realpath(__file__)), "fiat_currencies.json" + ), + "r", + ) +) + +exchange_rate_providers = { + "bitfinex": { + "name": "Bitfinex", + "domain": "bitfinex.com", + "api_url": "https://api.bitfinex.com/v1/pubticker/{from}{to}", + "getter": lambda data, replacements: data["last_price"], + }, + "bitstamp": { + "name": "Bitstamp", + "domain": "bitstamp.net", + "api_url": "https://www.bitstamp.net/api/v2/ticker/{from}{to}/", + "getter": lambda data, replacements: data["last"], + }, + "coinbase": { + "name": "Coinbase", + "domain": "coinbase.com", + "api_url": "https://api.coinbase.com/v2/exchange-rates?currency={FROM}", + "getter": lambda data, replacements: data["data"]["rates"][replacements["TO"]], + }, + "coinmate": { + "name": "CoinMate", + "domain": "coinmate.io", + "api_url": "https://coinmate.io/api/ticker?currencyPair={FROM}_{TO}", + "getter": lambda data, replacements: data["data"]["last"], + }, + "kraken": { + "name": "Kraken", + "domain": "kraken.com", + "api_url": "https://api.kraken.com/0/public/Ticker?pair=XBT{TO}", + "getter": lambda data, replacements: data["result"][ + "XXBTZ" + replacements["TO"] + ]["c"][0], + }, +} + +exchange_rate_providers_serializable = {} +for ref, exchange_rate_provider in exchange_rate_providers.items(): + exchange_rate_provider_serializable = {} + for key, value in exchange_rate_provider.items(): + if not callable(value): + exchange_rate_provider_serializable[key] = value + exchange_rate_providers_serializable[ref] = exchange_rate_provider_serializable + + +async def fetch_fiat_exchange_rate(currency: str, provider: str): + + replacements = { + "FROM": "BTC", + "from": "btc", + "TO": currency.upper(), + "to": currency.lower(), + } + + url = exchange_rate_providers[provider]["api_url"] + for key in replacements.keys(): + url = url.replace("{" + key + "}", replacements[key]) + + getter = exchange_rate_providers[provider]["getter"] + + async with httpx.AsyncClient() as client: + r = await client.get(url) + r.raise_for_status() + data = r.json() + rate = float(getter(data, replacements)) + + return rate diff --git a/lnbits/extensions/bleskomat/fiat_currencies.json b/lnbits/extensions/bleskomat/fiat_currencies.json new file mode 100644 index 000000000..ff831f3ec --- /dev/null +++ b/lnbits/extensions/bleskomat/fiat_currencies.json @@ -0,0 +1,166 @@ +{ + "AED": "United Arab Emirates Dirham", + "AFN": "Afghan Afghani", + "ALL": "Albanian Lek", + "AMD": "Armenian Dram", + "ANG": "Netherlands Antillean Gulden", + "AOA": "Angolan Kwanza", + "ARS": "Argentine Peso", + "AUD": "Australian Dollar", + "AWG": "Aruban Florin", + "AZN": "Azerbaijani Manat", + "BAM": "Bosnia and Herzegovina Convertible Mark", + "BBD": "Barbadian Dollar", + "BDT": "Bangladeshi Taka", + "BGN": "Bulgarian Lev", + "BHD": "Bahraini Dinar", + "BIF": "Burundian Franc", + "BMD": "Bermudian Dollar", + "BND": "Brunei Dollar", + "BOB": "Bolivian Boliviano", + "BRL": "Brazilian Real", + "BSD": "Bahamian Dollar", + "BTN": "Bhutanese Ngultrum", + "BWP": "Botswana Pula", + "BYN": "Belarusian Ruble", + "BYR": "Belarusian Ruble", + "BZD": "Belize Dollar", + "CAD": "Canadian Dollar", + "CDF": "Congolese Franc", + "CHF": "Swiss Franc", + "CLF": "Unidad de Fomento", + "CLP": "Chilean Peso", + "CNH": "Chinese Renminbi Yuan Offshore", + "CNY": "Chinese Renminbi Yuan", + "COP": "Colombian Peso", + "CRC": "Costa Rican Colón", + "CUC": "Cuban Convertible Peso", + "CVE": "Cape Verdean Escudo", + "CZK": "Czech Koruna", + "DJF": "Djiboutian Franc", + "DKK": "Danish Krone", + "DOP": "Dominican Peso", + "DZD": "Algerian Dinar", + "EGP": "Egyptian Pound", + "ERN": "Eritrean Nakfa", + "ETB": "Ethiopian Birr", + "EUR": "Euro", + "FJD": "Fijian Dollar", + "FKP": "Falkland Pound", + "GBP": "British Pound", + "GEL": "Georgian Lari", + "GGP": "Guernsey Pound", + "GHS": "Ghanaian Cedi", + "GIP": "Gibraltar Pound", + "GMD": "Gambian Dalasi", + "GNF": "Guinean Franc", + "GTQ": "Guatemalan Quetzal", + "GYD": "Guyanese Dollar", + "HKD": "Hong Kong Dollar", + "HNL": "Honduran Lempira", + "HRK": "Croatian Kuna", + "HTG": "Haitian Gourde", + "HUF": "Hungarian Forint", + "IDR": "Indonesian Rupiah", + "ILS": "Israeli New Sheqel", + "IMP": "Isle of Man Pound", + "INR": "Indian Rupee", + "IQD": "Iraqi Dinar", + "ISK": "Icelandic Króna", + "JEP": "Jersey Pound", + "JMD": "Jamaican Dollar", + "JOD": "Jordanian Dinar", + "JPY": "Japanese Yen", + "KES": "Kenyan Shilling", + "KGS": "Kyrgyzstani Som", + "KHR": "Cambodian Riel", + "KMF": "Comorian Franc", + "KRW": "South Korean Won", + "KWD": "Kuwaiti Dinar", + "KYD": "Cayman Islands Dollar", + "KZT": "Kazakhstani Tenge", + "LAK": "Lao Kip", + "LBP": "Lebanese Pound", + "LKR": "Sri Lankan Rupee", + "LRD": "Liberian Dollar", + "LSL": "Lesotho Loti", + "LYD": "Libyan Dinar", + "MAD": "Moroccan Dirham", + "MDL": "Moldovan Leu", + "MGA": "Malagasy Ariary", + "MKD": "Macedonian Denar", + "MMK": "Myanmar Kyat", + "MNT": "Mongolian Tögrög", + "MOP": "Macanese Pataca", + "MRO": "Mauritanian Ouguiya", + "MUR": "Mauritian Rupee", + "MVR": "Maldivian Rufiyaa", + "MWK": "Malawian Kwacha", + "MXN": "Mexican Peso", + "MYR": "Malaysian Ringgit", + "MZN": "Mozambican Metical", + "NAD": "Namibian Dollar", + "NGN": "Nigerian Naira", + "NIO": "Nicaraguan Córdoba", + "NOK": "Norwegian Krone", + "NPR": "Nepalese Rupee", + "NZD": "New Zealand Dollar", + "OMR": "Omani Rial", + "PAB": "Panamanian Balboa", + "PEN": "Peruvian Sol", + "PGK": "Papua New Guinean Kina", + "PHP": "Philippine Peso", + "PKR": "Pakistani Rupee", + "PLN": "Polish Złoty", + "PYG": "Paraguayan Guaraní", + "QAR": "Qatari Riyal", + "RON": "Romanian Leu", + "RSD": "Serbian Dinar", + "RUB": "Russian Ruble", + "RWF": "Rwandan Franc", + "SAR": "Saudi Riyal", + "SBD": "Solomon Islands Dollar", + "SCR": "Seychellois Rupee", + "SEK": "Swedish Krona", + "SGD": "Singapore Dollar", + "SHP": "Saint Helenian Pound", + "SLL": "Sierra Leonean Leone", + "SOS": "Somali Shilling", + "SRD": "Surinamese Dollar", + "SSP": "South Sudanese Pound", + "STD": "São Tomé and Príncipe Dobra", + "SVC": "Salvadoran Colón", + "SZL": "Swazi Lilangeni", + "THB": "Thai Baht", + "TJS": "Tajikistani Somoni", + "TMT": "Turkmenistani Manat", + "TND": "Tunisian Dinar", + "TOP": "Tongan Paʻanga", + "TRY": "Turkish Lira", + "TTD": "Trinidad and Tobago Dollar", + "TWD": "New Taiwan Dollar", + "TZS": "Tanzanian Shilling", + "UAH": "Ukrainian Hryvnia", + "UGX": "Ugandan Shilling", + "USD": "US Dollar", + "UYU": "Uruguayan Peso", + "UZS": "Uzbekistan Som", + "VEF": "Venezuelan Bolívar", + "VES": "Venezuelan Bolívar Soberano", + "VND": "Vietnamese Đồng", + "VUV": "Vanuatu Vatu", + "WST": "Samoan Tala", + "XAF": "Central African Cfa Franc", + "XAG": "Silver (Troy Ounce)", + "XAU": "Gold (Troy Ounce)", + "XCD": "East Caribbean Dollar", + "XDR": "Special Drawing Rights", + "XOF": "West African Cfa Franc", + "XPD": "Palladium", + "XPF": "Cfp Franc", + "XPT": "Platinum", + "YER": "Yemeni Rial", + "ZAR": "South African Rand", + "ZMW": "Zambian Kwacha", + "ZWL": "Zimbabwean Dollar" +} \ No newline at end of file diff --git a/lnbits/extensions/bleskomat/helpers.py b/lnbits/extensions/bleskomat/helpers.py new file mode 100644 index 000000000..a3857b773 --- /dev/null +++ b/lnbits/extensions/bleskomat/helpers.py @@ -0,0 +1,153 @@ +import base64 +import hashlib +import hmac +from http import HTTPStatus +from binascii import unhexlify +from typing import Dict +from quart import url_for +import urllib + + +def generate_bleskomat_lnurl_hash(secret: str): + m = hashlib.sha256() + m.update(f"{secret}".encode()) + return m.hexdigest() + + +def generate_bleskomat_lnurl_signature( + payload: str, api_key_secret: str, api_key_encoding: str = "hex" +): + if api_key_encoding == "hex": + key = unhexlify(api_key_secret) + elif api_key_encoding == "base64": + key = base64.b64decode(api_key_secret) + else: + key = bytes(f"{api_key_secret}") + return hmac.new(key=key, msg=payload.encode(), digestmod=hashlib.sha256).hexdigest() + + +def generate_bleskomat_lnurl_secret(api_key_id: str, signature: str): + # The secret is not randomly generated by the server. + # Instead it is the hash of the API key ID and signature concatenated together. + m = hashlib.sha256() + m.update(f"{api_key_id}-{signature}".encode()) + return m.hexdigest() + + +def get_callback_url(): + return url_for("bleskomat.api_bleskomat_lnurl", _external=True) + + +def is_supported_lnurl_subprotocol(tag: str) -> bool: + return tag == "withdrawRequest" + + +class LnurlHttpError(Exception): + def __init__( + self, + message: str = "", + http_status: HTTPStatus = HTTPStatus.INTERNAL_SERVER_ERROR, + ): + self.message = message + self.http_status = http_status + super().__init__(self.message) + + +class LnurlValidationError(Exception): + pass + + +def prepare_lnurl_params(tag: str, query: Dict[str, str]): + params = {} + if not is_supported_lnurl_subprotocol(tag): + raise LnurlValidationError(f'Unsupported subprotocol: "{tag}"') + if tag == "withdrawRequest": + params["minWithdrawable"] = float(query["minWithdrawable"]) + params["maxWithdrawable"] = float(query["maxWithdrawable"]) + params["defaultDescription"] = query["defaultDescription"] + if not params["minWithdrawable"] > 0: + raise LnurlValidationError('"minWithdrawable" must be greater than zero') + if not params["maxWithdrawable"] >= params["minWithdrawable"]: + raise LnurlValidationError( + '"maxWithdrawable" must be greater than or equal to "minWithdrawable"' + ) + return params + + +encode_uri_component_safe_chars = ( + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.!~*'()" +) + + +def query_to_signing_payload(query: Dict[str, str]) -> str: + # Sort the query by key, then stringify it to create the payload. + sorted_keys = sorted(query.keys(), key=str.lower) + payload = [] + for key in sorted_keys: + if not key == "signature": + encoded_key = urllib.parse.quote(key, safe=encode_uri_component_safe_chars) + encoded_value = urllib.parse.quote( + query[key], safe=encode_uri_component_safe_chars + ) + payload.append(f"{encoded_key}={encoded_value}") + return "&".join(payload) + + +unshorten_rules = { + "query": {"n": "nonce", "s": "signature", "t": "tag"}, + "tags": { + "c": "channelRequest", + "l": "login", + "p": "payRequest", + "w": "withdrawRequest", + }, + "params": { + "channelRequest": {"pl": "localAmt", "pp": "pushAmt"}, + "login": {}, + "payRequest": {"pn": "minSendable", "px": "maxSendable", "pm": "metadata"}, + "withdrawRequest": { + "pn": "minWithdrawable", + "px": "maxWithdrawable", + "pd": "defaultDescription", + }, + }, +} + + +def unshorten_lnurl_query(query: Dict[str, str]) -> Dict[str, str]: + new_query = {} + rules = unshorten_rules + if "tag" in query: + tag = query["tag"] + elif "t" in query: + tag = query["t"] + else: + raise LnurlValidationError('Missing required query parameter: "tag"') + # Unshorten tag: + if tag in rules["tags"]: + long_tag = rules["tags"][tag] + new_query["tag"] = long_tag + tag = long_tag + if not tag in rules["params"]: + raise LnurlValidationError(f'Unknown tag: "{tag}"') + for key in query: + if key in rules["params"][tag]: + short_param_key = key + long_param_key = rules["params"][tag][short_param_key] + if short_param_key in query: + new_query[long_param_key] = query[short_param_key] + else: + new_query[long_param_key] = query[long_param_key] + elif key in rules["query"]: + # Unshorten general keys: + short_key = key + long_key = rules["query"][short_key] + if not long_key in new_query: + if short_key in query: + new_query[long_key] = query[short_key] + else: + new_query[long_key] = query[long_key] + else: + # Keep unknown key/value pairs unchanged: + new_query[key] = query[key] + return new_query diff --git a/lnbits/extensions/bleskomat/lnurl_api.py b/lnbits/extensions/bleskomat/lnurl_api.py new file mode 100644 index 000000000..086562d1c --- /dev/null +++ b/lnbits/extensions/bleskomat/lnurl_api.py @@ -0,0 +1,134 @@ +import json +import math +from quart import jsonify, request +from http import HTTPStatus +import traceback + +from . import bleskomat_ext +from .crud import ( + create_bleskomat_lnurl, + get_bleskomat_by_api_key_id, + get_bleskomat_lnurl, +) + +from .exchange_rates import ( + fetch_fiat_exchange_rate, +) + +from .helpers import ( + generate_bleskomat_lnurl_signature, + generate_bleskomat_lnurl_secret, + LnurlHttpError, + LnurlValidationError, + prepare_lnurl_params, + query_to_signing_payload, + unshorten_lnurl_query, +) + + +# Handles signed URL from Bleskomat ATMs and "action" callback of auto-generated LNURLs. +@bleskomat_ext.route("/u", methods=["GET"]) +async def api_bleskomat_lnurl(): + try: + query = request.args.to_dict() + + # Unshorten query if "s" is used instead of "signature". + if "s" in query: + query = unshorten_lnurl_query(query) + + if "signature" in query: + + # Signature provided. + # Use signature to verify that the URL was generated by an authorized device. + # Later validate parameters, auto-generate LNURL, reply with LNURL response object. + signature = query["signature"] + + # The API key ID, nonce, and tag should be present in the query string. + for field in ["id", "nonce", "tag"]: + if not field in query: + raise LnurlHttpError( + f'Failed API key signature check: Missing "{field}"', + HTTPStatus.BAD_REQUEST, + ) + + # URL signing scheme is described here: + # https://github.com/chill117/lnurl-node#how-to-implement-url-signing-scheme + payload = query_to_signing_payload(query) + api_key_id = query["id"] + bleskomat = await get_bleskomat_by_api_key_id(api_key_id) + if not bleskomat: + raise LnurlHttpError("Unknown API key", HTTPStatus.BAD_REQUEST) + api_key_secret = bleskomat.api_key_secret + api_key_encoding = bleskomat.api_key_encoding + expected_signature = generate_bleskomat_lnurl_signature( + payload, api_key_secret, api_key_encoding + ) + if signature != expected_signature: + raise LnurlHttpError("Invalid API key signature", HTTPStatus.FORBIDDEN) + + # Signature is valid. + # In the case of signed URLs, the secret is deterministic based on the API key ID and signature. + secret = generate_bleskomat_lnurl_secret(api_key_id, signature) + lnurl = await get_bleskomat_lnurl(secret) + if not lnurl: + try: + tag = query["tag"] + params = prepare_lnurl_params(tag, query) + if "f" in query: + rate = await fetch_fiat_exchange_rate( + currency=query["f"], + provider=bleskomat.exchange_rate_provider, + ) + # Convert fee (%) to decimal: + fee = float(bleskomat.fee) / 100 + if tag == "withdrawRequest": + for key in ["minWithdrawable", "maxWithdrawable"]: + amount_sats = int( + math.floor((params[key] / rate) * 1e8) + ) + fee_sats = int(math.floor(amount_sats * fee)) + amount_sats_less_fee = amount_sats - fee_sats + # Convert to msats: + params[key] = int(amount_sats_less_fee * 1e3) + except LnurlValidationError as e: + raise LnurlHttpError(e.message, HTTPStatus.BAD_REQUEST) + # Create a new LNURL using the query parameters provided in the signed URL. + params = json.JSONEncoder().encode(params) + lnurl = await create_bleskomat_lnurl( + bleskomat=bleskomat, secret=secret, tag=tag, params=params, uses=1 + ) + + # Reply with LNURL response object. + return jsonify(lnurl.get_info_response_object(secret)), HTTPStatus.OK + + # No signature provided. + # Treat as "action" callback. + + if not "k1" in query: + raise LnurlHttpError("Missing secret", HTTPStatus.BAD_REQUEST) + + secret = query["k1"] + lnurl = await get_bleskomat_lnurl(secret) + if not lnurl: + raise LnurlHttpError("Invalid secret", HTTPStatus.BAD_REQUEST) + + if not lnurl.has_uses_remaining(): + raise LnurlHttpError( + "Maximum number of uses already reached", HTTPStatus.BAD_REQUEST + ) + + try: + await lnurl.execute_action(query) + except LnurlValidationError as e: + raise LnurlHttpError(str(e), HTTPStatus.BAD_REQUEST) + + except LnurlHttpError as e: + return jsonify({"status": "ERROR", "reason": str(e)}), e.http_status + except Exception: + traceback.print_exc() + return ( + jsonify({"status": "ERROR", "reason": "Unexpected error"}), + HTTPStatus.INTERNAL_SERVER_ERROR, + ) + + return jsonify({"status": "OK"}), HTTPStatus.OK diff --git a/lnbits/extensions/bleskomat/migrations.py b/lnbits/extensions/bleskomat/migrations.py new file mode 100644 index 000000000..f79565009 --- /dev/null +++ b/lnbits/extensions/bleskomat/migrations.py @@ -0,0 +1,37 @@ +async def m001_initial(db): + + await db.execute( + """ + CREATE TABLE IF NOT EXISTS bleskomats ( + id TEXT PRIMARY KEY, + wallet TEXT NOT NULL, + api_key_id TEXT NOT NULL, + api_key_secret TEXT NOT NULL, + api_key_encoding TEXT NOT NULL, + name TEXT NOT NULL, + fiat_currency TEXT NOT NULL, + exchange_rate_provider TEXT NOT NULL, + fee TEXT NOT NULL, + UNIQUE(api_key_id) + ); + """ + ) + + await db.execute( + """ + CREATE TABLE IF NOT EXISTS bleskomat_lnurls ( + id TEXT PRIMARY KEY, + bleskomat TEXT NOT NULL, + wallet TEXT NOT NULL, + hash TEXT NOT NULL, + tag TEXT NOT NULL, + params TEXT NOT NULL, + api_key_id TEXT NOT NULL, + initial_uses INTEGER DEFAULT 1, + remaining_uses INTEGER DEFAULT 0, + created_time INTEGER, + updated_time INTEGER, + UNIQUE(hash) + ); + """ + ) diff --git a/lnbits/extensions/bleskomat/models.py b/lnbits/extensions/bleskomat/models.py new file mode 100644 index 000000000..d014f25ad --- /dev/null +++ b/lnbits/extensions/bleskomat/models.py @@ -0,0 +1,107 @@ +import json +import time +from typing import NamedTuple, Dict +from lnbits import bolt11 +from lnbits.core.services import pay_invoice +from . import db +from .helpers import get_callback_url, LnurlValidationError + + +class Bleskomat(NamedTuple): + id: str + wallet: str + api_key_id: str + api_key_secret: str + api_key_encoding: str + name: str + fiat_currency: str + exchange_rate_provider: str + fee: str + + +class BleskomatLnurl(NamedTuple): + id: str + bleskomat: str + wallet: str + hash: str + tag: str + params: str + api_key_id: str + initial_uses: int + remaining_uses: int + created_time: int + updated_time: int + + def has_uses_remaining(self) -> bool: + # When initial uses is 0 then the LNURL has unlimited uses. + return self.initial_uses == 0 or self.remaining_uses > 0 + + def get_info_response_object(self, secret: str) -> Dict[str, str]: + tag = self.tag + params = json.loads(self.params) + response = {"tag": tag} + if tag == "withdrawRequest": + for key in ["minWithdrawable", "maxWithdrawable", "defaultDescription"]: + response[key] = params[key] + response["callback"] = get_callback_url() + response["k1"] = secret + return response + + def validate_action(self, query: Dict[str, str]) -> None: + tag = self.tag + params = json.loads(self.params) + # Perform tag-specific checks. + if tag == "withdrawRequest": + for field in ["pr"]: + if not field in query: + raise LnurlValidationError(f'Missing required parameter: "{field}"') + # Check the bolt11 invoice(s) provided. + pr = query["pr"] + if "," in pr: + raise LnurlValidationError("Multiple payment requests not supported") + try: + invoice = bolt11.decode(pr) + except ValueError: + raise LnurlValidationError( + 'Invalid parameter ("pr"): Lightning payment request expected' + ) + if invoice.amount_msat < params["minWithdrawable"]: + raise LnurlValidationError( + 'Amount in invoice must be greater than or equal to "minWithdrawable"' + ) + if invoice.amount_msat > params["maxWithdrawable"]: + raise LnurlValidationError( + 'Amount in invoice must be less than or equal to "maxWithdrawable"' + ) + else: + raise LnurlValidationError(f'Unknown subprotocol: "{tag}"') + + async def execute_action(self, query: Dict[str, str]): + self.validate_action(query) + used = False + async with db.connect() as conn: + if self.initial_uses > 0: + used = await self.use(conn) + if not used: + raise LnurlValidationError("Maximum number of uses already reached") + tag = self.tag + if tag == "withdrawRequest": + payment_hash = await pay_invoice( + wallet_id=self.wallet, + payment_request=query["pr"], + ) + if not payment_hash: + raise LnurlValidationError("Failed to pay invoice") + + async def use(self, conn) -> bool: + now = int(time.time()) + result = await conn.execute( + """ + UPDATE bleskomat_lnurls + SET remaining_uses = remaining_uses - 1, updated_time = ? + WHERE id = ? + AND remaining_uses > 0 + """, + (now, self.id), + ) + return result.rowcount > 0 diff --git a/lnbits/extensions/bleskomat/static/js/index.js b/lnbits/extensions/bleskomat/static/js/index.js new file mode 100644 index 000000000..fd166ff39 --- /dev/null +++ b/lnbits/extensions/bleskomat/static/js/index.js @@ -0,0 +1,216 @@ +/* global Vue, VueQrcode, _, Quasar, LOCALE, windowMixin, LNbits */ + +Vue.component(VueQrcode.name, VueQrcode) + +var mapBleskomat = function (obj) { + obj._data = _.clone(obj) + return obj +} + +var defaultValues = { + name: 'My Bleskomat', + fiat_currency: 'EUR', + exchange_rate_provider: 'coinbase', + fee: '0.00' +} + +new Vue({ + el: '#vue', + mixins: [windowMixin], + data: function () { + return { + checker: null, + bleskomats: [], + bleskomatsTable: { + columns: [ + { + name: 'api_key_id', + align: 'left', + label: 'API Key ID', + field: 'api_key_id' + }, + { + name: 'name', + align: 'left', + label: 'Name', + field: 'name' + }, + { + name: 'fiat_currency', + align: 'left', + label: 'Fiat Currency', + field: 'fiat_currency' + }, + { + name: 'exchange_rate_provider', + align: 'left', + label: 'Exchange Rate Provider', + field: 'exchange_rate_provider' + }, + { + name: 'fee', + align: 'left', + label: 'Fee (%)', + field: 'fee' + } + ], + pagination: { + rowsPerPage: 10 + } + }, + formDialog: { + show: false, + fiatCurrencies: _.keys(window.bleskomat_vars.fiat_currencies), + exchangeRateProviders: _.keys( + window.bleskomat_vars.exchange_rate_providers + ), + data: _.clone(defaultValues) + } + } + }, + computed: { + sortedBleskomats: function () { + return this.bleskomats.sort(function (a, b) { + // Sort by API Key ID alphabetically. + var apiKeyId_A = a.api_key_id.toLowerCase() + var apiKeyId_B = b.api_key_id.toLowerCase() + return apiKeyId_A < apiKeyId_B ? -1 : apiKeyId_A > apiKeyId_B ? 1 : 0 + }) + } + }, + methods: { + getBleskomats: function () { + var self = this + LNbits.api + .request( + 'GET', + '/bleskomat/api/v1/bleskomats?all_wallets', + this.g.user.wallets[0].adminkey + ) + .then(function (response) { + self.bleskomats = response.data.map(function (obj) { + return mapBleskomat(obj) + }) + }) + .catch(function (error) { + clearInterval(self.checker) + LNbits.utils.notifyApiError(error) + }) + }, + closeFormDialog: function () { + this.formDialog.data = _.clone(defaultValues) + }, + exportConfigFile: function (bleskomatId) { + var bleskomat = _.findWhere(this.bleskomats, {id: bleskomatId}) + var fieldToKey = { + api_key_id: 'apiKey.id', + api_key_secret: 'apiKey.key', + api_key_encoding: 'apiKey.encoding', + fiat_currency: 'fiatCurrency' + } + var lines = _.chain(bleskomat) + .map(function (value, field) { + var key = fieldToKey[field] || null + return key ? [key, value].join('=') : null + }) + .compact() + .value() + lines.push('callbackUrl=' + window.bleskomat_vars.callback_url) + lines.push('shorten=true') + var content = lines.join('\n') + var status = Quasar.utils.exportFile( + 'bleskomat.conf', + content, + 'text/plain' + ) + if (status !== true) { + Quasar.plugins.Notify.create({ + message: 'Browser denied file download...', + color: 'negative', + icon: null + }) + } + }, + openUpdateDialog: function (bleskomatId) { + var bleskomat = _.findWhere(this.bleskomats, {id: bleskomatId}) + this.formDialog.data = _.clone(bleskomat._data) + this.formDialog.show = true + }, + sendFormData: function () { + var wallet = _.findWhere(this.g.user.wallets, { + id: this.formDialog.data.wallet + }) + var data = _.omit(this.formDialog.data, 'wallet') + if (data.id) { + this.updateBleskomat(wallet, data) + } else { + this.createBleskomat(wallet, data) + } + }, + updateBleskomat: function (wallet, data) { + var self = this + LNbits.api + .request( + 'PUT', + '/bleskomat/api/v1/bleskomat/' + data.id, + wallet.adminkey, + _.pick(data, 'name', 'fiat_currency', 'exchange_rate_provider', 'fee') + ) + .then(function (response) { + self.bleskomats = _.reject(self.bleskomats, function (obj) { + return obj.id === data.id + }) + self.bleskomats.push(mapBleskomat(response.data)) + self.formDialog.show = false + }) + .catch(function (error) { + LNbits.utils.notifyApiError(error) + }) + }, + createBleskomat: function (wallet, data) { + var self = this + LNbits.api + .request('POST', '/bleskomat/api/v1/bleskomat', wallet.adminkey, data) + .then(function (response) { + self.bleskomats.push(mapBleskomat(response.data)) + self.formDialog.show = false + }) + .catch(function (error) { + LNbits.utils.notifyApiError(error) + }) + }, + deleteBleskomat: function (bleskomatId) { + var self = this + var bleskomat = _.findWhere(this.bleskomats, {id: bleskomatId}) + LNbits.utils + .confirmDialog( + 'Are you sure you want to delete "' + bleskomat.name + '"?' + ) + .onOk(function () { + LNbits.api + .request( + 'DELETE', + '/bleskomat/api/v1/bleskomat/' + bleskomatId, + _.findWhere(self.g.user.wallets, {id: bleskomat.wallet}).adminkey + ) + .then(function (response) { + self.bleskomats = _.reject(self.bleskomats, function (obj) { + return obj.id === bleskomatId + }) + }) + .catch(function (error) { + LNbits.utils.notifyApiError(error) + }) + }) + } + }, + created: function () { + if (this.g.user.wallets.length) { + var getBleskomats = this.getBleskomats + getBleskomats() + this.checker = setInterval(function () { + getBleskomats() + }, 20000) + } + } +}) diff --git a/lnbits/extensions/bleskomat/templates/bleskomat/_api_docs.html b/lnbits/extensions/bleskomat/templates/bleskomat/_api_docs.html new file mode 100644 index 000000000..50431c415 --- /dev/null +++ b/lnbits/extensions/bleskomat/templates/bleskomat/_api_docs.html @@ -0,0 +1,65 @@ + + + +

+ This extension allows you to connect a Bleskomat ATM to an lnbits + wallet. It will work with both the + open-source DIY Bleskomat ATM project + as well as the + commercial Bleskomat ATM. +

+
Connect Your Bleskomat ATM
+
+
    +
  1. Click the "Add Bleskomat" button on this page to begin.
  2. +
  3. + Choose a wallet. This will be the wallet that is used to pay + satoshis to your ATM customers. +
  4. +
  5. + Choose the fiat currency. This should match the fiat currency that + your ATM accepts. +
  6. +
  7. + Pick an exchange rate provider. This is the API that will be used to + query the fiat to satoshi exchange rate at the time your customer + attempts to withdraw their funds. +
  8. +
  9. Set your ATM's fee percentage.
  10. +
  11. Click the "Done" button.
  12. +
  13. + Find the new Bleskomat in the list and then click the export icon to + download a new configuration file for your ATM. +
  14. +
  15. + Copy the configuration file ("bleskomat.conf") to your ATM's SD + card. +
  16. +
  17. + Restart Your Bleskomat ATM. It should automatically reload the + configurations from the SD card. +
  18. +
+
+
How does it work?
+

+ Since the Bleskomat ATMs are designed to be offline, a cryptographic + signing scheme is used to verify that the URL was generated by an + authorized device. When one of your customers inserts fiat money into + the device, a signed URL (lnurl-withdraw) is created and displayed as a + QR code. Your customer scans the QR code with their lnurl-supporting + mobile app, their mobile app communicates with the web API of lnbits to + verify the signature, the fiat currency amount is converted to sats, the + customer accepts the withdrawal, and finally lnbits will pay the + customer from your lnbits wallet. +

+
+
+
diff --git a/lnbits/extensions/bleskomat/templates/bleskomat/index.html b/lnbits/extensions/bleskomat/templates/bleskomat/index.html new file mode 100644 index 000000000..d00937c16 --- /dev/null +++ b/lnbits/extensions/bleskomat/templates/bleskomat/index.html @@ -0,0 +1,178 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block scripts %} {{ window_vars(user) }} + + +{% endblock %} {% block page %} +
+
+ + + Add Bleskomat + + + + + +
+
+
Bleskomats
+
+
+ + {% raw %} + + + {% endraw %} + +
+
+
+ +
+ + +
LNbits Bleskomat extension
+
+ + + {% include "bleskomat/_api_docs.html" %} + +
+
+ + + + + + + + + + + + +
+ Update Bleskomat + Add Bleskomat + Cancel +
+
+
+
+
+{% endblock %} diff --git a/lnbits/extensions/bleskomat/views.py b/lnbits/extensions/bleskomat/views.py new file mode 100644 index 000000000..3a7f72637 --- /dev/null +++ b/lnbits/extensions/bleskomat/views.py @@ -0,0 +1,22 @@ +from quart import g, render_template + +from lnbits.decorators import check_user_exists, validate_uuids + +from . import bleskomat_ext + +from .exchange_rates import exchange_rate_providers_serializable, fiat_currencies +from .helpers import get_callback_url + + +@bleskomat_ext.route("/") +@validate_uuids(["usr"], required=True) +@check_user_exists() +async def index(): + bleskomat_vars = { + "callback_url": get_callback_url(), + "exchange_rate_providers": exchange_rate_providers_serializable, + "fiat_currencies": fiat_currencies, + } + return await render_template( + "bleskomat/index.html", user=g.user, bleskomat_vars=bleskomat_vars + ) diff --git a/lnbits/extensions/bleskomat/views_api.py b/lnbits/extensions/bleskomat/views_api.py new file mode 100644 index 000000000..2971b0669 --- /dev/null +++ b/lnbits/extensions/bleskomat/views_api.py @@ -0,0 +1,120 @@ +from quart import g, jsonify, request +from http import HTTPStatus + +from lnbits.core.crud import get_user +from lnbits.decorators import api_check_wallet_key, api_validate_post_request + +from . import bleskomat_ext +from .crud import ( + create_bleskomat, + get_bleskomat, + get_bleskomats, + update_bleskomat, + delete_bleskomat, +) + +from .exchange_rates import ( + exchange_rate_providers, + fetch_fiat_exchange_rate, + fiat_currencies, +) + + +@bleskomat_ext.route("/api/v1/bleskomats", methods=["GET"]) +@api_check_wallet_key("admin") +async def api_bleskomats(): + wallet_ids = [g.wallet.id] + + if "all_wallets" in request.args: + wallet_ids = (await get_user(g.wallet.user)).wallet_ids + + return ( + jsonify( + [bleskomat._asdict() for bleskomat in await get_bleskomats(wallet_ids)] + ), + HTTPStatus.OK, + ) + + +@bleskomat_ext.route("/api/v1/bleskomat/", methods=["GET"]) +@api_check_wallet_key("admin") +async def api_bleskomat_retrieve(bleskomat_id): + bleskomat = await get_bleskomat(bleskomat_id) + + if not bleskomat or bleskomat.wallet != g.wallet.id: + return ( + jsonify({"message": "Bleskomat configuration not found."}), + HTTPStatus.NOT_FOUND, + ) + + return jsonify(bleskomat._asdict()), HTTPStatus.OK + + +@bleskomat_ext.route("/api/v1/bleskomat", methods=["POST"]) +@bleskomat_ext.route("/api/v1/bleskomat/", methods=["PUT"]) +@api_check_wallet_key("admin") +@api_validate_post_request( + schema={ + "name": {"type": "string", "empty": False, "required": True}, + "fiat_currency": { + "type": "string", + "allowed": fiat_currencies.keys(), + "required": True, + }, + "exchange_rate_provider": { + "type": "string", + "allowed": exchange_rate_providers.keys(), + "required": True, + }, + "fee": {"type": ["string", "float", "number", "integer"], "required": True}, + } +) +async def api_bleskomat_create_or_update(bleskomat_id=None): + try: + fiat_currency = g.data["fiat_currency"] + exchange_rate_provider = g.data["exchange_rate_provider"] + await fetch_fiat_exchange_rate( + currency=fiat_currency, provider=exchange_rate_provider + ) + except Exception as e: + print(e) + return ( + jsonify( + { + "message": f'Failed to fetch BTC/{fiat_currency} currency pair from "{exchange_rate_provider}"' + } + ), + HTTPStatus.INTERNAL_SERVER_ERROR, + ) + + if bleskomat_id: + bleskomat = await get_bleskomat(bleskomat_id) + if not bleskomat or bleskomat.wallet != g.wallet.id: + return ( + jsonify({"message": "Bleskomat configuration not found."}), + HTTPStatus.NOT_FOUND, + ) + bleskomat = await update_bleskomat(bleskomat_id, **g.data) + else: + bleskomat = await create_bleskomat(wallet_id=g.wallet.id, **g.data) + + return ( + jsonify(bleskomat._asdict()), + HTTPStatus.OK if bleskomat_id else HTTPStatus.CREATED, + ) + + +@bleskomat_ext.route("/api/v1/bleskomat/", methods=["DELETE"]) +@api_check_wallet_key("admin") +async def api_bleskomat_delete(bleskomat_id): + bleskomat = await get_bleskomat(bleskomat_id) + + if not bleskomat or bleskomat.wallet != g.wallet.id: + return ( + jsonify({"message": "Bleskomat configuration not found."}), + HTTPStatus.NOT_FOUND, + ) + + await delete_bleskomat(bleskomat_id) + + return "", HTTPStatus.NO_CONTENT diff --git a/lnbits/extensions/captcha/README.md b/lnbits/extensions/captcha/README.md new file mode 100644 index 000000000..277294592 --- /dev/null +++ b/lnbits/extensions/captcha/README.md @@ -0,0 +1,11 @@ +

Example Extension

+

*tagline*

+This is an example extension to help you organise and build you own. + +Try to include an image + + + +

If your extension has API endpoints, include useful ones here

+ +curl -H "Content-type: application/json" -X POST https://YOUR-LNBITS/YOUR-EXTENSION/api/v1/EXAMPLE -d '{"amount":"100","memo":"example"}' -H "X-Api-Key: YOUR_WALLET-ADMIN/INVOICE-KEY" diff --git a/lnbits/extensions/captcha/__init__.py b/lnbits/extensions/captcha/__init__.py new file mode 100644 index 000000000..f25dccce2 --- /dev/null +++ b/lnbits/extensions/captcha/__init__.py @@ -0,0 +1,12 @@ +from quart import Blueprint +from lnbits.db import Database + +db = Database("ext_captcha") + +captcha_ext: Blueprint = Blueprint( + "captcha", __name__, static_folder="static", template_folder="templates" +) + + +from .views_api import * # noqa +from .views import * # noqa diff --git a/lnbits/extensions/captcha/config.json b/lnbits/extensions/captcha/config.json new file mode 100644 index 000000000..4ef7c43fb --- /dev/null +++ b/lnbits/extensions/captcha/config.json @@ -0,0 +1,6 @@ +{ + "name": "Captcha", + "short_description": "Create captcha to stop spam", + "icon": "block", + "contributors": ["pseudozach"] +} diff --git a/lnbits/extensions/captcha/crud.py b/lnbits/extensions/captcha/crud.py new file mode 100644 index 000000000..7526306b6 --- /dev/null +++ b/lnbits/extensions/captcha/crud.py @@ -0,0 +1,51 @@ +from typing import List, Optional, Union + +from lnbits.helpers import urlsafe_short_hash + +from . import db +from .models import Captcha + + +async def create_captcha( + *, + wallet_id: str, + url: str, + memo: str, + description: Optional[str] = None, + amount: int = 0, + remembers: bool = True, +) -> Captcha: + captcha_id = urlsafe_short_hash() + await db.execute( + """ + INSERT INTO captchas (id, wallet, url, memo, description, amount, remembers) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + (captcha_id, wallet_id, url, memo, description, amount, int(remembers)), + ) + + captcha = await get_captcha(captcha_id) + assert captcha, "Newly created captcha couldn't be retrieved" + return captcha + + +async def get_captcha(captcha_id: str) -> Optional[Captcha]: + row = await db.fetchone("SELECT * FROM captchas WHERE id = ?", (captcha_id,)) + + return Captcha.from_row(row) if row else None + + +async def get_captchas(wallet_ids: Union[str, List[str]]) -> List[Captcha]: + if isinstance(wallet_ids, str): + wallet_ids = [wallet_ids] + + q = ",".join(["?"] * len(wallet_ids)) + rows = await db.fetchall( + f"SELECT * FROM captchas WHERE wallet IN ({q})", (*wallet_ids,) + ) + + return [Captcha.from_row(row) for row in rows] + + +async def delete_captcha(captcha_id: str) -> None: + await db.execute("DELETE FROM captchas WHERE id = ?", (captcha_id,)) diff --git a/lnbits/extensions/captcha/migrations.py b/lnbits/extensions/captcha/migrations.py new file mode 100644 index 000000000..9fe2e604f --- /dev/null +++ b/lnbits/extensions/captcha/migrations.py @@ -0,0 +1,67 @@ +from sqlalchemy.exc import OperationalError # type: ignore + + +async def m001_initial(db): + """ + Initial captchas table. + """ + await db.execute( + """ + CREATE TABLE IF NOT EXISTS captchas ( + id TEXT PRIMARY KEY, + wallet TEXT NOT NULL, + secret TEXT NOT NULL, + url TEXT NOT NULL, + memo TEXT NOT NULL, + amount INTEGER NOT NULL, + time TIMESTAMP NOT NULL DEFAULT (strftime('%s', 'now')) + ); + """ + ) + + +async def m002_redux(db): + """ + Creates an improved captchas table and migrates the existing data. + """ + try: + await db.execute("SELECT remembers FROM captchas") + + except OperationalError: + await db.execute("ALTER TABLE captchas RENAME TO captchas_old") + await db.execute( + """ + CREATE TABLE IF NOT EXISTS captchas ( + id TEXT PRIMARY KEY, + wallet TEXT NOT NULL, + url TEXT NOT NULL, + memo TEXT NOT NULL, + description TEXT NULL, + amount INTEGER DEFAULT 0, + time TIMESTAMP NOT NULL DEFAULT (strftime('%s', 'now')), + remembers INTEGER DEFAULT 0, + extras TEXT NULL + ); + """ + ) + await db.execute("CREATE INDEX IF NOT EXISTS wallet_idx ON captchas (wallet)") + + for row in [ + list(row) for row in await db.fetchall("SELECT * FROM captchas_old") + ]: + await db.execute( + """ + INSERT INTO captchas ( + id, + wallet, + url, + memo, + amount, + time + ) + VALUES (?, ?, ?, ?, ?, ?) + """, + (row[0], row[1], row[3], row[4], row[5], row[6]), + ) + + await db.execute("DROP TABLE captchas_old") diff --git a/lnbits/extensions/captcha/models.py b/lnbits/extensions/captcha/models.py new file mode 100644 index 000000000..3179d5c18 --- /dev/null +++ b/lnbits/extensions/captcha/models.py @@ -0,0 +1,23 @@ +import json + +from sqlite3 import Row +from typing import NamedTuple, Optional + + +class Captcha(NamedTuple): + id: str + wallet: str + url: str + memo: str + description: str + amount: int + time: int + remembers: bool + extras: Optional[dict] + + @classmethod + def from_row(cls, row: Row) -> "Captcha": + data = dict(row) + data["remembers"] = bool(data["remembers"]) + data["extras"] = json.loads(data["extras"]) if data["extras"] else None + return cls(**data) diff --git a/lnbits/extensions/captcha/static/js/captcha.js b/lnbits/extensions/captcha/static/js/captcha.js new file mode 100644 index 000000000..b23872897 --- /dev/null +++ b/lnbits/extensions/captcha/static/js/captcha.js @@ -0,0 +1,80 @@ +var ciframeLoaded = !1, + captchaStyleAdded = !1 + +function ccreateIframeElement(t = {}) { + const e = document.createElement('iframe') + // e.style.marginLeft = "25px", + ;(e.style.border = 'none'), + (e.style.width = '100%'), + (e.style.height = '100%'), + (e.scrolling = 'no'), + (e.id = 'captcha-iframe') + t.dest, t.amount, t.currency, t.label, t.opReturn + var captchaid = document + .getElementById('captchascript') + .getAttribute('data-captchaid') + return (e.src = 'http://localhost:5000/captcha/' + captchaid), e +} +document.addEventListener('DOMContentLoaded', function () { + if (captchaStyleAdded) console.log('Captcha stuff already added!') + else { + console.log('Adding captcha stuff'), (captchaStyleAdded = !0) + var t = document.createElement('style') + t.innerHTML = + "\t/*Button*/\t\t.button-captcha-filled\t\t\t{\t\t\tdisplay: flex;\t\t\talign-items: center;\t\t\tjustify-content: center;\t\t\twidth: 120px;\t\t\tmin-width: 30px;\t\t\theight: 40px;\t\t\tline-height: 2.5;\t\t\ttext-align: center;\t\t\tcursor: pointer;\t\t\t/* Rectangle 2: */\t\t\tbackground: #FF7979;\t\t\tbox-shadow: 0 2px 4px 0 rgba(0,0,0,0.20);\t\t\tborder-radius: 20px;\t\t\t/* Sign up: */\t\t\tfont-family: 'Avenir-Heavy', Futura, Helvetica, Arial;\t\t\tfont-size: 16px;\t\t\tcolor: #FFFFFF;\t\t}\t\t.button-captcha-filled:hover\t\t{\t\t\tbackground:#FFFFFF;\t\t\tcolor: #FF7979;\t\t\tbox-shadow: 0 0 4px 0 rgba(0,0,0,0.20);\t\t}\t\t.button-captcha-filled:active\t\t{\t\t\tbackground:#FFFFFF;\t\t\tcolor: #FF7979;\t\t\t/*Move it down a little bit*/\t\t\tposition: relative;\t\t\ttop: 1px;\t\t}\t\t.button-captcha-filled-dark\t\t\t{\t\t\tdisplay: flex;\t\t\talign-items: center;\t\t\tjustify-content: center;\t\t\twidth: 120px;\t\t\tmin-width: 30px;\t\t\theight: 40px;\t\t\tline-height: 2.5;\t\t\ttext-align: center;\t\t\tcursor: pointer;\t\t\t/* Rectangle 2: */\t\t\tbackground: #161C38;\t\t\tbox-shadow: 0 0px 4px 0 rgba(0,0,0,0.20);\t\t\tborder-radius: 20px;\t\t\t/* Sign up: */\t\t\tfont-family: 'Avenir-Heavy', Futura, Helvetica, Arial;\t\t\tfont-size: 16px;\t\t\tcolor: #FFFFFF;\t\t}\t\t.button-captcha-filled-dark:hover\t\t{\t\t\tbackground:#FFFFFF;\t\t\tcolor: #161C38;\t\t\tbox-shadow: 0 0px 4px 0 rgba(0,0,0,0.20);\t\t}\t\t.button-captcha-filled-dark:active\t\t{\t\t\tbackground:#FFFFFF;\t\t\tcolor: #161C38;\t\t\t/*Move it down a little bit*/\t\t\tposition: relative;\t\t\ttop: 1px;\t\t}\t\t.modal-captcha-container {\t\t position: fixed;\t\t z-index: 1000;\t\t text-align: left;/*Si no añado esto, a veces hereda el text-align:center del body, y entonces el popup queda movido a la derecha, por center + margin left que aplico*/\t\t left: 0;\t\t top: 0;\t\t width: 100%;\t\t height: 100%;\t\t background-color: rgba(0, 0, 0, 0.5);\t\t opacity: 0;\t\t visibility: hidden;\t\t transform: scale(1.1);\t\t transition: visibility 0s linear 0.25s, opacity 0.25s 0s, transform 0.25s;\t\t}\t\t.modal-captcha-content {\t\t position: absolute;\t\t top: 50%;\t\t left: 50%;\t\t transform: translate(-50%, -50%);\t\t background-color: white;\t\t width: 100%;\t\t height: 100%;\t\t border-radius: 0.5rem;\t\t /*Rounded shadowed borders*/\t\t\tbox-shadow: 2px 2px 4px 0 rgba(0,0,0,0.15);\t\t\tborder-radius: 5px;\t\t}\t\t.close-button-captcha {\t\t float: right;\t\t width: 1.5rem;\t\t line-height: 1.5rem;\t\t text-align: center;\t\t cursor: pointer;\t\t margin-right:20px;\t\t margin-top:10px;\t\t border-radius: 0.25rem;\t\t background-color: lightgray;\t\t}\t\t.close-button-captcha:hover {\t\t background-color: darkgray;\t\t}\t\t.show-modal-captcha {\t\t opacity: 1;\t\t visibility: visible;\t\t transform: scale(1.0);\t\t transition: visibility 0s linear 0s, opacity 0.25s 0s, transform 0.25s;\t\t}\t\t/* Mobile */\t\t@media screen and (min-device-width: 160px) and ( max-width: 1077px ) /*No tendria ni por que poner un min-device, porq abarca todo lo humano...*/\t\t{\t\t}" + var e = document.querySelector('script') + e.parentNode.insertBefore(t, e) + var i = document.getElementById('captchacheckbox'), + n = i.dataset, + o = 'true' === n.dark + var a = document.createElement('div') + ;(a.className += ' modal-captcha-container'), + (a.innerHTML = + '\t\t\t'), + document.getElementsByTagName('body')[0].appendChild(a) + var r = document.getElementsByClassName('modal-captcha-content').item(0) + document + .getElementsByClassName('close-button-captcha') + .item(0) + .addEventListener('click', d), + window.addEventListener('click', function (t) { + t.target === a && d() + }), + i.addEventListener('change', function () { + if (this.checked) { + // console.log("checkbox checked"); + if (0 == ciframeLoaded) { + // console.log("n: ", n); + var t = ccreateIframeElement(n) + r.appendChild(t), (ciframeLoaded = !0) + } + d() + } + }) + } + + function d() { + a.classList.toggle('show-modal-captcha') + } +}) + +function receiveMessage(event) { + if (event.data.includes('paymenthash')) { + // console.log("paymenthash received: ", event.data); + document.getElementById('captchapayhash').value = event.data.split('_')[1] + } + if (event.data.includes('removetheiframe')) { + if (event.data.includes('nok')) { + //invoice was NOT paid + // console.log("receiveMessage not paid") + document.getElementById('captchacheckbox').checked = false + } + ciframeLoaded = !1 + var element = document.getElementById('captcha-iframe') + document + .getElementsByClassName('modal-captcha-container')[0] + .classList.toggle('show-modal-captcha') + element.parentNode.removeChild(element) + } +} +window.addEventListener('message', receiveMessage, false) diff --git a/lnbits/extensions/captcha/templates/captcha/_api_docs.html b/lnbits/extensions/captcha/templates/captcha/_api_docs.html new file mode 100644 index 000000000..dfe2f32f8 --- /dev/null +++ b/lnbits/extensions/captcha/templates/captcha/_api_docs.html @@ -0,0 +1,147 @@ + + + + + GET /captcha/api/v1/captchas +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ [<captcha_object>, ...] +
Curl example
+ curl -X GET {{ request.url_root }}captcha/api/v1/captchas -H + "X-Api-Key: {{ g.user.wallets[0].inkey }}" + +
+
+
+ + + + POST /captcha/api/v1/captchas +
Headers
+ {"X-Api-Key": <admin_key>}
+
Body (application/json)
+ {"amount": <integer>, "description": <string>, "memo": + <string>, "remembers": <boolean>, "url": + <string>} +
+ Returns 201 CREATED (application/json) +
+ {"amount": <integer>, "description": <string>, "id": + <string>, "memo": <string>, "remembers": <boolean>, + "time": <int>, "url": <string>, "wallet": + <string>} +
Curl example
+ curl -X POST {{ request.url_root }}captcha/api/v1/captchas -d + '{"url": <string>, "memo": <string>, "description": + <string>, "amount": <integer>, "remembers": + <boolean>}' -H "Content-type: application/json" -H "X-Api-Key: + {{ g.user.wallets[0].adminkey }}" + +
+
+
+ + + + POST + /captcha/api/v1/captchas/<captcha_id>/invoice +
Body (application/json)
+ {"amount": <integer>} +
+ Returns 201 CREATED (application/json) +
+ {"payment_hash": <string>, "payment_request": + <string>} +
Curl example
+ curl -X POST {{ request.url_root + }}captcha/api/v1/captchas/<captcha_id>/invoice -d '{"amount": + <integer>}' -H "Content-type: application/json" + +
+
+
+ + + + POST + /captcha/api/v1/captchas/<captcha_id>/check_invoice +
Body (application/json)
+ {"payment_hash": <string>} +
+ Returns 200 OK (application/json) +
+ {"paid": false}
+ {"paid": true, "url": <string>, "remembers": + <boolean>} +
Curl example
+ curl -X POST {{ request.url_root + }}captcha/api/v1/captchas/<captcha_id>/check_invoice -d + '{"payment_hash": <string>}' -H "Content-type: application/json" + +
+
+
+ + + + DELETE + /captcha/api/v1/captchas/<captcha_id> +
Headers
+ {"X-Api-Key": <admin_key>}
+
Returns 204 NO CONTENT
+ +
Curl example
+ curl -X DELETE {{ request.url_root + }}captcha/api/v1/captchas/<captcha_id> -H "X-Api-Key: {{ + g.user.wallets[0].adminkey }}" + +
+
+
+
diff --git a/lnbits/extensions/captcha/templates/captcha/display.html b/lnbits/extensions/captcha/templates/captcha/display.html new file mode 100644 index 000000000..80e59e63d --- /dev/null +++ b/lnbits/extensions/captcha/templates/captcha/display.html @@ -0,0 +1,178 @@ +{% extends "public.html" %} {% block page %} +
+
+ + +
{{ captcha.memo }}
+ {% if captcha.description %} +

{{ captcha.description }}

+ {% endif %} +
+ + + + + +
+ + + + + +
+ Copy invoice + Cancel +
+
+
+
+ +

+ Captcha accepted. You are probably human.
+ +

+ +
+
+
+
+
+{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/lnbits/extensions/captcha/templates/captcha/index.html b/lnbits/extensions/captcha/templates/captcha/index.html new file mode 100644 index 000000000..2250bcedf --- /dev/null +++ b/lnbits/extensions/captcha/templates/captcha/index.html @@ -0,0 +1,425 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + + New captcha + + + + + +
+
+
Captchas
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
+
+ +
+ + +
LNbits captcha extension
+
+ + + {% include "captcha/_api_docs.html" %} + +
+
+ + + + + + + + + + + + + + + + + Remember payments + A succesful payment will be registered in the browser's + storage, so the user doesn't need to pay again to prove they are + human. + + + +
+ Create captcha + Cancel +
+
+
+
+ + + + {% raw %} + + + + {{ qrCodeDialog.data.snippet }} + +

+ Copy the snippet above and paste into your website/form. The checkbox + can be in checked state only after user pays. +

+
+

+ ID: {{ qrCodeDialog.data.id }}
+ Amount: {{ qrCodeDialog.data.amount }}
+ +

+ {% endraw %} +
+ Copy Snippet + + Close +
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/lnbits/extensions/captcha/views.py b/lnbits/extensions/captcha/views.py new file mode 100644 index 000000000..2b3643fa2 --- /dev/null +++ b/lnbits/extensions/captcha/views.py @@ -0,0 +1,22 @@ +from quart import g, abort, render_template +from http import HTTPStatus + +from lnbits.decorators import check_user_exists, validate_uuids + +from . import captcha_ext +from .crud import get_captcha + + +@captcha_ext.route("/") +@validate_uuids(["usr"], required=True) +@check_user_exists() +async def index(): + return await render_template("captcha/index.html", user=g.user) + + +@captcha_ext.route("/") +async def display(captcha_id): + captcha = await get_captcha(captcha_id) or abort( + HTTPStatus.NOT_FOUND, "captcha does not exist." + ) + return await render_template("captcha/display.html", captcha=captcha) diff --git a/lnbits/extensions/captcha/views_api.py b/lnbits/extensions/captcha/views_api.py new file mode 100644 index 000000000..c1b5ade8e --- /dev/null +++ b/lnbits/extensions/captcha/views_api.py @@ -0,0 +1,121 @@ +from quart import g, jsonify, request +from http import HTTPStatus + +from lnbits.core.crud import get_user, get_wallet +from lnbits.core.services import create_invoice, check_invoice_status +from lnbits.decorators import api_check_wallet_key, api_validate_post_request + +from . import captcha_ext +from .crud import create_captcha, get_captcha, get_captchas, delete_captcha + + +@captcha_ext.route("/api/v1/captchas", methods=["GET"]) +@api_check_wallet_key("invoice") +async def api_captchas(): + wallet_ids = [g.wallet.id] + + if "all_wallets" in request.args: + wallet_ids = (await get_user(g.wallet.user)).wallet_ids + + return ( + jsonify([captcha._asdict() for captcha in await get_captchas(wallet_ids)]), + HTTPStatus.OK, + ) + + +@captcha_ext.route("/api/v1/captchas", methods=["POST"]) +@api_check_wallet_key("invoice") +@api_validate_post_request( + schema={ + "url": {"type": "string", "empty": False, "required": True}, + "memo": {"type": "string", "empty": False, "required": True}, + "description": { + "type": "string", + "empty": True, + "nullable": True, + "required": False, + }, + "amount": {"type": "integer", "min": 0, "required": True}, + "remembers": {"type": "boolean", "required": True}, + } +) +async def api_captcha_create(): + captcha = await create_captcha(wallet_id=g.wallet.id, **g.data) + return jsonify(captcha._asdict()), HTTPStatus.CREATED + + +@captcha_ext.route("/api/v1/captchas/", methods=["DELETE"]) +@api_check_wallet_key("invoice") +async def api_captcha_delete(captcha_id): + captcha = await get_captcha(captcha_id) + + if not captcha: + return jsonify({"message": "captcha does not exist."}), HTTPStatus.NOT_FOUND + + if captcha.wallet != g.wallet.id: + return jsonify({"message": "Not your captcha."}), HTTPStatus.FORBIDDEN + + await delete_captcha(captcha_id) + + return "", HTTPStatus.NO_CONTENT + + +@captcha_ext.route("/api/v1/captchas//invoice", methods=["POST"]) +@api_validate_post_request( + schema={"amount": {"type": "integer", "min": 1, "required": True}} +) +async def api_captcha_create_invoice(captcha_id): + captcha = await get_captcha(captcha_id) + + if g.data["amount"] < captcha.amount: + return ( + jsonify({"message": f"Minimum amount is {captcha.amount} sat."}), + HTTPStatus.BAD_REQUEST, + ) + + try: + amount = ( + g.data["amount"] if g.data["amount"] > captcha.amount else captcha.amount + ) + payment_hash, payment_request = await create_invoice( + wallet_id=captcha.wallet, + amount=amount, + memo=f"{captcha.memo}", + extra={"tag": "captcha"}, + ) + except Exception as e: + return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR + + return ( + jsonify({"payment_hash": payment_hash, "payment_request": payment_request}), + HTTPStatus.CREATED, + ) + + +@captcha_ext.route("/api/v1/captchas//check_invoice", methods=["POST"]) +@api_validate_post_request( + schema={"payment_hash": {"type": "string", "empty": False, "required": True}} +) +async def api_paywal_check_invoice(captcha_id): + captcha = await get_captcha(captcha_id) + + if not captcha: + return jsonify({"message": "captcha does not exist."}), HTTPStatus.NOT_FOUND + + try: + status = await check_invoice_status(captcha.wallet, g.data["payment_hash"]) + is_paid = not status.pending + except Exception: + return jsonify({"paid": False}), HTTPStatus.OK + + if is_paid: + wallet = await get_wallet(captcha.wallet) + payment = await wallet.get_payment(g.data["payment_hash"]) + await payment.set_pending(False) + + return ( + jsonify({"paid": True, "url": captcha.url, "remembers": captcha.remembers}), + HTTPStatus.OK, + ) + + return jsonify({"paid": False}), HTTPStatus.OK diff --git a/lnbits/extensions/diagonalley/__init__.py b/lnbits/extensions/diagonalley/__init__.py index 41afebb62..ac907f5c7 100644 --- a/lnbits/extensions/diagonalley/__init__.py +++ b/lnbits/extensions/diagonalley/__init__.py @@ -1,7 +1,9 @@ from quart import Blueprint -diagonalley_ext: Blueprint = Blueprint("diagonalley", __name__, static_folder="static", template_folder="templates") +diagonalley_ext: Blueprint = Blueprint( + "diagonalley", __name__, static_folder="static", template_folder="templates" +) from .views_api import * # noqa diff --git a/lnbits/extensions/diagonalley/crud.py b/lnbits/extensions/diagonalley/crud.py index a7ef58222..8e89c35a5 100644 --- a/lnbits/extensions/diagonalley/crud.py +++ b/lnbits/extensions/diagonalley/crud.py @@ -21,7 +21,14 @@ regex = re.compile( def create_diagonalleys_product( - *, wallet_id: str, product: str, categories: str, description: str, image: str, price: int, quantity: int + *, + wallet_id: str, + product: str, + categories: str, + description: str, + image: str, + price: int, + quantity: int, ) -> Products: with open_ext_db("diagonalley") as db: product_id = urlsafe_b64encode(uuid4().bytes_le).decode("utf-8") @@ -30,7 +37,16 @@ def create_diagonalleys_product( INSERT INTO products (id, wallet, product, categories, description, image, price, quantity) VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, - (product_id, wallet_id, product, categories, description, image, price, quantity), + ( + product_id, + wallet_id, + product, + categories, + description, + image, + price, + quantity, + ), ) return get_diagonalleys_product(product_id) @@ -40,7 +56,9 @@ def update_diagonalleys_product(product_id: str, **kwargs) -> Optional[Indexers] q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) with open_ext_db("diagonalley") as db: - db.execute(f"UPDATE products SET {q} WHERE id = ?", (*kwargs.values(), product_id)) + db.execute( + f"UPDATE products SET {q} WHERE id = ?", (*kwargs.values(), product_id) + ) row = db.fetchone("SELECT * FROM products WHERE id = ?", (product_id,)) return get_diagonalleys_indexer(product_id) @@ -59,7 +77,9 @@ def get_diagonalleys_products(wallet_ids: Union[str, List[str]]) -> List[Product with open_ext_db("diagonalley") as db: q = ",".join(["?"] * len(wallet_ids)) - rows = db.fetchall(f"SELECT * FROM products WHERE wallet IN ({q})", (*wallet_ids,)) + rows = db.fetchall( + f"SELECT * FROM products WHERE wallet IN ({q})", (*wallet_ids,) + ) return [Products(**row) for row in rows] @@ -110,7 +130,9 @@ def update_diagonalleys_indexer(indexer_id: str, **kwargs) -> Optional[Indexers] q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) with open_ext_db("diagonalley") as db: - db.execute(f"UPDATE indexers SET {q} WHERE id = ?", (*kwargs.values(), indexer_id)) + db.execute( + f"UPDATE indexers SET {q} WHERE id = ?", (*kwargs.values(), indexer_id) + ) row = db.fetchone("SELECT * FROM indexers WHERE id = ?", (indexer_id,)) return get_diagonalleys_indexer(indexer_id) @@ -154,7 +176,9 @@ def get_diagonalleys_indexers(wallet_ids: Union[str, List[str]]) -> List[Indexer with open_ext_db("diagonalley") as db: q = ",".join(["?"] * len(wallet_ids)) - rows = db.fetchall(f"SELECT * FROM indexers WHERE wallet IN ({q})", (*wallet_ids,)) + rows = db.fetchall( + f"SELECT * FROM indexers WHERE wallet IN ({q})", (*wallet_ids,) + ) for r in rows: try: @@ -181,7 +205,9 @@ def get_diagonalleys_indexers(wallet_ids: Union[str, List[str]]) -> List[Indexer print("An exception occurred") with open_ext_db("diagonalley") as db: q = ",".join(["?"] * len(wallet_ids)) - rows = db.fetchall(f"SELECT * FROM indexers WHERE wallet IN ({q})", (*wallet_ids,)) + rows = db.fetchall( + f"SELECT * FROM indexers WHERE wallet IN ({q})", (*wallet_ids,) + ) return [Indexers(**row) for row in rows] @@ -213,7 +239,19 @@ def create_diagonalleys_order( INSERT INTO orders (id, productid, wallet, product, quantity, shippingzone, address, email, invoiceid, paid, shipped) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, - (order_id, productid, wallet, product, quantity, shippingzone, address, email, invoiceid, False, False), + ( + order_id, + productid, + wallet, + product, + quantity, + shippingzone, + address, + email, + invoiceid, + False, + False, + ), ) return get_diagonalleys_order(order_id) @@ -232,9 +270,11 @@ def get_diagonalleys_orders(wallet_ids: Union[str, List[str]]) -> List[Orders]: with open_ext_db("diagonalley") as db: q = ",".join(["?"] * len(wallet_ids)) - rows = db.fetchall(f"SELECT * FROM orders WHERE wallet IN ({q})", (*wallet_ids,)) + rows = db.fetchall( + f"SELECT * FROM orders WHERE wallet IN ({q})", (*wallet_ids,) + ) for r in rows: - PAID = WALLET.get_invoice_status(r["invoiceid"]).paid + PAID = (await WALLET.get_invoice_status(r["invoiceid"])).paid if PAID: with open_ext_db("diagonalley") as db: db.execute( @@ -244,7 +284,9 @@ def get_diagonalleys_orders(wallet_ids: Union[str, List[str]]) -> List[Orders]: r["id"], ), ) - rows = db.fetchall(f"SELECT * FROM orders WHERE wallet IN ({q})", (*wallet_ids,)) + rows = db.fetchall( + f"SELECT * FROM orders WHERE wallet IN ({q})", (*wallet_ids,) + ) return [Orders(**row) for row in rows] diff --git a/lnbits/extensions/diagonalley/views_api.py b/lnbits/extensions/diagonalley/views_api.py index 9ded7c2cc..cfb835666 100644 --- a/lnbits/extensions/diagonalley/views_api.py +++ b/lnbits/extensions/diagonalley/views_api.py @@ -36,7 +36,12 @@ async def api_diagonalley_products(): if "all_wallets" in request.args: wallet_ids = get_user(g.wallet.user).wallet_ids - return jsonify([product._asdict() for product in get_diagonalleys_products(wallet_ids)]), HTTPStatus.OK + return ( + jsonify( + [product._asdict() for product in get_diagonalleys_products(wallet_ids)] + ), + HTTPStatus.OK, + ) @diagonalley_ext.route("/api/v1/diagonalley/products", methods=["POST"]) @@ -58,16 +63,25 @@ async def api_diagonalley_product_create(product_id=None): product = get_diagonalleys_indexer(product_id) if not product: - return jsonify({"message": "Withdraw product does not exist."}), HTTPStatus.NOT_FOUND + return ( + jsonify({"message": "Withdraw product does not exist."}), + HTTPStatus.NOT_FOUND, + ) if product.wallet != g.wallet.id: - return jsonify({"message": "Not your withdraw product."}), HTTPStatus.FORBIDDEN + return ( + jsonify({"message": "Not your withdraw product."}), + HTTPStatus.FORBIDDEN, + ) product = update_diagonalleys_product(product_id, **g.data) else: product = create_diagonalleys_product(wallet_id=g.wallet.id, **g.data) - return jsonify(product._asdict()), HTTPStatus.OK if product_id else HTTPStatus.CREATED + return ( + jsonify(product._asdict()), + HTTPStatus.OK if product_id else HTTPStatus.CREATED, + ) @diagonalley_ext.route("/api/v1/diagonalley/products/", methods=["DELETE"]) @@ -97,7 +111,12 @@ async def api_diagonalley_indexers(): if "all_wallets" in request.args: wallet_ids = get_user(g.wallet.user).wallet_ids - return jsonify([indexer._asdict() for indexer in get_diagonalleys_indexers(wallet_ids)]), HTTPStatus.OK + return ( + jsonify( + [indexer._asdict() for indexer in get_diagonalleys_indexers(wallet_ids)] + ), + HTTPStatus.OK, + ) @diagonalley_ext.route("/api/v1/diagonalley/indexers", methods=["POST"]) @@ -120,16 +139,25 @@ async def api_diagonalley_indexer_create(indexer_id=None): indexer = get_diagonalleys_indexer(indexer_id) if not indexer: - return jsonify({"message": "Withdraw indexer does not exist."}), HTTPStatus.NOT_FOUND + return ( + jsonify({"message": "Withdraw indexer does not exist."}), + HTTPStatus.NOT_FOUND, + ) if indexer.wallet != g.wallet.id: - return jsonify({"message": "Not your withdraw indexer."}), HTTPStatus.FORBIDDEN + return ( + jsonify({"message": "Not your withdraw indexer."}), + HTTPStatus.FORBIDDEN, + ) indexer = update_diagonalleys_indexer(indexer_id, **g.data) else: indexer = create_diagonalleys_indexer(wallet_id=g.wallet.id, **g.data) - return jsonify(indexer._asdict()), HTTPStatus.OK if indexer_id else HTTPStatus.CREATED + return ( + jsonify(indexer._asdict()), + HTTPStatus.OK if indexer_id else HTTPStatus.CREATED, + ) @diagonalley_ext.route("/api/v1/diagonalley/indexers/", methods=["DELETE"]) @@ -159,7 +187,10 @@ async def api_diagonalley_orders(): if "all_wallets" in request.args: wallet_ids = get_user(g.wallet.user).wallet_ids - return jsonify([order._asdict() for order in get_diagonalleys_orders(wallet_ids)]), HTTPStatus.OK + return ( + jsonify([order._asdict() for order in get_diagonalleys_orders(wallet_ids)]), + HTTPStatus.OK, + ) @diagonalley_ext.route("/api/v1/diagonalley/orders", methods=["POST"]) @@ -221,13 +252,20 @@ async def api_diagonalleys_order_shipped(order_id): ) order = db.fetchone("SELECT * FROM orders WHERE id = ?", (order_id,)) - return jsonify([order._asdict() for order in get_diagonalleys_orders(order["wallet"])]), HTTPStatus.OK + return ( + jsonify( + [order._asdict() for order in get_diagonalleys_orders(order["wallet"])] + ), + HTTPStatus.OK, + ) ###List products based on indexer id -@diagonalley_ext.route("/api/v1/diagonalley/stall/products/", methods=["GET"]) +@diagonalley_ext.route( + "/api/v1/diagonalley/stall/products/", methods=["GET"] +) async def api_diagonalleys_stall_products(indexer_id): with open_ext_db("diagonalley") as db: rows = db.fetchone("SELECT * FROM indexers WHERE id = ?", (indexer_id,)) @@ -239,13 +277,20 @@ async def api_diagonalleys_stall_products(indexer_id): if not products: return jsonify({"message": "No products"}), HTTPStatus.NOT_FOUND - return jsonify([products._asdict() for products in get_diagonalleys_products(rows[1])]), HTTPStatus.OK + return ( + jsonify( + [products._asdict() for products in get_diagonalleys_products(rows[1])] + ), + HTTPStatus.OK, + ) ###Check a product has been shipped -@diagonalley_ext.route("/api/v1/diagonalley/stall/checkshipped/", methods=["GET"]) +@diagonalley_ext.route( + "/api/v1/diagonalley/stall/checkshipped/", methods=["GET"] +) async def api_diagonalleys_stall_checkshipped(checking_id): with open_ext_db("diagonalley") as db: rows = db.fetchone("SELECT * FROM orders WHERE invoiceid = ?", (checking_id,)) @@ -276,7 +321,9 @@ async def api_diagonalley_stall_order(indexer_id): shippingcost = shipping.zone2cost checking_id, payment_request = create_invoice( - wallet_id=product.wallet, amount=shippingcost + (g.data["quantity"] * product.price), memo=g.data["id"] + wallet_id=product.wallet, + amount=shippingcost + (g.data["quantity"] * product.price), + memo=g.data["id"], ) selling_id = urlsafe_b64encode(uuid4().bytes_le).decode("utf-8") with open_ext_db("diagonalley") as db: @@ -299,4 +346,7 @@ async def api_diagonalley_stall_order(indexer_id): False, ), ) - return jsonify({"checking_id": checking_id, "payment_request": payment_request}), HTTPStatus.OK + return ( + jsonify({"checking_id": checking_id, "payment_request": payment_request}), + HTTPStatus.OK, + ) diff --git a/lnbits/extensions/events/__init__.py b/lnbits/extensions/events/__init__.py index 4496b2c11..b8f4deb55 100644 --- a/lnbits/extensions/events/__init__.py +++ b/lnbits/extensions/events/__init__.py @@ -4,7 +4,9 @@ from lnbits.db import Database db = Database("ext_events") -events_ext: Blueprint = Blueprint("events", __name__, static_folder="static", template_folder="templates") +events_ext: Blueprint = Blueprint( + "events", __name__, static_folder="static", template_folder="templates" +) from .views_api import * # noqa diff --git a/lnbits/extensions/events/crud.py b/lnbits/extensions/events/crud.py index efc407317..20faa09ac 100644 --- a/lnbits/extensions/events/crud.py +++ b/lnbits/extensions/events/crud.py @@ -9,7 +9,9 @@ from .models import Tickets, Events # TICKETS -async def create_ticket(payment_hash: str, wallet: str, event: str, name: str, email: str) -> Tickets: +async def create_ticket( + payment_hash: str, wallet: str, event: str, name: str, email: str +) -> Tickets: await db.execute( """ INSERT INTO ticket (id, wallet, event, name, email, registered, paid) @@ -64,7 +66,9 @@ async def get_tickets(wallet_ids: Union[str, List[str]]) -> List[Tickets]: wallet_ids = [wallet_ids] q = ",".join(["?"] * len(wallet_ids)) - rows = await db.fetchall(f"SELECT * FROM ticket WHERE wallet IN ({q})", (*wallet_ids,)) + rows = await db.fetchall( + f"SELECT * FROM ticket WHERE wallet IN ({q})", (*wallet_ids,) + ) return [Tickets(**row) for row in rows] @@ -113,7 +117,9 @@ async def create_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 SET {q} WHERE id = ?", (*kwargs.values(), event_id)) + await db.execute( + f"UPDATE 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 @@ -129,7 +135,9 @@ async def get_events(wallet_ids: Union[str, List[str]]) -> List[Events]: wallet_ids = [wallet_ids] q = ",".join(["?"] * len(wallet_ids)) - rows = await db.fetchall(f"SELECT * FROM events WHERE wallet IN ({q})", (*wallet_ids,)) + rows = await db.fetchall( + f"SELECT * FROM events WHERE wallet IN ({q})", (*wallet_ids,) + ) return [Events(**row) for row in rows] @@ -142,7 +150,9 @@ async def delete_event(event_id: str) -> None: async def get_event_tickets(event_id: str, wallet_id: str) -> List[Tickets]: - rows = await db.fetchall("SELECT * FROM ticket WHERE wallet = ? AND event = ?", (wallet_id, event_id)) + rows = await db.fetchall( + "SELECT * FROM ticket WHERE wallet = ? AND event = ?", (wallet_id, event_id) + ) return [Tickets(**row) for row in rows] diff --git a/lnbits/extensions/events/views.py b/lnbits/extensions/events/views.py index 86426acb3..e15513208 100644 --- a/lnbits/extensions/events/views.py +++ b/lnbits/extensions/events/views.py @@ -23,12 +23,16 @@ async def display(event_id): if event.amount_tickets < 1: return await render_template( - "events/error.html", event_name=event.name, event_error="Sorry, tickets are sold out :(" + "events/error.html", + event_name=event.name, + event_error="Sorry, tickets are sold out :(", ) datetime_object = datetime.strptime(event.closing_date, "%Y-%m-%d").date() if date.today() > datetime_object: return await render_template( - "events/error.html", event_name=event.name, event_error="Sorry, ticket closing date has passed :(" + "events/error.html", + event_name=event.name, + event_error="Sorry, ticket closing date has passed :(", ) return await render_template( @@ -51,7 +55,10 @@ async def ticket(ticket_id): abort(HTTPStatus.NOT_FOUND, "Event does not exist.") return await render_template( - "events/ticket.html", ticket_id=ticket_id, ticket_name=event.name, ticket_info=event.info + "events/ticket.html", + ticket_id=ticket_id, + ticket_name=event.name, + ticket_info=event.info, ) @@ -62,5 +69,8 @@ async def register(event_id): abort(HTTPStatus.NOT_FOUND, "Event does not exist.") return await render_template( - "events/register.html", event_id=event_id, event_name=event.name, wallet_id=event.wallet + "events/register.html", + 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 index 3467cc2d9..3ea618437 100644 --- a/lnbits/extensions/events/views_api.py +++ b/lnbits/extensions/events/views_api.py @@ -33,7 +33,10 @@ async def api_events(): if "all_wallets" in request.args: wallet_ids = (await get_user(g.wallet.user)).wallet_ids - return jsonify([event._asdict() for event in await get_events(wallet_ids)]), HTTPStatus.OK + return ( + jsonify([event._asdict() for event in await get_events(wallet_ids)]), + HTTPStatus.OK, + ) @events_ext.route("/api/v1/events", methods=["POST"]) @@ -92,7 +95,10 @@ async def api_tickets(): if "all_wallets" in request.args: wallet_ids = (await get_user(g.wallet.user)).wallet_ids - return jsonify([ticket._asdict() for ticket in await get_tickets(wallet_ids)]), HTTPStatus.OK + return ( + jsonify([ticket._asdict() for ticket in await get_tickets(wallet_ids)]), + HTTPStatus.OK, + ) @events_ext.route("/api/v1/tickets//", methods=["POST"]) @@ -108,17 +114,25 @@ async def api_ticket_make_ticket(event_id, sats): return jsonify({"message": "Event does not exist."}), HTTPStatus.NOT_FOUND try: payment_hash, payment_request = await create_invoice( - wallet_id=event.wallet, amount=int(sats), memo=f"{event_id}", extra={"tag": "events"} + wallet_id=event.wallet, + amount=int(sats), + memo=f"{event_id}", + extra={"tag": "events"}, ) except Exception as e: return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR - ticket = await create_ticket(payment_hash=payment_hash, wallet=event.wallet, event=event_id, **g.data) + ticket = await create_ticket( + payment_hash=payment_hash, wallet=event.wallet, event=event_id, **g.data + ) if not ticket: return jsonify({"message": "Event could not be fetched."}), HTTPStatus.NOT_FOUND - return jsonify({"payment_hash": payment_hash, "payment_request": payment_request}), HTTPStatus.OK + return ( + jsonify({"payment_hash": payment_hash, "payment_request": payment_request}), + HTTPStatus.OK, + ) @events_ext.route("/api/v1/tickets/", methods=["GET"]) @@ -163,7 +177,14 @@ async def api_ticket_delete(ticket_id): @events_ext.route("/api/v1/eventtickets//", methods=["GET"]) async def api_event_tickets(wallet_id, event_id): return ( - jsonify([ticket._asdict() for ticket in await get_event_tickets(wallet_id=wallet_id, event_id=event_id)]), + jsonify( + [ + ticket._asdict() + for ticket in await get_event_tickets( + wallet_id=wallet_id, event_id=event_id + ) + ] + ), HTTPStatus.OK, ) @@ -177,4 +198,7 @@ async def api_event_register_ticket(ticket_id): if ticket.registered == True: return jsonify({"message": "Ticket already registered"}), HTTPStatus.FORBIDDEN - return jsonify([ticket._asdict() for ticket in await reg_ticket(ticket_id)]), HTTPStatus.OK + return ( + jsonify([ticket._asdict() for ticket in await reg_ticket(ticket_id)]), + HTTPStatus.OK, + ) diff --git a/lnbits/extensions/example/__init__.py b/lnbits/extensions/example/__init__.py index 43a8223cc..e16e0372f 100644 --- a/lnbits/extensions/example/__init__.py +++ b/lnbits/extensions/example/__init__.py @@ -3,7 +3,9 @@ from lnbits.db import Database db = Database("ext_example") -example_ext: Blueprint = Blueprint("example", __name__, static_folder="static", template_folder="templates") +example_ext: Blueprint = Blueprint( + "example", __name__, static_folder="static", template_folder="templates" +) from .views_api import * # noqa diff --git a/lnbits/extensions/lndhub/__init__.py b/lnbits/extensions/lndhub/__init__.py index ed368c041..7610b0a3e 100644 --- a/lnbits/extensions/lndhub/__init__.py +++ b/lnbits/extensions/lndhub/__init__.py @@ -3,7 +3,9 @@ from lnbits.db import Database db = Database("ext_lndhub") -lndhub_ext: Blueprint = Blueprint("lndhub", __name__, static_folder="static", template_folder="templates") +lndhub_ext: Blueprint = Blueprint( + "lndhub", __name__, static_folder="static", template_folder="templates" +) from .views_api import * # noqa diff --git a/lnbits/extensions/lndhub/decorators.py b/lnbits/extensions/lndhub/decorators.py index 07a109bc5..c9c3bb71e 100644 --- a/lnbits/extensions/lndhub/decorators.py +++ b/lnbits/extensions/lndhub/decorators.py @@ -13,11 +13,15 @@ def check_wallet(requires_admin=False): key_type, key = b64decode(token).decode("utf-8").split(":") if requires_admin and key_type != "admin": - return jsonify({"error": True, "code": 2, "message": "insufficient permissions"}) + return jsonify( + {"error": True, "code": 2, "message": "insufficient permissions"} + ) g.wallet = await get_wallet_for_key(key, key_type) if not g.wallet: - return jsonify({"error": True, "code": 2, "message": "insufficient permissions"}) + return jsonify( + {"error": True, "code": 2, "message": "insufficient permissions"} + ) return await view(**kwargs) return wrapped_view diff --git a/lnbits/extensions/lndhub/views_api.py b/lnbits/extensions/lndhub/views_api.py index d7fb409c7..5f3b2a8b9 100644 --- a/lnbits/extensions/lndhub/views_api.py +++ b/lnbits/extensions/lndhub/views_api.py @@ -23,14 +23,20 @@ async def lndhub_getinfo(): schema={ "login": {"type": "string", "required": True, "excludes": "refresh_token"}, "password": {"type": "string", "required": True, "excludes": "refresh_token"}, - "refresh_token": {"type": "string", "required": True, "excludes": ["login", "password"]}, + "refresh_token": { + "type": "string", + "required": True, + "excludes": ["login", "password"], + }, } ) async def lndhub_auth(): token = ( g.data["refresh_token"] if "refresh_token" in g.data and g.data["refresh_token"] - else urlsafe_b64encode((g.data["login"] + ":" + g.data["password"]).encode("utf-8")).decode("ascii") + else urlsafe_b64encode( + (g.data["login"] + ":" + g.data["password"]).encode("utf-8") + ).decode("ascii") ) return jsonify({"refresh_token": token, "access_token": token}) @@ -120,9 +126,15 @@ async def lndhub_balance(): @check_wallet() async def lndhub_gettxs(): for payment in await g.wallet.get_payments( - complete=False, pending=True, outgoing=True, incoming=False, exclude_uncheckable=True + complete=False, + pending=True, + outgoing=True, + incoming=False, + exclude_uncheckable=True, ): - await payment.set_pending(WALLET.get_payment_status(payment.checking_id).pending) + await payment.set_pending( + (await WALLET.get_payment_status(payment.checking_id)).pending + ) limit = int(request.args.get("limit", 200)) return jsonify( @@ -135,10 +147,16 @@ async def lndhub_gettxs(): "fee": payment.fee, "value": int(payment.amount / 1000), "timestamp": payment.time, - "memo": payment.memo if not payment.pending else "Payment in transition", + "memo": payment.memo + if not payment.pending + else "Payment in transition", } for payment in reversed( - (await g.wallet.get_payments(pending=True, complete=True, outgoing=True, incoming=False))[:limit] + ( + await g.wallet.get_payments( + pending=True, complete=True, outgoing=True, incoming=False + ) + )[:limit] ) ] ) @@ -149,9 +167,15 @@ async def lndhub_gettxs(): async def lndhub_getuserinvoices(): await delete_expired_invoices() for invoice in await g.wallet.get_payments( - complete=False, pending=True, outgoing=False, incoming=True, exclude_uncheckable=True + complete=False, + pending=True, + outgoing=False, + incoming=True, + exclude_uncheckable=True, ): - await invoice.set_pending(WALLET.get_invoice_status(invoice.checking_id).pending) + await invoice.set_pending( + (await WALLET.get_invoice_status(invoice.checking_id)).pending + ) limit = int(request.args.get("limit", 200)) return jsonify( @@ -169,7 +193,11 @@ async def lndhub_getuserinvoices(): "type": "user_invoice", } for invoice in reversed( - (await g.wallet.get_payments(pending=True, complete=True, incoming=True, outgoing=False))[:limit] + ( + await g.wallet.get_payments( + pending=True, complete=True, incoming=True, outgoing=False + ) + )[:limit] ) ] ) diff --git a/lnbits/extensions/lnticket/__init__.py b/lnbits/extensions/lnticket/__init__.py index 21ef19a18..0e0aa146a 100644 --- a/lnbits/extensions/lnticket/__init__.py +++ b/lnbits/extensions/lnticket/__init__.py @@ -3,7 +3,9 @@ from lnbits.db import Database db = Database("ext_lnticket") -lnticket_ext: Blueprint = Blueprint("lnticket", __name__, static_folder="static", template_folder="templates") +lnticket_ext: Blueprint = Blueprint( + "lnticket", __name__, static_folder="static", template_folder="templates" +) from .views_api import * # noqa diff --git a/lnbits/extensions/lnticket/crud.py b/lnbits/extensions/lnticket/crud.py index 8a071cbc1..6c39afb88 100644 --- a/lnbits/extensions/lnticket/crud.py +++ b/lnbits/extensions/lnticket/crud.py @@ -60,7 +60,12 @@ async def set_ticket_paid(payment_hash: str) -> Tickets: try: r = await client.post( formdata.webhook, - json={"form": ticket.form, "name": ticket.name, "email": ticket.email, "content": ticket.ltext}, + json={ + "form": ticket.form, + "name": ticket.name, + "email": ticket.email, + "content": ticket.ltext, + }, timeout=40, ) except AssertionError: @@ -80,7 +85,9 @@ async def get_tickets(wallet_ids: Union[str, List[str]]) -> List[Tickets]: wallet_ids = [wallet_ids] q = ",".join(["?"] * len(wallet_ids)) - rows = await db.fetchall(f"SELECT * FROM ticket WHERE wallet IN ({q})", (*wallet_ids,)) + rows = await db.fetchall( + f"SELECT * FROM ticket WHERE wallet IN ({q})", (*wallet_ids,) + ) return [Tickets(**row) for row in rows] @@ -93,7 +100,12 @@ async def delete_ticket(ticket_id: str) -> None: async def create_form( - *, wallet: str, name: str, webhook: Optional[str] = None, description: str, costpword: int + *, + wallet: str, + name: str, + webhook: Optional[str] = None, + description: str, + costpword: int, ) -> Forms: form_id = urlsafe_short_hash() await db.execute( @@ -127,7 +139,9 @@ async def get_forms(wallet_ids: Union[str, List[str]]) -> List[Forms]: wallet_ids = [wallet_ids] q = ",".join(["?"] * len(wallet_ids)) - rows = await db.fetchall(f"SELECT * FROM form WHERE wallet IN ({q})", (*wallet_ids,)) + rows = await db.fetchall( + f"SELECT * FROM form WHERE wallet IN ({q})", (*wallet_ids,) + ) return [Forms(**row) for row in rows] diff --git a/lnbits/extensions/lnticket/views_api.py b/lnbits/extensions/lnticket/views_api.py index d22a3eb6d..3c77ced0b 100644 --- a/lnbits/extensions/lnticket/views_api.py +++ b/lnbits/extensions/lnticket/views_api.py @@ -32,7 +32,10 @@ async def api_forms(): if "all_wallets" in request.args: wallet_ids = (await get_user(g.wallet.user)).wallet_ids - return jsonify([form._asdict() for form in await get_forms(wallet_ids)]), HTTPStatus.OK + return ( + jsonify([form._asdict() for form in await get_forms(wallet_ids)]), + HTTPStatus.OK, + ) @lnticket_ext.route("/api/v1/forms", methods=["POST"]) @@ -90,7 +93,10 @@ async def api_tickets(): if "all_wallets" in request.args: wallet_ids = (await get_user(g.wallet.user)).wallet_ids - return jsonify([form._asdict() for form in await get_tickets(wallet_ids)]), HTTPStatus.OK + return ( + jsonify([form._asdict() for form in await get_tickets(wallet_ids)]), + HTTPStatus.OK, + ) @lnticket_ext.route("/api/v1/tickets/", methods=["POST"]) @@ -117,12 +123,20 @@ async def api_ticket_make_ticket(form_id): extra={"tag": "lnticket"}, ) - ticket = await create_ticket(payment_hash=payment_hash, wallet=form.wallet, **g.data) + ticket = await create_ticket( + payment_hash=payment_hash, wallet=form.wallet, **g.data + ) if not ticket: - return jsonify({"message": "LNTicket could not be fetched."}), HTTPStatus.NOT_FOUND + return ( + jsonify({"message": "LNTicket could not be fetched."}), + HTTPStatus.NOT_FOUND, + ) - return jsonify({"payment_hash": payment_hash, "payment_request": payment_request}), HTTPStatus.OK + return ( + jsonify({"payment_hash": payment_hash, "payment_request": payment_request}), + HTTPStatus.OK, + ) @lnticket_ext.route("/api/v1/tickets/", methods=["GET"]) diff --git a/lnbits/extensions/lnurlp/__init__.py b/lnbits/extensions/lnurlp/__init__.py index 7a44f9d5b..d820b1973 100644 --- a/lnbits/extensions/lnurlp/__init__.py +++ b/lnbits/extensions/lnurlp/__init__.py @@ -3,7 +3,9 @@ from lnbits.db import Database db = Database("ext_lnurlp") -lnurlp_ext: Blueprint = Blueprint("lnurlp", __name__, static_folder="static", template_folder="templates") +lnurlp_ext: Blueprint = Blueprint( + "lnurlp", __name__, static_folder="static", template_folder="templates" +) from .views_api import * # noqa diff --git a/lnbits/extensions/lnurlp/crud.py b/lnbits/extensions/lnurlp/crud.py index fe96825bc..9c2b182a9 100644 --- a/lnbits/extensions/lnurlp/crud.py +++ b/lnbits/extensions/lnurlp/crud.py @@ -74,14 +74,18 @@ async def get_pay_links(wallet_ids: Union[str, List[str]]) -> List[PayLink]: async def update_pay_link(link_id: int, **kwargs) -> Optional[PayLink]: q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) - await db.execute(f"UPDATE pay_links SET {q} WHERE id = ?", (*kwargs.values(), link_id)) + await db.execute( + f"UPDATE pay_links SET {q} WHERE id = ?", (*kwargs.values(), link_id) + ) row = await db.fetchone("SELECT * FROM pay_links WHERE id = ?", (link_id,)) return PayLink.from_row(row) if row else None async def increment_pay_link(link_id: int, **kwargs) -> Optional[PayLink]: q = ", ".join([f"{field[0]} = {field[0]} + ?" for field in kwargs.items()]) - await db.execute(f"UPDATE pay_links SET {q} WHERE id = ?", (*kwargs.values(), link_id)) + await db.execute( + f"UPDATE pay_links SET {q} WHERE id = ?", (*kwargs.values(), link_id) + ) row = await db.fetchone("SELECT * FROM pay_links WHERE id = ?", (link_id,)) return PayLink.from_row(row) if row else None diff --git a/lnbits/extensions/lnurlp/helpers.py b/lnbits/extensions/lnurlp/helpers.py deleted file mode 100644 index 13e748574..000000000 --- a/lnbits/extensions/lnurlp/helpers.py +++ /dev/null @@ -1,48 +0,0 @@ -import trio # type: ignore -import httpx - - -async def get_fiat_rate(currency: str): - assert currency == "USD", "Only USD is supported as fiat currency." - return await get_usd_rate() - - -async def get_usd_rate(): - """ - Returns an average satoshi price from multiple sources. - """ - - satoshi_prices = [None, None, None] - - async def fetch_price(index, url, getter): - try: - async with httpx.AsyncClient() as client: - r = await client.get(url) - r.raise_for_status() - satoshi_price = int(100_000_000 / float(getter(r.json()))) - satoshi_prices[index] = satoshi_price - except Exception: - pass - - async with trio.open_nursery() as nursery: - nursery.start_soon( - fetch_price, - 0, - "https://api.kraken.com/0/public/Ticker?pair=XXBTZUSD", - lambda d: d["result"]["XXBTCZUSD"]["c"][0], - ) - nursery.start_soon( - fetch_price, - 1, - "https://www.bitstamp.net/api/v2/ticker/btcusd", - lambda d: d["last"], - ) - nursery.start_soon( - fetch_price, - 2, - "https://api.coincap.io/v2/rates/bitcoin", - lambda d: d["data"]["rateUsd"], - ) - - satoshi_prices = [x for x in satoshi_prices if x] - return sum(satoshi_prices) / len(satoshi_prices) diff --git a/lnbits/extensions/lnurlp/lnurl.py b/lnbits/extensions/lnurlp/lnurl.py index 74dd6e352..79076564e 100644 --- a/lnbits/extensions/lnurlp/lnurl.py +++ b/lnbits/extensions/lnurlp/lnurl.py @@ -5,19 +5,22 @@ from quart import jsonify, url_for, request 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 from . import lnurlp_ext from .crud import increment_pay_link -from .helpers import get_fiat_rate @lnurlp_ext.route("/api/v1/lnurl/", methods=["GET"]) async def api_lnurl_response(link_id): link = await increment_pay_link(link_id, served_meta=1) if not link: - return jsonify({"status": "ERROR", "reason": "LNURL-pay not found."}), HTTPStatus.OK + return ( + jsonify({"status": "ERROR", "reason": "LNURL-pay not found."}), + HTTPStatus.OK, + ) - rate = await get_fiat_rate(link.currency) if link.currency else 1 + rate = await get_fiat_rate_satoshis(link.currency) if link.currency else 1 resp = LnurlPayResponse( callback=url_for("lnurlp.api_lnurl_callback", link_id=link.id, _external=True), min_sendable=math.ceil(link.min * rate) * 1000, @@ -36,10 +39,13 @@ async def api_lnurl_response(link_id): async def api_lnurl_callback(link_id): link = await increment_pay_link(link_id, served_pr=1) if not link: - return jsonify({"status": "ERROR", "reason": "LNURL-pay not found."}), HTTPStatus.OK + return ( + jsonify({"status": "ERROR", "reason": "LNURL-pay not found."}), + HTTPStatus.OK, + ) min, max = link.min, link.max - rate = await get_fiat_rate(link.currency) if link.currency else 1 + rate = await get_fiat_rate_satoshis(link.currency) if link.currency else 1 if link.currency: # allow some fluctuation (as the fiat price may have changed between the calls) min = rate * 995 * link.min @@ -51,12 +57,20 @@ async def api_lnurl_callback(link_id): amount_received = int(request.args.get("amount")) if amount_received < min: return ( - jsonify(LnurlErrorResponse(reason=f"Amount {amount_received} is smaller than minimum {min}.").dict()), + jsonify( + LnurlErrorResponse( + reason=f"Amount {amount_received} is smaller than minimum {min}." + ).dict() + ), HTTPStatus.OK, ) elif amount_received > max: return ( - jsonify(LnurlErrorResponse(reason=f"Amount {amount_received} is greater than maximum {max}.").dict()), + jsonify( + LnurlErrorResponse( + reason=f"Amount {amount_received} is greater than maximum {max}." + ).dict() + ), HTTPStatus.OK, ) @@ -75,7 +89,9 @@ async def api_lnurl_callback(link_id): wallet_id=link.wallet, amount=int(amount_received / 1000), memo=link.description, - description_hash=hashlib.sha256(link.lnurlpay_metadata.encode("utf-8")).digest(), + description_hash=hashlib.sha256( + link.lnurlpay_metadata.encode("utf-8") + ).digest(), extra={"tag": "lnurlp", "link": link.id, "comment": comment}, ) diff --git a/lnbits/extensions/lnurlp/migrations.py b/lnbits/extensions/lnurlp/migrations.py index 13dbc9599..b7ebd3f1b 100644 --- a/lnbits/extensions/lnurlp/migrations.py +++ b/lnbits/extensions/lnurlp/migrations.py @@ -40,8 +40,12 @@ async def m003_min_max_comment_fiat(db): Support for min/max amounts, comments and fiat prices that get converted automatically to satoshis based on some API. """ - await db.execute("ALTER TABLE pay_links ADD COLUMN currency TEXT;") # null = satoshis - await db.execute("ALTER TABLE pay_links ADD COLUMN comment_chars INTEGER DEFAULT 0;") + await db.execute( + "ALTER TABLE pay_links ADD COLUMN currency TEXT;" + ) # null = satoshis + await db.execute( + "ALTER TABLE pay_links ADD COLUMN comment_chars INTEGER DEFAULT 0;" + ) await db.execute("ALTER TABLE pay_links RENAME COLUMN amount TO min;") await db.execute("ALTER TABLE pay_links ADD COLUMN max INTEGER;") await db.execute("UPDATE pay_links SET max = min;") diff --git a/lnbits/extensions/lnurlp/static/js/index.js b/lnbits/extensions/lnurlp/static/js/index.js index bbf5baf08..dbc0df1e3 100644 --- a/lnbits/extensions/lnurlp/static/js/index.js +++ b/lnbits/extensions/lnurlp/static/js/index.js @@ -26,6 +26,7 @@ new Vue({ mixins: [windowMixin], data() { return { + currencies: [], fiatRates: {}, checker: null, payLinks: [], @@ -203,5 +204,14 @@ new Vue({ getPayLinks() }, 20000) } + + LNbits.api + .request('GET', '/lnurlp/api/v1/currencies') + .then(response => { + this.currencies = ['satoshis', ...response.data] + }) + .catch(err => { + LNbits.utils.notifyApiError(err) + }) } }) diff --git a/lnbits/extensions/lnurlp/templates/lnurlp/index.html b/lnbits/extensions/lnurlp/templates/lnurlp/index.html index 79c44a508..c7d60667b 100644 --- a/lnbits/extensions/lnurlp/templates/lnurlp/index.html +++ b/lnbits/extensions/lnurlp/templates/lnurlp/index.html @@ -133,7 +133,7 @@ - + ", methods=["DELETE"]) @@ -109,7 +129,7 @@ async def api_link_delete(link_id): @lnurlp_ext.route("/api/v1/rate/", methods=["GET"]) async def api_check_fiat_rate(currency): try: - rate = await get_fiat_rate(currency) + rate = await get_fiat_rate_satoshis(currency) except AssertionError: rate = None diff --git a/lnbits/extensions/offlineshop/README.md b/lnbits/extensions/offlineshop/README.md new file mode 100644 index 000000000..254bc6884 --- /dev/null +++ b/lnbits/extensions/offlineshop/README.md @@ -0,0 +1 @@ +# Offline Shop diff --git a/lnbits/extensions/offlineshop/__init__.py b/lnbits/extensions/offlineshop/__init__.py new file mode 100644 index 000000000..1f9dd1231 --- /dev/null +++ b/lnbits/extensions/offlineshop/__init__.py @@ -0,0 +1,14 @@ +from quart import Blueprint + +from lnbits.db import Database + +db = Database("ext_offlineshop") + +offlineshop_ext: Blueprint = Blueprint( + "offlineshop", __name__, static_folder="static", template_folder="templates" +) + + +from .views_api import * # noqa +from .views import * # noqa +from .lnurl import * # noqa diff --git a/lnbits/extensions/offlineshop/config.json b/lnbits/extensions/offlineshop/config.json new file mode 100644 index 000000000..0dcb1d6b0 --- /dev/null +++ b/lnbits/extensions/offlineshop/config.json @@ -0,0 +1,8 @@ +{ + "name": "OfflineShop", + "short_description": "Receive payments for products offline!", + "icon": "nature_people", + "contributors": [ + "fiatjaf" + ] +} diff --git a/lnbits/extensions/offlineshop/crud.py b/lnbits/extensions/offlineshop/crud.py new file mode 100644 index 000000000..e6bd0d6c6 --- /dev/null +++ b/lnbits/extensions/offlineshop/crud.py @@ -0,0 +1,101 @@ +from typing import List, Optional + +from . import db +from .wordlists import animals +from .models import Shop, Item + + +async def create_shop(*, wallet_id: str) -> int: + result = await db.execute( + """ + INSERT INTO shops (wallet, wordlist, method) + VALUES (?, ?, 'wordlist') + """, + (wallet_id, "\n".join(animals)), + ) + return result._result_proxy.lastrowid + + +async def get_shop(id: int) -> Optional[Shop]: + row = await db.fetchone("SELECT * FROM shops WHERE id = ?", (id,)) + return Shop(**dict(row)) if row else None + + +async def get_or_create_shop_by_wallet(wallet: str) -> Optional[Shop]: + row = await db.fetchone("SELECT * FROM shops WHERE wallet = ?", (wallet,)) + + if not row: + # create on the fly + ls_id = await create_shop(wallet_id=wallet) + return await get_shop(ls_id) + + return Shop(**dict(row)) if row else None + + +async def set_method(shop: int, method: str, wordlist: str = "") -> Optional[Shop]: + await db.execute( + "UPDATE shops SET method = ?, wordlist = ? WHERE id = ?", + (method, wordlist, shop), + ) + return await get_shop(shop) + + +async def add_item( + shop: int, + name: str, + description: str, + image: Optional[str], + price: int, + unit: str, +) -> int: + result = await db.execute( + """ + INSERT INTO items (shop, name, description, image, price, unit) + VALUES (?, ?, ?, ?, ?, ?) + """, + (shop, name, description, image, price, unit), + ) + return result._result_proxy.lastrowid + + +async def update_item( + shop: int, + item_id: int, + name: str, + description: str, + image: Optional[str], + price: int, + unit: str, +) -> int: + await db.execute( + """ + UPDATE items SET + name = ?, + description = ?, + image = ?, + price = ?, + unit = ? + WHERE shop = ? AND id = ? + """, + (name, description, image, price, unit, shop, item_id), + ) + return item_id + + +async def get_item(id: int) -> Optional[Item]: + row = await db.fetchone("SELECT * FROM items WHERE id = ? LIMIT 1", (id,)) + return Item(**dict(row)) if row else None + + +async def get_items(shop: int) -> List[Item]: + rows = await db.fetchall("SELECT * FROM items WHERE shop = ?", (shop,)) + return [Item(**dict(row)) for row in rows] + + +async def delete_item_from_shop(shop: int, item_id: int): + await db.execute( + """ + DELETE FROM items WHERE shop = ? AND id = ? + """, + (shop, item_id), + ) diff --git a/lnbits/extensions/offlineshop/helpers.py b/lnbits/extensions/offlineshop/helpers.py new file mode 100644 index 000000000..6b56cf559 --- /dev/null +++ b/lnbits/extensions/offlineshop/helpers.py @@ -0,0 +1,17 @@ +import base64 +import struct +import hmac +import time + + +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 + return str(binary)[-digits:].zfill(digits) + + +def totp(key, time_step=30, digits=6, digest="sha1"): + return hotp(key, int(time.time() / time_step), digits, digest) diff --git a/lnbits/extensions/offlineshop/lnurl.py b/lnbits/extensions/offlineshop/lnurl.py new file mode 100644 index 000000000..adee1d035 --- /dev/null +++ b/lnbits/extensions/offlineshop/lnurl.py @@ -0,0 +1,83 @@ +import hashlib +from quart import jsonify, url_for, request +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 + +from . import offlineshop_ext +from .crud import get_shop, get_item + + +@offlineshop_ext.route("/lnurl/", methods=["GET"]) +async def lnurl_response(item_id): + item = await get_item(item_id) + if not item: + return jsonify({"status": "ERROR", "reason": "Item not found."}) + + if not item.enabled: + return jsonify({"status": "ERROR", "reason": "Item disabled."}) + + price_msat = ( + await fiat_amount_as_satoshis(item.price, item.unit) + if item.unit != "sat" + else item.price + ) * 1000 + + resp = LnurlPayResponse( + callback=url_for("offlineshop.lnurl_callback", item_id=item.id, _external=True), + min_sendable=price_msat, + max_sendable=price_msat, + metadata=await item.lnurlpay_metadata(), + ) + + return jsonify(resp.dict()) + + +@offlineshop_ext.route("/lnurl/cb/", methods=["GET"]) +async def lnurl_callback(item_id): + item = await get_item(item_id) + if not item: + return jsonify({"status": "ERROR", "reason": "Couldn't find item."}) + + if item.unit == "sat": + min = item.price * 1000 + max = item.price * 1000 + else: + price = await fiat_amount_as_satoshis(item.price, item.unit) + # allow some fluctuation (the fiat price may have changed between the calls) + min = price * 995 + max = price * 1010 + + amount_received = int(request.args.get("amount")) + if amount_received < min: + return jsonify( + LnurlErrorResponse( + reason=f"Amount {amount_received} is smaller than minimum {min}." + ).dict() + ) + elif amount_received > max: + return jsonify( + LnurlErrorResponse( + reason=f"Amount {amount_received} is greater than maximum {max}." + ).dict() + ) + + shop = await get_shop(item.shop) + payment_hash, payment_request = await create_invoice( + wallet_id=shop.wallet, + amount=int(amount_received / 1000), + memo=item.name, + description_hash=hashlib.sha256( + (await item.lnurlpay_metadata()).encode("utf-8") + ).digest(), + extra={"tag": "offlineshop", "item": item.id}, + ) + + resp = LnurlPayActionResponse( + pr=payment_request, + success_action=item.success_action(shop, payment_hash) if shop.method else None, + routes=[], + ) + + return jsonify(resp.dict()) diff --git a/lnbits/extensions/offlineshop/migrations.py b/lnbits/extensions/offlineshop/migrations.py new file mode 100644 index 000000000..8e8a4877b --- /dev/null +++ b/lnbits/extensions/offlineshop/migrations.py @@ -0,0 +1,29 @@ +async def m001_initial(db): + """ + Initial offlineshop tables. + """ + await db.execute( + """ + CREATE TABLE shops ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + wallet TEXT NOT NULL, + method TEXT NOT NULL, + wordlist TEXT + ); + """ + ) + + await db.execute( + """ + CREATE TABLE items ( + shop INTEGER NOT NULL REFERENCES shop (id), + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + description TEXT NOT NULL, + image TEXT, -- image/png;base64,... + enabled BOOLEAN NOT NULL DEFAULT true, + price INTEGER NOT NULL, + unit TEXT NOT NULL DEFAULT 'sat' + ); + """ + ) diff --git a/lnbits/extensions/offlineshop/models.py b/lnbits/extensions/offlineshop/models.py new file mode 100644 index 000000000..eb767cdf8 --- /dev/null +++ b/lnbits/extensions/offlineshop/models.py @@ -0,0 +1,120 @@ +import json +import base64 +import hashlib +from collections import OrderedDict +from quart import url_for +from typing import NamedTuple, Optional, List, Dict +from lnurl import encode as lnurl_encode # type: ignore +from lnurl.types import LnurlPayMetadata # type: ignore +from lnurl.models import LnurlPaySuccessAction, UrlAction # type: ignore + +from .helpers import totp + +shop_counters: Dict = {} + + +class ShopCounter(object): + fulfilled_payments: OrderedDict + counter: int + + @classmethod + def invoke(cls, shop: "Shop"): + shop_counter = shop_counters.get(shop.id) + if not shop_counter: + shop_counter = cls(wordlist=shop.wordlist.split("\n")) + shop_counters[shop.id] = shop_counter + return shop_counter + + @classmethod + def reset(cls, shop: "Shop"): + shop_counter = cls.invoke(shop) + shop_counter.counter = -1 + shop_counter.wordlist = shop.wordlist.split("\n") + + def __init__(self, wordlist: List[str]): + self.wordlist = wordlist + self.fulfilled_payments = OrderedDict() + self.counter = -1 + + def get_word(self, payment_hash): + if payment_hash in self.fulfilled_payments: + return self.fulfilled_payments[payment_hash] + + # get a new word + self.counter += 1 + word = self.wordlist[self.counter % len(self.wordlist)] + self.fulfilled_payments[payment_hash] = word + + # cleanup confirmation words cache + to_remove = len(self.fulfilled_payments) - 23 + if to_remove > 0: + for i in range(to_remove): + self.fulfilled_payments.popitem(False) + + return word + + +class Shop(NamedTuple): + id: int + wallet: str + method: str + wordlist: str + + @property + def otp_key(self) -> str: + return base64.b32encode( + hashlib.sha256( + ("otpkey" + str(self.id) + self.wallet).encode("ascii"), + ).digest() + ).decode("ascii") + + def get_code(self, payment_hash: str) -> str: + if self.method == "wordlist": + sc = ShopCounter.invoke(self) + return sc.get_word(payment_hash) + elif self.method == "totp": + return totp(self.otp_key) + return "" + + +class Item(NamedTuple): + shop: int + id: int + name: str + description: str + image: str + enabled: bool + price: int + unit: str + + @property + def lnurl(self) -> str: + return lnurl_encode( + url_for("offlineshop.lnurl_response", item_id=self.id, _external=True) + ) + + def values(self): + values = self._asdict() + values["lnurl"] = self.lnurl + return values + + async def lnurlpay_metadata(self) -> LnurlPayMetadata: + metadata = [["text/plain", self.description]] + + if self.image: + metadata.append(self.image.split(":")[1].split(",")) + + return LnurlPayMetadata(json.dumps(metadata)) + + def success_action( + self, shop: Shop, payment_hash: str + ) -> Optional[LnurlPaySuccessAction]: + if not shop.wordlist: + return None + + return UrlAction( + url=url_for( + "offlineshop.confirmation_code", p=payment_hash, _external=True + ), + description="Open to get the confirmation code for your purchase.", + ) diff --git a/lnbits/extensions/offlineshop/static/js/index.js b/lnbits/extensions/offlineshop/static/js/index.js new file mode 100644 index 000000000..00e932416 --- /dev/null +++ b/lnbits/extensions/offlineshop/static/js/index.js @@ -0,0 +1,220 @@ +/* globals Quasar, Vue, _, VueQrcode, windowMixin, LNbits, LOCALE */ + +Vue.component(VueQrcode.name, VueQrcode) + +const pica = window.pica() + +const defaultItemData = { + unit: 'sat' +} + +new Vue({ + el: '#vue', + mixins: [windowMixin], + data() { + return { + selectedWallet: null, + confirmationMethod: 'wordlist', + wordlistTainted: false, + offlineshop: { + method: null, + wordlist: [], + items: [] + }, + itemDialog: { + show: false, + data: {...defaultItemData}, + units: ['sat'] + } + } + }, + computed: { + printItems() { + return this.offlineshop.items.filter(({enabled}) => enabled) + } + }, + methods: { + openNewDialog() { + this.itemDialog.show = true + this.itemDialog.data = {...defaultItemData} + }, + openUpdateDialog(itemId) { + this.itemDialog.show = true + let item = this.offlineshop.items.find(item => item.id === itemId) + this.itemDialog.data = item + }, + imageAdded(file) { + let blobURL = URL.createObjectURL(file) + let image = new Image() + image.src = blobURL + image.onload = async () => { + let canvas = document.createElement('canvas') + canvas.setAttribute('width', 100) + canvas.setAttribute('height', 100) + await pica.resize(image, canvas, { + quality: 0, + alpha: true, + unsharpAmount: 95, + unsharpRadius: 0.9, + unsharpThreshold: 70 + }) + this.itemDialog.data.image = canvas.toDataURL() + this.itemDialog = {...this.itemDialog} + } + }, + imageCleared() { + this.itemDialog.data.image = null + this.itemDialog = {...this.itemDialog} + }, + disabledAddItemButton() { + return ( + !this.itemDialog.data.name || + this.itemDialog.data.name.length === 0 || + !this.itemDialog.data.price || + !this.itemDialog.data.description || + !this.itemDialog.data.unit || + this.itemDialog.data.unit.length === 0 + ) + }, + changedWallet(wallet) { + this.selectedWallet = wallet + this.loadShop() + }, + loadShop() { + LNbits.api + .request( + 'GET', + '/offlineshop/api/v1/offlineshop', + this.selectedWallet.inkey + ) + .then(response => { + this.offlineshop = response.data + this.confirmationMethod = response.data.method + this.wordlistTainted = false + }) + .catch(err => { + LNbits.utils.notifyApiError(err) + }) + }, + async setMethod() { + try { + await LNbits.api.request( + 'PUT', + '/offlineshop/api/v1/offlineshop/method', + this.selectedWallet.inkey, + {method: this.confirmationMethod, wordlist: this.offlineshop.wordlist} + ) + } catch (err) { + LNbits.utils.notifyApiError(err) + return + } + + this.$q.notify({ + message: + `Method set to ${this.confirmationMethod}.` + + (this.confirmationMethod === 'wordlist' ? ' Counter reset.' : ''), + timeout: 700 + }) + this.loadShop() + }, + async sendItem() { + let {id, name, image, description, price, unit} = this.itemDialog.data + const data = { + name, + description, + image, + price, + unit + } + + try { + if (id) { + await LNbits.api.request( + 'PUT', + '/offlineshop/api/v1/offlineshop/items/' + id, + this.selectedWallet.inkey, + data + ) + } else { + await LNbits.api.request( + 'POST', + '/offlineshop/api/v1/offlineshop/items', + this.selectedWallet.inkey, + data + ) + this.$q.notify({ + message: `Item '${this.itemDialog.data.name}' added.`, + timeout: 700 + }) + } + } catch (err) { + LNbits.utils.notifyApiError(err) + return + } + + this.loadShop() + this.itemDialog.show = false + this.itemDialog.data = {...defaultItemData} + }, + toggleItem(itemId) { + let item = this.offlineshop.items.find(item => item.id === itemId) + item.enabled = !item.enabled + + LNbits.api + .request( + 'PUT', + '/offlineshop/api/v1/offlineshop/items/' + itemId, + this.selectedWallet.inkey, + item + ) + .then(response => { + this.$q.notify({ + message: `Item ${item.enabled ? 'enabled' : 'disabled'}.`, + timeout: 700 + }) + this.offlineshop.items = this.offlineshop.items + }) + .catch(err => { + LNbits.utils.notifyApiError(err) + }) + }, + deleteItem(itemId) { + LNbits.utils + .confirmDialog('Are you sure you want to delete this item?') + .onOk(() => { + LNbits.api + .request( + 'DELETE', + '/offlineshop/api/v1/offlineshop/items/' + itemId, + this.selectedWallet.inkey + ) + .then(response => { + this.$q.notify({ + message: `Item deleted.`, + timeout: 700 + }) + this.offlineshop.items.splice( + this.offlineshop.items.findIndex(item => item.id === itemId), + 1 + ) + }) + .catch(err => { + LNbits.utils.notifyApiError(err) + }) + }) + } + }, + created() { + this.selectedWallet = this.g.user.wallets[0] + this.loadShop() + + LNbits.api + .request('GET', '/offlineshop/api/v1/currencies') + .then(response => { + this.itemDialog = {...this.itemDialog, units: ['sat', ...response.data]} + }) + .catch(err => { + LNbits.utils.notifyApiError(err) + }) + } +}) diff --git a/lnbits/extensions/offlineshop/templates/offlineshop/_api_docs.html b/lnbits/extensions/offlineshop/templates/offlineshop/_api_docs.html new file mode 100644 index 000000000..1e3bf0519 --- /dev/null +++ b/lnbits/extensions/offlineshop/templates/offlineshop/_api_docs.html @@ -0,0 +1,147 @@ + + + +
    +
  1. Register items.
  2. +
  3. + Print QR codes and paste them on your store, your menu, somewhere, + somehow. +
  4. +
  5. + Clients scan the QR codes and get information about the items plus the + price on their phones directly (they must have internet) +
  6. +
  7. + Once they decide to pay, they'll get an invoice on their phones + automatically +
  8. +
  9. + When the payment is confirmed, a confirmation code will be issued for + them. +
  10. +
+

+ The confirmation codes are words from a predefined sequential word list. + Each new payment bumps the words sequence by 1. So you can check the + confirmation codes manually by just looking at them. +

+

+ For example, if your wordlist is + [apple, banana, coconut] the first purchase will be + apple, the second banana and so on. When it + gets to the end it starts from the beginning again. +

+

Powered by LNURL-pay.

+
+
+
+ + + + + + POST +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
Returns 201 OK
+
Curl example
+ curl -X GET {{ request.url_root + }}/offlineshop/api/v1/offlineshop/items -H "Content-Type: + application/json" -H "X-Api-Key: {{ g.user.wallets[0].inkey }}" -d + '{"name": <string>, "description": <string>, "image": + <data-uri string>, "price": <integer>, "unit": <"sat" + or "USD">}' + +
+
+
+ + + + GET +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ {"id": <integer>, "wallet": <string>, "wordlist": + <string>, "items": [{"id": <integer>, "name": + <string>, "description": <string>, "image": + <string>, "enabled": <boolean>, "price": <integer>, + "unit": <string>, "lnurl": <string>}, ...]}< +
Curl example
+ curl -X GET {{ request.url_root }}/offlineshop/api/v1/offlineshop -H + "X-Api-Key: {{ g.user.wallets[0].inkey }}" + +
+
+
+ + + + PUT +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
Returns 200 OK
+
Curl example
+ curl -X GET {{ request.url_root + }}/offlineshop/api/v1/offlineshop/items/<item_id> -H + "Content-Type: application/json" -H "X-Api-Key: {{ + g.user.wallets[0].inkey }}" -d '{"name": <string>, + "description": <string>, "image": <data-uri string>, + "price": <integer>, "unit": <"sat" or "USD">}' + +
+
+
+ + + + DELETE +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
Returns 200 OK
+
Curl example
+ curl -X GET {{ request.url_root + }}/offlineshop/api/v1/offlineshop/items/<item_id> -H "X-Api-Key: + {{ g.user.wallets[0].inkey }}" + +
+
+
+
diff --git a/lnbits/extensions/offlineshop/templates/offlineshop/index.html b/lnbits/extensions/offlineshop/templates/offlineshop/index.html new file mode 100644 index 000000000..6dfbc9933 --- /dev/null +++ b/lnbits/extensions/offlineshop/templates/offlineshop/index.html @@ -0,0 +1,332 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + +
+
+
Items
+
+
+ Add new item +
+
+ {% raw %} + + + + + {% endraw %} +
+
+ + + +
+
Wallet Shop
+
+ + + + + + +
+ Print QR Codes +
+
+
+ + + + + + + + + + +
+
+ +
+
+ + Update Wordlist + + Reset +
+
+
+ +
+
+
+ + + +
+
+ + Set TOTP + +
+
+
+ +
+

+ Setting this option disables the confirmation code message that + appears in the consumer wallet after a purchase is paid for. It's ok + if the consumer is to be trusted when they claim to have paid. +

+ + + Disable Confirmation Codes + +
+
+
+
+ +
+ + +
LNbits OfflineShop extension
+
+ + + {% include "offlineshop/_api_docs.html" %} + +
+
+ + + + +
+
Adding a new item
+ + + + + +
+ Copy LNURL +
+ + + + + + + + + + +
+
+ + {% raw %}{{ itemDialog.data.id ? 'Update' : 'Add' }}{% endraw %} + Item + +
+
+ Cancel +
+
+
+
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + + +{% endblock %} diff --git a/lnbits/extensions/offlineshop/templates/offlineshop/print.html b/lnbits/extensions/offlineshop/templates/offlineshop/print.html new file mode 100644 index 000000000..fff12b4c3 --- /dev/null +++ b/lnbits/extensions/offlineshop/templates/offlineshop/print.html @@ -0,0 +1,25 @@ +{% extends "print.html" %} {% block page %} {% raw %} +
+
+
{{ item.name }}
+ +
{{ item.price }}
+
+
+{% endraw %} {% endblock %} {% block scripts %} + +{% endblock %} diff --git a/lnbits/extensions/offlineshop/views.py b/lnbits/extensions/offlineshop/views.py new file mode 100644 index 000000000..33702f6ba --- /dev/null +++ b/lnbits/extensions/offlineshop/views.py @@ -0,0 +1,70 @@ +import time +from datetime import datetime +from quart import g, render_template, request +from http import HTTPStatus + +from lnbits.decorators import check_user_exists, validate_uuids +from lnbits.core.models import Payment +from lnbits.core.crud import get_standalone_payment + +from . import offlineshop_ext +from .crud import get_item, get_shop + + +@offlineshop_ext.route("/") +@validate_uuids(["usr"], required=True) +@check_user_exists() +async def index(): + return await render_template("offlineshop/index.html", user=g.user) + + +@offlineshop_ext.route("/print") +async def print_qr_codes(): + items = [] + for item_id in request.args.get("items").split(","): + item = await get_item(item_id) + if item: + items.append( + { + "lnurl": item.lnurl, + "name": item.name, + "price": f"{item.price} {item.unit}", + } + ) + + return await render_template("offlineshop/print.html", items=items) + + +@offlineshop_ext.route("/confirmation") +async def confirmation_code(): + style = "" + + payment_hash = request.args.get("p") + payment: Payment = await get_standalone_payment(payment_hash) + if not payment: + return ( + f"Couldn't find the payment {payment_hash}." + style, + HTTPStatus.NOT_FOUND, + ) + if payment.pending: + return ( + f"Payment {payment_hash} wasn't received yet. Please try again in a minute." + + style, + HTTPStatus.PAYMENT_REQUIRED, + ) + + if payment.time + 60 * 15 < time.time(): + return "too much time has passed." + style + + item = await get_item(payment.extra.get("item")) + shop = await get_shop(item.shop) + + return ( + f""" +[{shop.get_code(payment_hash)}]
+{item.name}
+{item.price} {item.unit}
+{datetime.utcfromtimestamp(payment.time).strftime('%Y-%m-%d %H:%M:%S')} + """ + + style + ) diff --git a/lnbits/extensions/offlineshop/views_api.py b/lnbits/extensions/offlineshop/views_api.py new file mode 100644 index 000000000..ee3631a77 --- /dev/null +++ b/lnbits/extensions/offlineshop/views_api.py @@ -0,0 +1,128 @@ +from quart import g, jsonify +from http import HTTPStatus +from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl # type: ignore + +from lnbits.decorators import api_check_wallet_key, api_validate_post_request +from lnbits.utils.exchange_rates import currencies + +from . import offlineshop_ext +from .crud import ( + get_or_create_shop_by_wallet, + set_method, + add_item, + update_item, + get_items, + delete_item_from_shop, +) +from .models import ShopCounter + + +@offlineshop_ext.route("/api/v1/currencies", methods=["GET"]) +async def api_list_currencies_available(): + return jsonify(list(currencies.keys())) + + +@offlineshop_ext.route("/api/v1/offlineshop", methods=["GET"]) +@api_check_wallet_key("invoice") +async def api_shop_from_wallet(): + shop = await get_or_create_shop_by_wallet(g.wallet.id) + items = await get_items(shop.id) + + try: + return ( + jsonify( + { + **shop._asdict(), + **{ + "otp_key": shop.otp_key, + "items": [item.values() for item in items], + }, + } + ), + HTTPStatus.OK, + ) + except LnurlInvalidUrl: + return ( + jsonify( + { + "message": "LNURLs need to be delivered over a publically accessible `https` domain or Tor." + } + ), + HTTPStatus.UPGRADE_REQUIRED, + ) + + +@offlineshop_ext.route("/api/v1/offlineshop/items", methods=["POST"]) +@offlineshop_ext.route("/api/v1/offlineshop/items/", methods=["PUT"]) +@api_check_wallet_key("invoice") +@api_validate_post_request( + schema={ + "name": {"type": "string", "empty": False, "required": True}, + "description": {"type": "string", "empty": False, "required": True}, + "image": {"type": "string", "required": False, "nullable": True}, + "price": {"type": "number", "required": True}, + "unit": {"type": "string", "required": True}, + } +) +async def api_add_or_update_item(item_id=None): + shop = await get_or_create_shop_by_wallet(g.wallet.id) + if item_id == None: + await add_item( + shop.id, + g.data["name"], + g.data["description"], + g.data.get("image"), + g.data["price"], + g.data["unit"], + ) + return "", HTTPStatus.CREATED + else: + await update_item( + shop.id, + item_id, + g.data["name"], + g.data["description"], + g.data.get("image"), + g.data["price"], + g.data["unit"], + ) + return "", HTTPStatus.OK + + +@offlineshop_ext.route("/api/v1/offlineshop/items/", methods=["DELETE"]) +@api_check_wallet_key("invoice") +async def api_delete_item(item_id): + shop = await get_or_create_shop_by_wallet(g.wallet.id) + await delete_item_from_shop(shop.id, item_id) + return "", HTTPStatus.NO_CONTENT + + +@offlineshop_ext.route("/api/v1/offlineshop/method", methods=["PUT"]) +@api_check_wallet_key("invoice") +@api_validate_post_request( + schema={ + "method": {"type": "string", "required": True, "nullable": False}, + "wordlist": { + "type": "string", + "empty": True, + "nullable": True, + "required": False, + }, + } +) +async def api_set_method(): + method = g.data["method"] + + wordlist = g.data["wordlist"].split("\n") if g.data["wordlist"] else None + wordlist = [word.strip() for word in wordlist if word.strip()] + + shop = await get_or_create_shop_by_wallet(g.wallet.id) + if not shop: + return "", HTTPStatus.NOT_FOUND + + updated_shop = await set_method(shop.id, method, "\n".join(wordlist)) + if not updated_shop: + return "", HTTPStatus.NOT_FOUND + + ShopCounter.reset(updated_shop) + return "", HTTPStatus.OK diff --git a/lnbits/extensions/offlineshop/wordlists.py b/lnbits/extensions/offlineshop/wordlists.py new file mode 100644 index 000000000..ee3663e34 --- /dev/null +++ b/lnbits/extensions/offlineshop/wordlists.py @@ -0,0 +1,28 @@ +animals = [ + "albatross", + "bison", + "chicken", + "duck", + "eagle", + "flamingo", + "gorila", + "hamster", + "iguana", + "jaguar", + "koala", + "llama", + "macaroni penguim", + "numbat", + "octopus", + "platypus", + "quetzal", + "rabbit", + "salmon", + "tuna", + "unicorn", + "vulture", + "wolf", + "xenops", + "yak", + "zebra", +] diff --git a/lnbits/extensions/paywall/__init__.py b/lnbits/extensions/paywall/__init__.py index 8ab130cf1..cf9570a15 100644 --- a/lnbits/extensions/paywall/__init__.py +++ b/lnbits/extensions/paywall/__init__.py @@ -3,7 +3,9 @@ from lnbits.db import Database db = Database("ext_paywall") -paywall_ext: Blueprint = Blueprint("paywall", __name__, static_folder="static", template_folder="templates") +paywall_ext: Blueprint = Blueprint( + "paywall", __name__, static_folder="static", template_folder="templates" +) from .views_api import * # noqa diff --git a/lnbits/extensions/paywall/crud.py b/lnbits/extensions/paywall/crud.py index 6c2338e26..b12cc1ecb 100644 --- a/lnbits/extensions/paywall/crud.py +++ b/lnbits/extensions/paywall/crud.py @@ -7,7 +7,13 @@ from .models import Paywall async def create_paywall( - *, wallet_id: str, url: str, memo: str, description: Optional[str] = None, amount: int = 0, remembers: bool = True + *, + wallet_id: str, + url: str, + memo: str, + description: Optional[str] = None, + amount: int = 0, + remembers: bool = True, ) -> Paywall: paywall_id = urlsafe_short_hash() await db.execute( @@ -34,7 +40,9 @@ async def get_paywalls(wallet_ids: Union[str, List[str]]) -> List[Paywall]: wallet_ids = [wallet_ids] q = ",".join(["?"] * len(wallet_ids)) - rows = await db.fetchall(f"SELECT * FROM paywalls WHERE wallet IN ({q})", (*wallet_ids,)) + rows = await db.fetchall( + f"SELECT * FROM paywalls WHERE wallet IN ({q})", (*wallet_ids,) + ) return [Paywall.from_row(row) for row in rows] diff --git a/lnbits/extensions/paywall/migrations.py b/lnbits/extensions/paywall/migrations.py index fd1dd5ec1..f2faae2b4 100644 --- a/lnbits/extensions/paywall/migrations.py +++ b/lnbits/extensions/paywall/migrations.py @@ -46,7 +46,9 @@ async def m002_redux(db): ) await db.execute("CREATE INDEX IF NOT EXISTS wallet_idx ON paywalls (wallet)") - for row in [list(row) for row in await db.fetchall("SELECT * FROM paywalls_old")]: + for row in [ + list(row) for row in await db.fetchall("SELECT * FROM paywalls_old") + ]: await db.execute( """ INSERT INTO paywalls ( diff --git a/lnbits/extensions/paywall/templates/paywall/_api_docs.html b/lnbits/extensions/paywall/templates/paywall/_api_docs.html index 3884c3b52..1157fa467 100644 --- a/lnbits/extensions/paywall/templates/paywall/_api_docs.html +++ b/lnbits/extensions/paywall/templates/paywall/_api_docs.html @@ -17,8 +17,8 @@ [<paywall_object>, ...]
Curl example
curl -X GET {{ request.url_root }}api/v1/paywalls -H - "X-Api-Key: {{ g.user.wallets[0].inkey }}" + >curl -X GET {{ request.url_root }}api/v1/paywalls -H "X-Api-Key: {{ + g.user.wallets[0].inkey }}"
@@ -48,11 +48,11 @@ >
Curl example
curl -X POST {{ request.url_root }}api/v1/paywalls -d - '{"url": <string>, "memo": <string>, "description": - <string>, "amount": <integer>, "remembers": - <boolean>}' -H "Content-type: application/json" -H "X-Api-Key: - {{ g.user.wallets[0].adminkey }}" + >curl -X POST {{ request.url_root }}api/v1/paywalls -d '{"url": + <string>, "memo": <string>, "description": <string>, + "amount": <integer>, "remembers": <boolean>}' -H + "Content-type: application/json" -H "X-Api-Key: {{ + g.user.wallets[0].adminkey }}" diff --git a/lnbits/extensions/paywall/views.py b/lnbits/extensions/paywall/views.py index 7373d5c41..0dcbad2f5 100644 --- a/lnbits/extensions/paywall/views.py +++ b/lnbits/extensions/paywall/views.py @@ -16,5 +16,7 @@ async def index(): @paywall_ext.route("/") async def display(paywall_id): - paywall = await get_paywall(paywall_id) or abort(HTTPStatus.NOT_FOUND, "Paywall does not exist.") + paywall = await get_paywall(paywall_id) or abort( + HTTPStatus.NOT_FOUND, "Paywall does not exist." + ) return await render_template("paywall/display.html", paywall=paywall) diff --git a/lnbits/extensions/paywall/views_api.py b/lnbits/extensions/paywall/views_api.py index c2a2c62ae..45c80af4b 100644 --- a/lnbits/extensions/paywall/views_api.py +++ b/lnbits/extensions/paywall/views_api.py @@ -17,7 +17,10 @@ async def api_paywalls(): if "all_wallets" in request.args: wallet_ids = (await get_user(g.wallet.user)).wallet_ids - return jsonify([paywall._asdict() for paywall in await get_paywalls(wallet_ids)]), HTTPStatus.OK + return ( + jsonify([paywall._asdict() for paywall in await get_paywalls(wallet_ids)]), + HTTPStatus.OK, + ) @paywall_ext.route("/api/v1/paywalls", methods=["POST"]) @@ -26,7 +29,12 @@ async def api_paywalls(): schema={ "url": {"type": "string", "empty": False, "required": True}, "memo": {"type": "string", "empty": False, "required": True}, - "description": {"type": "string", "empty": True, "nullable": True, "required": False}, + "description": { + "type": "string", + "empty": True, + "nullable": True, + "required": False, + }, "amount": {"type": "integer", "min": 0, "required": True}, "remembers": {"type": "boolean", "required": True}, } @@ -53,26 +61,41 @@ async def api_paywall_delete(paywall_id): @paywall_ext.route("/api/v1/paywalls//invoice", methods=["POST"]) -@api_validate_post_request(schema={"amount": {"type": "integer", "min": 1, "required": True}}) +@api_validate_post_request( + schema={"amount": {"type": "integer", "min": 1, "required": True}} +) async def api_paywall_create_invoice(paywall_id): paywall = await get_paywall(paywall_id) if g.data["amount"] < paywall.amount: - return jsonify({"message": f"Minimum amount is {paywall.amount} sat."}), HTTPStatus.BAD_REQUEST + return ( + jsonify({"message": f"Minimum amount is {paywall.amount} sat."}), + HTTPStatus.BAD_REQUEST, + ) try: - amount = g.data["amount"] if g.data["amount"] > paywall.amount else paywall.amount + amount = ( + g.data["amount"] if g.data["amount"] > paywall.amount else paywall.amount + ) payment_hash, payment_request = await create_invoice( - wallet_id=paywall.wallet, amount=amount, memo=f"{paywall.memo}", extra={"tag": "paywall"} + wallet_id=paywall.wallet, + amount=amount, + memo=f"{paywall.memo}", + extra={"tag": "paywall"}, ) except Exception as e: return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR - return jsonify({"payment_hash": payment_hash, "payment_request": payment_request}), HTTPStatus.CREATED + return ( + jsonify({"payment_hash": payment_hash, "payment_request": payment_request}), + HTTPStatus.CREATED, + ) @paywall_ext.route("/api/v1/paywalls//check_invoice", methods=["POST"]) -@api_validate_post_request(schema={"payment_hash": {"type": "string", "empty": False, "required": True}}) +@api_validate_post_request( + schema={"payment_hash": {"type": "string", "empty": False, "required": True}} +) async def api_paywal_check_invoice(paywall_id): paywall = await get_paywall(paywall_id) @@ -90,6 +113,9 @@ async def api_paywal_check_invoice(paywall_id): payment = await wallet.get_payment(g.data["payment_hash"]) await payment.set_pending(False) - return jsonify({"paid": True, "url": paywall.url, "remembers": paywall.remembers}), HTTPStatus.OK + return ( + jsonify({"paid": True, "url": paywall.url, "remembers": paywall.remembers}), + HTTPStatus.OK, + ) return jsonify({"paid": False}), HTTPStatus.OK diff --git a/lnbits/extensions/subdomains/__init__.py b/lnbits/extensions/subdomains/__init__.py index cad9fee8d..5013230c9 100644 --- a/lnbits/extensions/subdomains/__init__.py +++ b/lnbits/extensions/subdomains/__init__.py @@ -3,7 +3,9 @@ from lnbits.db import Database db = Database("ext_subdomains") -subdomains_ext: Blueprint = Blueprint("subdomains", __name__, static_folder="static", template_folder="templates") +subdomains_ext: Blueprint = Blueprint( + "subdomains", __name__, static_folder="static", template_folder="templates" +) from .views_api import * # noqa diff --git a/lnbits/extensions/subdomains/cloudflare.py b/lnbits/extensions/subdomains/cloudflare.py index 7af85f40d..9a0fc4cfb 100644 --- a/lnbits/extensions/subdomains/cloudflare.py +++ b/lnbits/extensions/subdomains/cloudflare.py @@ -2,11 +2,20 @@ from lnbits.extensions.subdomains.models import Domains import httpx, json -async def cloudflare_create_subdomain(domain: Domains, subdomain: str, record_type: str, ip: str): +async def cloudflare_create_subdomain( + domain: Domains, subdomain: str, record_type: str, ip: str +): # Call to cloudflare sort of a dry-run - if success delete the domain and wait for payment ### SEND REQUEST TO CLOUDFLARE - url = "https://api.cloudflare.com/client/v4/zones/" + domain.cf_zone_id + "/dns_records" - header = {"Authorization": "Bearer " + domain.cf_token, "Content-Type": "application/json"} + url = ( + "https://api.cloudflare.com/client/v4/zones/" + + domain.cf_zone_id + + "/dns_records" + ) + header = { + "Authorization": "Bearer " + domain.cf_token, + "Content-Type": "application/json", + } aRecord = subdomain + "." + domain.domain cf_response = "" async with httpx.AsyncClient() as client: @@ -30,8 +39,15 @@ async def cloudflare_create_subdomain(domain: Domains, subdomain: str, record_ty async def cloudflare_deletesubdomain(domain: Domains, domain_id: str): - url = "https://api.cloudflare.com/client/v4/zones/" + domain.cf_zone_id + "/dns_records" - header = {"Authorization": "Bearer " + domain.cf_token, "Content-Type": "application/json"} + url = ( + "https://api.cloudflare.com/client/v4/zones/" + + domain.cf_zone_id + + "/dns_records" + ) + header = { + "Authorization": "Bearer " + domain.cf_token, + "Content-Type": "application/json", + } async with httpx.AsyncClient() as client: try: r = await client.delete( diff --git a/lnbits/extensions/subdomains/crud.py b/lnbits/extensions/subdomains/crud.py index 696665251..e64d4cd69 100644 --- a/lnbits/extensions/subdomains/crud.py +++ b/lnbits/extensions/subdomains/crud.py @@ -23,7 +23,18 @@ async def create_subdomain( INSERT INTO subdomain (id, domain, email, subdomain, ip, wallet, sats, duration, paid, record_type) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, - (payment_hash, domain, email, subdomain, ip, wallet, sats, duration, False, record_type), + ( + payment_hash, + domain, + email, + subdomain, + ip, + wallet, + sats, + duration, + False, + record_type, + ), ) subdomain = await get_subdomain(payment_hash) @@ -118,7 +129,18 @@ async def create_domain( INSERT INTO domain (id, wallet, domain, webhook, cf_token, cf_zone_id, description, cost, amountmade, allowed_record_types) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, - (domain_id, wallet, domain, webhook, cf_token, cf_zone_id, description, cost, 0, allowed_record_types), + ( + domain_id, + wallet, + domain, + webhook, + cf_token, + cf_zone_id, + description, + cost, + 0, + allowed_record_types, + ), ) domain = await get_domain(domain_id) @@ -128,7 +150,9 @@ async def create_domain( async def update_domain(domain_id: str, **kwargs) -> Domains: q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) - await db.execute(f"UPDATE domain SET {q} WHERE id = ?", (*kwargs.values(), domain_id)) + await db.execute( + f"UPDATE domain SET {q} WHERE id = ?", (*kwargs.values(), domain_id) + ) row = await db.fetchone("SELECT * FROM domain WHERE id = ?", (domain_id,)) assert row, "Newly updated domain couldn't be retrieved" return Domains(**row) @@ -144,7 +168,9 @@ async def get_domains(wallet_ids: Union[str, List[str]]) -> List[Domains]: wallet_ids = [wallet_ids] q = ",".join(["?"] * len(wallet_ids)) - rows = await db.fetchall(f"SELECT * FROM domain WHERE wallet IN ({q})", (*wallet_ids,)) + rows = await db.fetchall( + f"SELECT * FROM domain WHERE wallet IN ({q})", (*wallet_ids,) + ) return [Domains(**row) for row in rows] diff --git a/lnbits/extensions/subdomains/tasks.py b/lnbits/extensions/subdomains/tasks.py index f5f193a62..09c3f73d7 100644 --- a/lnbits/extensions/subdomains/tasks.py +++ b/lnbits/extensions/subdomains/tasks.py @@ -33,7 +33,10 @@ async def on_invoice_paid(payment: Payment) -> None: ### Create subdomain cf_response = cloudflare_create_subdomain( - domain=domain, subdomain=subdomain.subdomain, record_type=subdomain.record_type, ip=subdomain.ip + domain=domain, + subdomain=subdomain.subdomain, + record_type=subdomain.record_type, + ip=subdomain.ip, ) ### Use webhook to notify about cloudflare registration diff --git a/lnbits/extensions/subdomains/views.py b/lnbits/extensions/subdomains/views.py index 6dafa792d..14e4853fe 100644 --- a/lnbits/extensions/subdomains/views.py +++ b/lnbits/extensions/subdomains/views.py @@ -19,7 +19,9 @@ async def display(domain_id): domain = await get_domain(domain_id) if not domain: abort(HTTPStatus.NOT_FOUND, "Domain does not exist.") - allowed_records = domain.allowed_record_types.replace('"', "").replace(" ", "").split(",") + allowed_records = ( + domain.allowed_record_types.replace('"', "").replace(" ", "").split(",") + ) print(allowed_records) return await render_template( "subdomains/display.html", diff --git a/lnbits/extensions/subdomains/views_api.py b/lnbits/extensions/subdomains/views_api.py index e7d2d21f1..1edc562e3 100644 --- a/lnbits/extensions/subdomains/views_api.py +++ b/lnbits/extensions/subdomains/views_api.py @@ -37,7 +37,10 @@ async def api_domains(): if "all_wallets" in request.args: wallet_ids = (await get_user(g.wallet.user)).wallet_ids - return jsonify([domain._asdict() for domain in await get_domains(wallet_ids)]), HTTPStatus.OK + return ( + jsonify([domain._asdict() for domain in await get_domains(wallet_ids)]), + HTTPStatus.OK, + ) @subdomains_ext.route("/api/v1/domains", methods=["POST"]) @@ -98,7 +101,10 @@ async def api_subdomains(): if "all_wallets" in request.args: wallet_ids = (await get_user(g.wallet.user)).wallet_ids - return jsonify([domain._asdict() for domain in await get_subdomains(wallet_ids)]), HTTPStatus.OK + return ( + jsonify([domain._asdict() for domain in await get_subdomains(wallet_ids)]), + HTTPStatus.OK, + ) @subdomains_ext.route("/api/v1/subdomains/", methods=["POST"]) @@ -122,24 +128,42 @@ async def api_subdomain_make_subdomain(domain_id): ## If record_type is not one of the allowed ones reject the request if g.data["record_type"] not in domain.allowed_record_types: - return jsonify({"message": g.data["record_type"] + "Not a valid record"}), HTTPStatus.BAD_REQUEST + return ( + jsonify({"message": g.data["record_type"] + "Not a valid record"}), + HTTPStatus.BAD_REQUEST, + ) ## If domain already exist in our database reject it if await get_subdomainBySubdomain(g.data["subdomain"]) is not None: return ( - jsonify({"message": g.data["subdomain"] + "." + domain.domain + " domain already taken"}), + jsonify( + { + "message": g.data["subdomain"] + + "." + + domain.domain + + " domain already taken" + } + ), HTTPStatus.BAD_REQUEST, ) ## Dry run cloudflare... (create and if create is sucessful delete it) cf_response = await cloudflare_create_subdomain( - domain=domain, subdomain=g.data["subdomain"], record_type=g.data["record_type"], ip=g.data["ip"] + domain=domain, + subdomain=g.data["subdomain"], + record_type=g.data["record_type"], + ip=g.data["ip"], ) if cf_response["success"] == True: cloudflare_deletesubdomain(domain=domain, domain_id=cf_response["result"]["id"]) else: return ( - jsonify({"message": "Problem with cloudflare: " + cf_response["errors"][0]["message"]}), + jsonify( + { + "message": "Problem with cloudflare: " + + cf_response["errors"][0]["message"] + } + ), HTTPStatus.BAD_REQUEST, ) @@ -152,12 +176,20 @@ async def api_subdomain_make_subdomain(domain_id): extra={"tag": "lnsubdomain"}, ) - subdomain = await create_subdomain(payment_hash=payment_hash, wallet=domain.wallet, **g.data) + subdomain = await create_subdomain( + payment_hash=payment_hash, wallet=domain.wallet, **g.data + ) if not subdomain: - return jsonify({"message": "LNsubdomain could not be fetched."}), HTTPStatus.NOT_FOUND + return ( + jsonify({"message": "LNsubdomain could not be fetched."}), + HTTPStatus.NOT_FOUND, + ) - return jsonify({"payment_hash": payment_hash, "payment_request": payment_request}), HTTPStatus.OK + return ( + jsonify({"payment_hash": payment_hash, "payment_request": payment_request}), + HTTPStatus.OK, + ) @subdomains_ext.route("/api/v1/subdomains/", methods=["GET"]) diff --git a/lnbits/extensions/tpos/__init__.py b/lnbits/extensions/tpos/__init__.py index 78732d868..daa3022e5 100644 --- a/lnbits/extensions/tpos/__init__.py +++ b/lnbits/extensions/tpos/__init__.py @@ -3,7 +3,9 @@ from lnbits.db import Database db = Database("ext_tpos") -tpos_ext: Blueprint = Blueprint("tpos", __name__, static_folder="static", template_folder="templates") +tpos_ext: Blueprint = Blueprint( + "tpos", __name__, static_folder="static", template_folder="templates" +) from .views_api import * # noqa diff --git a/lnbits/extensions/tpos/crud.py b/lnbits/extensions/tpos/crud.py index 69f707307..afd4f9732 100644 --- a/lnbits/extensions/tpos/crud.py +++ b/lnbits/extensions/tpos/crud.py @@ -31,7 +31,9 @@ async def get_tposs(wallet_ids: Union[str, List[str]]) -> List[TPoS]: wallet_ids = [wallet_ids] q = ",".join(["?"] * len(wallet_ids)) - rows = await db.fetchall(f"SELECT * FROM tposs WHERE wallet IN ({q})", (*wallet_ids,)) + rows = await db.fetchall( + f"SELECT * FROM tposs WHERE wallet IN ({q})", (*wallet_ids,) + ) return [TPoS.from_row(row) for row in rows] diff --git a/lnbits/extensions/tpos/views_api.py b/lnbits/extensions/tpos/views_api.py index 22980fcef..1f0802c77 100644 --- a/lnbits/extensions/tpos/views_api.py +++ b/lnbits/extensions/tpos/views_api.py @@ -16,7 +16,10 @@ async def api_tposs(): if "all_wallets" in request.args: wallet_ids = (await get_user(g.wallet.user)).wallet_ids - return jsonify([tpos._asdict() for tpos in await get_tposs(wallet_ids)]), HTTPStatus.OK + return ( + jsonify([tpos._asdict() for tpos in await get_tposs(wallet_ids)]), + HTTPStatus.OK, + ) @tpos_ext.route("/api/v1/tposs", methods=["POST"]) @@ -49,7 +52,9 @@ async def api_tpos_delete(tpos_id): @tpos_ext.route("/api/v1/tposs//invoices/", methods=["POST"]) -@api_validate_post_request(schema={"amount": {"type": "integer", "min": 1, "required": True}}) +@api_validate_post_request( + schema={"amount": {"type": "integer", "min": 1, "required": True}} +) async def api_tpos_create_invoice(tpos_id): tpos = await get_tpos(tpos_id) @@ -58,12 +63,18 @@ async def api_tpos_create_invoice(tpos_id): try: payment_hash, payment_request = await create_invoice( - wallet_id=tpos.wallet, amount=g.data["amount"], memo=f"{tpos.name}", extra={"tag": "tpos"} + wallet_id=tpos.wallet, + amount=g.data["amount"], + memo=f"{tpos.name}", + extra={"tag": "tpos"}, ) except Exception as e: return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR - return jsonify({"payment_hash": payment_hash, "payment_request": payment_request}), HTTPStatus.CREATED + return ( + jsonify({"payment_hash": payment_hash, "payment_request": payment_request}), + HTTPStatus.CREATED, + ) @tpos_ext.route("/api/v1/tposs//invoices/", methods=["GET"]) diff --git a/lnbits/extensions/usermanager/__init__.py b/lnbits/extensions/usermanager/__init__.py index 2bdbf0b56..53154812d 100644 --- a/lnbits/extensions/usermanager/__init__.py +++ b/lnbits/extensions/usermanager/__init__.py @@ -3,7 +3,9 @@ from lnbits.db import Database db = Database("ext_usermanager") -usermanager_ext: Blueprint = Blueprint("usermanager", __name__, static_folder="static", template_folder="templates") +usermanager_ext: Blueprint = Blueprint( + "usermanager", __name__, static_folder="static", template_folder="templates" +) from .views_api import * # noqa diff --git a/lnbits/extensions/usermanager/crud.py b/lnbits/extensions/usermanager/crud.py index db41b8dd6..155290fa1 100644 --- a/lnbits/extensions/usermanager/crud.py +++ b/lnbits/extensions/usermanager/crud.py @@ -4,7 +4,7 @@ from lnbits.core.models import Payment from lnbits.core.crud import ( create_account, get_user, - get_wallet_payments, + get_payments, create_wallet, delete_wallet, ) @@ -16,7 +16,9 @@ from .models import Users, Wallets ### Users -async def create_usermanager_user(user_name: str, wallet_name: str, admin_id: str) -> Users: +async def create_usermanager_user( + user_name: str, wallet_name: str, admin_id: str +) -> Users: account = await create_account() user = await get_user(account.id) assert user, "Newly created user couldn't be retrieved" @@ -66,7 +68,9 @@ async def delete_usermanager_user(user_id: str) -> None: ### Wallets -async def create_usermanager_wallet(user_id: str, wallet_name: str, admin_id: str) -> 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( """ @@ -91,7 +95,9 @@ async def get_usermanager_wallets(user_id: str) -> List[Wallets]: async def get_usermanager_wallet_transactions(wallet_id: str) -> List[Payment]: - return await get_wallet_payments(wallet_id=wallet_id, complete=True, pending=False, outgoing=True, incoming=True) + 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: diff --git a/lnbits/extensions/usermanager/templates/usermanager/_api_docs.html b/lnbits/extensions/usermanager/templates/usermanager/_api_docs.html index 7b1925a50..fbd13e725 100644 --- a/lnbits/extensions/usermanager/templates/usermanager/_api_docs.html +++ b/lnbits/extensions/usermanager/templates/usermanager/_api_docs.html @@ -42,8 +42,8 @@ JSON list of users
Curl example
curl -X GET {{ request.url_root }}api/v1/users -H - "X-Api-Key: {{ g.user.wallets[0].inkey }}" + >curl -X GET {{ request.url_root }}api/v1/users -H "X-Api-Key: {{ + g.user.wallets[0].inkey }}" @@ -64,9 +64,8 @@ JSON wallet data
Curl example
curl -X GET {{ request.url_root - }}api/v1/wallets/<user_id> -H "X-Api-Key: {{ - g.user.wallets[0].inkey }}" + >curl -X GET {{ request.url_root }}api/v1/wallets/<user_id> -H + "X-Api-Key: {{ g.user.wallets[0].inkey }}" @@ -87,9 +86,8 @@ JSON a wallets transactions
Curl example
curl -X GET {{ request.url_root - }}api/v1/wallets<wallet_id> -H "X-Api-Key: {{ - g.user.wallets[0].inkey }}" + >curl -X GET {{ request.url_root }}api/v1/wallets<wallet_id> -H + "X-Api-Key: {{ g.user.wallets[0].inkey }}" @@ -128,10 +126,10 @@ >
Curl example
curl -X POST {{ request.url_root }}api/v1/users -d - '{"admin_id": "{{ g.user.id }}", "wallet_name": <string>, - "user_name": <string>}' -H "X-Api-Key: {{ - g.user.wallets[0].inkey }}" -H "Content-type: application/json" + >curl -X POST {{ request.url_root }}api/v1/users -d '{"admin_id": "{{ + g.user.id }}", "wallet_name": <string>, "user_name": + <string>}' -H "X-Api-Key: {{ g.user.wallets[0].inkey }}" -H + "Content-type: application/json" @@ -165,10 +163,10 @@ >
Curl example
curl -X POST {{ request.url_root }}api/v1/wallets -d - '{"user_id": <string>, "wallet_name": <string>, - "admin_id": "{{ g.user.id }}"}' -H "X-Api-Key: {{ - g.user.wallets[0].inkey }}" -H "Content-type: application/json" + >curl -X POST {{ request.url_root }}api/v1/wallets -d '{"user_id": + <string>, "wallet_name": <string>, "admin_id": "{{ + g.user.id }}"}' -H "X-Api-Key: {{ g.user.wallets[0].inkey }}" -H + "Content-type: application/json" @@ -189,9 +187,8 @@ {"X-Api-Key": <string>}
Curl example
curl -X DELETE {{ request.url_root - }}api/v1/users/<user_id> -H "X-Api-Key: {{ - g.user.wallets[0].inkey }}" + >curl -X DELETE {{ request.url_root }}api/v1/users/<user_id> -H + "X-Api-Key: {{ g.user.wallets[0].inkey }}" @@ -207,9 +204,8 @@ {"X-Api-Key": <string>}
Curl example
curl -X DELETE {{ request.url_root - }}api/v1/wallets/<wallet_id> -H "X-Api-Key: {{ - g.user.wallets[0].inkey }}" + >curl -X DELETE {{ request.url_root }}api/v1/wallets/<wallet_id> + -H "X-Api-Key: {{ g.user.wallets[0].inkey }}" @@ -230,8 +226,8 @@ {"X-Api-Key": <string>}
Curl example
curl -X POST {{ request.url_root }}api/v1/extensions -d - '{"userid": <string>, "extension": <string>, "active": + >curl -X POST {{ request.url_root }}api/v1/extensions -d '{"userid": + <string>, "extension": <string>, "active": <integer>}' -H "X-Api-Key: {{ g.user.wallets[0].inkey }}" -H "Content-type: application/json" diff --git a/lnbits/extensions/usermanager/views_api.py b/lnbits/extensions/usermanager/views_api.py index 557aa8b9b..bd64c0700 100644 --- a/lnbits/extensions/usermanager/views_api.py +++ b/lnbits/extensions/usermanager/views_api.py @@ -26,7 +26,10 @@ from lnbits.core import update_user_extension @api_check_wallet_key(key_type="invoice") async def api_usermanager_users(): user_id = g.wallet.user - return jsonify([user._asdict() for user in await get_usermanager_users(user_id)]), HTTPStatus.OK + return ( + jsonify([user._asdict() for user in await get_usermanager_users(user_id)]), + HTTPStatus.OK, + ) @usermanager_ext.route("/api/v1/users", methods=["POST"]) @@ -39,7 +42,9 @@ async def api_usermanager_users(): } ) async def api_usermanager_users_create(): - user = await create_usermanager_user(g.data["user_name"], g.data["wallet_name"], g.data["admin_id"]) + user = await create_usermanager_user( + g.data["user_name"], g.data["wallet_name"], g.data["admin_id"] + ) return jsonify(user._asdict()), HTTPStatus.CREATED @@ -69,7 +74,9 @@ async def api_usermanager_activate_extension(): user = await get_user(g.data["userid"]) if not user: return jsonify({"message": "no such user"}), HTTPStatus.NOT_FOUND - update_user_extension(user_id=g.data["userid"], extension=g.data["extension"], active=g.data["active"]) + update_user_extension( + user_id=g.data["userid"], extension=g.data["extension"], active=g.data["active"] + ) return jsonify({"extension": "updated"}), HTTPStatus.CREATED @@ -80,7 +87,12 @@ async def api_usermanager_activate_extension(): @api_check_wallet_key(key_type="invoice") async def api_usermanager_wallets(): user_id = g.wallet.user - return jsonify([wallet._asdict() for wallet in await get_usermanager_wallets(user_id)]), HTTPStatus.OK + return ( + jsonify( + [wallet._asdict() for wallet in await get_usermanager_wallets(user_id)] + ), + HTTPStatus.OK, + ) @usermanager_ext.route("/api/v1/wallets", methods=["POST"]) @@ -93,7 +105,9 @@ async def api_usermanager_wallets(): } ) async def api_usermanager_wallets_create(): - user = await create_usermanager_wallet(g.data["user_id"], g.data["wallet_name"], g.data["admin_id"]) + user = await create_usermanager_wallet( + g.data["user_id"], g.data["wallet_name"], g.data["admin_id"] + ) return jsonify(user._asdict()), HTTPStatus.CREATED diff --git a/lnbits/extensions/withdraw/__init__.py b/lnbits/extensions/withdraw/__init__.py index 7afbf23ca..69e45e4d3 100644 --- a/lnbits/extensions/withdraw/__init__.py +++ b/lnbits/extensions/withdraw/__init__.py @@ -4,7 +4,9 @@ from lnbits.db import Database db = Database("ext_withdraw") -withdraw_ext: Blueprint = Blueprint("withdraw", __name__, static_folder="static", template_folder="templates") +withdraw_ext: Blueprint = Blueprint( + "withdraw", __name__, static_folder="static", template_folder="templates" +) from .views_api import * # noqa diff --git a/lnbits/extensions/withdraw/crud.py b/lnbits/extensions/withdraw/crud.py index 78fd7f566..dcc72af68 100644 --- a/lnbits/extensions/withdraw/crud.py +++ b/lnbits/extensions/withdraw/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 WithdrawLink +from .models import WithdrawLink, HashCheck async def create_withdraw_link( @@ -58,6 +58,9 @@ async def create_withdraw_link( async def get_withdraw_link(link_id: str, num=0) -> Optional[WithdrawLink]: row = await db.fetchone("SELECT * FROM withdraw_link WHERE id = ?", (link_id,)) + if not row: + return None + link = [] for item in row: link.append(item) @@ -66,7 +69,12 @@ async def get_withdraw_link(link_id: str, num=0) -> Optional[WithdrawLink]: async def get_withdraw_link_by_hash(unique_hash: str, num=0) -> Optional[WithdrawLink]: - row = await db.fetchone("SELECT * FROM withdraw_link WHERE unique_hash = ?", (unique_hash,)) + row = await db.fetchone( + "SELECT * FROM withdraw_link WHERE unique_hash = ?", (unique_hash,) + ) + if not row: + return None + link = [] for item in row: link.append(item) @@ -79,14 +87,18 @@ async def get_withdraw_links(wallet_ids: Union[str, List[str]]) -> List[Withdraw wallet_ids = [wallet_ids] q = ",".join(["?"] * len(wallet_ids)) - rows = await db.fetchall(f"SELECT * FROM withdraw_link WHERE wallet IN ({q})", (*wallet_ids,)) + rows = await db.fetchall( + f"SELECT * FROM withdraw_link WHERE wallet IN ({q})", (*wallet_ids,) + ) return [WithdrawLink.from_row(row) for row in rows] async def update_withdraw_link(link_id: str, **kwargs) -> Optional[WithdrawLink]: q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) - await db.execute(f"UPDATE withdraw_link SET {q} WHERE id = ?", (*kwargs.values(), link_id)) + await db.execute( + f"UPDATE withdraw_link SET {q} WHERE id = ?", (*kwargs.values(), link_id) + ) row = await db.fetchone("SELECT * FROM withdraw_link WHERE id = ?", (link_id,)) return WithdrawLink.from_row(row) if row else None @@ -98,3 +110,40 @@ async def delete_withdraw_link(link_id: str) -> None: def chunks(lst, n): for i in range(0, len(lst), n): yield lst[i : i + n] + + +async def create_hash_check( + the_hash: str, + lnurl_id: str, +) -> HashCheck: + await db.execute( + """ + INSERT INTO hash_check ( + id, + lnurl_id + ) + VALUES (?, ?) + """, + ( + the_hash, + lnurl_id, + ), + ) + hashCheck = await get_hash_check(the_hash, lnurl_id) + return hashCheck + + +async def get_hash_check(the_hash: str, lnurl_id: str) -> Optional[HashCheck]: + rowid = await db.fetchone("SELECT * FROM hash_check WHERE id = ?", (the_hash,)) + rowlnurl = await db.fetchone( + "SELECT * FROM hash_check WHERE lnurl_id = ?", (lnurl_id,) + ) + if not rowlnurl: + await create_hash_check(the_hash, lnurl_id) + return {"lnurl": True, "hash": False} + else: + if not rowid: + await create_hash_check(the_hash, lnurl_id) + return {"lnurl": True, "hash": False} + else: + return {"lnurl": True, "hash": True} diff --git a/lnbits/extensions/withdraw/lnurl.py b/lnbits/extensions/withdraw/lnurl.py index 0a3cc629b..39c3d917d 100644 --- a/lnbits/extensions/withdraw/lnurl.py +++ b/lnbits/extensions/withdraw/lnurl.py @@ -17,10 +17,16 @@ async def api_lnurl_response(unique_hash): link = await get_withdraw_link_by_hash(unique_hash) if not link: - return jsonify({"status": "ERROR", "reason": "LNURL-withdraw not found."}), HTTPStatus.OK + return ( + jsonify({"status": "ERROR", "reason": "LNURL-withdraw not found."}), + HTTPStatus.OK, + ) if link.is_spent: - return jsonify({"status": "ERROR", "reason": "Withdraw is spent."}), HTTPStatus.OK + return ( + jsonify({"status": "ERROR", "reason": "Withdraw is spent."}), + HTTPStatus.OK, + ) return jsonify(link.lnurl_response.dict()), HTTPStatus.OK @@ -33,10 +39,16 @@ async def api_lnurl_multi_response(unique_hash, id_unique_hash): link = await get_withdraw_link_by_hash(unique_hash) if not link: - return jsonify({"status": "ERROR", "reason": "LNURL-withdraw not found."}), HTTPStatus.OK + return ( + jsonify({"status": "ERROR", "reason": "LNURL-withdraw not found."}), + HTTPStatus.OK, + ) if link.is_spent: - return jsonify({"status": "ERROR", "reason": "Withdraw is spent."}), HTTPStatus.OK + return ( + jsonify({"status": "ERROR", "reason": "Withdraw is spent."}), + HTTPStatus.OK, + ) useslist = link.usescsv.split(",") found = False @@ -45,7 +57,10 @@ async def api_lnurl_multi_response(unique_hash, id_unique_hash): if id_unique_hash == shortuuid.uuid(name=tohash): found = True if not found: - return jsonify({"status": "ERROR", "reason": "LNURL-withdraw not found."}), HTTPStatus.OK + return ( + jsonify({"status": "ERROR", "reason": "LNURL-withdraw not found."}), + HTTPStatus.OK, + ) return jsonify(link.lnurl_response.dict()), HTTPStatus.OK @@ -61,16 +76,27 @@ async def api_lnurl_callback(unique_hash): now = int(datetime.now().timestamp()) if not link: - return jsonify({"status": "ERROR", "reason": "LNURL-withdraw not found."}), HTTPStatus.OK + return ( + jsonify({"status": "ERROR", "reason": "LNURL-withdraw not found."}), + HTTPStatus.OK, + ) if link.is_spent: - return jsonify({"status": "ERROR", "reason": "Withdraw is spent."}), HTTPStatus.OK + return ( + jsonify({"status": "ERROR", "reason": "Withdraw is spent."}), + HTTPStatus.OK, + ) if link.k1 != k1: return jsonify({"status": "ERROR", "reason": "Bad request."}), HTTPStatus.OK if now < link.open_time: - return jsonify({"status": "ERROR", "reason": f"Wait {link.open_time - now} seconds."}), HTTPStatus.OK + return ( + jsonify( + {"status": "ERROR", "reason": f"Wait {link.open_time - now} seconds."} + ), + HTTPStatus.OK, + ) try: await pay_invoice( @@ -85,12 +111,19 @@ async def api_lnurl_callback(unique_hash): usecv = link.usescsv.split(",") usescsv += "," + str(usecv[x]) usescsv = usescsv[1:] - changes = {"open_time": link.wait_time + now, "used": link.used + 1, "usescsv": usescsv} + changes = { + "open_time": link.wait_time + now, + "used": link.used + 1, + "usescsv": usescsv, + } await update_withdraw_link(link.id, **changes) except ValueError as e: return jsonify({"status": "ERROR", "reason": str(e)}), HTTPStatus.OK except PermissionError: - return jsonify({"status": "ERROR", "reason": "Withdraw link is empty."}), HTTPStatus.OK + return ( + jsonify({"status": "ERROR", "reason": "Withdraw link is empty."}), + HTTPStatus.OK, + ) return jsonify({"status": "OK"}), HTTPStatus.OK diff --git a/lnbits/extensions/withdraw/migrations.py b/lnbits/extensions/withdraw/migrations.py index 4af24f8fa..197a629c2 100644 --- a/lnbits/extensions/withdraw/migrations.py +++ b/lnbits/extensions/withdraw/migrations.py @@ -47,7 +47,9 @@ async def m002_change_withdraw_table(db): """ ) await db.execute("CREATE INDEX IF NOT EXISTS wallet_idx ON withdraw_link (wallet)") - await db.execute("CREATE UNIQUE INDEX IF NOT EXISTS unique_hash_idx ON withdraw_link (unique_hash)") + await db.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS unique_hash_idx ON withdraw_link (unique_hash)" + ) for row in [list(row) for row in await db.fetchall("SELECT * FROM withdraw_links")]: usescsv = "" @@ -94,3 +96,17 @@ async def m002_change_withdraw_table(db): ), ) await db.execute("DROP TABLE withdraw_links") + + +async def m003_make_hash_check(db): + """ + Creates a hash check table. + """ + await db.execute( + """ + CREATE TABLE IF NOT EXISTS hash_check ( + id TEXT PRIMARY KEY, + lnurl_id TEXT + ); + """ + ) diff --git a/lnbits/extensions/withdraw/models.py b/lnbits/extensions/withdraw/models.py index 7e80a789b..b7a989706 100644 --- a/lnbits/extensions/withdraw/models.py +++ b/lnbits/extensions/withdraw/models.py @@ -45,17 +45,32 @@ class WithdrawLink(NamedTuple): _external=True, ) else: - url = url_for("withdraw.api_lnurl_response", unique_hash=self.unique_hash, _external=True) + url = url_for( + "withdraw.api_lnurl_response", + unique_hash=self.unique_hash, + _external=True, + ) return lnurl_encode(url) @property def lnurl_response(self) -> LnurlWithdrawResponse: - url = url_for("withdraw.api_lnurl_callback", unique_hash=self.unique_hash, _external=True) + url = url_for( + "withdraw.api_lnurl_callback", unique_hash=self.unique_hash, _external=True + ) return LnurlWithdrawResponse( callback=url, k1=self.k1, min_withdrawable=self.min_withdrawable * 1000, max_withdrawable=self.max_withdrawable * 1000, - default_description="LNbits voucher", + default_description=self.title, ) + + +class HashCheck(NamedTuple): + id: str + lnurl_id: str + + @classmethod + def from_row(cls, row: Row) -> "Hash": + return cls(**dict(row)) diff --git a/lnbits/extensions/withdraw/templates/withdraw/_api_docs.html b/lnbits/extensions/withdraw/templates/withdraw/_api_docs.html index bc1aac2b2..484464baf 100644 --- a/lnbits/extensions/withdraw/templates/withdraw/_api_docs.html +++ b/lnbits/extensions/withdraw/templates/withdraw/_api_docs.html @@ -22,8 +22,8 @@ [<withdraw_link_object>, ...]
Curl example
curl -X GET {{ request.url_root }}api/v1/links -H - "X-Api-Key: {{ g.user.wallets[0].inkey }}" + >curl -X GET {{ request.url_root }}api/v1/links -H "X-Api-Key: {{ + g.user.wallets[0].inkey }}" @@ -49,9 +49,8 @@ {"lnurl": <string>}
Curl example
curl -X GET {{ request.url_root - }}api/v1/links/<withdraw_id> -H "X-Api-Key: {{ - g.user.wallets[0].inkey }}" + >curl -X GET {{ request.url_root }}api/v1/links/<withdraw_id> -H + "X-Api-Key: {{ g.user.wallets[0].inkey }}" @@ -79,8 +78,8 @@ {"lnurl": <string>}
Curl example
curl -X POST {{ request.url_root }}api/v1/links -d - '{"title": <string>, "min_withdrawable": <integer>, + >curl -X POST {{ request.url_root }}api/v1/links -d '{"title": + <string>, "min_withdrawable": <integer>, "max_withdrawable": <integer>, "uses": <integer>, "wait_time": <integer>, "is_unique": <boolean>}' -H "Content-type: application/json" -H "X-Api-Key: {{ @@ -115,9 +114,8 @@ {"lnurl": <string>}
Curl example
curl -X PUT {{ request.url_root - }}api/v1/links/<withdraw_id> -d '{"title": - <string>, "min_withdrawable": <integer>, + >curl -X PUT {{ request.url_root }}api/v1/links/<withdraw_id> -d + '{"title": <string>, "min_withdrawable": <integer>, "max_withdrawable": <integer>, "uses": <integer>, "wait_time": <integer>, "is_unique": <boolean>}' -H "Content-type: application/json" -H "X-Api-Key: {{ @@ -131,7 +129,6 @@ dense expand-separator label="Delete a withdraw link" - class="q-pb-md" > @@ -145,9 +142,56 @@
Curl example
curl -X DELETE {{ request.url_root - }}api/v1/links/<withdraw_id> -H "X-Api-Key: {{ - g.user.wallets[0].adminkey }}" + >curl -X DELETE {{ request.url_root }}api/v1/links/<withdraw_id> + -H "X-Api-Key: {{ g.user.wallets[0].adminkey }}" + +
+
+ + + + + GET + /withdraw/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: {{ + g.user.wallets[0].inkey }}" + +
+
+
+ + + + GET + /withdraw/img/<lnurl_id> +
Curl example
+ curl -X GET {{ request.url_root }}/withdraw/img/<lnurl_id>"
diff --git a/lnbits/extensions/withdraw/templates/withdraw/index.html b/lnbits/extensions/withdraw/templates/withdraw/index.html index 9e142a49d..7442ca96a 100644 --- a/lnbits/extensions/withdraw/templates/withdraw/index.html +++ b/lnbits/extensions/withdraw/templates/withdraw/index.html @@ -58,7 +58,20 @@ type="a" :href="props.row.withdraw_url" target="_blank" - > + > + shareable link + embeddable image + > view LNURL {{ col.value }} diff --git a/lnbits/extensions/withdraw/views.py b/lnbits/extensions/withdraw/views.py index 574cb3ad5..28f25756f 100644 --- a/lnbits/extensions/withdraw/views.py +++ b/lnbits/extensions/withdraw/views.py @@ -1,6 +1,7 @@ from quart import g, abort, render_template from http import HTTPStatus - +import pyqrcode +from io import BytesIO from lnbits.decorators import check_user_exists, validate_uuids from . import withdraw_ext @@ -16,19 +17,45 @@ async def index(): @withdraw_ext.route("/") async def display(link_id): - link = await get_withdraw_link(link_id, 0) or abort(HTTPStatus.NOT_FOUND, "Withdraw link does not exist.") + link = await get_withdraw_link(link_id, 0) or abort( + HTTPStatus.NOT_FOUND, "Withdraw link does not exist." + ) return await render_template("withdraw/display.html", link=link, unique=True) +@withdraw_ext.route("/img/") +async def img(link_id): + link = await get_withdraw_link(link_id, 0) or abort( + HTTPStatus.NOT_FOUND, "Withdraw 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", + }, + ) + + @withdraw_ext.route("/print/") async def print_qr(link_id): - link = await get_withdraw_link(link_id) or abort(HTTPStatus.NOT_FOUND, "Withdraw link does not exist.") + link = await get_withdraw_link(link_id) or abort( + HTTPStatus.NOT_FOUND, "Withdraw link does not exist." + ) if link.uses == 0: return await render_template("withdraw/print_qr.html", link=link, unique=False) links = [] count = 0 for x in link.usescsv.split(","): - linkk = await get_withdraw_link(link_id, count) or abort(HTTPStatus.NOT_FOUND, "Withdraw link does not exist.") + linkk = await get_withdraw_link(link_id, count) or abort( + HTTPStatus.NOT_FOUND, "Withdraw link does not exist." + ) links.append(str(linkk.lnurl)) count = count + 1 page_link = list(chunks(links, 2)) diff --git a/lnbits/extensions/withdraw/views_api.py b/lnbits/extensions/withdraw/views_api.py index cb8b7f0a7..4979b9328 100644 --- a/lnbits/extensions/withdraw/views_api.py +++ b/lnbits/extensions/withdraw/views_api.py @@ -12,6 +12,8 @@ from .crud import ( get_withdraw_links, update_withdraw_link, delete_withdraw_link, + create_hash_check, + get_hash_check, ) @@ -37,7 +39,11 @@ async def api_links(): ) except LnurlInvalidUrl: return ( - jsonify({"message": "LNURLs need to be delivered over a publically accessible `https` domain or Tor."}), + jsonify( + { + "message": "LNURLs need to be delivered over a publically accessible `https` domain or Tor." + } + ), HTTPStatus.UPGRADE_REQUIRED, ) @@ -48,7 +54,10 @@ async def api_link_retrieve(link_id): link = await get_withdraw_link(link_id, 0) if not link: - return jsonify({"message": "Withdraw link does not exist."}), HTTPStatus.NOT_FOUND + return ( + jsonify({"message": "Withdraw link does not exist."}), + HTTPStatus.NOT_FOUND, + ) if link.wallet != g.wallet.id: return jsonify({"message": "Not your withdraw link."}), HTTPStatus.FORBIDDEN @@ -72,7 +81,11 @@ async def api_link_retrieve(link_id): async def api_link_create_or_update(link_id=None): if g.data["max_withdrawable"] < g.data["min_withdrawable"]: return ( - jsonify({"message": "`max_withdrawable` needs to be at least `min_withdrawable`."}), + jsonify( + { + "message": "`max_withdrawable` needs to be at least `min_withdrawable`." + } + ), HTTPStatus.BAD_REQUEST, ) @@ -87,14 +100,22 @@ async def api_link_create_or_update(link_id=None): if link_id: link = await get_withdraw_link(link_id, 0) if not link: - return jsonify({"message": "Withdraw link does not exist."}), HTTPStatus.NOT_FOUND + return ( + jsonify({"message": "Withdraw link does not exist."}), + HTTPStatus.NOT_FOUND, + ) if link.wallet != g.wallet.id: return jsonify({"message": "Not your withdraw link."}), HTTPStatus.FORBIDDEN link = await update_withdraw_link(link_id, **g.data, usescsv=usescsv, used=0) else: - link = await create_withdraw_link(wallet_id=g.wallet.id, **g.data, usescsv=usescsv) + link = await create_withdraw_link( + wallet_id=g.wallet.id, **g.data, usescsv=usescsv + ) - return jsonify({**link._asdict(), **{"lnurl": link.lnurl}}), HTTPStatus.OK if link_id else HTTPStatus.CREATED + return ( + jsonify({**link._asdict(), **{"lnurl": link.lnurl}}), + HTTPStatus.OK if link_id else HTTPStatus.CREATED, + ) @withdraw_ext.route("/api/v1/links/", methods=["DELETE"]) @@ -103,7 +124,10 @@ async def api_link_delete(link_id): link = await get_withdraw_link(link_id) if not link: - return jsonify({"message": "Withdraw link does not exist."}), HTTPStatus.NOT_FOUND + return ( + jsonify({"message": "Withdraw link does not exist."}), + HTTPStatus.NOT_FOUND, + ) if link.wallet != g.wallet.id: return jsonify({"message": "Not your withdraw link."}), HTTPStatus.FORBIDDEN @@ -111,3 +135,10 @@ async def api_link_delete(link_id): await delete_withdraw_link(link_id) return "", HTTPStatus.NO_CONTENT + + +@withdraw_ext.route("/api/v1/links//", methods=["GET"]) +@api_check_wallet_key("invoice") +async def api_hash_retrieve(the_hash, lnurl_id): + hashCheck = await get_hash_check(the_hash, lnurl_id) + return jsonify(hashCheck), HTTPStatus.OK diff --git a/lnbits/helpers.py b/lnbits/helpers.py index ec7ec904a..0370edbc1 100644 --- a/lnbits/helpers.py +++ b/lnbits/helpers.py @@ -15,20 +15,27 @@ class Extension(NamedTuple): short_description: Optional[str] = None icon: Optional[str] = None contributors: Optional[List[str]] = None + hidden: bool = False class ExtensionManager: def __init__(self): self._disabled: List[str] = LNBITS_DISABLED_EXTENSIONS - self._extension_folders: List[str] = [x[1] for x in os.walk(os.path.join(LNBITS_PATH, "extensions"))][0] + self._extension_folders: List[str] = [ + x[1] for x in os.walk(os.path.join(LNBITS_PATH, "extensions")) + ][0] @property def extensions(self) -> List[Extension]: output = [] - for extension in [ext for ext in self._extension_folders if ext not in self._disabled]: + for extension in [ + ext for ext in self._extension_folders if ext not in self._disabled + ]: try: - with open(os.path.join(LNBITS_PATH, "extensions", extension, "config.json")) as json_file: + with open( + os.path.join(LNBITS_PATH, "extensions", extension, "config.json") + ) as json_file: config = json.load(json_file) is_valid = True except Exception: @@ -43,6 +50,7 @@ class ExtensionManager: config.get("short_description"), config.get("icon"), config.get("contributors"), + config.get("hidden") or False, ) ) @@ -50,7 +58,9 @@ class ExtensionManager: def get_valid_extensions() -> List[Extension]: - return [extension for extension in ExtensionManager().extensions if extension.is_valid] + return [ + extension for extension in ExtensionManager().extensions if extension.is_valid + ] def urlsafe_short_hash() -> str: @@ -91,7 +101,9 @@ def get_css_vendored(prefer_minified: bool = False) -> List[str]: def get_vendored(ext: str, prefer_minified: bool = False) -> List[str]: paths: List[str] = [] - for path in glob.glob(os.path.join(LNBITS_PATH, "static/vendor/**"), recursive=True): + for path in glob.glob( + os.path.join(LNBITS_PATH, "static/vendor/**"), recursive=True + ): if path.endswith(".min" + ext): # path is minified unminified = path.replace(".min" + ext, ext) diff --git a/lnbits/settings.py b/lnbits/settings.py index 1ce46ec2f..b42d06ecb 100644 --- a/lnbits/settings.py +++ b/lnbits/settings.py @@ -10,7 +10,9 @@ env = Env() env.read_env() wallets_module = importlib.import_module("lnbits.wallets") -wallet_class = getattr(wallets_module, env.str("LNBITS_BACKEND_WALLET_CLASS", default="VoidWallet")) +wallet_class = getattr( + wallets_module, env.str("LNBITS_BACKEND_WALLET_CLASS", default="VoidWallet") +) ENV = env.str("QUART_ENV", default="production") DEBUG = env.bool("QUART_DEBUG", default=False) or ENV == "development" @@ -18,9 +20,15 @@ HOST = env.str("HOST", default="127.0.0.1") PORT = env.int("PORT", default=5000) LNBITS_PATH = path.dirname(path.realpath(__file__)) -LNBITS_DATA_FOLDER = env.str("LNBITS_DATA_FOLDER", default=path.join(LNBITS_PATH, "data")) -LNBITS_ALLOWED_USERS: List[str] = env.list("LNBITS_ALLOWED_USERS", default=[], subcast=str) -LNBITS_DISABLED_EXTENSIONS: List[str] = env.list("LNBITS_DISABLED_EXTENSIONS", default=[], subcast=str) +LNBITS_DATA_FOLDER = env.str( + "LNBITS_DATA_FOLDER", default=path.join(LNBITS_PATH, "data") +) +LNBITS_ALLOWED_USERS: List[str] = env.list( + "LNBITS_ALLOWED_USERS", default=[], subcast=str +) +LNBITS_DISABLED_EXTENSIONS: List[str] = env.list( + "LNBITS_DISABLED_EXTENSIONS", default=[], subcast=str +) LNBITS_SITE_TITLE = env.str("LNBITS_SITE_TITLE", default="LNbits") WALLET = wallet_class() diff --git a/lnbits/static/js/base.js b/lnbits/static/js/base.js index ed0583e50..122d676d3 100644 --- a/lnbits/static/js/base.js +++ b/lnbits/static/js/base.js @@ -52,13 +52,8 @@ window.LNbits = { getWallet: function (wallet) { return this.request('get', '/api/v1/wallet', wallet.inkey) }, - getPayments: function (wallet, checkPending) { - var query_param = checkPending ? '?check_pending' : '' - return this.request( - 'get', - ['/api/v1/payments', query_param].join(''), - wallet.inkey - ) + getPayments: function (wallet) { + return this.request('get', '/api/v1/payments', wallet.inkey) }, getPayment: function (wallet, paymentHash) { return this.request( @@ -93,7 +88,15 @@ window.LNbits = { map: { extension: function (data) { var obj = _.object( - ['code', 'isValid', 'name', 'shortDescription', 'icon'], + [ + 'code', + 'isValid', + 'name', + 'shortDescription', + 'icon', + 'contributors', + 'hidden' + ], data ) obj.url = ['/', obj.code, '/'].join('') @@ -309,6 +312,9 @@ window.windowMixin = { .map(function (data) { return window.LNbits.map.extension(data) }) + .filter(function (obj) { + return !obj.hidden + }) .map(function (obj) { if (user) { obj.isEnabled = user.extensions.indexOf(obj.code) !== -1 diff --git a/lnbits/tasks.py b/lnbits/tasks.py index 3acb2a07f..d8f26a757 100644 --- a/lnbits/tasks.py +++ b/lnbits/tasks.py @@ -1,10 +1,15 @@ +import time import trio # type: ignore from http import HTTPStatus from typing import Optional, List, Callable from quart_trio import QuartTrio from lnbits.settings import WALLET -from lnbits.core.crud import get_standalone_payment +from lnbits.core.crud import ( + get_payments, + get_standalone_payment, + delete_expired_invoices, +) main_app: Optional[QuartTrio] = None @@ -54,16 +59,38 @@ async def webhook_handler(): internal_invoice_paid, internal_invoice_received = trio.open_memory_channel(0) -async def internal_invoice_listener(): - async with trio.open_nursery() as nursery: - async for checking_id in internal_invoice_received: - nursery.start_soon(invoice_callback_dispatcher, checking_id) +async def internal_invoice_listener(nursery): + async for checking_id in internal_invoice_received: + nursery.start_soon(invoice_callback_dispatcher, checking_id) -async def invoice_listener(): - async with trio.open_nursery() as nursery: - async for checking_id in WALLET.paid_invoices_stream(): - nursery.start_soon(invoice_callback_dispatcher, checking_id) +async def invoice_listener(nursery): + async for checking_id in WALLET.paid_invoices_stream(): + nursery.start_soon(invoice_callback_dispatcher, checking_id) + + +async def check_pending_payments(): + await delete_expired_invoices() + + outgoing = True + incoming = True + + while True: + for payment in await get_payments( + since=(int(time.time()) - 60 * 60 * 24 * 15), # 15 days ago + complete=False, + pending=True, + outgoing=outgoing, + incoming=incoming, + exclude_uncheckable=True, + ): + await payment.check_pending() + + # after the first check we will only check outgoing, not incoming + # that will be handled by the global invoice listeners, hopefully + incoming = False + + await trio.sleep(60 * 30) # every 30 minutes async def invoice_callback_dispatcher(checking_id: str): diff --git a/lnbits/utils/__init__.py b/lnbits/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/lnbits/utils/exchange_rates.py b/lnbits/utils/exchange_rates.py new file mode 100644 index 000000000..ef4d3306d --- /dev/null +++ b/lnbits/utils/exchange_rates.py @@ -0,0 +1,267 @@ +import trio # type: ignore +import httpx +from typing import Callable, NamedTuple + +currencies = { + "AED": "United Arab Emirates Dirham", + "AFN": "Afghan Afghani", + "ALL": "Albanian Lek", + "AMD": "Armenian Dram", + "ANG": "Netherlands Antillean Gulden", + "AOA": "Angolan Kwanza", + "ARS": "Argentine Peso", + "AUD": "Australian Dollar", + "AWG": "Aruban Florin", + "AZN": "Azerbaijani Manat", + "BAM": "Bosnia and Herzegovina Convertible Mark", + "BBD": "Barbadian Dollar", + "BDT": "Bangladeshi Taka", + "BGN": "Bulgarian Lev", + "BHD": "Bahraini Dinar", + "BIF": "Burundian Franc", + "BMD": "Bermudian Dollar", + "BND": "Brunei Dollar", + "BOB": "Bolivian Boliviano", + "BRL": "Brazilian Real", + "BSD": "Bahamian Dollar", + "BTN": "Bhutanese Ngultrum", + "BWP": "Botswana Pula", + "BYN": "Belarusian Ruble", + "BYR": "Belarusian Ruble", + "BZD": "Belize Dollar", + "CAD": "Canadian Dollar", + "CDF": "Congolese Franc", + "CHF": "Swiss Franc", + "CLF": "Unidad de Fomento", + "CLP": "Chilean Peso", + "CNH": "Chinese Renminbi Yuan Offshore", + "CNY": "Chinese Renminbi Yuan", + "COP": "Colombian Peso", + "CRC": "Costa Rican Colón", + "CUC": "Cuban Convertible Peso", + "CVE": "Cape Verdean Escudo", + "CZK": "Czech Koruna", + "DJF": "Djiboutian Franc", + "DKK": "Danish Krone", + "DOP": "Dominican Peso", + "DZD": "Algerian Dinar", + "EGP": "Egyptian Pound", + "ERN": "Eritrean Nakfa", + "ETB": "Ethiopian Birr", + "EUR": "Euro", + "FJD": "Fijian Dollar", + "FKP": "Falkland Pound", + "GBP": "British Pound", + "GEL": "Georgian Lari", + "GGP": "Guernsey Pound", + "GHS": "Ghanaian Cedi", + "GIP": "Gibraltar Pound", + "GMD": "Gambian Dalasi", + "GNF": "Guinean Franc", + "GTQ": "Guatemalan Quetzal", + "GYD": "Guyanese Dollar", + "HKD": "Hong Kong Dollar", + "HNL": "Honduran Lempira", + "HRK": "Croatian Kuna", + "HTG": "Haitian Gourde", + "HUF": "Hungarian Forint", + "IDR": "Indonesian Rupiah", + "ILS": "Israeli New Sheqel", + "IMP": "Isle of Man Pound", + "INR": "Indian Rupee", + "IQD": "Iraqi Dinar", + "ISK": "Icelandic Króna", + "JEP": "Jersey Pound", + "JMD": "Jamaican Dollar", + "JOD": "Jordanian Dinar", + "JPY": "Japanese Yen", + "KES": "Kenyan Shilling", + "KGS": "Kyrgyzstani Som", + "KHR": "Cambodian Riel", + "KMF": "Comorian Franc", + "KRW": "South Korean Won", + "KWD": "Kuwaiti Dinar", + "KYD": "Cayman Islands Dollar", + "KZT": "Kazakhstani Tenge", + "LAK": "Lao Kip", + "LBP": "Lebanese Pound", + "LKR": "Sri Lankan Rupee", + "LRD": "Liberian Dollar", + "LSL": "Lesotho Loti", + "LYD": "Libyan Dinar", + "MAD": "Moroccan Dirham", + "MDL": "Moldovan Leu", + "MGA": "Malagasy Ariary", + "MKD": "Macedonian Denar", + "MMK": "Myanmar Kyat", + "MNT": "Mongolian Tögrög", + "MOP": "Macanese Pataca", + "MRO": "Mauritanian Ouguiya", + "MUR": "Mauritian Rupee", + "MVR": "Maldivian Rufiyaa", + "MWK": "Malawian Kwacha", + "MXN": "Mexican Peso", + "MYR": "Malaysian Ringgit", + "MZN": "Mozambican Metical", + "NAD": "Namibian Dollar", + "NGN": "Nigerian Naira", + "NIO": "Nicaraguan Córdoba", + "NOK": "Norwegian Krone", + "NPR": "Nepalese Rupee", + "NZD": "New Zealand Dollar", + "OMR": "Omani Rial", + "PAB": "Panamanian Balboa", + "PEN": "Peruvian Sol", + "PGK": "Papua New Guinean Kina", + "PHP": "Philippine Peso", + "PKR": "Pakistani Rupee", + "PLN": "Polish Złoty", + "PYG": "Paraguayan Guaraní", + "QAR": "Qatari Riyal", + "RON": "Romanian Leu", + "RSD": "Serbian Dinar", + "RUB": "Russian Ruble", + "RWF": "Rwandan Franc", + "SAR": "Saudi Riyal", + "SBD": "Solomon Islands Dollar", + "SCR": "Seychellois Rupee", + "SEK": "Swedish Krona", + "SGD": "Singapore Dollar", + "SHP": "Saint Helenian Pound", + "SLL": "Sierra Leonean Leone", + "SOS": "Somali Shilling", + "SRD": "Surinamese Dollar", + "SSP": "South Sudanese Pound", + "STD": "São Tomé and Príncipe Dobra", + "SVC": "Salvadoran Colón", + "SZL": "Swazi Lilangeni", + "THB": "Thai Baht", + "TJS": "Tajikistani Somoni", + "TMT": "Turkmenistani Manat", + "TND": "Tunisian Dinar", + "TOP": "Tongan Paʻanga", + "TRY": "Turkish Lira", + "TTD": "Trinidad and Tobago Dollar", + "TWD": "New Taiwan Dollar", + "TZS": "Tanzanian Shilling", + "UAH": "Ukrainian Hryvnia", + "UGX": "Ugandan Shilling", + "USD": "US Dollar", + "UYU": "Uruguayan Peso", + "UZS": "Uzbekistan Som", + "VEF": "Venezuelan Bolívar", + "VES": "Venezuelan Bolívar Soberano", + "VND": "Vietnamese Đồng", + "VUV": "Vanuatu Vatu", + "WST": "Samoan Tala", + "XAF": "Central African Cfa Franc", + "XAG": "Silver (Troy Ounce)", + "XAU": "Gold (Troy Ounce)", + "XCD": "East Caribbean Dollar", + "XDR": "Special Drawing Rights", + "XOF": "West African Cfa Franc", + "XPD": "Palladium", + "XPF": "Cfp Franc", + "XPT": "Platinum", + "YER": "Yemeni Rial", + "ZAR": "South African Rand", + "ZMW": "Zambian Kwacha", + "ZWL": "Zimbabwean Dollar", +} + + +class Provider(NamedTuple): + name: str + domain: str + api_url: str + getter: Callable + + +exchange_rate_providers = { + "bitfinex": Provider( + "Bitfinex", + "bitfinex.com", + "https://api.bitfinex.com/v1/pubticker/{from}{to}", + lambda data, replacements: data["last_price"], + ), + "bitstamp": Provider( + "Bitstamp", + "bitstamp.net", + "https://www.bitstamp.net/api/v2/ticker/{from}{to}/", + lambda data, replacements: data["last"], + ), + "coinbase": Provider( + "Coinbase", + "coinbase.com", + "https://api.coinbase.com/v2/exchange-rates?currency={FROM}", + lambda data, replacements: data["data"]["rates"][replacements["TO"]], + ), + "coinmate": Provider( + "CoinMate", + "coinmate.io", + "https://coinmate.io/api/ticker?currencyPair={FROM}_{TO}", + lambda data, replacements: data["data"]["last"], + ), + "kraken": Provider( + "Kraken", + "kraken.com", + "https://api.kraken.com/0/public/Ticker?pair=XBT{TO}", + lambda data, replacements: data["result"]["XXBTZ" + replacements["TO"]]["c"][0], + ), +} + + +async def btc_price(currency: str) -> float: + replacements = { + "FROM": "BTC", + "from": "btc", + "TO": currency.upper(), + "to": currency.lower(), + } + rates = [] + send_channel, receive_channel = trio.open_memory_channel(0) + + async def controller(nursery): + failures = 0 + while True: + rate = await receive_channel.receive() + if rate: + rates.append(rate) + else: + failures += 1 + if len(rates) >= 2 or len(rates) == 1 and failures >= 2: + nursery.cancel_scope.cancel() + break + if failures == len(exchange_rate_providers): + nursery.cancel_scope.cancel() + break + + async def fetch_price(key: str, provider: Provider): + try: + url = provider.api_url.format(**replacements) + async with httpx.AsyncClient() as client: + r = await client.get(url, timeout=0.5) + r.raise_for_status() + data = r.json() + rate = float(provider.getter(data, replacements)) + await send_channel.send(rate) + except Exception: + await send_channel.send(None) + + async with trio.open_nursery() as nursery: + nursery.start_soon(controller, nursery) + for key, provider in exchange_rate_providers.items(): + nursery.start_soon(fetch_price, key, provider) + + if not rates: + return 9999999999 + + return sum([rate for rate in rates]) / len(rates) + + +async def get_fiat_rate_satoshis(currency: str) -> float: + return int(100_000_000 / (await btc_price(currency))) + + +async def fiat_amount_as_satoshis(amount: float, currency: str) -> int: + return int(amount * (await get_fiat_rate_satoshis(currency))) diff --git a/lnbits/wallets/base.py b/lnbits/wallets/base.py index d2486c731..973c18085 100644 --- a/lnbits/wallets/base.py +++ b/lnbits/wallets/base.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from typing import NamedTuple, Optional, AsyncGenerator +from typing import NamedTuple, Optional, AsyncGenerator, Coroutine class StatusResponse(NamedTuple): @@ -15,7 +15,8 @@ class InvoiceResponse(NamedTuple): class PaymentResponse(NamedTuple): - ok: bool + # when ok is None it means we don't know if this succeeded + ok: Optional[bool] = None checking_id: Optional[str] = None # payment_hash, rcp_id fee_msat: int = 0 preimage: Optional[str] = None @@ -29,28 +30,49 @@ class PaymentStatus(NamedTuple): def pending(self) -> bool: return self.paid is not True + @property + def failed(self) -> bool: + return self.paid == False + + def __str__(self) -> str: + if self.paid == True: + return "settled" + elif self.paid == False: + return "failed" + elif self.paid == None: + return "still pending" + else: + return "unknown (should never happen)" + class Wallet(ABC): @abstractmethod - def status(self) -> StatusResponse: + def status(self) -> Coroutine[None, None, StatusResponse]: pass @abstractmethod def create_invoice( - self, amount: int, memo: Optional[str] = None, description_hash: Optional[bytes] = None - ) -> InvoiceResponse: + self, + amount: int, + memo: Optional[str] = None, + description_hash: Optional[bytes] = None, + ) -> Coroutine[None, None, InvoiceResponse]: pass @abstractmethod - def pay_invoice(self, bolt11: str) -> PaymentResponse: + def pay_invoice(self, bolt11: str) -> Coroutine[None, None, PaymentResponse]: pass @abstractmethod - def get_invoice_status(self, checking_id: str) -> PaymentStatus: + def get_invoice_status( + self, checking_id: str + ) -> Coroutine[None, None, PaymentStatus]: pass @abstractmethod - def get_payment_status(self, checking_id: str) -> PaymentStatus: + def get_payment_status( + self, checking_id: str + ) -> Coroutine[None, None, PaymentStatus]: pass @abstractmethod diff --git a/lnbits/wallets/clightning.py b/lnbits/wallets/clightning.py index 48d304bb6..b9f24f571 100644 --- a/lnbits/wallets/clightning.py +++ b/lnbits/wallets/clightning.py @@ -10,13 +10,22 @@ import json from os import getenv from typing import Optional, AsyncGenerator -from .base import StatusResponse, InvoiceResponse, PaymentResponse, PaymentStatus, Wallet, Unsupported +from .base import ( + StatusResponse, + InvoiceResponse, + PaymentResponse, + PaymentStatus, + Wallet, + Unsupported, +) class CLightningWallet(Wallet): def __init__(self): if LightningRpc is None: # pragma: nocover - raise ImportError("The `pylightning` library must be installed to use `CLightningWallet`.") + raise ImportError( + "The `pylightning` library must be installed to use `CLightningWallet`." + ) self.rpc = getenv("CLIGHTNING_RPC") self.ln = LightningRpc(self.rpc) @@ -40,7 +49,7 @@ class CLightningWallet(Wallet): self.last_pay_index = inv["pay_index"] break - def status(self) -> StatusResponse: + async def status(self) -> StatusResponse: try: funds = self.ln.listfunds() return StatusResponse( @@ -51,8 +60,11 @@ class CLightningWallet(Wallet): error_message = f"lightningd '{exc.method}' failed with '{exc.error}'." return StatusResponse(error_message, 0) - def create_invoice( - self, amount: int, memo: Optional[str] = None, description_hash: Optional[bytes] = None + async def create_invoice( + self, + amount: int, + memo: Optional[str] = None, + description_hash: Optional[bytes] = None, ) -> InvoiceResponse: label = "lbl{}".format(random.random()) msat = amount * 1000 @@ -72,7 +84,7 @@ class CLightningWallet(Wallet): error_message = f"lightningd '{exc.method}' failed with '{exc.error}'." return InvoiceResponse(False, label, None, error_message) - def pay_invoice(self, bolt11: str) -> PaymentResponse: + async def pay_invoice(self, bolt11: str) -> PaymentResponse: try: r = self.ln.pay(bolt11) except RpcError as exc: @@ -82,7 +94,7 @@ class CLightningWallet(Wallet): preimage = r["payment_preimage"] return PaymentResponse(True, r["payment_hash"], fee_msat, preimage, None) - def get_invoice_status(self, checking_id: str) -> PaymentStatus: + async def get_invoice_status(self, checking_id: str) -> PaymentStatus: r = self.ln.listinvoices(checking_id) if not r["invoices"]: return PaymentStatus(False) @@ -90,7 +102,7 @@ class CLightningWallet(Wallet): return PaymentStatus(r["invoices"][0]["status"] == "paid") raise KeyError("supplied an invalid checking_id") - def get_payment_status(self, checking_id: str) -> PaymentStatus: + async def get_payment_status(self, checking_id: str) -> PaymentStatus: r = self.ln.call("listpays", {"payment_hash": checking_id}) if not r["pays"]: return PaymentStatus(False) diff --git a/lnbits/wallets/lnbits.py b/lnbits/wallets/lnbits.py index 30c62c602..786a4b030 100644 --- a/lnbits/wallets/lnbits.py +++ b/lnbits/wallets/lnbits.py @@ -3,7 +3,13 @@ import httpx from os import getenv from typing import Optional, Dict, AsyncGenerator -from .base import StatusResponse, InvoiceResponse, PaymentResponse, PaymentStatus, Wallet +from .base import ( + StatusResponse, + InvoiceResponse, + PaymentResponse, + PaymentStatus, + Wallet, +) class LNbitsWallet(Wallet): @@ -12,23 +18,34 @@ class LNbitsWallet(Wallet): def __init__(self): self.endpoint = getenv("LNBITS_ENDPOINT") - key = getenv("LNBITS_KEY") or getenv("LNBITS_ADMIN_KEY") or getenv("LNBITS_INVOICE_KEY") + key = ( + getenv("LNBITS_KEY") + or getenv("LNBITS_ADMIN_KEY") + or getenv("LNBITS_INVOICE_KEY") + ) self.key = {"X-Api-Key": key} - def status(self) -> StatusResponse: - r = httpx.get(url=f"{self.endpoint}/api/v1/wallet", headers=self.key) + async def status(self) -> StatusResponse: + async with httpx.AsyncClient() as client: + r = await client.get(url=f"{self.endpoint}/api/v1/wallet", headers=self.key) + try: data = r.json() except: - return StatusResponse(f"Failed to connect to {self.endpoint}, got: '{r.text[:200]}...'", 0) + return StatusResponse( + f"Failed to connect to {self.endpoint}, got: '{r.text[:200]}...'", 0 + ) if r.is_error: return StatusResponse(data["message"], 0) return StatusResponse(None, data["balance"]) - def create_invoice( - self, amount: int, memo: Optional[str] = None, description_hash: Optional[bytes] = None + async def create_invoice( + self, + amount: int, + memo: Optional[str] = None, + description_hash: Optional[bytes] = None, ) -> InvoiceResponse: data: Dict = {"out": False, "amount": amount} if description_hash: @@ -36,12 +53,18 @@ class LNbitsWallet(Wallet): else: data["memo"] = memo or "" - r = httpx.post( - url=f"{self.endpoint}/api/v1/payments", - headers=self.key, - json=data, + async with httpx.AsyncClient() as client: + r = await client.post( + url=f"{self.endpoint}/api/v1/payments", + headers=self.key, + json=data, + ) + ok, checking_id, payment_request, error_message = ( + not r.is_error, + None, + None, + None, ) - ok, checking_id, payment_request, error_message = not r.is_error, None, None, None if r.is_error: error_message = r.json()["message"] @@ -51,8 +74,13 @@ class LNbitsWallet(Wallet): return InvoiceResponse(ok, checking_id, payment_request, error_message) - def pay_invoice(self, bolt11: str) -> PaymentResponse: - r = httpx.post(url=f"{self.endpoint}/api/v1/payments", headers=self.key, json={"out": True, "bolt11": bolt11}) + async def pay_invoice(self, bolt11: str) -> PaymentResponse: + async with httpx.AsyncClient() as client: + r = await client.post( + url=f"{self.endpoint}/api/v1/payments", + headers=self.key, + json={"out": True, "bolt11": bolt11}, + ) ok, checking_id, fee_msat, error_message = not r.is_error, None, 0, None if r.is_error: @@ -63,16 +91,22 @@ class LNbitsWallet(Wallet): return PaymentResponse(ok, checking_id, fee_msat, error_message) - def get_invoice_status(self, checking_id: str) -> PaymentStatus: - r = httpx.get(url=f"{self.endpoint}/api/v1/payments/{checking_id}", headers=self.key) + async def get_invoice_status(self, checking_id: str) -> PaymentStatus: + async with httpx.AsyncClient() as client: + r = await client.get( + url=f"{self.endpoint}/api/v1/payments/{checking_id}", headers=self.key + ) if r.is_error: return PaymentStatus(None) return PaymentStatus(r.json()["paid"]) - def get_payment_status(self, checking_id: str) -> PaymentStatus: - r = httpx.get(url=f"{self.endpoint}/api/v1/payments/{checking_id}", headers=self.key) + async def get_payment_status(self, checking_id: str) -> PaymentStatus: + async with httpx.AsyncClient() as client: + r = await client.get( + url=f"{self.endpoint}/api/v1/payments/{checking_id}", headers=self.key + ) if r.is_error: return PaymentStatus(None) diff --git a/lnbits/wallets/lndgrpc.py b/lnbits/wallets/lndgrpc.py index d92b568f9..25c9b2830 100644 --- a/lnbits/wallets/lndgrpc.py +++ b/lnbits/wallets/lndgrpc.py @@ -15,7 +15,13 @@ import hashlib from os import getenv from typing import Optional, Dict, AsyncGenerator -from .base import StatusResponse, InvoiceResponse, PaymentResponse, PaymentStatus, Wallet +from .base import ( + StatusResponse, + InvoiceResponse, + PaymentResponse, + PaymentStatus, + Wallet, +) def get_ssl_context(cert_path: str): @@ -76,10 +82,14 @@ def stringify_checking_id(r_hash: bytes) -> str: class LndWallet(Wallet): def __init__(self): if lndgrpc is None: # pragma: nocover - raise ImportError("The `lndgrpc` library must be installed to use `LndWallet`.") + raise ImportError( + "The `lndgrpc` library must be installed to use `LndWallet`." + ) if purerpc is None: # pragma: nocover - raise ImportError("The `purerpc` library must be installed to use `LndWallet`.") + raise ImportError( + "The `purerpc` library must be installed to use `LndWallet`." + ) endpoint = getenv("LND_GRPC_ENDPOINT") self.endpoint = endpoint[:-1] if endpoint.endswith("/") else endpoint @@ -102,7 +112,7 @@ class LndWallet(Wallet): macaroon_filepath=self.macaroon_path, ) - def status(self) -> StatusResponse: + async def status(self) -> StatusResponse: try: resp = self.rpc._ln_stub.ChannelBalance(ln.ChannelBalanceRequest()) except Exception as exc: @@ -110,8 +120,11 @@ class LndWallet(Wallet): return StatusResponse(None, resp.balance * 1000) - def create_invoice( - self, amount: int, memo: Optional[str] = None, description_hash: Optional[bytes] = None + async def create_invoice( + self, + amount: int, + memo: Optional[str] = None, + description_hash: Optional[bytes] = None, ) -> InvoiceResponse: params: Dict = {"value": amount, "expiry": 600, "private": True} @@ -131,7 +144,7 @@ class LndWallet(Wallet): payment_request = str(resp.payment_request) return InvoiceResponse(True, checking_id, payment_request, None) - def pay_invoice(self, bolt11: str) -> PaymentResponse: + async def pay_invoice(self, bolt11: str) -> PaymentResponse: resp = self.rpc.send_payment(payment_request=bolt11) if resp.payment_error: @@ -143,7 +156,7 @@ class LndWallet(Wallet): preimage = resp.payment_preimage.hex() return PaymentResponse(True, checking_id, fee_msat, preimage, None) - def get_invoice_status(self, checking_id: str) -> PaymentStatus: + async def get_invoice_status(self, checking_id: str) -> PaymentStatus: try: r_hash = parse_checking_id(checking_id) if len(r_hash) != 32: @@ -159,7 +172,7 @@ class LndWallet(Wallet): return PaymentStatus(None) - def get_payment_status(self, checking_id: str) -> PaymentStatus: + async def get_payment_status(self, checking_id: str) -> PaymentStatus: return PaymentStatus(True) async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: diff --git a/lnbits/wallets/lndrest.py b/lnbits/wallets/lndrest.py index 4ee42aa74..fa46c573e 100644 --- a/lnbits/wallets/lndrest.py +++ b/lnbits/wallets/lndrest.py @@ -5,7 +5,13 @@ import base64 from os import getenv from typing import Optional, Dict, AsyncGenerator -from .base import StatusResponse, InvoiceResponse, PaymentResponse, PaymentStatus, Wallet +from .base import ( + StatusResponse, + InvoiceResponse, + PaymentResponse, + PaymentStatus, + Wallet, +) class LndRestWallet(Wallet): @@ -14,7 +20,9 @@ class LndRestWallet(Wallet): def __init__(self): endpoint = getenv("LND_REST_ENDPOINT") endpoint = endpoint[:-1] if endpoint.endswith("/") else endpoint - endpoint = "https://" + endpoint if not endpoint.startswith("http") else endpoint + endpoint = ( + "https://" + endpoint if not endpoint.startswith("http") else endpoint + ) self.endpoint = endpoint macaroon = ( @@ -27,13 +35,13 @@ class LndRestWallet(Wallet): self.auth = {"Grpc-Metadata-macaroon": macaroon} self.cert = getenv("LND_REST_CERT") - def status(self) -> StatusResponse: + async def status(self) -> StatusResponse: try: - r = httpx.get( - f"{self.endpoint}/v1/balance/channels", - headers=self.auth, - verify=self.cert, - ) + async with httpx.AsyncClient(verify=self.cert) as client: + r = await client.get( + f"{self.endpoint}/v1/balance/channels", + headers=self.auth, + ) except (httpx.ConnectError, httpx.RequestError): return StatusResponse(f"Unable to connect to {self.endpoint}.", 0) @@ -46,24 +54,29 @@ class LndRestWallet(Wallet): return StatusResponse(None, int(data["balance"]) * 1000) - def create_invoice( - self, amount: int, memo: Optional[str] = None, description_hash: Optional[bytes] = None + async def create_invoice( + self, + amount: int, + memo: Optional[str] = None, + description_hash: Optional[bytes] = None, ) -> InvoiceResponse: data: Dict = { "value": amount, "private": True, } if description_hash: - data["description_hash"] = base64.b64encode(description_hash).decode("ascii") + data["description_hash"] = base64.b64encode(description_hash).decode( + "ascii" + ) else: data["memo"] = memo or "" - r = httpx.post( - url=f"{self.endpoint}/v1/invoices", - headers=self.auth, - verify=self.cert, - json=data, - ) + async with httpx.AsyncClient(verify=self.cert) as client: + r = await client.post( + url=f"{self.endpoint}/v1/invoices", + headers=self.auth, + json=data, + ) if r.is_error: error_message = r.text @@ -80,14 +93,14 @@ class LndRestWallet(Wallet): return InvoiceResponse(True, checking_id, payment_request, None) - def pay_invoice(self, bolt11: str) -> PaymentResponse: - r = httpx.post( - url=f"{self.endpoint}/v1/channels/transactions", - headers=self.auth, - verify=self.cert, - json={"payment_request": bolt11}, - timeout=180, - ) + async def pay_invoice(self, bolt11: str) -> PaymentResponse: + async with httpx.AsyncClient(verify=self.cert) as client: + r = await client.post( + url=f"{self.endpoint}/v1/channels/transactions", + headers=self.auth, + json={"payment_request": bolt11}, + timeout=180, + ) if r.is_error: error_message = r.text @@ -103,13 +116,14 @@ class LndRestWallet(Wallet): preimage = base64.b64decode(data["payment_preimage"]).hex() return PaymentResponse(True, checking_id, 0, preimage, None) - def get_invoice_status(self, checking_id: str) -> PaymentStatus: + async def get_invoice_status(self, checking_id: str) -> PaymentStatus: checking_id = checking_id.replace("_", "/") - r = httpx.get( - url=f"{self.endpoint}/v1/invoice/{checking_id}", - headers=self.auth, - verify=self.cert, - ) + + async with httpx.AsyncClient(verify=self.cert) as client: + r = await client.get( + url=f"{self.endpoint}/v1/invoice/{checking_id}", + headers=self.auth, + ) if r.is_error or not r.json().get("settled"): # this must also work when checking_id is not a hex recognizable by lnd @@ -118,20 +132,25 @@ class LndRestWallet(Wallet): return PaymentStatus(True) - def get_payment_status(self, checking_id: str) -> PaymentStatus: - r = httpx.get( - url=f"{self.endpoint}/v1/payments", - headers=self.auth, - verify=self.cert, - params={"max_payments": "20", "reversed": True}, - ) + async def get_payment_status(self, checking_id: str) -> PaymentStatus: + async with httpx.AsyncClient(verify=self.cert) as client: + r = await client.get( + url=f"{self.endpoint}/v1/payments", + headers=self.auth, + params={"max_payments": "20", "reversed": True}, + ) if r.is_error: return PaymentStatus(None) # check payment.status: # https://api.lightning.community/rest/index.html?python#peersynctype - statuses = {"UNKNOWN": None, "IN_FLIGHT": None, "SUCCEEDED": True, "FAILED": False} + statuses = { + "UNKNOWN": None, + "IN_FLIGHT": None, + "SUCCEEDED": True, + "FAILED": False, + } # for some reason our checking_ids are in base64 but the payment hashes # returned here are in hex, lnd is weird diff --git a/lnbits/wallets/lnpay.py b/lnbits/wallets/lnpay.py index 9951e4ecb..dc4a2e58c 100644 --- a/lnbits/wallets/lnpay.py +++ b/lnbits/wallets/lnpay.py @@ -6,7 +6,13 @@ from http import HTTPStatus from typing import Optional, Dict, AsyncGenerator from quart import request -from .base import StatusResponse, InvoiceResponse, PaymentResponse, PaymentStatus, Wallet +from .base import ( + StatusResponse, + InvoiceResponse, + PaymentResponse, + PaymentStatus, + Wallet, +) class LNPayWallet(Wallet): @@ -18,10 +24,11 @@ class LNPayWallet(Wallet): self.wallet_key = getenv("LNPAY_WALLET_KEY") or getenv("LNPAY_ADMIN_KEY") self.auth = {"X-Api-Key": getenv("LNPAY_API_KEY")} - def status(self) -> StatusResponse: + async def status(self) -> StatusResponse: url = f"{self.endpoint}/wallet/{self.wallet_key}" try: - r = httpx.get(url, headers=self.auth, timeout=60) + async with httpx.AsyncClient() as client: + r = await client.get(url, headers=self.auth, timeout=60) except (httpx.ConnectError, httpx.RequestError): return StatusResponse(f"Unable to connect to '{url}'", 0) @@ -31,12 +38,13 @@ class LNPayWallet(Wallet): data = r.json() if data["statusType"]["name"] != "active": return StatusResponse( - f"Wallet {data['user_label']} (data['id']) not active, but {data['statusType']['name']}", 0 + f"Wallet {data['user_label']} (data['id']) not active, but {data['statusType']['name']}", + 0, ) return StatusResponse(None, data["balance"] * 1000) - def create_invoice( + async def create_invoice( self, amount: int, memo: Optional[str] = None, @@ -48,12 +56,13 @@ class LNPayWallet(Wallet): else: data["memo"] = memo or "" - r = httpx.post( - f"{self.endpoint}/wallet/{self.wallet_key}/invoice", - headers=self.auth, - json=data, - timeout=60, - ) + async with httpx.AsyncClient() as client: + r = await client.post( + f"{self.endpoint}/wallet/{self.wallet_key}/invoice", + headers=self.auth, + json=data, + timeout=60, + ) ok, checking_id, payment_request, error_message = ( r.status_code == 201, None, @@ -67,18 +76,21 @@ class LNPayWallet(Wallet): return InvoiceResponse(ok, checking_id, payment_request, error_message) - def pay_invoice(self, bolt11: str) -> PaymentResponse: - r = httpx.post( - f"{self.endpoint}/wallet/{self.wallet_key}/withdraw", - headers=self.auth, - json={"payment_request": bolt11}, - timeout=180, - ) + async def pay_invoice(self, bolt11: str) -> PaymentResponse: + async with httpx.AsyncClient() as client: + r = await client.post( + f"{self.endpoint}/wallet/{self.wallet_key}/withdraw", + headers=self.auth, + json={"payment_request": bolt11}, + timeout=180, + ) try: data = r.json() except: - return PaymentResponse(False, None, 0, None, f"Got invalid JSON: {r.text[:200]}") + return PaymentResponse( + False, None, 0, None, f"Got invalid JSON: {r.text[:200]}" + ) if r.is_error: return PaymentResponse(False, None, 0, None, data["message"]) @@ -88,14 +100,15 @@ class LNPayWallet(Wallet): preimage = data["lnTx"]["payment_preimage"] return PaymentResponse(True, checking_id, fee_msat, preimage, None) - def get_invoice_status(self, checking_id: str) -> PaymentStatus: - return self.get_payment_status(checking_id) + async def get_invoice_status(self, checking_id: str) -> PaymentStatus: + return await self.get_payment_status(checking_id) - def get_payment_status(self, checking_id: str) -> PaymentStatus: - r = httpx.get( - url=f"{self.endpoint}/lntx/{checking_id}?fields=settled", - headers=self.auth, - ) + async def get_payment_status(self, checking_id: str) -> PaymentStatus: + async with httpx.AsyncClient() as client: + r = await client.get( + url=f"{self.endpoint}/lntx/{checking_id}?fields=settled", + headers=self.auth, + ) if r.is_error: return PaymentStatus(None) @@ -115,7 +128,11 @@ class LNPayWallet(Wallet): except json.decoder.JSONDecodeError: print(f"got something wrong on lnpay webhook endpoint: {text[:200]}") data = None - if type(data) is not dict or "event" not in data or data["event"].get("name") != "wallet_receive": + if ( + type(data) is not dict + or "event" not in data + or data["event"].get("name") != "wallet_receive" + ): return "", HTTPStatus.NO_CONTENT lntx_id = data["data"]["wtx"]["lnTx"]["id"] diff --git a/lnbits/wallets/lntxbot.py b/lnbits/wallets/lntxbot.py index bd3be4755..a346cd431 100644 --- a/lnbits/wallets/lntxbot.py +++ b/lnbits/wallets/lntxbot.py @@ -4,7 +4,13 @@ import httpx from os import getenv from typing import Optional, Dict, AsyncGenerator -from .base import StatusResponse, InvoiceResponse, PaymentResponse, PaymentStatus, Wallet +from .base import ( + StatusResponse, + InvoiceResponse, + PaymentResponse, + PaymentStatus, + Wallet, +) class LntxbotWallet(Wallet): @@ -14,27 +20,37 @@ class LntxbotWallet(Wallet): endpoint = getenv("LNTXBOT_API_ENDPOINT") self.endpoint = endpoint[:-1] if endpoint.endswith("/") else endpoint - key = getenv("LNTXBOT_KEY") or getenv("LNTXBOT_ADMIN_KEY") or getenv("LNTXBOT_INVOICE_KEY") + key = ( + getenv("LNTXBOT_KEY") + or getenv("LNTXBOT_ADMIN_KEY") + or getenv("LNTXBOT_INVOICE_KEY") + ) self.auth = {"Authorization": f"Basic {key}"} - def status(self) -> StatusResponse: - r = httpx.get( - f"{self.endpoint}/balance", - headers=self.auth, - timeout=40, - ) + async def status(self) -> StatusResponse: + async with httpx.AsyncClient() as client: + r = await client.get( + f"{self.endpoint}/balance", + headers=self.auth, + timeout=40, + ) try: data = r.json() except: - return StatusResponse(f"Failed to connect to {self.endpoint}, got: '{r.text[:200]}...'", 0) + return StatusResponse( + f"Failed to connect to {self.endpoint}, got: '{r.text[:200]}...'", 0 + ) if data.get("error"): return StatusResponse(data["message"], 0) return StatusResponse(None, data["BTC"]["AvailableBalance"] * 1000) - def create_invoice( - self, amount: int, memo: Optional[str] = None, description_hash: Optional[bytes] = None + async def create_invoice( + self, + amount: int, + memo: Optional[str] = None, + description_hash: Optional[bytes] = None, ) -> InvoiceResponse: data: Dict = {"amt": str(amount)} if description_hash: @@ -42,12 +58,13 @@ class LntxbotWallet(Wallet): else: data["memo"] = memo or "" - r = httpx.post( - f"{self.endpoint}/addinvoice", - headers=self.auth, - json=data, - timeout=40, - ) + async with httpx.AsyncClient() as client: + r = await client.post( + f"{self.endpoint}/addinvoice", + headers=self.auth, + json=data, + timeout=40, + ) if r.is_error: try: @@ -62,13 +79,14 @@ class LntxbotWallet(Wallet): data = r.json() return InvoiceResponse(True, data["payment_hash"], data["pay_req"], None) - def pay_invoice(self, bolt11: str) -> PaymentResponse: - r = httpx.post( - f"{self.endpoint}/payinvoice", - headers=self.auth, - json={"invoice": bolt11}, - timeout=40, - ) + async def pay_invoice(self, bolt11: str) -> PaymentResponse: + async with httpx.AsyncClient() as client: + r = await client.post( + f"{self.endpoint}/payinvoice", + headers=self.auth, + json={"invoice": bolt11}, + timeout=40, + ) if r.is_error: try: @@ -86,11 +104,12 @@ class LntxbotWallet(Wallet): preimage = data["payment_preimage"] return PaymentResponse(True, checking_id, fee_msat, preimage, None) - def get_invoice_status(self, checking_id: str) -> PaymentStatus: - r = httpx.post( - f"{self.endpoint}/invoicestatus/{checking_id}?wait=false", - headers=self.auth, - ) + async def get_invoice_status(self, checking_id: str) -> PaymentStatus: + async with httpx.AsyncClient() as client: + r = await client.post( + f"{self.endpoint}/invoicestatus/{checking_id}?wait=false", + headers=self.auth, + ) data = r.json() if r.is_error or "error" in data: @@ -101,11 +120,12 @@ class LntxbotWallet(Wallet): return PaymentStatus(True) - def get_payment_status(self, checking_id: str) -> PaymentStatus: - r = httpx.post( - url=f"{self.endpoint}/paymentstatus/{checking_id}", - headers=self.auth, - ) + 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, + ) data = r.json() if r.is_error or "error" in data: diff --git a/lnbits/wallets/opennode.py b/lnbits/wallets/opennode.py index 97b395d06..8354e8199 100644 --- a/lnbits/wallets/opennode.py +++ b/lnbits/wallets/opennode.py @@ -1,4 +1,3 @@ -import json import trio # type: ignore import hmac import httpx @@ -7,7 +6,14 @@ from os import getenv from typing import Optional, AsyncGenerator from quart import request, url_for -from .base import StatusResponse, InvoiceResponse, PaymentResponse, PaymentStatus, Wallet, Unsupported +from .base import ( + StatusResponse, + InvoiceResponse, + PaymentResponse, + PaymentStatus, + Wallet, + Unsupported, +) class OpenNodeWallet(Wallet): @@ -17,16 +23,21 @@ class OpenNodeWallet(Wallet): endpoint = getenv("OPENNODE_API_ENDPOINT") self.endpoint = endpoint[:-1] if endpoint.endswith("/") else endpoint - key = getenv("OPENNODE_KEY") or getenv("OPENNODE_ADMIN_KEY") or getenv("OPENNODE_INVOICE_KEY") + key = ( + getenv("OPENNODE_KEY") + or getenv("OPENNODE_ADMIN_KEY") + or getenv("OPENNODE_INVOICE_KEY") + ) self.auth = {"Authorization": key} - def status(self) -> StatusResponse: + async def status(self) -> StatusResponse: try: - r = httpx.get( - f"{self.endpoint}/v1/account/balance", - headers=self.auth, - timeout=40, - ) + async with httpx.AsyncClient() as client: + r = await client.get( + 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) @@ -36,22 +47,26 @@ class OpenNodeWallet(Wallet): return StatusResponse(None, data["balance"]["BTC"] / 100_000_000_000) - def create_invoice( - self, amount: int, memo: Optional[str] = None, description_hash: Optional[bytes] = None + async def create_invoice( + self, + amount: int, + memo: Optional[str] = None, + description_hash: Optional[bytes] = None, ) -> InvoiceResponse: if description_hash: raise Unsupported("description_hash") - r = httpx.post( - f"{self.endpoint}/v1/charges", - headers=self.auth, - json={ - "amount": amount, - "description": memo or "", - "callback_url": url_for("webhook_listener", _external=True), - }, - timeout=40, - ) + async with httpx.AsyncClient() as client: + r = await client.post( + f"{self.endpoint}/v1/charges", + headers=self.auth, + json={ + "amount": amount, + "description": memo or "", + "callback_url": url_for("webhook_listener", _external=True), + }, + timeout=40, + ) if r.is_error: error_message = r.json()["message"] @@ -62,13 +77,14 @@ class OpenNodeWallet(Wallet): payment_request = data["lightning_invoice"]["payreq"] return InvoiceResponse(True, checking_id, payment_request, None) - def pay_invoice(self, bolt11: str) -> PaymentResponse: - r = httpx.post( - f"{self.endpoint}/v2/withdrawals", - headers=self.auth, - json={"type": "ln", "address": bolt11}, - timeout=180, - ) + async def pay_invoice(self, bolt11: str) -> PaymentResponse: + async with httpx.AsyncClient() as client: + r = await client.post( + f"{self.endpoint}/v2/withdrawals", + headers=self.auth, + json={"type": "ln", "address": bolt11}, + timeout=180, + ) if r.is_error: error_message = r.json()["message"] @@ -79,21 +95,33 @@ class OpenNodeWallet(Wallet): fee_msat = data["fee"] * 1000 return PaymentResponse(True, checking_id, fee_msat, None, None) - def get_invoice_status(self, checking_id: str) -> PaymentStatus: - r = httpx.get(f"{self.endpoint}/v1/charge/{checking_id}", headers=self.auth) + async def get_invoice_status(self, checking_id: str) -> PaymentStatus: + async with httpx.AsyncClient() as client: + r = await client.get( + f"{self.endpoint}/v1/charge/{checking_id}", headers=self.auth + ) if r.is_error: return PaymentStatus(None) statuses = {"processing": None, "paid": True, "unpaid": False} return PaymentStatus(statuses[r.json()["data"]["status"]]) - def get_payment_status(self, checking_id: str) -> PaymentStatus: - r = httpx.get(f"{self.endpoint}/v1/withdrawal/{checking_id}", headers=self.auth) + async def get_payment_status(self, checking_id: str) -> PaymentStatus: + async with httpx.AsyncClient() as client: + r = await client.get( + f"{self.endpoint}/v1/withdrawal/{checking_id}", headers=self.auth + ) if r.is_error: return PaymentStatus(None) - statuses = {"initial": None, "pending": None, "confirmed": True, "error": False, "failed": False} + statuses = { + "initial": None, + "pending": None, + "confirmed": True, + "error": False, + "failed": False, + } return PaymentStatus(statuses[r.json()["data"]["status"]]) async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: diff --git a/lnbits/wallets/spark.py b/lnbits/wallets/spark.py index 5ab40ddd2..d80e3cc9d 100644 --- a/lnbits/wallets/spark.py +++ b/lnbits/wallets/spark.py @@ -1,11 +1,17 @@ import trio # type: ignore -import random import json import httpx +import random from os import getenv from typing import Optional, AsyncGenerator -from .base import StatusResponse, InvoiceResponse, PaymentResponse, PaymentStatus, Wallet +from .base import ( + StatusResponse, + InvoiceResponse, + PaymentResponse, + PaymentStatus, + Wallet, +) class SparkError(Exception): @@ -22,9 +28,11 @@ class SparkWallet(Wallet): self.token = getenv("SPARK_TOKEN") def __getattr__(self, key): - def call(*args, **kwargs): + async def call(*args, **kwargs): if args and kwargs: - raise TypeError(f"must supply either named arguments or a list of arguments, not both: {args} {kwargs}") + raise TypeError( + f"must supply either named arguments or a list of arguments, not both: {args} {kwargs}" + ) elif args: params = args elif kwargs: @@ -32,12 +40,17 @@ class SparkWallet(Wallet): else: params = {} - r = httpx.post( - self.url + "/rpc", - headers={"X-Access": self.token}, - json={"method": key, "params": params}, - timeout=40, - ) + try: + async with httpx.AsyncClient() as client: + r = await client.post( + self.url + "/rpc", + headers={"X-Access": self.token}, + json={"method": key, "params": params}, + timeout=40, + ) + except (OSError, httpx.ConnectError, httpx.RequestError) as exc: + raise SparkError("error connecting to spark: " + str(exc)) + try: data = r.json() except: @@ -53,9 +66,9 @@ class SparkWallet(Wallet): return call - def status(self) -> StatusResponse: + async def status(self) -> StatusResponse: try: - funds = self.listfunds() + funds = await self.listfunds() except (httpx.ConnectError, httpx.RequestError): return StatusResponse("Couldn't connect to Spark server", 0) except (SparkError, UnknownError) as e: @@ -66,22 +79,28 @@ class SparkWallet(Wallet): sum([ch["channel_sat"] * 1000 for ch in funds["channels"]]), ) - def create_invoice( - self, amount: int, memo: Optional[str] = None, description_hash: Optional[bytes] = None + async def create_invoice( + self, + amount: int, + memo: Optional[str] = None, + description_hash: Optional[bytes] = None, ) -> InvoiceResponse: label = "lbs{}".format(random.random()) checking_id = label try: if description_hash: - r = self.invoicewithdescriptionhash( + r = await self.invoicewithdescriptionhash( msatoshi=amount * 1000, label=label, description_hash=description_hash.hex(), ) else: - r = self.invoice( - msatoshi=amount * 1000, label=label, description=memo or "", exposeprivatechannels=True + r = await self.invoice( + msatoshi=amount * 1000, + label=label, + description=memo or "", + exposeprivatechannels=True, ) ok, payment_request, error_message = True, r["bolt11"], "" except (SparkError, UnknownError) as e: @@ -89,26 +108,70 @@ class SparkWallet(Wallet): return InvoiceResponse(ok, checking_id, payment_request, error_message) - def pay_invoice(self, bolt11: str) -> PaymentResponse: + async def pay_invoice(self, bolt11: str) -> PaymentResponse: try: - r = self.pay(bolt11) + r = await self.pay(bolt11) except (SparkError, UnknownError) as exc: - return PaymentResponse(False, None, 0, None, str(exc)) + listpays = await self.listpays(bolt11) + if listpays: + pays = listpays["pays"] + + if len(pays) == 0: + return PaymentResponse(False, None, 0, None, str(exc)) + + pay = pays[0] + payment_hash = pay["payment_hash"] + + if len(pays) > 1: + raise Exception( + f"listpays({payment_hash}) returned an unexpected response: {listpays}" + ) + + if pay["status"] == "failed": + return PaymentResponse(False, None, 0, None, str(exc)) + elif pay["status"] == "pending": + return PaymentResponse(None, payment_hash, 0, None, None) + elif pay["status"] == "complete": + r = pay + r["payment_preimage"] = pay["preimage"] + r["msatoshi"] = int(pay["amount_msat"][0:-4]) + r["msatoshi_sent"] = int(pay["amount_sent_msat"][0:-4]) + # this may result in an error if it was paid previously + # our database won't allow the same payment_hash to be added twice + # this is good + pass fee_msat = r["msatoshi_sent"] - r["msatoshi"] preimage = r["payment_preimage"] return PaymentResponse(True, r["payment_hash"], fee_msat, preimage, None) - def get_invoice_status(self, checking_id: str) -> PaymentStatus: - r = self.listinvoices(label=checking_id) + async def get_invoice_status(self, checking_id: str) -> PaymentStatus: + try: + r = await self.listinvoices(label=checking_id) + except (SparkError, UnknownError): + return PaymentStatus(None) + if not r or not r.get("invoices"): return PaymentStatus(None) if r["invoices"][0]["status"] == "unpaid": return PaymentStatus(False) return PaymentStatus(True) - def get_payment_status(self, checking_id: str) -> PaymentStatus: - r = self.listpays(payment_hash=checking_id) + async def get_payment_status(self, checking_id: str) -> PaymentStatus: + # check if it's 32 bytes hex + if len(checking_id) != 64: + return PaymentStatus(None) + try: + int(checking_id, 16) + except ValueError: + return PaymentStatus(None) + + # ask sparko + try: + r = await self.listpays(payment_hash=checking_id) + except (SparkError, UnknownError): + return PaymentStatus(None) + if not r["pays"]: return PaymentStatus(False) if r["pays"][0]["payment_hash"] == checking_id: @@ -132,7 +195,7 @@ class SparkWallet(Wallet): data = json.loads(line[5:]) if "pay_index" in data and data.get("status") == "paid": yield data["label"] - except (OSError, httpx.ReadError): + except (OSError, httpx.ReadError, httpx.ConnectError): pass print("lost connection to spark /stream, retrying in 5 seconds") diff --git a/lnbits/wallets/void.py b/lnbits/wallets/void.py index b66173633..591fa042d 100644 --- a/lnbits/wallets/void.py +++ b/lnbits/wallets/void.py @@ -1,27 +1,37 @@ from typing import Optional, AsyncGenerator -from .base import StatusResponse, InvoiceResponse, PaymentResponse, PaymentStatus, Wallet, Unsupported +from .base import ( + StatusResponse, + InvoiceResponse, + PaymentResponse, + PaymentStatus, + Wallet, + Unsupported, +) class VoidWallet(Wallet): - def create_invoice( - self, amount: int, memo: Optional[str] = None, description_hash: Optional[bytes] = None + async def create_invoice( + self, + amount: int, + memo: Optional[str] = None, + description_hash: Optional[bytes] = None, ) -> InvoiceResponse: raise Unsupported("") - def status(self) -> StatusResponse: + async def status(self) -> StatusResponse: return StatusResponse( "This backend does nothing, it is here just as a placeholder, you must configure an actual backend before being able to do anything useful with LNbits.", 0, ) - def pay_invoice(self, bolt11: str) -> PaymentResponse: + async def pay_invoice(self, bolt11: str) -> PaymentResponse: raise Unsupported("") - def get_invoice_status(self, checking_id: str) -> PaymentStatus: + async def get_invoice_status(self, checking_id: str) -> PaymentStatus: raise Unsupported("") - def get_payment_status(self, checking_id: str) -> PaymentStatus: + async def get_payment_status(self, checking_id: str) -> PaymentStatus: raise Unsupported("") async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index 55ec8d784..000000000 --- a/pyproject.toml +++ /dev/null @@ -1,2 +0,0 @@ -[tool.black] -line-length = 120 diff --git a/requirements.txt b/requirements.txt index 19d57cb1c..6cab24b96 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,40 +6,42 @@ bitstring==3.1.7 blinker==1.4 brotli==1.0.9 cerberus==1.3.2 -certifi==2020.11.8 +certifi==2020.12.5 click==7.1.2 ecdsa==0.16.1 -environs==9.2.0 -h11==0.11.0 +environs==9.3.1 +h11==0.12.0 h2==4.0.0 hpack==4.0.0 -httpcore==0.12.2 +httpcore==0.12.3 httpx==0.16.1 -hypercorn==0.11.1 +hypercorn==0.11.2 hyperframe==6.0.0 -idna==2.10 +idna==3.1 itsdangerous==1.1.0 -jinja2==2.11.2 +jinja2==2.11.3 lnurl==0.3.5 markupsafe==1.1.1 -marshmallow==3.9.1 +marshmallow==3.10.0 outcome==1.1.0 priority==1.3.0 -pydantic==1.7.2 +pydantic==1.8 +pypng==0.0.20 +pyqrcode==1.2.1 pyscss==1.3.7 python-dotenv==0.15.0 -quart==0.13.1 +quart==0.14.1 quart-compress==0.2.1 quart-cors==0.3.0 -quart-trio==0.6.0 -represent==1.6.0 +quart-trio==0.7.0 +represent==1.6.0.post0 rfc3986==1.4.0 secure==0.2.1 shortuuid==1.0.1 six==1.15.0 sniffio==1.2.0 sortedcontainers==2.3.0 -sqlalchemy==1.3.20 +sqlalchemy==1.3.23 sqlalchemy-aio==0.16.0 toml==0.10.2 trio==0.16.0