diff --git a/crud.py b/crud.py
index 7d79ccd..f898267 100644
--- a/crud.py
+++ b/crud.py
@@ -1,10 +1,6 @@
-from datetime import datetime
-
-import shortuuid
from lnbits.db import Database
-from lnbits.helpers import urlsafe_short_hash
-from .models import CreateWithdrawData, HashCheck, PaginatedWithdraws, WithdrawLink
+from .models import CreateWithdrawData, PaginatedWithdraws, WithdrawLink, WithdrawSecret
db = Database("ext_withdraw")
@@ -12,56 +8,47 @@
async def create_withdraw_link(
data: CreateWithdrawData, wallet_id: str
) -> WithdrawLink:
- link_id = urlsafe_short_hash()[:22]
- available_links = ",".join([str(i) for i in range(data.uses)])
withdraw_link = WithdrawLink(
- id=link_id,
- wallet=wallet_id,
- unique_hash=urlsafe_short_hash(),
- k1=urlsafe_short_hash(),
- created_at=datetime.now(),
- open_time=int(datetime.now().timestamp()) + data.wait_time,
title=data.title,
+ wallet=wallet_id,
min_withdrawable=data.min_withdrawable,
max_withdrawable=data.max_withdrawable,
- uses=data.uses,
wait_time=data.wait_time,
- is_unique=data.is_unique,
- usescsv=available_links,
+ is_static=data.is_static,
+ is_public=data.is_public,
webhook_url=data.webhook_url,
webhook_headers=data.webhook_headers,
webhook_body=data.webhook_body,
custom_url=data.custom_url,
- number=0,
)
+ secrets = []
+ for _ in range(data.uses):
+ secrets.append(
+ WithdrawSecret(
+ withdraw_id=withdraw_link.id,
+ amount=withdraw_link.max_withdrawable,
+ )
+ )
+ withdraw_link.secrets.total = data.uses
+ withdraw_link.secrets.items = secrets
await db.insert("withdraw.withdraw_link", withdraw_link)
return withdraw_link
-async def get_withdraw_link(link_id: str, num=0) -> WithdrawLink | None:
- link = await db.fetchone(
+async def get_withdraw_link(link_id: str) -> WithdrawLink | None:
+ return await db.fetchone(
"SELECT * FROM withdraw.withdraw_link WHERE id = :id",
{"id": link_id},
WithdrawLink,
)
- if not link:
- return None
-
- link.number = num
- return link
-async def get_withdraw_link_by_hash(unique_hash: str, num=0) -> WithdrawLink | None:
- link = await db.fetchone(
- "SELECT * FROM withdraw.withdraw_link WHERE unique_hash = :hash",
- {"hash": unique_hash},
+async def get_withdraw_link_by_k1(k1: str) -> WithdrawLink | None:
+ return await db.fetchone(
+ "SELECT * FROM withdraw.withdraw_link WHERE secrets LIKE :k1",
+ {"k1": f"%{k1}%"},
WithdrawLink,
)
- if not link:
- return None
-
- link.number = num
- return link
async def get_withdraw_links(
@@ -96,22 +83,6 @@ async def get_withdraw_links(
return PaginatedWithdraws(data=links, total=int(result2.total))
-async def remove_unique_withdraw_link(link: WithdrawLink, unique_hash: str) -> None:
- unique_links = [
- x.strip()
- for x in link.usescsv.split(",")
- if unique_hash != shortuuid.uuid(name=link.id + link.unique_hash + x.strip())
- ]
- link.usescsv = ",".join(unique_links)
- await update_withdraw_link(link)
-
-
-async def increment_withdraw_link(link: WithdrawLink) -> None:
- link.used = link.used + 1
- link.open_time = int(datetime.now().timestamp()) + link.wait_time
- await update_withdraw_link(link)
-
-
async def update_withdraw_link(link: WithdrawLink) -> WithdrawLink:
await db.update("withdraw.withdraw_link", link)
return link
@@ -121,55 +92,3 @@ async def delete_withdraw_link(link_id: str) -> None:
await db.execute(
"DELETE FROM withdraw.withdraw_link WHERE id = :id", {"id": link_id}
)
-
-
-def chunks(lst, n):
- for i in range(0, len(lst), n):
- yield lst[i : i + n]
-
-
-async def create_hash_check(the_hash: str, lnurl_id: str) -> HashCheck:
- await db.execute(
- """
- INSERT INTO withdraw.hash_check (id, lnurl_id)
- VALUES (:id, :lnurl_id)
- """,
- {"id": the_hash, "lnurl_id": lnurl_id},
- )
- hash_check = await get_hash_check(the_hash, lnurl_id)
- return hash_check
-
-
-async def get_hash_check(the_hash: str, lnurl_id: str) -> HashCheck:
-
- hash_check = await db.fetchone(
- """
- SELECT id as hash, lnurl_id as lnurl
- FROM withdraw.hash_check WHERE id = :id
- """,
- {"id": the_hash},
- HashCheck,
- )
- hash_check_lnurl = await db.fetchone(
- """
- SELECT id as hash, lnurl_id as lnurl
- FROM withdraw.hash_check WHERE lnurl_id = :id
- """,
- {"id": lnurl_id},
- HashCheck,
- )
- if not hash_check_lnurl:
- await create_hash_check(the_hash, lnurl_id)
- return HashCheck(lnurl=True, hash=False)
- else:
- if not hash_check:
- await create_hash_check(the_hash, lnurl_id)
- return HashCheck(lnurl=True, hash=False)
- else:
- return HashCheck(lnurl=True, hash=True)
-
-
-async def delete_hash_check(the_hash: str) -> None:
- await db.execute(
- "DELETE FROM withdraw.hash_check WHERE id = :hash", {"hash": the_hash}
- )
diff --git a/helpers.py b/helpers.py
deleted file mode 100644
index 51eb948..0000000
--- a/helpers.py
+++ /dev/null
@@ -1,28 +0,0 @@
-from fastapi import Request
-from lnurl import Lnurl
-from lnurl import encode as lnurl_encode
-from shortuuid import uuid
-
-from .models import WithdrawLink
-
-
-def create_lnurl(link: WithdrawLink, req: Request) -> Lnurl:
- if link.is_unique:
- usescssv = link.usescsv.split(",")
- tohash = link.id + link.unique_hash + usescssv[link.number]
- multihash = uuid(name=tohash)
- url = req.url_for(
- "withdraw.api_lnurl_multi_response",
- unique_hash=link.unique_hash,
- id_unique_hash=multihash,
- )
- else:
- url = req.url_for("withdraw.api_lnurl_response", unique_hash=link.unique_hash)
-
- try:
- return lnurl_encode(str(url))
- except Exception as e:
- raise ValueError(
- f"Error creating LNURL with url: `{url!s}`, "
- "check your webserver proxy configuration."
- ) from e
diff --git a/migrations.py b/migrations.py
index 754a57f..6a5b307 100644
--- a/migrations.py
+++ b/migrations.py
@@ -139,3 +139,13 @@ async def m007_add_created_at_timestamp(db):
"ALTER TABLE withdraw.withdraw_link "
f"ADD COLUMN created_at TIMESTAMP DEFAULT {db.timestamp_column_default}"
)
+
+
+async def m008_add_secrets_static_public(db):
+ await db.execute("ALTER TABLE withdraw.withdraw_link ADD COLUMN secrets TEXT;")
+ await db.execute("ALTER TABLE withdraw.withdraw_link ADD COLUMN is_static BOOLEAN;")
+ await db.execute("UPDATE withdraw.withdraw_link SET is_static = NOT is_unique;")
+ await db.execute(
+ "ALTER TABLE withdraw.withdraw_link ADD COLUMN is_public DEFAULT TRUE;"
+ )
+ # await db.execute("DROP TABLE withdraw.hash_check;")
diff --git a/models.py b/models.py
index b476dce..3642b26 100644
--- a/models.py
+++ b/models.py
@@ -1,51 +1,100 @@
-from datetime import datetime
+import json
+from datetime import datetime, timezone
from fastapi import Query
-from pydantic import BaseModel, Field
+from lnbits.helpers import urlsafe_short_hash
+from pydantic import BaseModel, Field, validator
class CreateWithdrawData(BaseModel):
title: str = Query(...)
min_withdrawable: int = Query(..., ge=1)
max_withdrawable: int = Query(..., ge=1)
- uses: int = Query(..., ge=1)
+ uses: int = Query(..., ge=1, le=250)
wait_time: int = Query(..., ge=1)
- is_unique: bool
- webhook_url: str = Query(None)
- webhook_headers: str = Query(None)
- webhook_body: str = Query(None)
- custom_url: str = Query(None)
+ is_static: bool = Query(True)
+ is_public: bool = Query(True)
+ webhook_url: str | None = Query(None)
+ webhook_headers: str | None = Query(None)
+ webhook_body: str | None = Query(None)
+ custom_url: str | None = Query(None)
+ @validator("max_withdrawable")
+ def check_max_withdrawable(cls, v: int, values) -> int:
+ if "min_withdrawable" in values and v < values["min_withdrawable"]:
+ raise ValueError("max_withdrawable must be at least min_withdrawable")
+ return v
-class WithdrawLink(BaseModel):
- id: str
- wallet: str = Query(None)
- title: str = Query(None)
- min_withdrawable: int = Query(0)
- max_withdrawable: int = Query(0)
- uses: int = Query(0)
- wait_time: int = Query(0)
- is_unique: bool = Query(False)
- unique_hash: str = Query(0)
- k1: str = Query(None)
- open_time: int = Query(0)
- used: int = Query(0)
- usescsv: str = Query(None)
- number: int = Field(default=0, no_database=True)
- webhook_url: str = Query(None)
- webhook_headers: str = Query(None)
- webhook_body: str = Query(None)
- custom_url: str = Query(None)
- created_at: datetime
+ @validator("webhook_body")
+ def check_webhook_body(cls, v):
+ if v:
+ try:
+ json.loads(v)
+ except Exception as exc:
+ raise ValueError("webhook_body must be valid JSON") from exc
+ return v
+
+ @validator("webhook_headers")
+ def check_headers_json(cls, v):
+ if v:
+ try:
+ json.loads(v)
+ except Exception as exc:
+ raise ValueError("webhook_headers must be valid JSON") from exc
+ return v
+
+
+class WithdrawSecret(BaseModel):
+ k1: str = Field(default_factory=urlsafe_short_hash)
+ withdraw_id: str
+ amount: int
+ used: bool = False
+ used_at: int | None = None
+ payment_hash_melt: str | None = None
+
+
+class WithdrawSecrets(BaseModel):
+ total: int = 0
+ used: int = 0
+ items: list[WithdrawSecret] = []
@property
def is_spent(self) -> bool:
- return self.used >= self.uses
+ return self.used >= self.total
+ @property
+ def next_secret(self) -> WithdrawSecret | None:
+ return next((item for item in self.items if not item.used), None)
+
+ def get_secret(self, k1: str) -> WithdrawSecret | None:
+ return next((item for item in self.items if item.k1 == k1), None)
+
+ def use_secret(self, k1: str) -> WithdrawSecret | None:
+ for item in self.items:
+ if item.k1 == k1 and not item.used:
+ item.used = True
+ item.used_at = int(datetime.now().timestamp())
+ self.used += 1
+ return item
+ return None
-class HashCheck(BaseModel):
- hash: bool
- lnurl: bool
+
+class WithdrawLink(BaseModel):
+ id: str = Field(default_factory=lambda: urlsafe_short_hash()[:22])
+ wallet: str
+ title: str
+ min_withdrawable: int
+ max_withdrawable: int
+ wait_time: int
+ is_static: bool
+ is_public: bool
+ webhook_url: str | None = None
+ webhook_headers: str | None = None
+ webhook_body: str | None = None
+ custom_url: str | None = None
+ open_time: int = Field(default_factory=lambda: int(datetime.now().timestamp()))
+ created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
+ secrets: WithdrawSecrets = WithdrawSecrets()
class PaginatedWithdraws(BaseModel):
diff --git a/pyproject.toml b/pyproject.toml
index 8f281dd..687afd1 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -53,9 +53,7 @@ line-length = 88
# RUF - ruff
# B - bugbear
select = ["F", "E", "W", "I", "A", "C", "N", "UP", "RUF", "B"]
-# UP007: pyupgrade: use X | Y instead of Optional. (python3.10)
-# C901 `api_link_create_or_update` is too complex (15 > 10)
-ignore = ["UP007", "C901"]
+ignore = []
# Allow autofix for all enabled rules (when `--fix`) is provided.
fixable = ["ALL"]
@@ -68,6 +66,7 @@ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
[tool.ruff.lint.pep8-naming]
classmethod-decorators = [
"root_validator",
+ "validator",
]
# Ignore unused imports in __init__.py files.
diff --git a/static/js/index.js b/static/js/index.js
index 9a8a9c1..ce2e98e 100644
--- a/static/js/index.js
+++ b/static/js/index.js
@@ -29,22 +29,10 @@ window.app = Vue.createApp({
}
},
{
- name: 'wait_time',
- align: 'right',
- label: 'Wait',
- field: 'wait_time'
- },
- {
- name: 'uses',
+ name: 'progress',
align: 'right',
- label: 'Uses',
- field: 'uses'
- },
- {
- name: 'uses_left',
- align: 'right',
- label: 'Uses left',
- field: 'uses_left'
+ label: 'Used / Total',
+ field: 'secrets'
},
{
name: 'max_withdrawable',
@@ -54,6 +42,12 @@ window.app = Vue.createApp({
format: v => {
return new Intl.NumberFormat(LOCALE).format(v)
}
+ },
+ {
+ name: 'wait_time',
+ align: 'right',
+ label: 'Wait',
+ field: 'wait_time'
}
],
pagination: {
@@ -62,13 +56,13 @@ window.app = Vue.createApp({
rowsNumber: 0
}
},
- nfcTagWriting: false,
formDialog: {
show: false,
secondMultiplier: 'seconds',
secondMultiplierOptions: ['seconds', 'minutes', 'hours'],
data: {
- is_unique: false,
+ is_static: false,
+ is_public: false,
use_custom: false,
has_webhook: false
}
@@ -76,7 +70,8 @@ window.app = Vue.createApp({
simpleformDialog: {
show: false,
data: {
- is_unique: true,
+ is_static: true,
+ is_public: false,
use_custom: false,
title: 'Vouchers',
min_withdrawable: 0,
@@ -112,7 +107,7 @@ window.app = Vue.createApp({
.request(
'GET',
`/withdraw/api/v1/links?all_wallets=true&limit=${query.limit}&offset=${query.offset}`,
- this.g.user.wallets[0].inkey
+ this.g.user.wallets[0].adminkey
)
.then(response => {
this.withdrawLinks = response.data.data.map(mapWithdrawLink)
@@ -125,27 +120,38 @@ window.app = Vue.createApp({
},
closeFormDialog() {
this.formDialog.data = {
- is_unique: false,
+ is_static: false,
+ is_public: false,
use_custom: false,
has_webhook: false
}
},
simplecloseFormDialog() {
this.simpleformDialog.data = {
- is_unique: false,
+ is_static: false,
+ is_public: false,
use_custom: false
}
},
+ getNextSecret(secrets) {
+ return _.findWhere(secrets, {used: false}).k1
+ },
openQrCodeDialog(linkId) {
const link = _.findWhere(this.withdrawLinks, {id: linkId})
this.qrCodeDialog.data = _.clone(link)
this.qrCodeDialog.show = true
- this.activeUrl = `${window.location.origin}/withdraw/api/v1/lnurl/${link.unique_hash}`
+ this.qrCodeDialog.progress =
+ link.secrets.used == 0 ? 0 : link.secrets.used / link.secrets.total
+ const id_or_k1 = link.is_static
+ ? link.id
+ : this.getNextSecret(link.secrets.items)
+ this.activeUrl = `${window.location.origin}/withdraw/api/v1/lnurl/${id_or_k1}`
},
openUpdateDialog(linkId) {
let link = _.findWhere(this.withdrawLinks, {id: linkId})
link._data.has_webhook = link._data.webhook_url ? true : false
this.formDialog.data = _.clone(link._data)
+ this.formDialog.data.uses = 1
this.formDialog.show = true
},
sendFormData() {
@@ -185,7 +191,7 @@ window.app = Vue.createApp({
data.wait_time = 1
data.min_withdrawable = data.max_withdrawable
data.title = 'vouchers'
- data.is_unique = true
+ data.is_static = true
if (!data.use_custom) {
data.custom_url = null
@@ -217,7 +223,7 @@ window.app = Vue.createApp({
data
)
.then(response => {
- this.withdrawLinks = _.reject(this.withdrawLinks, function (obj) {
+ this.withdrawLinks = _.reject(this.withdrawLinks, obj => {
return obj.id === data.id
})
this.withdrawLinks.push(mapWithdrawLink(response.data))
@@ -263,42 +269,6 @@ window.app = Vue.createApp({
})
})
},
- async writeNfcTag(lnurl) {
- try {
- if (typeof NDEFReader == 'undefined') {
- throw {
- toString: function () {
- return 'NFC not supported on this device or browser.'
- }
- }
- }
-
- const ndef = new NDEFReader()
-
- this.nfcTagWriting = true
- this.$q.notify({
- message: 'Tap your NFC tag to write the LNURL-withdraw link to it.'
- })
-
- await ndef.write({
- records: [{recordType: 'url', data: 'lightning:' + lnurl, lang: 'en'}]
- })
-
- this.nfcTagWriting = false
- this.$q.notify({
- type: 'positive',
- message: 'NFC tag written successfully.'
- })
- } catch (error) {
- this.nfcTagWriting = false
- this.$q.notify({
- type: 'negative',
- message: error
- ? error.toString()
- : 'An unexpected error has occurred.'
- })
- }
- },
exportCSV() {
LNbits.utils.exportCSV(
this.withdrawLinksTable.columns,
diff --git a/templates/withdraw/display.html b/templates/withdraw/display.html
index 4d06e11..927dc8e 100644
--- a/templates/withdraw/display.html
+++ b/templates/withdraw/display.html
@@ -11,22 +11,9 @@
- ID:
+ ID:
- Unique:
-
-
+
+ Static:
+
+
(QR code will change after each withdrawal)
- Max. withdrawable:
+ Max. withdrawable:
sat
- Wait time:
+ Wait time:
seconds
- Withdraws:
- /
-
+ Withdraws:
+ /
+
- {% for threes in page %}
-
- {% for one in threes %}
-
+
+
- {% endfor %}
- {% endfor %}
- {{ amt }} sats
-