Compare commits
69
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4744ef9bba | ||
|
|
fdf061c08d | ||
|
|
d348234cad | ||
|
|
fc5261132e | ||
|
|
4e18c423de | ||
|
|
37e5601207 | ||
|
|
231f6fc01c | ||
|
|
811957f5e1 | ||
|
|
c63a53cb14 | ||
|
|
78b8f7f16e | ||
|
|
4642878c05 | ||
|
|
d7c850ccbb | ||
|
|
4fbb4b5eef | ||
|
|
db2829a5f6 | ||
|
|
13ce1ac172 | ||
|
|
512697ae53 | ||
|
|
dfa89ca063 | ||
|
|
182694d2e5 | ||
|
|
2f10c57a10 | ||
|
|
d685824596 | ||
|
|
e816eb2dac | ||
|
|
d3c099350a | ||
|
|
691ac969dc | ||
|
|
85e8ac6057 | ||
|
|
99da300f6d | ||
|
|
cd413d85ed | ||
|
|
9245e2c268 | ||
|
|
0b5c8cdee2 | ||
|
|
c94cef07c2 | ||
|
|
22b4ab779f | ||
|
|
42061d0cc8 | ||
|
|
d0ea42fd70 | ||
|
|
cd58a0dc9b | ||
|
|
cac4e309f4 | ||
|
|
632cd257d7 | ||
|
|
a11274593c | ||
|
|
ae92965b69 | ||
|
|
3e1f75c4cc | ||
|
|
d668747ca2 | ||
|
|
dedfcd4818 | ||
|
|
f66f2fe6c2 | ||
|
|
f3ca37e846 | ||
|
|
d45709a80f | ||
|
|
e4b6dc84b5 | ||
|
|
67d1b4be3e | ||
|
|
bd514f1a46 | ||
|
|
03b302da69 | ||
|
|
b373e35915 | ||
|
|
0fd116e7ee | ||
|
|
2c84f9781d | ||
|
|
9fcf3121ac | ||
|
|
1df9fa831d | ||
|
|
42039070b9 | ||
|
|
2f9146fff3 | ||
|
|
5738795c8c | ||
|
|
3c77acf266 | ||
|
|
a4f2f55279 | ||
|
|
2ab7c17ceb | ||
|
|
7a2c509cd3 | ||
|
|
8a51ea53c8 | ||
|
|
19d3e9e64e | ||
|
|
9f9f14ead0 | ||
|
|
0a093d82ae | ||
|
|
17fd459fa7 | ||
|
|
fe9aa4d8c9 | ||
|
|
1af3b62739 | ||
|
|
e9cbc3492f | ||
|
|
ca2f78d25b | ||
|
|
9d9ab48345 |
@@ -24,10 +24,6 @@ LOG_ROTATION="100 MB"
|
||||
LOG_RETENTION="3 months"
|
||||
# for database cleanup commands
|
||||
# CLEANUP_WALLETS_DAYS=90
|
||||
# Hard limit for total created users. Set to 0 to disable the limit.
|
||||
# LNBITS_MAX_USERS=0
|
||||
# Hard limit for total installed extensions. Set to 0 to disable the limit.
|
||||
# LNBITS_MAX_EXTENSIONS=0
|
||||
|
||||
# === Admin Settings ===
|
||||
|
||||
|
||||
@@ -69,10 +69,7 @@ jobs:
|
||||
--onefile \
|
||||
--name lnbits \
|
||||
--hidden-import=embit \
|
||||
--hidden-import=bitstring.bitstore_bitarray \
|
||||
--collect-all embit \
|
||||
--collect-all bitstring \
|
||||
--collect-all bitarray \
|
||||
--collect-all lnbits \
|
||||
--collect-all sqlalchemy \
|
||||
--collect-all breez_sdk \
|
||||
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
make: openapi
|
||||
|
||||
regtest:
|
||||
needs: [ lint ]
|
||||
needs: [ lint, test-api, test-wallets, test-unit, migration, openapi ]
|
||||
uses: ./.github/workflows/regtest.yml
|
||||
strategy:
|
||||
matrix:
|
||||
|
||||
@@ -6,7 +6,6 @@ __pycache__
|
||||
*$py.class
|
||||
.mypy_cache
|
||||
.vscode
|
||||
.codex
|
||||
*-lock.json
|
||||
.python-version
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ from .extensions import (
|
||||
drop_extension_db,
|
||||
get_installed_extension,
|
||||
get_installed_extensions,
|
||||
get_installed_extensions_count,
|
||||
get_user_active_extensions_ids,
|
||||
get_user_extension,
|
||||
get_user_extensions,
|
||||
@@ -59,7 +58,6 @@ from .users import (
|
||||
get_account_by_username,
|
||||
get_account_by_username_or_email,
|
||||
get_accounts,
|
||||
get_accounts_count,
|
||||
get_user,
|
||||
get_user_access_control_lists,
|
||||
get_user_from_account,
|
||||
@@ -119,13 +117,11 @@ __all__ = [
|
||||
"get_account_by_username",
|
||||
"get_account_by_username_or_email",
|
||||
"get_accounts",
|
||||
"get_accounts_count",
|
||||
"get_admin_settings",
|
||||
"get_db_version",
|
||||
"get_db_versions",
|
||||
"get_installed_extension",
|
||||
"get_installed_extensions",
|
||||
"get_installed_extensions_count",
|
||||
"get_latest_payments_by_extension",
|
||||
"get_payment",
|
||||
"get_payments",
|
||||
|
||||
@@ -90,13 +90,6 @@ async def get_installed_extensions(
|
||||
return all_extensions
|
||||
|
||||
|
||||
async def get_installed_extensions_count(conn: Connection | None = None) -> int:
|
||||
row: dict | None = await (conn or db).fetchone(
|
||||
"SELECT COUNT(*) as count FROM installed_extensions"
|
||||
)
|
||||
return int(row["count"]) if row else 0
|
||||
|
||||
|
||||
async def get_user_extension(
|
||||
user_id: str, extension: str, conn: Connection | None = None
|
||||
) -> UserExtension | None:
|
||||
|
||||
@@ -37,13 +37,6 @@ async def create_account(
|
||||
return account
|
||||
|
||||
|
||||
async def get_accounts_count(conn: Connection | None = None) -> int:
|
||||
row: dict | None = await (conn or db).fetchone(
|
||||
"SELECT COUNT(*) as count FROM accounts"
|
||||
)
|
||||
return int(row["count"]) if row else 0
|
||||
|
||||
|
||||
async def update_account(account: Account, conn: Connection | None = None) -> Account:
|
||||
account.updated_at = datetime.now(timezone.utc)
|
||||
await (conn or db).update("accounts", account)
|
||||
|
||||
@@ -9,7 +9,6 @@ from lnbits.core.crud import (
|
||||
delete_installed_extension,
|
||||
get_db_version,
|
||||
get_installed_extension,
|
||||
get_installed_extensions_count,
|
||||
update_installed_extension_state,
|
||||
)
|
||||
from lnbits.core.crud.extensions import (
|
||||
@@ -39,8 +38,6 @@ async def install_extension(
|
||||
if installed_ext and installed_ext.meta:
|
||||
ext_info.meta.payments = installed_ext.meta.payments
|
||||
|
||||
await check_extensions_limit(installed_ext)
|
||||
|
||||
if not skip_download:
|
||||
await ext_info.download_archive()
|
||||
|
||||
@@ -66,15 +63,6 @@ async def install_extension(
|
||||
return extension
|
||||
|
||||
|
||||
async def check_extensions_limit(installed_ext: InstallableExtension | None = None):
|
||||
if settings.lnbits_max_extensions == 0 or installed_ext:
|
||||
return
|
||||
|
||||
extensions_count = await get_installed_extensions_count()
|
||||
if extensions_count >= settings.lnbits_max_extensions:
|
||||
raise ValueError("Max amount of extensions have been installed")
|
||||
|
||||
|
||||
async def uninstall_extension(ext_id: str):
|
||||
await stop_extension_background_work(ext_id)
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ from ..crud import (
|
||||
get_account_by_email,
|
||||
get_account_by_pubkey,
|
||||
get_account_by_username,
|
||||
get_accounts_count,
|
||||
get_super_settings,
|
||||
get_user_extensions,
|
||||
get_user_from_account,
|
||||
@@ -56,8 +55,6 @@ async def create_user_account_no_ckeck(
|
||||
conn: Connection | None = None,
|
||||
) -> User:
|
||||
async with db.reuse_conn(conn) if conn else db.connect() as conn:
|
||||
await check_users_limit(conn)
|
||||
|
||||
if account:
|
||||
account.validate_fields()
|
||||
if account.username and await get_account_by_username(
|
||||
@@ -98,15 +95,6 @@ async def create_user_account_no_ckeck(
|
||||
return user
|
||||
|
||||
|
||||
async def check_users_limit(conn: Connection | None = None):
|
||||
if settings.lnbits_max_users == 0:
|
||||
return
|
||||
|
||||
users_count = await get_accounts_count(conn=conn)
|
||||
if users_count >= settings.lnbits_max_users:
|
||||
raise ValueError("Max amount of users have been created")
|
||||
|
||||
|
||||
async def update_user_account(account: Account) -> Account:
|
||||
account.validate_fields()
|
||||
|
||||
|
||||
@@ -98,16 +98,12 @@ async def api_install_extension(data: CreateExtension):
|
||||
ext_info.clean_extension_files()
|
||||
detail = (
|
||||
str(exc)
|
||||
if isinstance(exc, (AssertionError, ValueError))
|
||||
if isinstance(exc, AssertionError)
|
||||
else f"Failed to install extension '{ext_info.id}'."
|
||||
f"({ext_info.installed_version})."
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=(
|
||||
HTTPStatus.BAD_REQUEST
|
||||
if isinstance(exc, (AssertionError, ValueError))
|
||||
else HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
),
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail=detail,
|
||||
) from exc
|
||||
|
||||
|
||||
+2
-6
@@ -324,8 +324,7 @@ class AssetSettings(LNbitsSettings):
|
||||
"heif",
|
||||
"heics",
|
||||
"text/plain",
|
||||
"text/json",
|
||||
"text/xml",
|
||||
"text/json" "text/xml",
|
||||
"application/json",
|
||||
"application/pdf",
|
||||
]
|
||||
@@ -589,8 +588,6 @@ class ZBDFundingSource(LNbitsSettings):
|
||||
class PhoenixdFundingSource(LNbitsSettings):
|
||||
phoenixd_api_endpoint: str | None = Field(default="http://localhost:9740/")
|
||||
phoenixd_api_password: str | None = Field(default=None)
|
||||
phoenixd_data_dir: str | None = Field(default=None)
|
||||
phoenixd_mnemonic: str | None = Field(default=None)
|
||||
|
||||
|
||||
class AlbyFundingSource(LNbitsSettings):
|
||||
@@ -1010,6 +1007,7 @@ class EnvSettings(LNbitsSettings):
|
||||
debug_database: bool = Field(default=False)
|
||||
bundle_assets: bool = Field(default=True)
|
||||
# When enabled, auth cookies require HTTPS and SSO will reject insecure HTTP.
|
||||
# Set to false for local/dev environments that run without TLS.
|
||||
auth_https_only: bool = Field(default=True)
|
||||
host: str = Field(default="127.0.0.1")
|
||||
port: int = Field(default=5000, gt=0)
|
||||
@@ -1028,8 +1026,6 @@ class EnvSettings(LNbitsSettings):
|
||||
|
||||
cleanup_wallets_days: int = Field(default=90, ge=0)
|
||||
funding_source_max_retries: int = Field(default=4, ge=0)
|
||||
lnbits_max_users: int = Field(default=0, ge=0)
|
||||
lnbits_max_extensions: int = Field(default=0, ge=0)
|
||||
|
||||
@property
|
||||
def has_default_extension_path(self) -> bool:
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+11
-11
File diff suppressed because one or more lines are too long
@@ -275,8 +275,6 @@ window.localisation.en = {
|
||||
requires_server_restart:
|
||||
'Changing these settings requires a server restart to take effect.',
|
||||
funding_source_info: 'Select the active funding wallet',
|
||||
phoenixd_warning:
|
||||
"Phoenixd mnemonic is only available if phoenixd data-dir is specified and is readable by LNbits. It's not indicative of phoenixd not running. It just means LNbits cannot access the mnemonic to display it here.",
|
||||
latest_update: 'You are on the latest version {version}.',
|
||||
notifications: 'Notifications',
|
||||
notifications_configure: 'Configure Notifications',
|
||||
|
||||
@@ -202,18 +202,7 @@ window.app.component('lnbits-admin-funding-sources', {
|
||||
'Phoenixd',
|
||||
{
|
||||
phoenixd_api_endpoint: 'Endpoint',
|
||||
phoenixd_api_password: 'Key',
|
||||
phoenixd_data_dir: {
|
||||
label: 'Data Directory',
|
||||
hint: 'Directory where phoenixd stores its data, including the seed phrase.'
|
||||
},
|
||||
phoenixd_mnemonic: {
|
||||
label: 'Phoenixd Seed Phrase',
|
||||
hint: 'Only available if phoenixd data-dir is specified',
|
||||
readonly: true,
|
||||
copy: true,
|
||||
qrcode: true
|
||||
}
|
||||
phoenixd_api_password: 'Key'
|
||||
}
|
||||
],
|
||||
[
|
||||
|
||||
@@ -41,37 +41,5 @@ window.app.component('lnbits-admin-site-customisation', {
|
||||
]
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onBackgroundImageInput(e) {
|
||||
const file = e.target.files[0]
|
||||
if (file) {
|
||||
this.uploadBackgroundImage(file)
|
||||
}
|
||||
e.target.value = null
|
||||
},
|
||||
async uploadBackgroundImage(file) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'POST',
|
||||
'/api/v1/assets?public_asset=true',
|
||||
null,
|
||||
formData,
|
||||
{
|
||||
headers: {'Content-Type': 'multipart/form-data'}
|
||||
}
|
||||
)
|
||||
const assetUrl = `${window.location.origin}/api/v1/assets/${data.id}/thumbnail`
|
||||
this.formData.lnbits_default_bgimage = assetUrl
|
||||
Quasar.Notify.create({
|
||||
type: 'positive',
|
||||
message: 'Background image uploaded.',
|
||||
icon: null
|
||||
})
|
||||
} catch (e) {
|
||||
LNbits.utils.notifyApiError(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
methods: {}
|
||||
})
|
||||
|
||||
@@ -545,56 +545,31 @@ window.PageAccount = {
|
||||
if (file) {
|
||||
this.uploadAsset(file)
|
||||
}
|
||||
e.target.value = null
|
||||
},
|
||||
onBackgroundImageInput(e) {
|
||||
const file = e.target.files[0]
|
||||
if (file) {
|
||||
this.uploadBackgroundImage(file)
|
||||
}
|
||||
e.target.value = null
|
||||
},
|
||||
async uploadAsset(
|
||||
file,
|
||||
{isPublic = this.assetsUploadToPublic, notifySuccess = true} = {}
|
||||
) {
|
||||
async uploadAsset(file) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
await LNbits.api.request(
|
||||
'POST',
|
||||
`/api/v1/assets?public_asset=${isPublic}`,
|
||||
`/api/v1/assets?public_asset=${this.assetsUploadToPublic}`,
|
||||
null,
|
||||
formData,
|
||||
{
|
||||
headers: {'Content-Type': 'multipart/form-data'}
|
||||
}
|
||||
)
|
||||
if (notifySuccess) {
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'Upload successful!',
|
||||
icon: null
|
||||
})
|
||||
}
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'Upload successful!',
|
||||
icon: null
|
||||
})
|
||||
await this.getUserAssets()
|
||||
return data
|
||||
} catch (e) {
|
||||
console.warn(e)
|
||||
LNbits.utils.notifyApiError(e)
|
||||
}
|
||||
},
|
||||
async uploadBackgroundImage(file) {
|
||||
const asset = await this.uploadAsset(file, {
|
||||
isPublic: false,
|
||||
notifySuccess: false
|
||||
})
|
||||
if (!asset) {
|
||||
return
|
||||
}
|
||||
const assetUrl = `${window.location.origin}/api/v1/assets/${asset.id}/thumbnail`
|
||||
await this.siteCustomisationChanged({bgimageChoice: assetUrl})
|
||||
},
|
||||
async deleteAsset(asset) {
|
||||
LNbits.utils
|
||||
.confirmDialog('Are you sure you want to delete this asset?')
|
||||
|
||||
Vendored
+153
-294
File diff suppressed because it is too large
Load Diff
@@ -75,35 +75,7 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isSuperUser">
|
||||
<lnbits-admin-funding-sources
|
||||
:form-data="formData"
|
||||
:allowed-funding-sources="settings.lnbits_allowed_funding_sources"
|
||||
/>
|
||||
<div class="row q-col-gutter-md q-my-md">
|
||||
<div class="col-12 col-sm-8">
|
||||
<q-item tag="div">
|
||||
<q-item-section>
|
||||
<q-item-label
|
||||
v-text="$t('funding_source_retries')"
|
||||
></q-item-label>
|
||||
<q-item-label
|
||||
caption
|
||||
v-text="$t('funding_source_retries_desc')"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.funding_source_max_retries"
|
||||
type="number"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-md q-mt-lg">
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12">
|
||||
<h6 class="q-my-none">
|
||||
<span v-text="$t('routing_fee_reserve_calculations')"></span>
|
||||
@@ -210,6 +182,34 @@
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isSuperUser">
|
||||
<lnbits-admin-funding-sources
|
||||
:form-data="formData"
|
||||
:allowed-funding-sources="settings.lnbits_allowed_funding_sources"
|
||||
/>
|
||||
<div class="row q-col-gutter-md q-my-md">
|
||||
<div class="col-12 col-sm-8">
|
||||
<q-item tag="div">
|
||||
<q-item-section>
|
||||
<q-item-label
|
||||
v-text="$t('funding_source_retries')"
|
||||
></q-item-label>
|
||||
<q-item-label
|
||||
caption
|
||||
v-text="$t('funding_source_retries_desc')"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.funding_source_max_retries"
|
||||
type="number"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<q-separator></q-separator>
|
||||
<h6 class="q-mt-lg q-mb-sm">
|
||||
<p v-text="$t('watchdog')"></p>
|
||||
|
||||
@@ -52,7 +52,6 @@
|
||||
:label="prop.label"
|
||||
:hint="prop.hint"
|
||||
:value="prop.value"
|
||||
:readonly="prop.readonly || false"
|
||||
>
|
||||
<q-btn
|
||||
v-if="prop.copy"
|
||||
@@ -74,15 +73,6 @@
|
||||
></q-btn>
|
||||
</q-input>
|
||||
</div>
|
||||
<p
|
||||
v-if="fund === 'PhoenixdWallet' && key === 'phoenixd_mnemonic'"
|
||||
class="col-12 q-my-md"
|
||||
>
|
||||
<span>
|
||||
<q-icon name="warning" color="orange" size="xs"></q-icon>
|
||||
<span v-text="$t('phoenixd_warning')"></span>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<q-expansion-item
|
||||
v-if="
|
||||
|
||||
@@ -251,27 +251,10 @@
|
||||
type="text"
|
||||
v-model="formData.lnbits_default_bgimage"
|
||||
label="Background Image"
|
||||
@update:model-value="applyGlobalBgimage"
|
||||
hint="This must be a trusted source. It can change the content and it can log your IP address."
|
||||
>
|
||||
<template v-slot:append>
|
||||
<q-btn
|
||||
dense
|
||||
flat
|
||||
round
|
||||
icon="upload"
|
||||
@click="$refs.adminBackgroundImageInput.click()"
|
||||
>
|
||||
<q-tooltip>Upload background image</q-tooltip>
|
||||
</q-btn>
|
||||
</template>
|
||||
</q-input>
|
||||
<input
|
||||
type="file"
|
||||
ref="adminBackgroundImageInput"
|
||||
accept="image/*"
|
||||
style="display: none"
|
||||
@change="onBackgroundImageInput"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-md q-mb-md">
|
||||
|
||||
@@ -492,28 +492,10 @@
|
||||
siteCustomisationChanged({bgimageChoice: $event})
|
||||
"
|
||||
>
|
||||
<template v-slot:append>
|
||||
<q-btn
|
||||
dense
|
||||
flat
|
||||
round
|
||||
icon="upload"
|
||||
@click="$refs.backgroundImageInput.click()"
|
||||
>
|
||||
<q-tooltip>Upload background image</q-tooltip>
|
||||
</q-btn>
|
||||
</template>
|
||||
<q-tooltip
|
||||
><span v-text="$t('background_image')"></span
|
||||
></q-tooltip>
|
||||
</q-input>
|
||||
<input
|
||||
type="file"
|
||||
ref="backgroundImageInput"
|
||||
accept="image/*"
|
||||
style="display: none"
|
||||
@change="onBackgroundImageInput"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mb-md">
|
||||
@@ -1126,130 +1108,82 @@
|
||||
<q-separator></q-separator>
|
||||
|
||||
<q-card-section>
|
||||
<div
|
||||
class="row items-center no-wrap q-col-gutter-sm"
|
||||
<q-btn-dropdown
|
||||
color="grey"
|
||||
dense
|
||||
outline
|
||||
no-caps
|
||||
:label="props.row.name"
|
||||
:icon="props.row.is_public ? 'public' : ''"
|
||||
>
|
||||
<div class="col">
|
||||
<q-btn-dropdown
|
||||
color="grey"
|
||||
dense
|
||||
outline
|
||||
no-caps
|
||||
class="full-width"
|
||||
:label="props.row.name"
|
||||
:icon="props.row.is_public ? 'public' : ''"
|
||||
<q-list>
|
||||
<q-item
|
||||
clickable
|
||||
v-close-popup
|
||||
@click="copyAssetLinkToClipboard(props.row)"
|
||||
>
|
||||
<q-list>
|
||||
<q-item
|
||||
clickable
|
||||
v-close-popup
|
||||
@click="
|
||||
copyAssetLinkToClipboard(props.row)
|
||||
"
|
||||
<q-item-section avatar>
|
||||
<q-avatar icon="content_copy" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Copy Link</q-item-label>
|
||||
<q-item-label caption
|
||||
>Copy asset link to
|
||||
clipboard</q-item-label
|
||||
>
|
||||
<q-item-section avatar>
|
||||
<q-avatar icon="content_copy" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Copy Link</q-item-label>
|
||||
<q-item-label caption
|
||||
>Copy asset link to
|
||||
clipboard</q-item-label
|
||||
>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item
|
||||
clickable
|
||||
v-close-popup
|
||||
@click="
|
||||
toggleAssetPublicAccess(props.row)
|
||||
<q-item
|
||||
clickable
|
||||
v-close-popup
|
||||
@click="toggleAssetPublicAccess(props.row)"
|
||||
>
|
||||
<q-item-section avatar>
|
||||
<q-avatar
|
||||
:icon="
|
||||
props.row.is_public
|
||||
? 'public_off'
|
||||
: 'public'
|
||||
"
|
||||
text-color="primary"
|
||||
/>
|
||||
</q-item-section>
|
||||
<q-item-section v-if="props.row.is_public">
|
||||
<q-item-label>Unpublish</q-item-label>
|
||||
<q-item-label caption
|
||||
>Make this asset private</q-item-label
|
||||
>
|
||||
<q-item-section avatar>
|
||||
<q-avatar
|
||||
:icon="
|
||||
props.row.is_public
|
||||
? 'public_off'
|
||||
: 'public'
|
||||
"
|
||||
text-color="primary"
|
||||
/>
|
||||
</q-item-section>
|
||||
<q-item-section
|
||||
v-if="props.row.is_public"
|
||||
>
|
||||
<q-item-label>Unpublish</q-item-label>
|
||||
<q-item-label caption
|
||||
>Make this asset
|
||||
private</q-item-label
|
||||
>
|
||||
</q-item-section>
|
||||
<q-item-section v-else>
|
||||
<q-item-label>Publish</q-item-label>
|
||||
<q-item-label caption
|
||||
>Make this asset
|
||||
public</q-item-label
|
||||
>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-item-section>
|
||||
<q-item-section v-else>
|
||||
<q-item-label>Publish</q-item-label>
|
||||
<q-item-label caption
|
||||
>Make this asset public</q-item-label
|
||||
>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item
|
||||
clickable
|
||||
v-close-popup
|
||||
@click="deleteAsset(props.row)"
|
||||
<q-item
|
||||
clickable
|
||||
v-close-popup
|
||||
@click="deleteAsset(props.row)"
|
||||
>
|
||||
<q-item-section avatar>
|
||||
<q-avatar
|
||||
icon="delete"
|
||||
text-color="negative"
|
||||
/>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Delete</q-item-label>
|
||||
<q-item-label caption
|
||||
>Permanently delete this
|
||||
asset</q-item-label
|
||||
>
|
||||
<q-item-section avatar>
|
||||
<q-avatar
|
||||
icon="delete"
|
||||
text-color="negative"
|
||||
/>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Delete</q-item-label>
|
||||
<q-item-label caption
|
||||
>Permanently delete this
|
||||
asset</q-item-label
|
||||
>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-btn-dropdown>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn
|
||||
type="a"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
color="primary"
|
||||
dense
|
||||
flat
|
||||
round
|
||||
icon="image"
|
||||
:href="`/api/v1/assets/${props.row.id}/data`"
|
||||
>
|
||||
<q-tooltip>Full image</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
<div
|
||||
class="col-auto"
|
||||
v-if="props.row.thumbnail_base64"
|
||||
>
|
||||
<q-btn
|
||||
type="a"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
color="secondary"
|
||||
dense
|
||||
flat
|
||||
round
|
||||
icon="photo_size_select_small"
|
||||
:href="`/api/v1/assets/${props.row.id}/thumbnail`"
|
||||
>
|
||||
<q-tooltip>Thumbnail</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-btn-dropdown>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
@@ -4,11 +4,9 @@ import hashlib
|
||||
import json
|
||||
import urllib.parse
|
||||
from collections.abc import AsyncGenerator
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from embit.bip39 import mnemonic_is_valid
|
||||
from httpx import RequestError, TimeoutException
|
||||
from loguru import logger
|
||||
from websockets import connect
|
||||
@@ -63,8 +61,6 @@ class PhoenixdWallet(Wallet):
|
||||
}
|
||||
|
||||
self.client = httpx.AsyncClient(base_url=self.endpoint, headers=self.headers)
|
||||
self._seed_mnemonic_to_persist: str | None = None
|
||||
self._load_mnemonic_from_seed_file()
|
||||
|
||||
async def cleanup(self):
|
||||
try:
|
||||
@@ -73,7 +69,6 @@ class PhoenixdWallet(Wallet):
|
||||
logger.warning(f"Error closing wallet connection: {e}")
|
||||
|
||||
async def status(self) -> StatusResponse:
|
||||
await self._persist_loaded_mnemonic()
|
||||
try:
|
||||
r = await self.client.get("/getinfo", timeout=10)
|
||||
r.raise_for_status()
|
||||
@@ -106,6 +101,7 @@ class PhoenixdWallet(Wallet):
|
||||
unhashed_description: bytes | None = None,
|
||||
**kwargs,
|
||||
) -> InvoiceResponse:
|
||||
|
||||
try:
|
||||
msats_amount = amount
|
||||
data: dict[str, Any] = {
|
||||
@@ -313,7 +309,7 @@ class PhoenixdWallet(Wallet):
|
||||
and message_json.get("type") == "payment_received"
|
||||
):
|
||||
logger.info(
|
||||
f"payment-received: {message_json['paymentHash']}"
|
||||
f'payment-received: {message_json["paymentHash"]}'
|
||||
)
|
||||
yield message_json["paymentHash"]
|
||||
|
||||
@@ -323,49 +319,3 @@ class PhoenixdWallet(Wallet):
|
||||
"retrying in 5 seconds"
|
||||
)
|
||||
await asyncio.sleep(5)
|
||||
|
||||
def _load_mnemonic_from_seed_file(self):
|
||||
data_dir = settings.phoenixd_data_dir
|
||||
if not data_dir:
|
||||
return
|
||||
|
||||
seed_path = Path(data_dir).expanduser() / "seed.dat"
|
||||
if not seed_path.is_file():
|
||||
return
|
||||
|
||||
try:
|
||||
mnemonic = seed_path.read_text(encoding="utf-8").strip()
|
||||
if mnemonic == settings.phoenixd_mnemonic:
|
||||
return
|
||||
except OSError as exc:
|
||||
logger.warning(f"Failed to read Phoenixd seed file '{seed_path}': {exc}")
|
||||
return
|
||||
|
||||
if not mnemonic:
|
||||
logger.warning(f"Phoenixd seed file '{seed_path}' is empty.")
|
||||
return
|
||||
|
||||
if not mnemonic_is_valid(mnemonic):
|
||||
logger.warning(
|
||||
f"Phoenixd seed file '{seed_path}' does not contain a valid "
|
||||
"BIP39 mnemonic."
|
||||
)
|
||||
return
|
||||
|
||||
settings.phoenixd_mnemonic = mnemonic
|
||||
self._seed_mnemonic_to_persist = mnemonic
|
||||
|
||||
async def _persist_loaded_mnemonic(self):
|
||||
if not self._seed_mnemonic_to_persist:
|
||||
return
|
||||
|
||||
logger.info("Updating 'PHOENIXD_MNEMONIC' mnemonic settings.")
|
||||
try:
|
||||
from lnbits.core.crud.settings import set_settings_field
|
||||
|
||||
await set_settings_field(
|
||||
"phoenixd_mnemonic", self._seed_mnemonic_to_persist
|
||||
)
|
||||
self._seed_mnemonic_to_persist = None
|
||||
except Exception as exc:
|
||||
logger.warning(f"Failed to persist Phoenixd mnemonic: {exc}")
|
||||
|
||||
Generated
+8
-12
@@ -6,7 +6,7 @@
|
||||
"": {
|
||||
"name": "lnbits",
|
||||
"dependencies": {
|
||||
"axios": "^1.15.0",
|
||||
"axios": "^1.13.5",
|
||||
"chart.js": "^4.5.1",
|
||||
"moment": "^2.30.1",
|
||||
"nostr-tools": "^2.18.2",
|
||||
@@ -734,14 +734,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz",
|
||||
"integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==",
|
||||
"version": "1.13.5",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
|
||||
"integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.11",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^2.1.0"
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
@@ -1555,13 +1555,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
|
||||
},
|
||||
"node_modules/pyright": {
|
||||
"version": "1.1.289",
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@
|
||||
"terser": "^5.44.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.15.0",
|
||||
"axios": "^1.13.5",
|
||||
"chart.js": "^4.5.1",
|
||||
"moment": "^2.30.1",
|
||||
"nostr-tools": "^2.18.2",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "lnbits"
|
||||
version = "1.5.4-rc1"
|
||||
version = "1.5.3"
|
||||
requires-python = ">=3.10,<3.13"
|
||||
description = "LNbits, free and open-source Lightning wallet and accounts system."
|
||||
authors = [{ name = "Alan Bits", email = "alan@lnbits.com" }]
|
||||
|
||||
@@ -135,8 +135,6 @@ async def test_auth_api_first_install_success_and_validation(
|
||||
)
|
||||
assert success.status_code == 200
|
||||
assert success.json()["access_token"]
|
||||
assert "cookie_access_token=" in success.headers["set-cookie"]
|
||||
assert "Secure" not in success.headers["set-cookie"]
|
||||
|
||||
updated_superuser = await get_account(settings.super_user, active_only=False)
|
||||
assert updated_superuser is not None
|
||||
@@ -161,43 +159,3 @@ async def test_auth_api_first_install_success_and_validation(
|
||||
await update_account(restored_superuser)
|
||||
settings.first_install = original_first_install
|
||||
settings.first_install_token = original_first_install_token
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_auth_api_first_install_uses_secure_cookie_when_enabled(
|
||||
http_client: AsyncClient, settings: Settings
|
||||
):
|
||||
superuser = await get_account(settings.super_user, active_only=False)
|
||||
assert superuser is not None
|
||||
|
||||
original_username = superuser.username
|
||||
original_password_hash = superuser.password_hash
|
||||
original_first_install = settings.first_install
|
||||
original_auth_https_only = settings.auth_https_only
|
||||
|
||||
new_username = f"secure_{uuid4().hex[:8]}"
|
||||
|
||||
try:
|
||||
settings.first_install = True
|
||||
settings.auth_https_only = True
|
||||
|
||||
success = await http_client.put(
|
||||
"/api/v1/auth/first_install",
|
||||
json={
|
||||
"username": new_username,
|
||||
"password": "secret1234",
|
||||
"password_repeat": "secret1234",
|
||||
},
|
||||
)
|
||||
|
||||
assert success.status_code == 200
|
||||
assert "cookie_access_token=" in success.headers["set-cookie"]
|
||||
assert "Secure" in success.headers["set-cookie"]
|
||||
finally:
|
||||
restored_superuser = await get_account(settings.super_user, active_only=False)
|
||||
assert restored_superuser is not None
|
||||
restored_superuser.username = original_username
|
||||
restored_superuser.password_hash = original_password_hash
|
||||
await update_account(restored_superuser)
|
||||
settings.first_install = original_first_install
|
||||
settings.auth_https_only = original_auth_https_only
|
||||
|
||||
+1
-3
@@ -40,9 +40,7 @@ from tests.helpers import (
|
||||
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
|
||||
|
||||
ADMIN_USER_ID = uuid4().hex
|
||||
# Snapshot the initialized module settings instead of a fresh Settings() instance.
|
||||
# The module settings include runtime-populated values like `version`.
|
||||
_PURE_SETTINGS = copy.deepcopy(lnbits_settings)
|
||||
_PURE_SETTINGS = Settings()
|
||||
_PURE_SETTINGS_FIELDS = tuple(
|
||||
sorted(
|
||||
{
|
||||
|
||||
+1
-3
@@ -28,7 +28,7 @@ from lnbits.core.models.extensions_builder import (
|
||||
PublicPageFields,
|
||||
SettingsFields,
|
||||
)
|
||||
from lnbits.wallets import get_funding_source, set_funding_source
|
||||
from lnbits.wallets import get_funding_source
|
||||
|
||||
|
||||
class DbTestModel(BaseModel):
|
||||
@@ -182,8 +182,6 @@ def make_lnurl_pay_response(
|
||||
)
|
||||
|
||||
|
||||
set_funding_source()
|
||||
|
||||
funding_source = get_funding_source()
|
||||
is_fake: bool = funding_source.__class__.__name__ == "FakeWallet"
|
||||
is_regtest: bool = not is_fake
|
||||
|
||||
@@ -9,7 +9,15 @@ from loguru import logger
|
||||
from lnbits.wallets import get_funding_source
|
||||
|
||||
funding_source = get_funding_source()
|
||||
is_boltz_wallet = funding_source.__class__.__name__ == "BoltzWallet"
|
||||
|
||||
|
||||
def is_boltz_wallet():
|
||||
print(
|
||||
"### funding_source.__class__.__name__ 2",
|
||||
get_funding_source().__class__.__name__,
|
||||
)
|
||||
return get_funding_source().__class__.__name__ == "BoltzWallet"
|
||||
|
||||
|
||||
docker_lightning_cli = [
|
||||
"docker",
|
||||
|
||||
@@ -21,7 +21,8 @@ async def test_create_invoice(from_wallet):
|
||||
)
|
||||
|
||||
# we cannot know the preimage of the swap yet
|
||||
if not is_boltz_wallet:
|
||||
funding_source = get_funding_source()
|
||||
if not is_boltz_wallet():
|
||||
assert payment.preimage
|
||||
|
||||
invoice = decode(payment.bolt11)
|
||||
@@ -42,7 +43,8 @@ async def test_create_internal_invoice(from_wallet):
|
||||
)
|
||||
|
||||
# we cannot know the preimage of the swap yet
|
||||
if not is_boltz_wallet:
|
||||
funding_source = get_funding_source()
|
||||
if not is_boltz_wallet():
|
||||
assert payment.preimage
|
||||
|
||||
invoice = decode(payment.bolt11)
|
||||
|
||||
@@ -20,7 +20,7 @@ async def test_services_pay_invoice(to_wallet, real_invoice):
|
||||
)
|
||||
assert payment
|
||||
assert payment.memo == description
|
||||
if not is_boltz_wallet:
|
||||
if not is_boltz_wallet():
|
||||
assert payment.status == PaymentState.SUCCESS
|
||||
assert payment.preimage
|
||||
else:
|
||||
|
||||
@@ -252,12 +252,7 @@ async def test_notification_for_internal_payment(
|
||||
assert _payment.bolt11 == payment.bolt11
|
||||
assert _payment.amount == 123_000
|
||||
updated_payment = await get_payment(_payment.checking_id)
|
||||
assert (
|
||||
updated_payment.webhook_status is not None
|
||||
), "Webhook should have been called."
|
||||
assert (
|
||||
int(updated_payment.webhook_status) >= 400
|
||||
), "Webhook should have been called and failed."
|
||||
assert updated_payment.webhook_status == "404"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
@@ -197,7 +197,7 @@ async def test_update_wallet_balance_validates_credit_and_debit(
|
||||
|
||||
settings.lnbits_wallet_limit_max_balance = 0
|
||||
queue_mock = mocker.patch(
|
||||
"lnbits.tasks.internal_invoice_queue_put",
|
||||
"lnbits.core.services.payments.internal_invoice_queue_put",
|
||||
mocker.AsyncMock(),
|
||||
)
|
||||
|
||||
|
||||
@@ -1263,7 +1263,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "lnbits"
|
||||
version = "1.5.4rc1"
|
||||
version = "1.5.3"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiosqlite" },
|
||||
@@ -2022,11 +2022,11 @@ email = [
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.20.0"
|
||||
version = "2.19.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2114,7 +2114,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.3"
|
||||
version = "9.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
@@ -2125,9 +2125,9 @@ dependencies = [
|
||||
{ name = "pygments" },
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2213,11 +2213,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "python-multipart"
|
||||
version = "0.0.26"
|
||||
version = "0.0.22"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/88/71/b145a380824a960ebd60e1014256dbb7d2253f2316ff2d73dfd8928ec2c3/python_multipart-0.0.26.tar.gz", hash = "sha256:08fadc45918cd615e26846437f50c5d6d23304da32c341f289a617127b081f17", size = 43501, upload-time = "2026-04-10T14:09:59.473Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/22/f1925cdda983ab66fc8ec6ec8014b959262747e58bdca26a4e3d1da29d56/python_multipart-0.0.26-py3-none-any.whl", hash = "sha256:c0b169f8c4484c13b0dcf2ef0ec3a4adb255c4b7d18d8e420477d2b1dd03f185", size = 28847, upload-time = "2026-04-10T14:09:58.131Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user