Merge remote-tracking branch 'origin/master' into watchonly

This commit is contained in:
Ben Arc 2021-03-31 10:43:55 +01:00
commit b05b8c0115
130 changed files with 6532 additions and 1003 deletions

View File

@ -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 = "*"

542
Pipfile.lock generated
View File

@ -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": [

View File

@ -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

View File

@ -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():

View File

@ -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()

View File

@ -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."
)

View File

@ -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",
)

View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -438,7 +438,7 @@
<q-input
filled
dense
v-model.number="parse.data.comment"
v-model="parse.data.comment"
:type="parse.lnurlpay.commentAllowed > 64 ? 'textarea' : 'text'"
label="Comment (optional)"
:maxlength="parse.lnurlpay.commentAllowed"

View File

@ -3,7 +3,7 @@ import json
import lnurl # type: ignore
import httpx
from urllib.parse import urlparse, urlunparse, urlencode, parse_qs, ParseResult
from quart import g, jsonify, request, make_response
from quart import g, jsonify, make_response
from http import HTTPStatus
from binascii import unhexlify
from typing import Dict, Union
@ -13,7 +13,6 @@ from lnbits.decorators import api_check_wallet_key, api_validate_post_request
from .. import core_app, db
from ..services import create_invoice, pay_invoice, perform_lnurlauth
from ..crud import delete_expired_invoices
from ..tasks import sse_listeners
@ -35,12 +34,6 @@ async def api_wallet():
@core_app.route("/api/v1/payments", methods=["GET"])
@api_check_wallet_key("invoice")
async def api_payments():
if "check_pending" in request.args:
await delete_expired_invoices()
for payment in await g.wallet.get_payments(complete=False, pending=True, exclude_uncheckable=True):
await payment.check_pending()
return jsonify(await g.wallet.get_payments(pending=True)), HTTPStatus.OK
@ -48,8 +41,18 @@ async def api_payments():
@api_validate_post_request(
schema={
"amount": {"type": "integer", "min": 1, "required": True},
"memo": {"type": "string", "empty": False, "required": True, "excludes": "description_hash"},
"description_hash": {"type": "string", "empty": False, "required": True, "excludes": "memo"},
"memo": {
"type": "string",
"empty": False,
"required": True,
"excludes": "description_hash",
},
"description_hash": {
"type": "string",
"empty": False,
"required": True,
"excludes": "memo",
},
"lnurl_callback": {"type": "string", "nullable": True, "required": False},
"extra": {"type": "dict", "nullable": True, "required": False},
"webhook": {"type": "string", "empty": False, "required": False},
@ -63,7 +66,7 @@ async def api_payments_create_invoice():
description_hash = b""
memo = g.data["memo"]
try:
async with db.connect() as conn:
payment_hash, payment_request = await create_invoice(
wallet_id=g.wallet.id,
amount=g.data["amount"],
@ -71,12 +74,8 @@ async def api_payments_create_invoice():
description_hash=description_hash,
extra=g.data.get("extra"),
webhook=g.data.get("webhook"),
conn=conn,
)
except Exception as exc:
await db.rollback()
raise exc
await db.commit()
invoice = bolt11.decode(payment_request)
@ -115,16 +114,20 @@ async def api_payments_create_invoice():
@api_check_wallet_key("admin")
@api_validate_post_request(schema={"bolt11": {"type": "string", "empty": False, "required": True}})
@api_validate_post_request(
schema={"bolt11": {"type": "string", "empty": False, "required": True}}
)
async def api_payments_pay_invoice():
try:
payment_hash = await pay_invoice(wallet_id=g.wallet.id, payment_request=g.data["bolt11"])
payment_hash = await pay_invoice(
wallet_id=g.wallet.id,
payment_request=g.data["bolt11"],
)
except ValueError as e:
return jsonify({"message": str(e)}), HTTPStatus.BAD_REQUEST
except PermissionError as e:
return jsonify({"message": str(e)}), HTTPStatus.FORBIDDEN
except Exception as exc:
await db.rollback()
raise exc
return (
@ -154,8 +157,18 @@ async def api_payments_create():
"description_hash": {"type": "string", "empty": False, "required": True},
"callback": {"type": "string", "empty": False, "required": True},
"amount": {"type": "number", "empty": False, "required": True},
"comment": {"type": "string", "nullable": True, "empty": True, "required": False},
"description": {"type": "string", "nullable": True, "empty": True, "required": False},
"comment": {
"type": "string",
"nullable": True,
"empty": True,
"required": False,
},
"description": {
"type": "string",
"nullable": True,
"empty": True,
"required": False,
},
}
)
async def api_payments_pay_lnurl():
@ -175,7 +188,10 @@ async def api_payments_pay_lnurl():
params = json.loads(r.text)
if params.get("status") == "ERROR":
return jsonify({"message": f"{domain} said: '{params.get('reason', '')}'"}), HTTPStatus.BAD_REQUEST
return (
jsonify({"message": f"{domain} said: '{params.get('reason', '')}'"}),
HTTPStatus.BAD_REQUEST,
)
invoice = bolt11.decode(params["pr"])
if invoice.amount_msat != g.data["amount"]:
@ -197,23 +213,19 @@ async def api_payments_pay_lnurl():
HTTPStatus.BAD_REQUEST,
)
try:
extra = {}
extra = {}
if params.get("successAction"):
extra["success_action"] = params["successAction"]
if g.data["comment"]:
extra["comment"] = g.data["comment"]
if params.get("successAction"):
extra["success_action"] = params["successAction"]
if g.data["comment"]:
extra["comment"] = g.data["comment"]
payment_hash = await pay_invoice(
wallet_id=g.wallet.id,
payment_request=params["pr"],
description=g.data.get("description", ""),
extra=extra,
)
except Exception as exc:
await db.rollback()
raise exc
payment_hash = await pay_invoice(
wallet_id=g.wallet.id,
payment_request=params["pr"],
description=g.data.get("description", ""),
extra=extra,
)
return (
jsonify(
@ -243,7 +255,10 @@ async def api_payment(payment_hash):
except Exception:
return jsonify({"paid": False}), HTTPStatus.OK
return jsonify({"paid": not payment.pending, "preimage": payment.preimage}), HTTPStatus.OK
return (
jsonify({"paid": not payment.pending, "preimage": payment.preimage}),
HTTPStatus.OK,
)
@core_app.route("/api/v1/payments/sse", methods=["GET"])
@ -332,12 +347,22 @@ async def api_lnurlscan(code: str):
data: lnurl.LnurlResponseModel = lnurl.LnurlResponse.from_dict(jdata)
except (json.decoder.JSONDecodeError, lnurl.exceptions.LnurlResponseException):
return (
jsonify({"domain": domain, "message": f"got invalid response '{r.text[:200]}'"}),
jsonify(
{
"domain": domain,
"message": f"got invalid response '{r.text[:200]}'",
}
),
HTTPStatus.SERVICE_UNAVAILABLE,
)
if type(data) is lnurl.LnurlChannelResponse:
return jsonify({"domain": domain, "kind": "channel", "message": "unsupported"}), HTTPStatus.BAD_REQUEST
return (
jsonify(
{"domain": domain, "kind": "channel", "message": "unsupported"}
),
HTTPStatus.BAD_REQUEST,
)
params.update(**data.dict())

View File

@ -2,14 +2,21 @@ import trio # type: ignore
import httpx
from os import path
from http import HTTPStatus
from quart import g, abort, redirect, request, render_template, send_from_directory, url_for
from quart import (
g,
abort,
redirect,
request,
render_template,
send_from_directory,
url_for,
)
from lnurl import LnurlResponse, LnurlWithdrawResponse, decode as decode_lnurl # type: ignore
from lnbits.core import core_app
from lnbits.core import core_app, db
from lnbits.decorators import check_user_exists, validate_uuids
from lnbits.settings import LNBITS_ALLOWED_USERS, SERVICE_FEE
from .. import db
from ..crud import (
create_account,
get_user,
@ -22,12 +29,16 @@ from ..services import redeem_lnurl_withdraw
@core_app.route("/favicon.ico")
async def favicon():
return await send_from_directory(path.join(core_app.root_path, "static"), "favicon.ico")
return await send_from_directory(
path.join(core_app.root_path, "static"), "favicon.ico"
)
@core_app.route("/")
async def home():
return await render_template("core/index.html", lnurl=request.args.get("lightning", None))
return await render_template(
"core/index.html", lnurl=request.args.get("lightning", None)
)
@core_app.route("/extensions")
@ -38,12 +49,18 @@ async def extensions():
extension_to_disable = request.args.get("disable", type=str)
if extension_to_enable and extension_to_disable:
abort(HTTPStatus.BAD_REQUEST, "You can either `enable` or `disable` an extension.")
abort(
HTTPStatus.BAD_REQUEST, "You can either `enable` or `disable` an extension."
)
if extension_to_enable:
await update_user_extension(user_id=g.user.id, extension=extension_to_enable, active=1)
await update_user_extension(
user_id=g.user.id, extension=extension_to_enable, active=1
)
elif extension_to_disable:
await update_user_extension(user_id=g.user.id, extension=extension_to_disable, active=0)
await update_user_extension(
user_id=g.user.id, extension=extension_to_disable, active=0
)
return await render_template("core/extensions.html", user=await get_user(g.user.id))
@ -85,7 +102,9 @@ async def wallet():
if not wallet:
abort(HTTPStatus.FORBIDDEN, "Not your wallet.")
return await render_template("core/wallet.html", user=user, wallet=wallet, service_fee=service_fee)
return await render_template(
"core/wallet.html", user=user, wallet=wallet, service_fee=service_fee
)
@core_app.route("/deletewallet")
@ -116,19 +135,33 @@ async def lnurlwallet():
withdraw_res = LnurlResponse.from_dict(r.json())
if not withdraw_res.ok:
return f"Could not process lnurl-withdraw: {withdraw_res.error_msg}", HTTPStatus.BAD_REQUEST
return (
f"Could not process lnurl-withdraw: {withdraw_res.error_msg}",
HTTPStatus.BAD_REQUEST,
)
if not isinstance(withdraw_res, LnurlWithdrawResponse):
return f"Expected an lnurl-withdraw code, got {withdraw_res.tag}", HTTPStatus.BAD_REQUEST
return (
f"Expected an lnurl-withdraw code, got {withdraw_res.tag}",
HTTPStatus.BAD_REQUEST,
)
except Exception as exc:
return f"Could not process lnurl-withdraw: {exc}", HTTPStatus.INTERNAL_SERVER_ERROR
return (
f"Could not process lnurl-withdraw: {exc}",
HTTPStatus.INTERNAL_SERVER_ERROR,
)
account = await create_account()
user = await get_user(account.id)
wallet = await create_wallet(user_id=user.id)
await db.commit()
async with db.connect() as conn:
account = await create_account(conn=conn)
user = await get_user(account.id, conn=conn)
wallet = await create_wallet(user_id=user.id, conn=conn)
g.nursery.start_soon(redeem_lnurl_withdraw, wallet.id, withdraw_res, "LNbits initial funding: voucher redeem.")
g.nursery.start_soon(
redeem_lnurl_withdraw,
wallet.id,
withdraw_res,
"LNbits initial funding: voucher redeem.",
)
await trio.sleep(3)
return redirect(url_for("core.wallet", usr=user.id, wal=wallet.id))

View File

@ -1,55 +1,54 @@
import os
from typing import Tuple, Optional, Any
from sqlalchemy_aio import TRIO_STRATEGY # type: ignore
import trio
from contextlib import asynccontextmanager
from sqlalchemy import create_engine # type: ignore
from quart import g
from sqlalchemy_aio import TRIO_STRATEGY # type: ignore
from sqlalchemy_aio.base import AsyncConnection
from .settings import LNBITS_DATA_FOLDER
class Connection:
def __init__(self, conn: AsyncConnection):
self.conn = conn
async def fetchall(self, query: str, values: tuple = ()) -> 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

View File

@ -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)):

View File

@ -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

View File

@ -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]

