feat: add camera permissions
This commit is contained in:
@@ -53,6 +53,7 @@ from .storage import (
|
|||||||
logger = logging.getLogger("lnbits.extensions")
|
logger = logging.getLogger("lnbits.extensions")
|
||||||
|
|
||||||
_EXTENSION_API_METHOD_ATTR = "__lnbits_extension_api_method__"
|
_EXTENSION_API_METHOD_ATTR = "__lnbits_extension_api_method__"
|
||||||
|
_EXTENSION_RUNTIME_PERMISSION_IDS = {"ui.camera.scan_qr"}
|
||||||
_RequestModel = TypeVar("_RequestModel", bound=BaseModel)
|
_RequestModel = TypeVar("_RequestModel", bound=BaseModel)
|
||||||
_ResponseModel = TypeVar("_ResponseModel", bound=BaseModel)
|
_ResponseModel = TypeVar("_ResponseModel", bound=BaseModel)
|
||||||
|
|
||||||
@@ -693,11 +694,13 @@ def _extension_api_method_sources(
|
|||||||
def extension_api_permission_ids(
|
def extension_api_permission_ids(
|
||||||
api_cls: type[ExtensionAPI] = ExtensionAPI,
|
api_cls: type[ExtensionAPI] = ExtensionAPI,
|
||||||
) -> set[str]:
|
) -> set[str]:
|
||||||
return {
|
permissions = {
|
||||||
method.required_permission
|
method.required_permission
|
||||||
for method in list_extension_api_methods(api_cls)
|
for method in list_extension_api_methods(api_cls)
|
||||||
if method.required_permission
|
if method.required_permission
|
||||||
}
|
}
|
||||||
|
permissions.update(_EXTENSION_RUNTIME_PERMISSION_IDS)
|
||||||
|
return permissions
|
||||||
|
|
||||||
|
|
||||||
def get_extension_api_method(
|
def get_extension_api_method(
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from pydantic import UUID4
|
|||||||
from starlette.staticfiles import PathLike as StaticFilesPathLike
|
from starlette.staticfiles import PathLike as StaticFilesPathLike
|
||||||
from starlette.types import Scope
|
from starlette.types import Scope
|
||||||
|
|
||||||
from lnbits.core.crud import get_user_from_account
|
from lnbits.core.crud import get_installed_extension, get_user_from_account
|
||||||
from lnbits.core.db import core_app_extra
|
from lnbits.core.db import core_app_extra
|
||||||
from lnbits.core.models import Account
|
from lnbits.core.models import Account
|
||||||
from lnbits.decorators import (
|
from lnbits.decorators import (
|
||||||
@@ -248,6 +248,8 @@ def _add_wasm_extension_frame_config_route(
|
|||||||
else:
|
else:
|
||||||
user_id = await _optional_wasm_user_id(request, access_token, usr)
|
user_id = await _optional_wasm_user_id(request, access_token, usr)
|
||||||
|
|
||||||
|
granted_permission_ids = await _wasm_extension_granted_permission_ids(extension)
|
||||||
|
|
||||||
return _wasm_extension_frame_config(
|
return _wasm_extension_frame_config(
|
||||||
extension,
|
extension,
|
||||||
ui_route["frame_path"],
|
ui_route["frame_path"],
|
||||||
@@ -256,6 +258,7 @@ def _add_wasm_extension_frame_config_route(
|
|||||||
ui_route["route_params"],
|
ui_route["route_params"],
|
||||||
_read_wasm_extension_route_query(body.get("query")),
|
_read_wasm_extension_route_query(body.get("query")),
|
||||||
user_id,
|
user_id,
|
||||||
|
granted_permission_ids,
|
||||||
)
|
)
|
||||||
|
|
||||||
app.add_api_route(
|
app.add_api_route(
|
||||||
@@ -624,6 +627,7 @@ def _wasm_extension_frame_config(
|
|||||||
route_params: dict[str, str],
|
route_params: dict[str, str],
|
||||||
query: dict[str, Any],
|
query: dict[str, Any],
|
||||||
user_id: str | None,
|
user_id: str | None,
|
||||||
|
permissions: set[str],
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
public = auth == "public"
|
public = auth == "public"
|
||||||
return {
|
return {
|
||||||
@@ -637,11 +641,21 @@ def _wasm_extension_frame_config(
|
|||||||
"public": public,
|
"public": public,
|
||||||
"routeParams": _map_wasm_extension_route_params(route_params, path_params),
|
"routeParams": _map_wasm_extension_route_params(route_params, path_params),
|
||||||
"query": query,
|
"query": query,
|
||||||
|
"permissions": sorted(permissions),
|
||||||
"apiRoutes": _wasm_extension_bridge_api_routes(extension, public),
|
"apiRoutes": _wasm_extension_bridge_api_routes(extension, public),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _wasm_extension_granted_permission_ids(
|
||||||
|
extension: WasmExtension,
|
||||||
|
) -> set[str]:
|
||||||
|
installed_extension = await get_installed_extension(extension.id)
|
||||||
|
if not installed_extension:
|
||||||
|
return set()
|
||||||
|
return {permission.id for permission in installed_extension.permissions}
|
||||||
|
|
||||||
|
|
||||||
def _map_wasm_extension_route_params(
|
def _map_wasm_extension_route_params(
|
||||||
route_params: dict[str, str],
|
route_params: dict[str, str],
|
||||||
path_params: dict[str, str],
|
path_params: dict[str, str],
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -536,6 +536,9 @@ window.localisation.en = {
|
|||||||
extension_permission_utils_basic: 'Use basic LNbits utilities',
|
extension_permission_utils_basic: 'Use basic LNbits utilities',
|
||||||
extension_permission_utils_basic_desc:
|
extension_permission_utils_basic_desc:
|
||||||
'Use public currency conversion, server health, and Lightning invoice helper functions.',
|
'Use public currency conversion, server health, and Lightning invoice helper functions.',
|
||||||
|
extension_permission_ui_camera_scan_qr: 'Scan QR codes',
|
||||||
|
extension_permission_ui_camera_scan_qr_desc:
|
||||||
|
'Use the LNbits scanner to read QR codes when you choose to scan.',
|
||||||
extension_permission_payments_watch: 'Watch payments',
|
extension_permission_payments_watch: 'Watch payments',
|
||||||
extension_permission_wallet_create_invoice: 'Create invoices',
|
extension_permission_wallet_create_invoice: 'Create invoices',
|
||||||
extension_permission_wallet_create_invoice_public:
|
extension_permission_wallet_create_invoice_public:
|
||||||
|
|||||||
@@ -18,6 +18,36 @@ window.WasmExtensionComponent = {
|
|||||||
allow="clipboard-write"
|
allow="clipboard-write"
|
||||||
referrerpolicy="no-referrer"
|
referrerpolicy="no-referrer"
|
||||||
></iframe>
|
></iframe>
|
||||||
|
<q-dialog v-model="cameraPrompt.show" persistent>
|
||||||
|
<q-card style="width: min(520px, calc(100vw - 32px)); max-width: 520px">
|
||||||
|
<q-card-section>
|
||||||
|
<div class="text-h6">Camera access</div>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section class="q-pt-none">
|
||||||
|
{{ cameraPrompt.extensionName }} wants to access the camera to scan a QR code.
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-actions align="right">
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
color="negative"
|
||||||
|
label="Deny"
|
||||||
|
@click="resolveCameraPrompt('deny')"
|
||||||
|
></q-btn>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
color="primary"
|
||||||
|
label="Allow"
|
||||||
|
@click="resolveCameraPrompt('allow')"
|
||||||
|
></q-btn>
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
label="Allow and Remember"
|
||||||
|
@click="resolveCameraPrompt('allow_remember')"
|
||||||
|
></q-btn>
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
data() {
|
data() {
|
||||||
@@ -26,11 +56,18 @@ window.WasmExtensionComponent = {
|
|||||||
bridge: {
|
bridge: {
|
||||||
apiRoutes: [],
|
apiRoutes: [],
|
||||||
extensionId: '',
|
extensionId: '',
|
||||||
|
permissions: [],
|
||||||
public: false,
|
public: false,
|
||||||
query: {},
|
query: {},
|
||||||
routeParams: {}
|
routeParams: {}
|
||||||
},
|
},
|
||||||
bridgePort: null,
|
bridgePort: null,
|
||||||
|
cameraPrompt: {
|
||||||
|
extensionName: '',
|
||||||
|
reject: null,
|
||||||
|
resolve: null,
|
||||||
|
show: false
|
||||||
|
},
|
||||||
error: '',
|
error: '',
|
||||||
extensionName: '',
|
extensionName: '',
|
||||||
frameUrl: '',
|
frameUrl: '',
|
||||||
@@ -46,6 +83,7 @@ window.WasmExtensionComponent = {
|
|||||||
},
|
},
|
||||||
unmounted() {
|
unmounted() {
|
||||||
window.removeEventListener('message', this.handleWindowMessage)
|
window.removeEventListener('message', this.handleWindowMessage)
|
||||||
|
this.rejectCameraPrompt('Camera scan cancelled.')
|
||||||
this.closeBridgePort()
|
this.closeBridgePort()
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@@ -61,6 +99,7 @@ window.WasmExtensionComponent = {
|
|||||||
return {
|
return {
|
||||||
apiRoutes: [],
|
apiRoutes: [],
|
||||||
extensionId: '',
|
extensionId: '',
|
||||||
|
permissions: [],
|
||||||
public: false,
|
public: false,
|
||||||
query: {},
|
query: {},
|
||||||
routeParams: {}
|
routeParams: {}
|
||||||
@@ -74,6 +113,20 @@ window.WasmExtensionComponent = {
|
|||||||
query: this.plainValue(this.bridge.query || {})
|
query: this.plainValue(this.bridge.query || {})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
hasBridgePermission(permission) {
|
||||||
|
return (this.bridge.permissions || []).includes(permission)
|
||||||
|
},
|
||||||
|
cameraPromptStorageKey() {
|
||||||
|
return `lnbits.ext.permissions.${this.bridge.extensionId}.ui.camera.scan_qr`
|
||||||
|
},
|
||||||
|
emptyCameraPrompt() {
|
||||||
|
return {
|
||||||
|
extensionName: '',
|
||||||
|
reject: null,
|
||||||
|
resolve: null,
|
||||||
|
show: false
|
||||||
|
}
|
||||||
|
},
|
||||||
plainValue(value) {
|
plainValue(value) {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(JSON.stringify(value))
|
return JSON.parse(JSON.stringify(value))
|
||||||
@@ -89,6 +142,7 @@ window.WasmExtensionComponent = {
|
|||||||
this.frameUrl = ''
|
this.frameUrl = ''
|
||||||
this.bridge = this.emptyBridge()
|
this.bridge = this.emptyBridge()
|
||||||
this.allowedPaymentHashes.clear()
|
this.allowedPaymentHashes.clear()
|
||||||
|
this.rejectCameraPrompt('Camera scan cancelled.')
|
||||||
this.closeBridgePort()
|
this.closeBridgePort()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -206,6 +260,110 @@ window.WasmExtensionComponent = {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async scanQrCode() {
|
||||||
|
if (!this.hasBridgePermission('ui.camera.scan_qr')) {
|
||||||
|
throw new Error('Extension is missing scanner permission.')
|
||||||
|
}
|
||||||
|
if (!this.g) {
|
||||||
|
throw new Error('LNbits scanner is not available.')
|
||||||
|
}
|
||||||
|
if (this.g.scanner) {
|
||||||
|
throw new Error('A scanner is already active.')
|
||||||
|
}
|
||||||
|
await this.requireCameraScanApproval()
|
||||||
|
if (this.g.scanner) {
|
||||||
|
throw new Error('A scanner is already active.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let completed = false
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
window.clearTimeout(timeout)
|
||||||
|
window.clearInterval(cancelPoll)
|
||||||
|
if (this.g.scanner === onScan) {
|
||||||
|
this.g.scanner = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const complete = callback => value => {
|
||||||
|
if (completed) return
|
||||||
|
completed = true
|
||||||
|
cleanup()
|
||||||
|
callback(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onScan = value => {
|
||||||
|
complete(resolve)({value: String(value || '')})
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = window.setTimeout(() => {
|
||||||
|
complete(reject)(new Error('QR scan timed out.'))
|
||||||
|
}, 120000)
|
||||||
|
|
||||||
|
const cancelPoll = window.setInterval(() => {
|
||||||
|
if (!completed && this.g.scanner !== onScan) {
|
||||||
|
complete(reject)(new Error('QR scan cancelled.'))
|
||||||
|
}
|
||||||
|
}, 250)
|
||||||
|
|
||||||
|
this.g.scanner = onScan
|
||||||
|
})
|
||||||
|
},
|
||||||
|
requireCameraScanApproval() {
|
||||||
|
if (this.isCameraScanRemembered()) return Promise.resolve()
|
||||||
|
if (this.cameraPrompt.show) {
|
||||||
|
return Promise.reject(
|
||||||
|
new Error('Camera access prompt is already open.')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.cameraPrompt = {
|
||||||
|
extensionName:
|
||||||
|
this.extensionName || this.bridge.extensionId || 'This extension',
|
||||||
|
reject,
|
||||||
|
resolve,
|
||||||
|
show: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
isCameraScanRemembered() {
|
||||||
|
try {
|
||||||
|
return (
|
||||||
|
this.$q.localStorage.getItem(this.cameraPromptStorageKey()) ===
|
||||||
|
'allow'
|
||||||
|
)
|
||||||
|
} catch (_error) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
rememberCameraScanApproval() {
|
||||||
|
try {
|
||||||
|
this.$q.localStorage.set(this.cameraPromptStorageKey(), 'allow')
|
||||||
|
} catch (_error) {}
|
||||||
|
},
|
||||||
|
resolveCameraPrompt(decision) {
|
||||||
|
const resolve = this.cameraPrompt.resolve
|
||||||
|
const reject = this.cameraPrompt.reject
|
||||||
|
this.cameraPrompt = this.emptyCameraPrompt()
|
||||||
|
|
||||||
|
if (decision === 'allow_remember') {
|
||||||
|
this.rememberCameraScanApproval()
|
||||||
|
resolve?.()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (decision === 'allow') {
|
||||||
|
resolve?.()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reject?.(new Error('Camera scan denied by user.'))
|
||||||
|
},
|
||||||
|
rejectCameraPrompt(message) {
|
||||||
|
const reject = this.cameraPrompt.reject
|
||||||
|
this.cameraPrompt = this.emptyCameraPrompt()
|
||||||
|
reject?.(new Error(message))
|
||||||
|
},
|
||||||
rememberPaymentHashes(value) {
|
rememberPaymentHashes(value) {
|
||||||
if (!value || typeof value !== 'object') return
|
if (!value || typeof value !== 'object') return
|
||||||
|
|
||||||
@@ -349,6 +507,14 @@ window.WasmExtensionComponent = {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (message.action === 'ui.scan_qr') {
|
||||||
|
this.sendResponse(reply, message.id, {
|
||||||
|
ok: true,
|
||||||
|
data: await this.scanQrCode()
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (message.action === 'payment.subscribe') {
|
if (message.action === 'payment.subscribe') {
|
||||||
this.subscribePayment(message)
|
this.subscribePayment(message)
|
||||||
this.sendResponse(reply, message.id, {
|
this.sendResponse(reply, message.id, {
|
||||||
|
|||||||
Reference in New Issue
Block a user