View File

@ -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/<amilk_id>", 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

View File

@ -0,0 +1,21 @@
# Bleskomat Extension for lnbits
This extension allows you to connect a Bleskomat ATM to an lnbits wallet. It will work with both the [open-source DIY Bleskomat ATM project](https://github.com/samotari/bleskomat) as well as the [commercial Bleskomat ATM](https://www.bleskomat.com/).
## Connect Your Bleskomat ATM
* Click the "Add Bleskomat" button on this page to begin.
* Choose a wallet. This will be the wallet that is used to pay satoshis to your ATM customers.
* Choose the fiat currency. This should match the fiat currency that your ATM accepts.
* Pick an exchange rate provider. This is the API that will be used to query the fiat to satoshi exchange rate at the time your customer attempts to withdraw their funds.
* Set your ATM's fee percentage.
* Click the "Done" button.
* Find the new Bleskomat in the list and then click the export icon to download a new configuration file for your ATM.
* Copy the configuration file ("bleskomat.conf") to your ATM's SD card.
* Restart Your Bleskomat ATM. It should automatically reload the configurations from the SD card.
## How Does It Work?
Since the Bleskomat ATMs are designed to be offline, a cryptographic signing scheme is used to verify that the URL was generated by an authorized device. When one of your customers inserts fiat money into the device, a signed URL (lnurl-withdraw) is created and displayed as a QR code. Your customer scans the QR code with their lnurl-supporting mobile app, their mobile app communicates with the web API of lnbits to verify the signature, the fiat currency amount is converted to sats, the customer accepts the withdrawal, and finally lnbits will pay the customer from your lnbits wallet.

View File

@ -0,0 +1,12 @@
from quart import Blueprint
from lnbits.db import Database
db = Database("ext_bleskomat")
bleskomat_ext: Blueprint = Blueprint(
"bleskomat", __name__, static_folder="static", template_folder="templates"
)
from .lnurl_api import * # noqa
from .views_api import * # noqa
from .views import * # noqa

View File

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

View File

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

View File

@ -0,0 +1,79 @@
import httpx
import json
import os
fiat_currencies = json.load(
open(
os.path.join(
os.path.dirname(os.path.realpath(__file__)), "fiat_currencies.json"
),
"r",
)
)
exchange_rate_providers = {
"bitfinex": {
"name": "Bitfinex",
"domain": "bitfinex.com",
"api_url": "https://api.bitfinex.com/v1/pubticker/{from}{to}",
"getter": lambda data, replacements: data["last_price"],
},
"bitstamp": {
"name": "Bitstamp",
"domain": "bitstamp.net",
"api_url": "https://www.bitstamp.net/api/v2/ticker/{from}{to}/",
"getter": lambda data, replacements: data["last"],
},
"coinbase": {
"name": "Coinbase",
"domain": "coinbase.com",
"api_url": "https://api.coinbase.com/v2/exchange-rates?currency={FROM}",
"getter": lambda data, replacements: data["data"]["rates"][replacements["TO"]],
},
"coinmate": {
"name": "CoinMate",
"domain": "coinmate.io",
"api_url": "https://coinmate.io/api/ticker?currencyPair={FROM}_{TO}",
"getter": lambda data, replacements: data["data"]["last"],
},
"kraken": {
"name": "Kraken",
"domain": "kraken.com",
"api_url": "https://api.kraken.com/0/public/Ticker?pair=XBT{TO}",
"getter": lambda data, replacements: data["result"][
"XXBTZ" + replacements["TO"]
]["c"][0],
},
}
exchange_rate_providers_serializable = {}
for ref, exchange_rate_provider in exchange_rate_providers.items():
exchange_rate_provider_serializable = {}
for key, value in exchange_rate_provider.items():
if not callable(value):
exchange_rate_provider_serializable[key] = value
exchange_rate_providers_serializable[ref] = exchange_rate_provider_serializable
async def fetch_fiat_exchange_rate(currency: str, provider: str):
replacements = {
"FROM": "BTC",
"from": "btc",
"TO": currency.upper(),
"to": currency.lower(),
}
url = exchange_rate_providers[provider]["api_url"]
for key in replacements.keys():
url = url.replace("{" + key + "}", replacements[key])
getter = exchange_rate_providers[provider]["getter"]
async with httpx.AsyncClient() as client:
r = await client.get(url)
r.raise_for_status()
data = r.json()
rate = float(getter(data, replacements))
return rate

View File

@ -0,0 +1,166 @@
{
"AED": "United Arab Emirates Dirham",
"AFN": "Afghan Afghani",
"ALL": "Albanian Lek",
"AMD": "Armenian Dram",
"ANG": "Netherlands Antillean Gulden",
"AOA": "Angolan Kwanza",
"ARS": "Argentine Peso",
"AUD": "Australian Dollar",
"AWG": "Aruban Florin",
"AZN": "Azerbaijani Manat",
"BAM": "Bosnia and Herzegovina Convertible Mark",
"BBD": "Barbadian Dollar",
"BDT": "Bangladeshi Taka",
"BGN": "Bulgarian Lev",
"BHD": "Bahraini Dinar",
"BIF": "Burundian Franc",
"BMD": "Bermudian Dollar",
"BND": "Brunei Dollar",
"BOB": "Bolivian Boliviano",
"BRL": "Brazilian Real",
"BSD": "Bahamian Dollar",
"BTN": "Bhutanese Ngultrum",
"BWP": "Botswana Pula",
"BYN": "Belarusian Ruble",
"BYR": "Belarusian Ruble",
"BZD": "Belize Dollar",
"CAD": "Canadian Dollar",
"CDF": "Congolese Franc",
"CHF": "Swiss Franc",
"CLF": "Unidad de Fomento",
"CLP": "Chilean Peso",
"CNH": "Chinese Renminbi Yuan Offshore",
"CNY": "Chinese Renminbi Yuan",
"COP": "Colombian Peso",
"CRC": "Costa Rican Colón",
"CUC": "Cuban Convertible Peso",
"CVE": "Cape Verdean Escudo",
"CZK": "Czech Koruna",
"DJF": "Djiboutian Franc",
"DKK": "Danish Krone",
"DOP": "Dominican Peso",
"DZD": "Algerian Dinar",
"EGP": "Egyptian Pound",
"ERN": "Eritrean Nakfa",
"ETB": "Ethiopian Birr",
"EUR": "Euro",
"FJD": "Fijian Dollar",
"FKP": "Falkland Pound",
"GBP": "British Pound",
"GEL": "Georgian Lari",
"GGP": "Guernsey Pound",
"GHS": "Ghanaian Cedi",
"GIP": "Gibraltar Pound",
"GMD": "Gambian Dalasi",
"GNF": "Guinean Franc",
"GTQ": "Guatemalan Quetzal",
"GYD": "Guyanese Dollar",
"HKD": "Hong Kong Dollar",
"HNL": "Honduran Lempira",
"HRK": "Croatian Kuna",
"HTG": "Haitian Gourde",
"HUF": "Hungarian Forint",
"IDR": "Indonesian Rupiah",
"ILS": "Israeli New Sheqel",
"IMP": "Isle of Man Pound",
"INR": "Indian Rupee",
"IQD": "Iraqi Dinar",
"ISK": "Icelandic Króna",
"JEP": "Jersey Pound",
"JMD": "Jamaican Dollar",
"JOD": "Jordanian Dinar",
"JPY": "Japanese Yen",
"KES": "Kenyan Shilling",
"KGS": "Kyrgyzstani Som",
"KHR": "Cambodian Riel",
"KMF": "Comorian Franc",
"KRW": "South Korean Won",
"KWD": "Kuwaiti Dinar",
"KYD": "Cayman Islands Dollar",
"KZT": "Kazakhstani Tenge",
"LAK": "Lao Kip",
"LBP": "Lebanese Pound",
"LKR": "Sri Lankan Rupee",
"LRD": "Liberian Dollar",
"LSL": "Lesotho Loti",
"LYD": "Libyan Dinar",
"MAD": "Moroccan Dirham",
"MDL": "Moldovan Leu",
"MGA": "Malagasy Ariary",
"MKD": "Macedonian Denar",
"MMK": "Myanmar Kyat",
"MNT": "Mongolian Tögrög",
"MOP": "Macanese Pataca",
"MRO": "Mauritanian Ouguiya",
"MUR": "Mauritian Rupee",
"MVR": "Maldivian Rufiyaa",
"MWK": "Malawian Kwacha",
"MXN": "Mexican Peso",
"MYR": "Malaysian Ringgit",
"MZN": "Mozambican Metical",
"NAD": "Namibian Dollar",
"NGN": "Nigerian Naira",
"NIO": "Nicaraguan Córdoba",
"NOK": "Norwegian Krone",
"NPR": "Nepalese Rupee",
"NZD": "New Zealand Dollar",
"OMR": "Omani Rial",
"PAB": "Panamanian Balboa",
"PEN": "Peruvian Sol",
"PGK": "Papua New Guinean Kina",
"PHP": "Philippine Peso",
"PKR": "Pakistani Rupee",
"PLN": "Polish Złoty",
"PYG": "Paraguayan Guaraní",
"QAR": "Qatari Riyal",
"RON": "Romanian Leu",
"RSD": "Serbian Dinar",
"RUB": "Russian Ruble",
"RWF": "Rwandan Franc",
"SAR": "Saudi Riyal",
"SBD": "Solomon Islands Dollar",
"SCR": "Seychellois Rupee",
"SEK": "Swedish Krona",
"SGD": "Singapore Dollar",
"SHP": "Saint Helenian Pound",
"SLL": "Sierra Leonean Leone",
"SOS": "Somali Shilling",
"SRD": "Surinamese Dollar",
"SSP": "South Sudanese Pound",
"STD": "São Tomé and Príncipe Dobra",
"SVC": "Salvadoran Colón",
"SZL": "Swazi Lilangeni",
"THB": "Thai Baht",
"TJS": "Tajikistani Somoni",
"TMT": "Turkmenistani Manat",
"TND": "Tunisian Dinar",
"TOP": "Tongan Paʻanga",
"TRY": "Turkish Lira",
"TTD": "Trinidad and Tobago Dollar",
"TWD": "New Taiwan Dollar",
"TZS": "Tanzanian Shilling",
"UAH": "Ukrainian Hryvnia",
"UGX": "Ugandan Shilling",
"USD": "US Dollar",
"UYU": "Uruguayan Peso",
"UZS": "Uzbekistan Som",
"VEF": "Venezuelan Bolívar",
"VES": "Venezuelan Bolívar Soberano",
"VND": "Vietnamese Đồng",
"VUV": "Vanuatu Vatu",
"WST": "Samoan Tala",
"XAF": "Central African Cfa Franc",
"XAG": "Silver (Troy Ounce)",
"XAU": "Gold (Troy Ounce)",
"XCD": "East Caribbean Dollar",
"XDR": "Special Drawing Rights",
"XOF": "West African Cfa Franc",
"XPD": "Palladium",
"XPF": "Cfp Franc",
"XPT": "Platinum",
"YER": "Yemeni Rial",
"ZAR": "South African Rand",
"ZMW": "Zambian Kwacha",
"ZWL": "Zimbabwean Dollar"
}

View File

@ -0,0 +1,153 @@
import base64
import hashlib
import hmac
from http import HTTPStatus
from binascii import unhexlify
from typing import Dict
from quart import url_for
import urllib
def generate_bleskomat_lnurl_hash(secret: str):
m = hashlib.sha256()
m.update(f"{secret}".encode())
return m.hexdigest()
def generate_bleskomat_lnurl_signature(
payload: str, api_key_secret: str, api_key_encoding: str = "hex"
):
if api_key_encoding == "hex":
key = unhexlify(api_key_secret)
elif api_key_encoding == "base64":
key = base64.b64decode(api_key_secret)
else:
key = bytes(f"{api_key_secret}")
return hmac.new(key=key, msg=payload.encode(), digestmod=hashlib.sha256).hexdigest()
def generate_bleskomat_lnurl_secret(api_key_id: str, signature: str):
# The secret is not randomly generated by the server.
# Instead it is the hash of the API key ID and signature concatenated together.
m = hashlib.sha256()
m.update(f"{api_key_id}-{signature}".encode())
return m.hexdigest()
def get_callback_url():
return url_for("bleskomat.api_bleskomat_lnurl", _external=True)
def is_supported_lnurl_subprotocol(tag: str) -> bool:
return tag == "withdrawRequest"
class LnurlHttpError(Exception):
def __init__(
self,
message: str = "",
http_status: HTTPStatus = HTTPStatus.INTERNAL_SERVER_ERROR,
):
self.message = message
self.http_status = http_status
super().__init__(self.message)
class LnurlValidationError(Exception):
pass
def prepare_lnurl_params(tag: str, query: Dict[str, str]):
params = {}
if not is_supported_lnurl_subprotocol(tag):
raise LnurlValidationError(f'Unsupported subprotocol: "{tag}"')
if tag == "withdrawRequest":
params["minWithdrawable"] = float(query["minWithdrawable"])
params["maxWithdrawable"] = float(query["maxWithdrawable"])
params["defaultDescription"] = query["defaultDescription"]
if not params["minWithdrawable"] > 0:
raise LnurlValidationError('"minWithdrawable" must be greater than zero')
if not params["maxWithdrawable"] >= params["minWithdrawable"]:
raise LnurlValidationError(
'"maxWithdrawable" must be greater than or equal to "minWithdrawable"'
)
return params
encode_uri_component_safe_chars = (
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.!~*'()"
)
def query_to_signing_payload(query: Dict[str, str]) -> str:
# Sort the query by key, then stringify it to create the payload.
sorted_keys = sorted(query.keys(), key=str.lower)
payload = []
for key in sorted_keys:
if not key == "signature":
encoded_key = urllib.parse.quote(key, safe=encode_uri_component_safe_chars)
encoded_value = urllib.parse.quote(
query[key], safe=encode_uri_component_safe_chars
)
payload.append(f"{encoded_key}={encoded_value}")
return "&".join(payload)
unshorten_rules = {
"query": {"n": "nonce", "s": "signature", "t": "tag"},
"tags": {
"c": "channelRequest",
"l": "login",
"p": "payRequest",
"w": "withdrawRequest",
},
"params": {
"channelRequest": {"pl": "localAmt", "pp": "pushAmt"},
"login": {},
"payRequest": {"pn": "minSendable", "px": "maxSendable", "pm": "metadata"},
"withdrawRequest": {
"pn": "minWithdrawable",
"px": "maxWithdrawable",
"pd": "defaultDescription",
},
},
}
def unshorten_lnurl_query(query: Dict[str, str]) -> Dict[str, str]:
new_query = {}
rules = unshorten_rules
if "tag" in query:
tag = query["tag"]
elif "t" in query:
tag = query["t"]
else:
raise LnurlValidationError('Missing required query parameter: "tag"')
# Unshorten tag:
if tag in rules["tags"]:
long_tag = rules["tags"][tag]
new_query["tag"] = long_tag
tag = long_tag
if not tag in rules["params"]:
raise LnurlValidationError(f'Unknown tag: "{tag}"')
for key in query:
if key in rules["params"][tag]:
short_param_key = key
long_param_key = rules["params"][tag][short_param_key]
if short_param_key in query:
new_query[long_param_key] = query[short_param_key]
else:
new_query[long_param_key] = query[long_param_key]
elif key in rules["query"]:
# Unshorten general keys:
short_key = key
long_key = rules["query"][short_key]
if not long_key in new_query:
if short_key in query:
new_query[long_key] = query[short_key]
else:
new_query[long_key] = query[long_key]
else:
# Keep unknown key/value pairs unchanged:
new_query[key] = query[key]
return new_query

View File

@ -0,0 +1,134 @@
import json
import math
from quart import jsonify, request
from http import HTTPStatus
import traceback
from . import bleskomat_ext
from .crud import (
create_bleskomat_lnurl,
get_bleskomat_by_api_key_id,
get_bleskomat_lnurl,
)
from .exchange_rates import (
fetch_fiat_exchange_rate,
)
from .helpers import (
generate_bleskomat_lnurl_signature,
generate_bleskomat_lnurl_secret,
LnurlHttpError,
LnurlValidationError,
prepare_lnurl_params,
query_to_signing_payload,
unshorten_lnurl_query,
)
# Handles signed URL from Bleskomat ATMs and "action" callback of auto-generated LNURLs.
@bleskomat_ext.route("/u", methods=["GET"])
async def api_bleskomat_lnurl():
try:
query = request.args.to_dict()
# Unshorten query if "s" is used instead of "signature".
if "s" in query:
query = unshorten_lnurl_query(query)
if "signature" in query:
# Signature provided.
# Use signature to verify that the URL was generated by an authorized device.
# Later validate parameters, auto-generate LNURL, reply with LNURL response object.
signature = query["signature"]
# The API key ID, nonce, and tag should be present in the query string.
for field in ["id", "nonce", "tag"]:
if not field in query:
raise LnurlHttpError(
f'Failed API key signature check: Missing "{field}"',
HTTPStatus.BAD_REQUEST,
)
# URL signing scheme is described here:
# https://github.com/chill117/lnurl-node#how-to-implement-url-signing-scheme
payload = query_to_signing_payload(query)
api_key_id = query["id"]
bleskomat = await get_bleskomat_by_api_key_id(api_key_id)
if not bleskomat:
raise LnurlHttpError("Unknown API key", HTTPStatus.BAD_REQUEST)
api_key_secret = bleskomat.api_key_secret
api_key_encoding = bleskomat.api_key_encoding
expected_signature = generate_bleskomat_lnurl_signature(
payload, api_key_secret, api_key_encoding
)
if signature != expected_signature:
raise LnurlHttpError("Invalid API key signature", HTTPStatus.FORBIDDEN)
# Signature is valid.
# In the case of signed URLs, the secret is deterministic based on the API key ID and signature.
secret = generate_bleskomat_lnurl_secret(api_key_id, signature)
lnurl = await get_bleskomat_lnurl(secret)
if not lnurl:
try:
tag = query["tag"]
params = prepare_lnurl_params(tag, query)
if "f" in query:
rate = await fetch_fiat_exchange_rate(
currency=query["f"],
provider=bleskomat.exchange_rate_provider,
)
# Convert fee (%) to decimal:
fee = float(bleskomat.fee) / 100
if tag == "withdrawRequest":
for key in ["minWithdrawable", "maxWithdrawable"]:
amount_sats = int(
math.floor((params[key] / rate) * 1e8)
)
fee_sats = int(math.floor(amount_sats * fee))
amount_sats_less_fee = amount_sats - fee_sats
# Convert to msats:
params[key] = int(amount_sats_less_fee * 1e3)
except LnurlValidationError as e:
raise LnurlHttpError(e.message, HTTPStatus.BAD_REQUEST)
# Create a new LNURL using the query parameters provided in the signed URL.
params = json.JSONEncoder().encode(params)
lnurl = await create_bleskomat_lnurl(
bleskomat=bleskomat, secret=secret, tag=tag, params=params, uses=1
)
# Reply with LNURL response object.
return jsonify(lnurl.get_info_response_object(secret)), HTTPStatus.OK
# No signature provided.
# Treat as "action" callback.
if not "k1" in query:
raise LnurlHttpError("Missing secret", HTTPStatus.BAD_REQUEST)
secret = query["k1"]
lnurl = await get_bleskomat_lnurl(secret)
if not lnurl:
raise LnurlHttpError("Invalid secret", HTTPStatus.BAD_REQUEST)
if not lnurl.has_uses_remaining():
raise LnurlHttpError(
"Maximum number of uses already reached", HTTPStatus.BAD_REQUEST
)
try:
await lnurl.execute_action(query)
except LnurlValidationError as e:
raise LnurlHttpError(str(e), HTTPStatus.BAD_REQUEST)
except LnurlHttpError as e:
return jsonify({"status": "ERROR", "reason": str(e)}), e.http_status
except Exception:
traceback.print_exc()
return (
jsonify({"status": "ERROR", "reason": "Unexpected error"}),
HTTPStatus.INTERNAL_SERVER_ERROR,
)
return jsonify({"status": "OK"}), HTTPStatus.OK

View File

@ -0,0 +1,37 @@
async def m001_initial(db):
await db.execute(
"""
CREATE TABLE 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)
);
"""
)

View File

@ -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

View File

@ -0,0 +1,216 @@
/* global Vue, VueQrcode, _, Quasar, LOCALE, windowMixin, LNbits */
Vue.component(VueQrcode.name, VueQrcode)
var mapBleskomat = function (obj) {
obj._data = _.clone(obj)
return obj
}
var defaultValues = {
name: 'My Bleskomat',
fiat_currency: 'EUR',
exchange_rate_provider: 'coinbase',
fee: '0.00'
}
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
checker: null,
bleskomats: [],
bleskomatsTable: {
columns: [
{
name: 'api_key_id',
align: 'left',
label: 'API Key ID',
field: 'api_key_id'
},
{
name: 'name',
align: 'left',
label: 'Name',
field: 'name'
},
{
name: 'fiat_currency',
align: 'left',
label: 'Fiat Currency',
field: 'fiat_currency'
},
{
name: 'exchange_rate_provider',
align: 'left',
label: 'Exchange Rate Provider',
field: 'exchange_rate_provider'
},
{
name: 'fee',
align: 'left',
label: 'Fee (%)',
field: 'fee'
}
],
pagination: {
rowsPerPage: 10
}
},
formDialog: {
show: false,
fiatCurrencies: _.keys(window.bleskomat_vars.fiat_currencies),
exchangeRateProviders: _.keys(
window.bleskomat_vars.exchange_rate_providers
),
data: _.clone(defaultValues)
}
}
},
computed: {
sortedBleskomats: function () {
return this.bleskomats.sort(function (a, b) {
// Sort by API Key ID alphabetically.
var apiKeyId_A = a.api_key_id.toLowerCase()
var apiKeyId_B = b.api_key_id.toLowerCase()
return apiKeyId_A < apiKeyId_B ? -1 : apiKeyId_A > apiKeyId_B ? 1 : 0
})
}
},
methods: {
getBleskomats: function () {
var self = this
LNbits.api
.request(
'GET',
'/bleskomat/api/v1/bleskomats?all_wallets',
this.g.user.wallets[0].adminkey
)
.then(function (response) {
self.bleskomats = response.data.map(function (obj) {
return mapBleskomat(obj)
})
})
.catch(function (error) {
clearInterval(self.checker)
LNbits.utils.notifyApiError(error)
})
},
closeFormDialog: function () {
this.formDialog.data = _.clone(defaultValues)
},
exportConfigFile: function (bleskomatId) {
var bleskomat = _.findWhere(this.bleskomats, {id: bleskomatId})
var fieldToKey = {
api_key_id: 'apiKey.id',
api_key_secret: 'apiKey.key',
api_key_encoding: 'apiKey.encoding',
fiat_currency: 'fiatCurrency'
}
var lines = _.chain(bleskomat)
.map(function (value, field) {
var key = fieldToKey[field] || null
return key ? [key, value].join('=') : null
})
.compact()
.value()
lines.push('callbackUrl=' + window.bleskomat_vars.callback_url)
lines.push('shorten=true')
var content = lines.join('\n')
var status = Quasar.utils.exportFile(
'bleskomat.conf',
content,
'text/plain'
)
if (status !== true) {
Quasar.plugins.Notify.create({
message: 'Browser denied file download...',
color: 'negative',
icon: null
})
}
},
openUpdateDialog: function (bleskomatId) {
var bleskomat = _.findWhere(this.bleskomats, {id: bleskomatId})
this.formDialog.data = _.clone(bleskomat._data)
this.formDialog.show = true
},
sendFormData: function () {
var wallet = _.findWhere(this.g.user.wallets, {
id: this.formDialog.data.wallet
})
var data = _.omit(this.formDialog.data, 'wallet')
if (data.id) {
this.updateBleskomat(wallet, data)
} else {
this.createBleskomat(wallet, data)
}
},
updateBleskomat: function (wallet, data) {
var self = this
LNbits.api
.request(
'PUT',
'/bleskomat/api/v1/bleskomat/' + data.id,
wallet.adminkey,
_.pick(data, 'name', 'fiat_currency', 'exchange_rate_provider', 'fee')
)
.then(function (response) {
self.bleskomats = _.reject(self.bleskomats, function (obj) {
return obj.id === data.id
})
self.bleskomats.push(mapBleskomat(response.data))
self.formDialog.show = false
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
createBleskomat: function (wallet, data) {
var self = this
LNbits.api
.request('POST', '/bleskomat/api/v1/bleskomat', wallet.adminkey, data)
.then(function (response) {
self.bleskomats.push(mapBleskomat(response.data))
self.formDialog.show = false
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
deleteBleskomat: function (bleskomatId) {
var self = this
var bleskomat = _.findWhere(this.bleskomats, {id: bleskomatId})
LNbits.utils
.confirmDialog(
'Are you sure you want to delete "' + bleskomat.name + '"?'
)
.onOk(function () {
LNbits.api
.request(
'DELETE',
'/bleskomat/api/v1/bleskomat/' + bleskomatId,
_.findWhere(self.g.user.wallets, {id: bleskomat.wallet}).adminkey
)
.then(function (response) {
self.bleskomats = _.reject(self.bleskomats, function (obj) {
return obj.id === bleskomatId
})
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
})
}
},
created: function () {
if (this.g.user.wallets.length) {
var getBleskomats = this.getBleskomats
getBleskomats()
this.checker = setInterval(function () {
getBleskomats()
}, 20000)
}
}
})

View File

@ -0,0 +1,65 @@
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="Bleskomat Extension for lnbits"
:content-inset-level="0.5"
>
<q-card>
<q-card-section>
<p>
This extension allows you to connect a Bleskomat ATM to an lnbits
wallet. It will work with both the
<a href="https://github.com/samotari/bleskomat"
>open-source DIY Bleskomat ATM project</a
>
as well as the
<a href="https://www.bleskomat.com/">commercial Bleskomat ATM</a>.
</p>
<h5 class="text-subtitle1 q-my-none">Connect Your Bleskomat ATM</h5>
<div>
<ol>
<li>Click the "Add Bleskomat" button on this page to begin.</li>
<li>
Choose a wallet. This will be the wallet that is used to pay
satoshis to your ATM customers.
</li>
<li>
Choose the fiat currency. This should match the fiat currency that
your ATM accepts.
</li>
<li>
Pick an exchange rate provider. This is the API that will be used to
query the fiat to satoshi exchange rate at the time your customer
attempts to withdraw their funds.
</li>
<li>Set your ATM's fee percentage.</li>
<li>Click the "Done" button.</li>
<li>
Find the new Bleskomat in the list and then click the export icon to
download a new configuration file for your ATM.
</li>
<li>
Copy the configuration file ("bleskomat.conf") to your ATM's SD
card.
</li>
<li>
Restart Your Bleskomat ATM. It should automatically reload the
configurations from the SD card.
</li>
</ol>
</div>
<h5 class="text-subtitle1 q-my-none">How does it work?</h5>
<p>
Since the Bleskomat ATMs are designed to be offline, a cryptographic
signing scheme is used to verify that the URL was generated by an
authorized device. When one of your customers inserts fiat money into
the device, a signed URL (lnurl-withdraw) is created and displayed as a
QR code. Your customer scans the QR code with their lnurl-supporting
mobile app, their mobile app communicates with the web API of lnbits to
verify the signature, the fiat currency amount is converted to sats, the
customer accepts the withdrawal, and finally lnbits will pay the
customer from your lnbits wallet.
</p>
</q-card-section>
</q-card>
</q-expansion-item>

View File

@ -0,0 +1,178 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block scripts %} {{ window_vars(user) }}
<script>
{% if bleskomat_vars %}
window.bleskomat_vars = {{ bleskomat_vars | tojson | safe }}
{% endif %}
</script>
<script src="/bleskomat/static/js/index.js"></script>
{% endblock %} {% block page %}
<div class="row q-col-gutter-md">
<div class="col-12 col-md-7 q-gutter-y-md">
<q-card>
<q-card-section>
<q-btn unelevated color="deep-purple" @click="formDialog.show = true"
>Add Bleskomat</q-btn
>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Bleskomats</h5>
</div>
</div>
<q-table
dense
flat
:data="sortedBleskomats"
row-key="id"
:columns="bleskomatsTable.columns"
:pagination.sync="bleskomatsTable.pagination"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label }}
</q-th>
<q-th auto-width></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
flat
dense
size="xs"
icon="file_download"
color="orange"
@click="exportConfigFile(props.row.id)"
>
<q-tooltip content-class="bg-accent"
>Export Configuration</q-tooltip
>
</q-btn>
</q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props">
{{ col.value }}
</q-td>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="openUpdateDialog(props.row.id)"
icon="edit"
color="light-blue"
>
<q-tooltip content-class="bg-accent">Edit</q-tooltip>
</q-btn>
<q-btn
flat
dense
size="xs"
@click="deleteBleskomat(props.row.id)"
icon="cancel"
color="pink"
>
<q-tooltip content-class="bg-accent">Delete</q-tooltip>
</q-btn>
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-5 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">LNbits Bleskomat extension</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list> {% include "bleskomat/_api_docs.html" %} </q-list>
</q-card-section>
</q-card>
</div>
<q-dialog v-model="formDialog.show" position="top" @hide="closeFormDialog">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="sendFormData" class="q-gutter-md">
<q-select
filled
dense
emit-value
v-model="formDialog.data.wallet"
:options="g.user.walletOptions"
label="Wallet *"
>
</q-select>
<q-input
filled
dense
v-model.trim="formDialog.data.name"
type="text"
label="Name *"
></q-input>
<q-select
filled
dense
v-model="formDialog.data.fiat_currency"
:options="formDialog.fiatCurrencies"
label="Fiat Currency *"
>
</q-select>
<q-select
filled
dense
v-model="formDialog.data.exchange_rate_provider"
:options="formDialog.exchangeRateProviders"
label="Exchange Rate Provider *"
>
</q-select>
<q-input
filled
dense
v-model.number="formDialog.data.fee"
type="string"
:default="0.00"
label="Fee (%) *"
></q-input>
<div class="row q-mt-lg">
<q-btn
v-if="formDialog.data.id"
unelevated
color="deep-purple"
type="submit"
>Update Bleskomat</q-btn
>
<q-btn
v-else
unelevated
color="deep-purple"
:disable="
formDialog.data.wallet == null ||
formDialog.data.name == null ||
formDialog.data.fiat_currency == null ||
formDialog.data.exchange_rate_provider == null ||
formDialog.data.fee == null"
type="submit"
>Add Bleskomat</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
</div>
{% endblock %}

View File

@ -0,0 +1,22 @@
from quart import g, render_template
from lnbits.decorators import check_user_exists, validate_uuids
from . import bleskomat_ext
from .exchange_rates import exchange_rate_providers_serializable, fiat_currencies
from .helpers import get_callback_url
@bleskomat_ext.route("/")
@validate_uuids(["usr"], required=True)
@check_user_exists()
async def index():
bleskomat_vars = {
"callback_url": get_callback_url(),
"exchange_rate_providers": exchange_rate_providers_serializable,
"fiat_currencies": fiat_currencies,
}
return await render_template(
"bleskomat/index.html", user=g.user, bleskomat_vars=bleskomat_vars
)

View File

@ -0,0 +1,120 @@
from quart import g, jsonify, request
from http import HTTPStatus
from lnbits.core.crud import get_user
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
from . import bleskomat_ext
from .crud import (
create_bleskomat,
get_bleskomat,
get_bleskomats,
update_bleskomat,
delete_bleskomat,
)
from .exchange_rates import (
exchange_rate_providers,
fetch_fiat_exchange_rate,
fiat_currencies,
)
@bleskomat_ext.route("/api/v1/bleskomats", methods=["GET"])
@api_check_wallet_key("admin")
async def api_bleskomats():
wallet_ids = [g.wallet.id]
if "all_wallets" in request.args:
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
return (
jsonify(
[bleskomat._asdict() for bleskomat in await get_bleskomats(wallet_ids)]
),
HTTPStatus.OK,
)
@bleskomat_ext.route("/api/v1/bleskomat/<bleskomat_id>", methods=["GET"])
@api_check_wallet_key("admin")
async def api_bleskomat_retrieve(bleskomat_id):
bleskomat = await get_bleskomat(bleskomat_id)
if not bleskomat or bleskomat.wallet != g.wallet.id:
return (
jsonify({"message": "Bleskomat configuration not found."}),
HTTPStatus.NOT_FOUND,
)
return jsonify(bleskomat._asdict()), HTTPStatus.OK
@bleskomat_ext.route("/api/v1/bleskomat", methods=["POST"])
@bleskomat_ext.route("/api/v1/bleskomat/<bleskomat_id>", methods=["PUT"])
@api_check_wallet_key("admin")
@api_validate_post_request(
schema={
"name": {"type": "string", "empty": False, "required": True},
"fiat_currency": {
"type": "string",
"allowed": fiat_currencies.keys(),
"required": True,
},
"exchange_rate_provider": {
"type": "string",
"allowed": exchange_rate_providers.keys(),
"required": True,
},
"fee": {"type": ["string", "float", "number", "integer"], "required": True},
}
)
async def api_bleskomat_create_or_update(bleskomat_id=None):
try:
fiat_currency = g.data["fiat_currency"]
exchange_rate_provider = g.data["exchange_rate_provider"]
await fetch_fiat_exchange_rate(
currency=fiat_currency, provider=exchange_rate_provider
)
except Exception as e:
print(e)
return (
jsonify(
{
"message": f'Failed to fetch BTC/{fiat_currency} currency pair from "{exchange_rate_provider}"'
}
),
HTTPStatus.INTERNAL_SERVER_ERROR,
)
if bleskomat_id:
bleskomat = await get_bleskomat(bleskomat_id)
if not bleskomat or bleskomat.wallet != g.wallet.id:
return (
jsonify({"message": "Bleskomat configuration not found."}),
HTTPStatus.NOT_FOUND,
)
bleskomat = await update_bleskomat(bleskomat_id, **g.data)
else:
bleskomat = await create_bleskomat(wallet_id=g.wallet.id, **g.data)
return (
jsonify(bleskomat._asdict()),
HTTPStatus.OK if bleskomat_id else HTTPStatus.CREATED,
)
@bleskomat_ext.route("/api/v1/bleskomat/<bleskomat_id>", methods=["DELETE"])
@api_check_wallet_key("admin")
async def api_bleskomat_delete(bleskomat_id):
bleskomat = await get_bleskomat(bleskomat_id)
if not bleskomat or bleskomat.wallet != g.wallet.id:
return (
jsonify({"message": "Bleskomat configuration not found."}),
HTTPStatus.NOT_FOUND,
)
await delete_bleskomat(bleskomat_id)
return "", HTTPStatus.NO_CONTENT

View File

@ -0,0 +1,11 @@
<h1>Example Extension</h1>
<h2>*tagline*</h2>
This is an example extension to help you organise and build you own.
Try to include an image
<img src="https://i.imgur.com/9i4xcQB.png">
<h2>If your extension has API endpoints, include useful ones here</h2>
<code>curl -H "Content-type: application/json" -X POST https://YOUR-LNBITS/YOUR-EXTENSION/api/v1/EXAMPLE -d '{"amount":"100","memo":"example"}' -H "X-Api-Key: YOUR_WALLET-ADMIN/INVOICE-KEY"</code>

View File

@ -0,0 +1,12 @@
from quart import Blueprint
from lnbits.db import Database
db = Database("ext_captcha")
captcha_ext: Blueprint = Blueprint(
"captcha", __name__, static_folder="static", template_folder="templates"
)
from .views_api import * # noqa
from .views import * # noqa

View File

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

View File

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

View File

@ -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")

View File

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

View File

@ -0,0 +1,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<div class="modal-captcha-content"> \t<span class="close-button-captcha" style="display: none;">&times;</span>\t\t</div>\t'),
document.getElementsByTagName('body')[0].appendChild(a)
var r = document.getElementsByClassName('modal-captcha-content').item(0)
document
.getElementsByClassName('close-button-captcha')
.item(0)
.addEventListener('click', d),
window.addEventListener('click', function (t) {
t.target === a && d()
}),
i.addEventListener('change', function () {
if (this.checked) {
// console.log("checkbox checked");
if (0 == ciframeLoaded) {
// console.log("n: ", n);
var t = ccreateIframeElement(n)
r.appendChild(t), (ciframeLoaded = !0)
}
d()
}
})
}
function d() {
a.classList.toggle('show-modal-captcha')
}
})
function receiveMessage(event) {
if (event.data.includes('paymenthash')) {
// console.log("paymenthash received: ", event.data);
document.getElementById('captchapayhash').value = event.data.split('_')[1]
}
if (event.data.includes('removetheiframe')) {
if (event.data.includes('nok')) {
//invoice was NOT paid
// console.log("receiveMessage not paid")
document.getElementById('captchacheckbox').checked = false
}
ciframeLoaded = !1
var element = document.getElementById('captcha-iframe')
document
.getElementsByClassName('modal-captcha-container')[0]
.classList.toggle('show-modal-captcha')
element.parentNode.removeChild(element)
}
}
window.addEventListener('message', receiveMessage, false)

View File

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

View File

@ -0,0 +1,178 @@
{% extends "public.html" %} {% block page %}
<div class="row q-col-gutter-md justify-center">
<div class="col-12 col-sm-8 col-md-5 col-lg-4">
<q-card class="q-pa-lg">
<q-card-section class="q-pa-none">
<h5 class="text-subtitle1 q-mt-none q-mb-sm">{{ captcha.memo }}</h5>
{% if captcha.description %}
<p>{{ captcha.description }}</p>
{% endif %}
<div v-if="!this.redirectUrl" class="q-mt-lg">
<q-form v-if="">
<q-input
filled
v-model.number="userAmount"
type="number"
:min="captchaAmount"
suffix="sat"
label="Choose an amount *"
:hint="'Minimum ' + captchaAmount + ' sat'"
>
<template v-slot:after>
<q-btn
round
dense
flat
icon="check"
color="deep-purple"
type="submit"
@click="createInvoice"
:disabled="userAmount < captchaAmount || paymentReq"
></q-btn>
</template>
</q-input>
</q-form>
<div v-if="paymentReq" class="q-mt-lg">
<a :href="'lightning:' + paymentReq">
<q-responsive :ratio="1" class="q-mx-xl q-mb-md">
<qrcode
:value="paymentReq"
:options="{width: 800}"
class="rounded-borders"
></qrcode>
</q-responsive>
</a>
<div class="row q-mt-lg">
<q-btn outline color="grey" @click="copyText(paymentReq)"
>Copy invoice</q-btn
>
<q-btn
@click="cancelPayment(false)"
flat
color="grey"
class="q-ml-auto"
>Cancel</q-btn
>
</div>
</div>
</div>
<div v-else>
<q-separator class="q-my-lg"></q-separator>
<p>
Captcha accepted. You are probably human.<br />
<!-- <strong>{% raw %}{{ redirectUrl }}{% endraw %}</strong> -->
</p>
<!-- <div class="row q-mt-lg">
<q-btn outline color="grey" type="a" :href="redirectUrl"
>Open URL</q-btn>
</div> -->
</div>
</q-card-section>
</q-card>
</div>
</div>
{% endblock %} {% block scripts %}
<script>
Vue.component(VueQrcode.name, VueQrcode)
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
userAmount: {{ captcha.amount }},
captchaAmount: {{ captcha.amount }},
paymentReq: null,
redirectUrl: null,
paymentDialog: {
dismissMsg: null,
checker: null
}
}
},
computed: {
amount: function () {
return (this.captchaAmount > this.userAmount) ? this.captchaAmount : this.userAmount
}
},
methods: {
cancelPayment: function (paid) {
this.paymentReq = null
clearInterval(this.paymentDialog.checker)
if (this.paymentDialog.dismissMsg) {
this.paymentDialog.dismissMsg()
}
var removeiframestring = "removetheiframe_nok";
var timeout = 500;
if(paid){
console.log("paid, dismissing iframe");
removeiframestring = "removetheiframe_ok";
timeout = 2000;
}
setTimeout(function () {
// parent.closeIFrame()
parent.window.postMessage(removeiframestring, "*");
}, timeout)
},
createInvoice: function () {
var self = this
axios
.post(
'/captcha/api/v1/captchas/{{ captcha.id }}/invoice',
{amount: this.amount}
)
.then(function (response) {
self.paymentReq = response.data.payment_request.toUpperCase()
self.paymentDialog.dismissMsg = self.$q.notify({
timeout: 0,
message: 'Waiting for payment...'
})
self.paymentDialog.checker = setInterval(function () {
axios
.post(
'/captcha/api/v1/captchas/{{ captcha.id }}/check_invoice',
{payment_hash: response.data.payment_hash}
)
.then(function (res) {
if (res.data.paid) {
self.cancelPayment(true)
self.redirectUrl = res.data.url
if (res.data.remembers) {
self.$q.localStorage.set(
'lnbits.captcha.{{ captcha.id }}',
res.data.url
)
}
parent.window.postMessage("paymenthash_"+response.data.payment_hash, "*");
self.$q.notify({
type: 'positive',
message: 'Payment received!',
icon: null
})
}
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
}, 2000)
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
}
},
created: function () {
var url = this.$q.localStorage.getItem('lnbits.captcha.{{ captcha.id }}')
if (url) {
this.redirectUrl = url
}
}
})
</script>
{% endblock %}

View File

@ -0,0 +1,425 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
<div class="row q-col-gutter-md">
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
<q-card>
<q-card-section>
<q-btn unelevated color="deep-purple" @click="formDialog.show = true"
>New captcha</q-btn
>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Captchas</h5>
</div>
<div class="col-auto">
<q-btn flat color="grey" @click="exportCSV">Export to CSV</q-btn>
</div>
</div>
<q-table
dense
flat
:data="captchas"
row-key="id"
:columns="captchasTable.columns"
:pagination.sync="captchasTable.pagination"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label }}
</q-th>
<q-th auto-width></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
unelevated
dense
size="xs"
icon="launch"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
type="a"
:href="props.row.displayUrl"
target="_blank"
></q-btn>
<q-btn
unelevated
dense
size="xs"
icon="visibility"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
:href="buildCaptchaSnippet(props.row.id)"
@click="openQrCodeDialog(props.row.id)"
></q-btn>
</q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props">
{{ col.value }}
</q-td>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="deleteCaptcha(props.row.id)"
icon="cancel"
color="pink"
></q-btn>
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-4 col-lg-5 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">LNbits captcha extension</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list> {% include "captcha/_api_docs.html" %} </q-list>
</q-card-section>
</q-card>
</div>
<q-dialog v-model="formDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="createCaptcha" class="q-gutter-md">
<q-select
filled
dense
emit-value
v-model="formDialog.data.wallet"
:options="g.user.walletOptions"
label="Wallet *"
>
</q-select>
<!-- <q-input
filled
dense
v-model.trim="formDialog.data.url"
type="hidden"
label="Redirect URL *"
:value="https://dummy.com"
></q-input> -->
<q-input
filled
dense
v-model.trim="formDialog.data.memo"
label="Title *"
placeholder="LNbits captcha"
></q-input>
<q-input
filled
dense
autogrow
v-model.trim="formDialog.data.description"
label="Description"
></q-input>
<q-input
filled
dense
v-model.number="formDialog.data.amount"
type="number"
label="Amount (sat) *"
hint="This is the minimum amount users can pay/donate."
></q-input>
<q-list>
<q-item tag="label" class="rounded-borders">
<q-item-section avatar>
<q-checkbox
v-model="formDialog.data.remembers"
color="deep-purple"
></q-checkbox>
</q-item-section>
<q-item-section>
<q-item-label>Remember payments</q-item-label>
<q-item-label caption
>A succesful payment will be registered in the browser's
storage, so the user doesn't need to pay again to prove they are
human.</q-item-label
>
</q-item-section>
</q-item>
</q-list>
<div class="row q-mt-lg">
<q-btn
unelevated
color="deep-purple"
:disable="formDialog.data.amount == null || formDialog.data.amount < 0 || formDialog.data.memo == null"
type="submit"
>Create captcha</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
<q-dialog v-model="qrCodeDialog.show" position="top">
<q-card v-if="qrCodeDialog.data" class="q-pa-lg lnbits__dialog-card">
{% raw %}
<q-responsive :ratio="1" class="q-mx-xl q-mb-md">
<!-- <qrcode
:value="qrCodeDialog.data.lnurl"
:options="{width: 800}"
class="rounded-borders"
></qrcode> -->
<code style="word-break: break-all">
{{ qrCodeDialog.data.snippet }}
</code>
<p style="margin-top: 20px">
Copy the snippet above and paste into your website/form. The checkbox
can be in checked state only after user pays.
</p>
</q-responsive>
<p style="word-break: break-all">
<strong>ID:</strong> {{ qrCodeDialog.data.id }}<br />
<strong>Amount:</strong> {{ qrCodeDialog.data.amount }}<br />
<!-- <span v-if="qrCodeDialog.data.currency"
><strong>{{ qrCodeDialog.data.currency }} price:</strong> {{
fiatRates[qrCodeDialog.data.currency] ?
fiatRates[qrCodeDialog.data.currency] + ' sat' : 'Loading...' }}<br
/></span>
<strong>Accepts comments:</strong> {{ qrCodeDialog.data.comments }}<br />
<strong>Dispatches webhook to:</strong> {{ qrCodeDialog.data.webhook
}}<br />
<strong>On success:</strong> {{ qrCodeDialog.data.success }}<br /> -->
</p>
{% endraw %}
<div class="row q-mt-lg q-gutter-sm">
<q-btn
outline
color="grey"
@click="copyText(qrCodeDialog.data.snippet, 'Snippet copied to clipboard!')"
class="q-ml-sm"
>Copy Snippet</q-btn
>
<!-- <q-btn
outline
color="grey"
@click="copyText(qrCodeDialog.data.pay_url, 'Link copied to clipboard!')"
>Shareable link</q-btn
>
<q-btn
outline
color="grey"
icon="print"
type="a"
:href="qrCodeDialog.data.print_url"
target="_blank"
></q-btn> -->
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
</q-card>
</q-dialog>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script>
var mapCaptcha = function (obj) {
obj.date = Quasar.utils.date.formatDate(
new Date(obj.time * 1000),
'YYYY-MM-DD HH:mm'
)
obj.fsat = new Intl.NumberFormat(LOCALE).format(obj.amount)
obj.displayUrl = ['/captcha/', obj.id].join('')
return obj
}
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
captchas: [],
captchasTable: {
columns: [
{name: 'id', align: 'left', label: 'ID', field: 'id'},
{name: 'memo', align: 'left', label: 'Memo', field: 'memo'},
{
name: 'amount',
align: 'right',
label: 'Amount (sat)',
field: 'fsat',
sortable: true,
sort: function (a, b, rowA, rowB) {
return rowA.amount - rowB.amount
}
},
{
name: 'remembers',
align: 'left',
label: 'Remember',
field: 'remembers'
},
{
name: 'date',
align: 'left',
label: 'Date',
field: 'date',
sortable: true
}
],
pagination: {
rowsPerPage: 10
}
},
formDialog: {
show: false,
data: {
remembers: false
}
},
qrCodeDialog: {
show: false,
data: null
}
}
},
methods: {
getCaptchas: function () {
var self = this
LNbits.api
.request(
'GET',
'/captcha/api/v1/captchas?all_wallets',
this.g.user.wallets[0].inkey
)
.then(function (response) {
self.captchas = response.data.map(function (obj) {
return mapCaptcha(obj)
})
})
},
createCaptcha: function () {
var data = {
// url: this.formDialog.data.url,
url: 'http://dummy.com',
memo: this.formDialog.data.memo,
amount: this.formDialog.data.amount,
description: this.formDialog.data.description,
remembers: this.formDialog.data.remembers
}
var self = this
LNbits.api
.request(
'POST',
'/captcha/api/v1/captchas',
_.findWhere(this.g.user.wallets, {id: this.formDialog.data.wallet})
.inkey,
data
)
.then(function (response) {
self.captchas.push(mapCaptcha(response.data))
self.formDialog.show = false
self.formDialog.data = {
remembers: false
}
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
deleteCaptcha: function (captchaId) {
var self = this
var captcha = _.findWhere(this.captchas, {id: captchaId})
LNbits.utils
.confirmDialog('Are you sure you want to delete this captcha link?')
.onOk(function () {
LNbits.api
.request(
'DELETE',
'/captcha/api/v1/captchas/' + captchaId,
_.findWhere(self.g.user.wallets, {id: captcha.wallet}).inkey
)
.then(function (response) {
self.captchas = _.reject(self.captchas, function (obj) {
return obj.id == captchaId
})
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
})
},
buildCaptchaSnippet: function (captchaId) {
var locationPath = [
window.location.protocol,
'//',
window.location.host,
window.location.pathname
].join('')
var captchasnippet =
'<!-- Captcha Checkbox Start -->\n' +
'<input type="checkbox" id="captchacheckbox">\n' +
'<label for="captchacheckbox">I\'m not a robot</label><br/>\n' +
'<input type="text" id="captchapayhash" style="display: none;"/>\n' +
'<script type="text/javascript" src="' +
locationPath +
'static/js/captcha.js" id="captchascript" data-captchaid="' +
captchaId +
'">\n' +
'<\/script>\n' +
'<!-- Captcha Checkbox End -->'
return captchasnippet
},
openQrCodeDialog(captchaId) {
// var link = _.findWhere(this.payLinks, {id: linkId})
var captcha = _.findWhere(this.captchas, {id: captchaId})
// if (link.currency) this.updateFiatRate(link.currency)
this.qrCodeDialog.data = {
id: captcha.id,
amount: captcha.amount,
// (link.min === link.max ? link.min : `${link.min} - ${link.max}`) +
// ' ' +
// (link.currency || 'sat'),
snippet: this.buildCaptchaSnippet(captcha.id)
// currency: link.currency,
// comments: link.comment_chars
// ? `${link.comment_chars} characters`
// : 'no',
// webhook: link.webhook_url || 'nowhere',
// success:
// link.success_text || link.success_url
// ? 'Display message "' +
// link.success_text +
// '"' +
// (link.success_url ? ' and URL "' + link.success_url + '"' : '')
// : 'do nothing',
// lnurl: link.lnurl,
// pay_url: link.pay_url,
// print_url: link.print_url
}
this.qrCodeDialog.show = true
},
exportCSV: function () {
LNbits.utils.exportCSV(this.captchasTable.columns, this.captchas)
}
},
created: function () {
if (this.g.user.wallets.length) {
this.getCaptchas()
}
}
})
</script>
{% endblock %}

View File

@ -0,0 +1,22 @@
from quart import g, abort, render_template
from http import HTTPStatus
from lnbits.decorators import check_user_exists, validate_uuids
from . import captcha_ext
from .crud import get_captcha
@captcha_ext.route("/")
@validate_uuids(["usr"], required=True)
@check_user_exists()
async def index():
return await render_template("captcha/index.html", user=g.user)
@captcha_ext.route("/<captcha_id>")
async def display(captcha_id):
captcha = await get_captcha(captcha_id) or abort(
HTTPStatus.NOT_FOUND, "captcha does not exist."
)
return await render_template("captcha/display.html", captcha=captcha)

View File

@ -0,0 +1,121 @@
from quart import g, jsonify, request
from http import HTTPStatus
from lnbits.core.crud import get_user, get_wallet
from lnbits.core.services import create_invoice, check_invoice_status
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
from . import captcha_ext
from .crud import create_captcha, get_captcha, get_captchas, delete_captcha
@captcha_ext.route("/api/v1/captchas", methods=["GET"])
@api_check_wallet_key("invoice")
async def api_captchas():
wallet_ids = [g.wallet.id]
if "all_wallets" in request.args:
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
return (
jsonify([captcha._asdict() for captcha in await get_captchas(wallet_ids)]),
HTTPStatus.OK,
)
@captcha_ext.route("/api/v1/captchas", methods=["POST"])
@api_check_wallet_key("invoice")
@api_validate_post_request(
schema={
"url": {"type": "string", "empty": False, "required": True},
"memo": {"type": "string", "empty": False, "required": True},
"description": {
"type": "string",
"empty": True,
"nullable": True,
"required": False,
},
"amount": {"type": "integer", "min": 0, "required": True},
"remembers": {"type": "boolean", "required": True},
}
)
async def api_captcha_create():
captcha = await create_captcha(wallet_id=g.wallet.id, **g.data)
return jsonify(captcha._asdict()), HTTPStatus.CREATED
@captcha_ext.route("/api/v1/captchas/<captcha_id>", methods=["DELETE"])
@api_check_wallet_key("invoice")
async def api_captcha_delete(captcha_id):
captcha = await get_captcha(captcha_id)
if not captcha:
return jsonify({"message": "captcha does not exist."}), HTTPStatus.NOT_FOUND
if captcha.wallet != g.wallet.id:
return jsonify({"message": "Not your captcha."}), HTTPStatus.FORBIDDEN
await delete_captcha(captcha_id)
return "", HTTPStatus.NO_CONTENT
@captcha_ext.route("/api/v1/captchas/<captcha_id>/invoice", methods=["POST"])
@api_validate_post_request(
schema={"amount": {"type": "integer", "min": 1, "required": True}}
)
async def api_captcha_create_invoice(captcha_id):
captcha = await get_captcha(captcha_id)
if g.data["amount"] < captcha.amount:
return (
jsonify({"message": f"Minimum amount is {captcha.amount} sat."}),
HTTPStatus.BAD_REQUEST,
)
try:
amount = (
g.data["amount"] if g.data["amount"] > captcha.amount else captcha.amount
)
payment_hash, payment_request = await create_invoice(
wallet_id=captcha.wallet,
amount=amount,
memo=f"{captcha.memo}",
extra={"tag": "captcha"},
)
except Exception as e:
return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR
return (
jsonify({"payment_hash": payment_hash, "payment_request": payment_request}),
HTTPStatus.CREATED,
)
@captcha_ext.route("/api/v1/captchas/<captcha_id>/check_invoice", methods=["POST"])
@api_validate_post_request(
schema={"payment_hash": {"type": "string", "empty": False, "required": True}}
)
async def api_paywal_check_invoice(captcha_id):
captcha = await get_captcha(captcha_id)
if not captcha:
return jsonify({"message": "captcha does not exist."}), HTTPStatus.NOT_FOUND
try:
status = await check_invoice_status(captcha.wallet, g.data["payment_hash"])
is_paid = not status.pending
except Exception:
return jsonify({"paid": False}), HTTPStatus.OK
if is_paid:
wallet = await get_wallet(captcha.wallet)
payment = await wallet.get_payment(g.data["payment_hash"])
await payment.set_pending(False)
return (
jsonify({"paid": True, "url": captcha.url, "remembers": captcha.remembers}),
HTTPStatus.OK,
)
return jsonify({"paid": False}), HTTPStatus.OK

View File

@ -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

View File

@ -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]

View File

@ -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/<product_id>", 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/<indexer_id>", 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/<indexer_id>", methods=["GET"])
@diagonalley_ext.route(
"/api/v1/diagonalley/stall/products/<indexer_id>", methods=["GET"]
)
async def api_diagonalleys_stall_products(indexer_id):
with open_ext_db("diagonalley") as db:
rows = db.fetchone("SELECT * FROM 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/<checking_id>", methods=["GET"])
@diagonalley_ext.route(
"/api/v1/diagonalley/stall/checkshipped/<checking_id>", methods=["GET"]
)
async def api_diagonalleys_stall_checkshipped(checking_id):
with open_ext_db("diagonalley") as db:
rows = db.fetchone("SELECT * FROM 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,
)

View File

@ -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

View File

@ -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]

View File

@ -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,
)

View File

@ -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/<event_id>/<sats>", 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/<payment_hash>", methods=["GET"])
@ -163,7 +177,14 @@ async def api_ticket_delete(ticket_id):
@events_ext.route("/api/v1/eventtickets/<wallet_id>/<event_id>", methods=["GET"])
async def api_event_tickets(wallet_id, event_id):
return (
jsonify([ticket._asdict() for ticket in await get_event_tickets(wallet_id=wallet_id, event_id=event_id)]),
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,
)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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]
)
]
)

View File

@ -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

View File

@ -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]

View File

@ -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/<form_id>", 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/<payment_hash>", methods=["GET"])

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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/<link_id>", 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},
)

View File

@ -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;")

View File

@ -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)
})
}
})

View File

@ -133,7 +133,7 @@
</q-card>
</div>
<q-dialog v-model="formDialog.show" position="top" @hide="closeFormDialog">
<q-dialog v-model="formDialog.show" @hide="closeFormDialog">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="sendFormData" class="q-gutter-md">
<q-select
@ -182,7 +182,7 @@
<div class="col">
<q-select
dense
:options='["satoshis", "USD"]'
:options="currencies"
v-model="formDialog.data.currency"
:display-value="formDialog.data.currency || 'satoshis'"
label="Currency"

View File

@ -4,6 +4,7 @@ from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl # type: ignore
from lnbits.core.crud import get_user
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
from lnbits.utils.exchange_rates import currencies, get_fiat_rate_satoshis
from . import lnurlp_ext
from .crud import (
@ -13,7 +14,11 @@ from .crud import (
update_pay_link,
delete_pay_link,
)
from .helpers import get_fiat_rate
@lnurlp_ext.route("/api/v1/currencies", methods=["GET"])
async def api_list_currencies_available():
return jsonify(list(currencies.keys()))
@lnurlp_ext.route("/api/v1/links", methods=["GET"])
@ -26,12 +31,21 @@ async def api_links():
try:
return (
jsonify([{**link._asdict(), **{"lnurl": link.lnurl}} for link in await get_pay_links(wallet_ids)]),
jsonify(
[
{**link._asdict(), **{"lnurl": link.lnurl}}
for link in await get_pay_links(wallet_ids)
]
),
HTTPStatus.OK,
)
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,
)
@ -58,7 +72,7 @@ async def api_link_retrieve(link_id):
"description": {"type": "string", "empty": False, "required": True},
"min": {"type": "number", "min": 0.01, "required": True},
"max": {"type": "number", "min": 0.01, "required": True},
"currency": {"type": "string", "allowed": ["USD"], "nullable": True, "required": False},
"currency": {"type": "string", "nullable": True, "required": False},
"comment_chars": {"type": "integer", "required": True, "min": 0, "max": 800},
"webhook_url": {"type": "string", "required": False},
"success_text": {"type": "string", "required": False},
@ -78,7 +92,10 @@ async def api_link_create_or_update(link_id=None):
link = await get_pay_link(link_id)
if not link:
return jsonify({"message": "Pay link does not exist."}), HTTPStatus.NOT_FOUND
return (
jsonify({"message": "Pay link does not exist."}),
HTTPStatus.NOT_FOUND,
)
if link.wallet != g.wallet.id:
return jsonify({"message": "Not your pay link."}), HTTPStatus.FORBIDDEN
@ -87,7 +104,10 @@ async def api_link_create_or_update(link_id=None):
else:
link = await create_pay_link(wallet_id=g.wallet.id, **g.data)
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,
)
@lnurlp_ext.route("/api/v1/links/<link_id>", methods=["DELETE"])
@ -109,7 +129,7 @@ async def api_link_delete(link_id):
@lnurlp_ext.route("/api/v1/rate/<currency>", 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

View File

@ -0,0 +1 @@
# Offline Shop

View File

@ -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

View File

@ -0,0 +1,8 @@
{
"name": "OfflineShop",
"short_description": "Receive payments for products offline!",
"icon": "nature_people",
"contributors": [
"fiatjaf"
]
}

View File

@ -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),
)

View File

@ -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)

View File

@ -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/<item_id>", 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/<item_id>", 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())

View File

@ -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'
);
"""
)

View File

@ -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.",
)

View File

@ -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)
})
}
})

View File

@ -0,0 +1,147 @@
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="How to use"
:content-inset-level="0.5"
>
<q-card>
<q-card-section>
<ol>
<li>Register items.</li>
<li>
Print QR codes and paste them on your store, your menu, somewhere,
somehow.
</li>
<li>
Clients scan the QR codes and get information about the items plus the
price on their phones directly (they must have internet)
</li>
<li>
Once they decide to pay, they'll get an invoice on their phones
automatically
</li>
<li>
When the payment is confirmed, a confirmation code will be issued for
them.
</li>
</ol>
<p>
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.
</p>
<p>
For example, if your wordlist is
<code>[apple, banana, coconut]</code> the first purchase will be
<code>apple</code>, the second <code>banana</code> and so on. When it
gets to the end it starts from the beginning again.
</p>
<p>Powered by LNURL-pay.</p>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="API info"
:content-inset-level="0.5"
>
<q-expansion-item
group="api"
dense
expand-separator
label="Create item (a shop will be created automatically based on the wallet you use)"
>
<q-card>
<q-card-section>
<code><span class="text-blue">POST</span></code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">Returns 201 OK</h5>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>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": &lt;string&gt;, "description": &lt;string&gt;, "image":
&lt;data-uri string&gt;, "price": &lt;integer&gt;, "unit": &lt;"sat"
or "USD"&gt;}'
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Get the shop data along with items"
>
<q-card>
<q-card-section>
<code><span class="text-blue">GET</span></code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code
>{"id": &lt;integer&gt;, "wallet": &lt;string&gt;, "wordlist":
&lt;string&gt;, "items": [{"id": &lt;integer&gt;, "name":
&lt;string&gt;, "description": &lt;string&gt;, "image":
&lt;string&gt;, "enabled": &lt;boolean&gt;, "price": &lt;integer&gt;,
"unit": &lt;string&gt;, "lnurl": &lt;string&gt;}, ...]}&lt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root }}/offlineshop/api/v1/offlineshop -H
"X-Api-Key: {{ g.user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Update item (all fields must be sent again)"
>
<q-card>
<q-card-section>
<code><span class="text-blue">PUT</span></code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">Returns 200 OK</h5>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root
}}/offlineshop/api/v1/offlineshop/items/&lt;item_id&gt; -H
"Content-Type: application/json" -H "X-Api-Key: {{
g.user.wallets[0].inkey }}" -d '{"name": &lt;string&gt;,
"description": &lt;string&gt;, "image": &lt;data-uri string&gt;,
"price": &lt;integer&gt;, "unit": &lt;"sat" or "USD"&gt;}'
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Delete item">
<q-card>
<q-card-section>
<code><span class="text-blue">DELETE</span></code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">Returns 200 OK</h5>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root
}}/offlineshop/api/v1/offlineshop/items/&lt;item_id&gt; -H "X-Api-Key:
{{ g.user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
</q-expansion-item>

View File

@ -0,0 +1,332 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
<div class="row q-col-gutter-md">
<div class="col-12 col-md-7 q-gutter-y-md">
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Items</h5>
</div>
<div class="col q-ml-lg">
<q-btn unelevated color="deep-purple" @click="openNewDialog()"
>Add new item</q-btn
>
</div>
</div>
{% raw %}
<q-table
dense
flat
selection="multiple"
:data="offlineshop.items"
row-key="id"
no-data-label="No items for sale yet"
:pagination="{rowsPerPage: 0}"
:binary-state-sort="true"
>
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th auto-width>Name</q-th>
<q-th auto-width>Description</q-th>
<q-th auto-width>Image</q-th>
<q-th auto-width>Price</q-th>
<q-th auto-width></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
unelevated
dense
size="xs"
:icon="props.row.enabled ? 'done' : 'block'"
:color="props.row.enabled ? 'green' : ($q.dark.isActive ? 'grey-7' : 'grey-5')"
type="a"
@click="toggleItem(props.row.id)"
target="_blank"
></q-btn>
</q-td>
<q-td auto-width class="text-center">{{ props.row.name }}</q-td>
<q-td auto-width> {{ props.row.description }} </q-td>
<q-td class="text-center" auto-width>
<img
v-if="props.row.image"
:src="props.row.image"
style="height: 1.5em"
/>
</q-td>
<q-td class="text-center" auto-width>
{{ props.row.price }} {{ props.row.unit }}
</q-td>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="openUpdateDialog(props.row.id)"
icon="edit"
color="light-blue"
></q-btn>
<q-btn
unelevated
dense
size="xs"
icon="delete"
color="negative"
type="a"
@click="deleteItem(props.row.id)"
target="_blank"
></q-btn>
</q-td>
</q-tr>
</template>
</q-table>
{% endraw %}
</q-card-section>
</q-card>
<q-card class="q-pa-sm col-5">
<q-card-section class="q-pa-none text-center">
<div class="row">
<h5 class="text-subtitle1 q-my-none">Wallet Shop</h5>
</div>
<q-form class="q-gutter-md">
<q-select
filled
dense
:options="g.user.wallets"
:value="selectedWallet"
label="Using wallet:"
option-label="name"
@input="changedWallet"
>
</q-select>
</q-form>
<div v-if="printItems.length > 0" class="row q-gutter-sm q-my-md">
<q-btn
type="a"
outline
color="purple"
:href="'print?items=' + printItems.map(({id}) => id).join(',')"
>Print QR Codes</q-btn
>
</div>
</q-card-section>
</q-card>
<q-card class="q-pa-sm col-5">
<q-tabs
v-model="confirmationMethod"
no-caps
class="bg-purple text-white shadow-2"
>
<q-tab name="wordlist" label="Wordlist"></q-tab>
<q-tab name="totp" label="TOTP (Google Authenticator)"></q-tab>
<q-tab name="none" label="Nothing"></q-tab>
</q-tabs>
<q-card-section class="q-py-sm text-center">
<q-form
v-if="confirmationMethod === 'wordlist'"
class="q-gutter-md q-y-md"
@submit="setMethod"
>
<div class="row">
<div class="col q-mx-lg">
<q-input
v-model="offlineshop.wordlist"
@input="wordlistTainted = true"
dense
filled
autogrow
/>
</div>
<div
class="col q-mx-lg items-align flex items-center justify-center"
>
<q-btn
unelevated
color="deep-purple"
type="submit"
:disabled="!wordlistTainted"
>
Update Wordlist
</q-btn>
<q-btn @click="loadShop" flat color="grey" class="q-ml-auto"
>Reset</q-btn
>
</div>
</div>
</q-form>
<div v-else-if="confirmationMethod === 'totp'">
<div class="row">
<div class="col q-mx-lg">
<q-responsive :ratio="1">
<qrcode
:value="`otpauth://totp/offlineshop:${selectedWallet.name}?secret=${offlineshop.otp_key}`"
:options="{width: 800}"
class="rounded-borders"
></qrcode>
</q-responsive>
</div>
<div
class="col q-mx-lg items-align flex items-center justify-center"
>
<q-btn
unelevated
color="deep-purple"
:disabled="offlineshop.method === 'totp'"
@click="setMethod"
>
Set TOTP
</q-btn>
</div>
</div>
</div>
<div v-else-if="confirmationMethod === 'none'">
<p>
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.
</p>
<q-btn
unelevated
color="deep-purple"
:disabled="offlineshop.method === 'none'"
@click="setMethod"
>
Disable Confirmation Codes
</q-btn>
</div>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-5 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">LNbits OfflineShop extension</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list> {% include "offlineshop/_api_docs.html" %} </q-list>
</q-card-section>
</q-card>
</div>
<q-dialog v-model="itemDialog.show">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-card-section>
<h5
class="q-ma-none"
v-if="itemDialog.data.id"
v-text="itemDialog.data.name"
></h5>
<h5 class="q-ma-none q-mb-xl" v-else>Adding a new item</h5>
<q-responsive v-if="itemDialog.data.id" :ratio="1">
<qrcode
:value="itemDialog.data.lnurl"
:options="{width: 800}"
class="rounded-borders"
></qrcode>
</q-responsive>
<div v-if="itemDialog.data.id" class="row q-gutter-sm justify-center">
<q-btn
outline
color="grey"
@click="copyText(itemDialog.data.lnurl, 'LNURL copied to clipboard!')"
class="q-mb-lg"
>Copy LNURL</q-btn
>
</div>
<q-form @submit="sendItem" class="q-gutter-md">
<q-input
filled
dense
v-model.trim="itemDialog.data.name"
type="text"
label="Item name"
></q-input>
<q-input
filled
dense
v-model.trim="itemDialog.data.description"
type="text"
label="Brief description"
></q-input>
<q-file
filled
dense
capture="environment"
accept="image/jpeg, image/png"
:max-file-size="3*1024**2"
label="Small image (optional)"
clearable
@input="imageAdded"
@clear="imageCleared"
>
<template v-if="itemDialog.data.image" v-slot:before>
<img style="height: 1em" :src="itemDialog.data.image" />
</template>
<template v-if="itemDialog.data.image" v-slot:append>
<q-icon
name="cancel"
@click.stop.prevent="imageCleared"
class="cursor-pointer"
/>
</template>
</q-file>
<q-input
filled
dense
v-model.number="itemDialog.data.price"
type="number"
min="1"
:label="`Item price (${itemDialog.data.unit})`"
></q-input>
<q-select
filled
dense
v-model="itemDialog.data.unit"
type="text"
label="Unit"
:options="itemDialog.units"
></q-select>
<div class="row q-mt-lg">
<div class="col q-ml-lg">
<q-btn
unelevated
color="deep-purple"
:disable="disabledAddItemButton()"
type="submit"
>
{% raw %}{{ itemDialog.data.id ? 'Update' : 'Add' }}{% endraw %}
Item
</q-btn>
</div>
<div class="col q-ml-lg">
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</div>
</q-form>
</q-card-section>
</q-card>
</q-dialog>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script src="https://cdn.jsdelivr.net/npm/pica@6.1.1/dist/pica.min.js"></script>
<script src="/offlineshop/static/js/index.js"></script>
{% endblock %}

View File

@ -0,0 +1,25 @@
{% extends "print.html" %} {% block page %} {% raw %}
<div class="row justify-center">
<div v-for="item in items" class="q-my-sm q-mx-lg">
<div class="text-center q-ma-none q-mb-sm">{{ item.name }}</div>
<qrcode :value="item.lnurl" :options="{margin: 0, width: 250}"></qrcode>
<div class="text-center q-ma-none q-mt-sm">{{ item.price }}</div>
</div>
</div>
{% endraw %} {% endblock %} {% block scripts %}
<script>
Vue.component(VueQrcode.name, VueQrcode)
new Vue({
el: '#vue',
created: function () {
window.print()
},
data: function () {
return {
items: JSON.parse('{{items | tojson}}')
}
}
})
</script>
{% endblock %}

View File

@ -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 = "<style>* { font-size: 100px}</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)}]<br>
{item.name}<br>
{item.price} {item.unit}<br>
{datetime.utcfromtimestamp(payment.time).strftime('%Y-%m-%d %H:%M:%S')}
"""
+ style
)

View File

@ -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/<item_id>", 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/<item_id>", 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

View File

@ -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",
]

View File

@ -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

View File

@ -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]

View File

@ -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 (

View File

@ -17,8 +17,8 @@
<code>[&lt;paywall_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root }}api/v1/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 }}"
</code>
</q-card-section>
</q-card>
@ -48,11 +48,11 @@
>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.url_root }}api/v1/paywalls -d
'{"url": &lt;string&gt;, "memo": &lt;string&gt;, "description":
&lt;string&gt;, "amount": &lt;integer&gt;, "remembers":
&lt;boolean&gt;}' -H "Content-type: application/json" -H "X-Api-Key:
{{ g.user.wallets[0].adminkey }}"
>curl -X POST {{ request.url_root }}api/v1/paywalls -d '{"url":
&lt;string&gt;, "memo": &lt;string&gt;, "description": &lt;string&gt;,
"amount": &lt;integer&gt;, "remembers": &lt;boolean&gt;}' -H
"Content-type: application/json" -H "X-Api-Key: {{
g.user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>

View File

@ -16,5 +16,7 @@ async def index():
@paywall_ext.route("/<paywall_id>")
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)

View File

@ -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/<paywall_id>/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/<paywall_id>/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

View File

@ -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

View File

@ -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(

View File

@ -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]

View File

@ -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

View File

@ -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",

View File

@ -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/<domain_id>", 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/<payment_hash>", methods=["GET"])

View File

@ -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

View File

@ -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]

View File

@ -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/<tpos_id>/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/<tpos_id>/invoices/<payment_hash>", methods=["GET"])

View File

@ -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

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