feat: call extensions
This commit is contained in:
@@ -19,6 +19,7 @@ from .models import (
|
|||||||
CreateInvoiceRequest,
|
CreateInvoiceRequest,
|
||||||
CreateInvoiceResponse,
|
CreateInvoiceResponse,
|
||||||
EmptyRequest,
|
EmptyRequest,
|
||||||
|
ExtensionApiRequest,
|
||||||
HttpRequest,
|
HttpRequest,
|
||||||
HttpResponse,
|
HttpResponse,
|
||||||
ListUserWalletsResponse,
|
ListUserWalletsResponse,
|
||||||
@@ -415,6 +416,27 @@ class ExtensionAPI:
|
|||||||
policy = self.permission_policies.get("http.request") or {}
|
policy = self.permission_policies.get("http.request") or {}
|
||||||
return await send_extension_http_request(self.extension_id, policy, request)
|
return await send_extension_http_request(self.extension_id, policy, request)
|
||||||
|
|
||||||
|
@extension_api_method(
|
||||||
|
method_id="extension.api.request",
|
||||||
|
namespace="extension",
|
||||||
|
name="Extension API request",
|
||||||
|
host_name="extension_api_request",
|
||||||
|
sdk_name="request",
|
||||||
|
description="Call an allowed installed extension API.",
|
||||||
|
required_permission="extension.api.request",
|
||||||
|
require_auth=True,
|
||||||
|
)
|
||||||
|
async def extension_api_request(self, request: ExtensionApiRequest) -> HttpResponse:
|
||||||
|
from .extension_client import send_extension_api_request
|
||||||
|
|
||||||
|
policy = self.permission_policies.get("extension.api.request") or {}
|
||||||
|
return await send_extension_api_request(
|
||||||
|
self.extension_id,
|
||||||
|
policy,
|
||||||
|
self.user_id,
|
||||||
|
request,
|
||||||
|
)
|
||||||
|
|
||||||
@extension_api_method(
|
@extension_api_method(
|
||||||
method_id="system.random_id",
|
method_id="system.random_id",
|
||||||
namespace="system",
|
namespace="system",
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import posixpath
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import unquote, urlsplit, urlunsplit
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from lnbits.core.crud.extensions import (
|
||||||
|
get_installed_extension,
|
||||||
|
get_user_active_extensions_ids,
|
||||||
|
)
|
||||||
|
from lnbits.core.crud.wallets import get_wallets
|
||||||
|
from lnbits.settings import settings
|
||||||
|
|
||||||
|
from .models import ExtensionApiRequest, HttpResponse
|
||||||
|
|
||||||
|
EXTENSION_API_TIMEOUT_SECONDS = 10.0
|
||||||
|
EXTENSION_API_MAX_RESPONSE_BYTES = 262_144
|
||||||
|
|
||||||
|
_READ_METHODS = {"GET", "HEAD"}
|
||||||
|
_WRITE_METHODS = {"DELETE", "PATCH", "POST", "PUT"}
|
||||||
|
_EXTENSION_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$")
|
||||||
|
_FORBIDDEN_RESPONSE_HEADERS = {
|
||||||
|
"connection",
|
||||||
|
"content-length",
|
||||||
|
"set-cookie",
|
||||||
|
"transfer-encoding",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def send_extension_api_request(
|
||||||
|
caller_extension_id: str,
|
||||||
|
policy: dict[str, Any],
|
||||||
|
user_id: str | None,
|
||||||
|
request: ExtensionApiRequest,
|
||||||
|
) -> HttpResponse:
|
||||||
|
if not user_id:
|
||||||
|
raise PermissionError("Extension API requests require authentication.")
|
||||||
|
|
||||||
|
target_extension_id = _target_extension_id(request.extension_id)
|
||||||
|
access = _target_extension_access(policy, target_extension_id)
|
||||||
|
_require_method_access(caller_extension_id, target_extension_id, access, request)
|
||||||
|
await _require_enabled_extension(target_extension_id, user_id)
|
||||||
|
|
||||||
|
api_key = await _user_api_key(user_id, request.method)
|
||||||
|
path = _extension_api_path(request.path)
|
||||||
|
body = request.body.encode() if request.body is not None else b""
|
||||||
|
if len(body) > 65_536:
|
||||||
|
raise ValueError("Extension API request body is too large.")
|
||||||
|
|
||||||
|
url = f"http://{settings.host}:{settings.port}/{target_extension_id}{path}"
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
follow_redirects=False,
|
||||||
|
timeout=EXTENSION_API_TIMEOUT_SECONDS,
|
||||||
|
trust_env=False,
|
||||||
|
) as client:
|
||||||
|
async with client.stream(
|
||||||
|
request.method,
|
||||||
|
url,
|
||||||
|
headers={"X-API-KEY": api_key},
|
||||||
|
content=body,
|
||||||
|
) as response:
|
||||||
|
response_body = await _read_limited_response(response)
|
||||||
|
return HttpResponse(
|
||||||
|
status_code=response.status_code,
|
||||||
|
headers=_response_headers(dict(response.headers)),
|
||||||
|
body=response_body.decode(response.encoding or "utf-8", "replace"),
|
||||||
|
)
|
||||||
|
except httpx.RequestError as exc:
|
||||||
|
raise ValueError("Extension API request failed.") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _target_extension_id(extension_id: str) -> str:
|
||||||
|
target = extension_id.strip()
|
||||||
|
if not target or not _EXTENSION_ID_RE.match(target):
|
||||||
|
raise PermissionError("Extension API request has an invalid target extension.")
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
def _target_extension_access(
|
||||||
|
policy: dict[str, Any], target_extension_id: str
|
||||||
|
) -> set[str]:
|
||||||
|
extensions = policy.get("extensions")
|
||||||
|
if not isinstance(extensions, list) or not extensions:
|
||||||
|
raise PermissionError(
|
||||||
|
"Extension API requests require a non-empty extensions policy."
|
||||||
|
)
|
||||||
|
|
||||||
|
for extension in extensions:
|
||||||
|
if isinstance(extension, str):
|
||||||
|
extension_id = extension
|
||||||
|
access = ["read"]
|
||||||
|
elif isinstance(extension, dict):
|
||||||
|
extension_id = extension.get("id")
|
||||||
|
access = extension.get("access")
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if extension_id != target_extension_id:
|
||||||
|
continue
|
||||||
|
if not isinstance(access, list):
|
||||||
|
raise PermissionError(
|
||||||
|
f"Extension API target '{target_extension_id}' has no access policy."
|
||||||
|
)
|
||||||
|
clean_access = {
|
||||||
|
item
|
||||||
|
for item in access
|
||||||
|
if isinstance(item, str) and item in {"read", "write"}
|
||||||
|
}
|
||||||
|
if clean_access:
|
||||||
|
return clean_access
|
||||||
|
break
|
||||||
|
|
||||||
|
raise PermissionError(
|
||||||
|
f"Extension API target '{target_extension_id}' is not allowed."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_method_access(
|
||||||
|
caller_extension_id: str,
|
||||||
|
target_extension_id: str,
|
||||||
|
access: set[str],
|
||||||
|
request: ExtensionApiRequest,
|
||||||
|
) -> None:
|
||||||
|
if request.method in _READ_METHODS:
|
||||||
|
required_access = "read"
|
||||||
|
elif request.method in _WRITE_METHODS:
|
||||||
|
required_access = "write"
|
||||||
|
else:
|
||||||
|
raise PermissionError("Extension API request method is not allowed.")
|
||||||
|
|
||||||
|
if required_access not in access:
|
||||||
|
raise PermissionError(
|
||||||
|
f"Extension '{caller_extension_id}' cannot {required_access} "
|
||||||
|
f"extension '{target_extension_id}'."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _require_enabled_extension(target_extension_id: str, user_id: str) -> None:
|
||||||
|
extension = await get_installed_extension(target_extension_id)
|
||||||
|
if not extension or not extension.active:
|
||||||
|
raise PermissionError(
|
||||||
|
f"Target extension '{target_extension_id}' is not installed or enabled."
|
||||||
|
)
|
||||||
|
|
||||||
|
active_extensions = await get_user_active_extensions_ids(user_id)
|
||||||
|
if target_extension_id not in active_extensions:
|
||||||
|
raise PermissionError(
|
||||||
|
f"Target extension '{target_extension_id}' is not active for this user."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _user_api_key(user_id: str, method: str) -> str:
|
||||||
|
wallets = await get_wallets(user_id)
|
||||||
|
if not wallets:
|
||||||
|
raise PermissionError("Extension API request requires a user wallet.")
|
||||||
|
wallet = wallets[0]
|
||||||
|
return wallet.inkey if method in _READ_METHODS else wallet.adminkey
|
||||||
|
|
||||||
|
|
||||||
|
def _extension_api_path(path: str) -> str:
|
||||||
|
parts = urlsplit(path)
|
||||||
|
if parts.scheme or parts.netloc:
|
||||||
|
raise PermissionError("Extension API request path must be relative.")
|
||||||
|
if parts.fragment:
|
||||||
|
raise PermissionError("Extension API request path cannot include a fragment.")
|
||||||
|
if not parts.path.startswith("/api/"):
|
||||||
|
raise PermissionError("Extension API request path must start with '/api/'.")
|
||||||
|
|
||||||
|
decoded_path = unquote(parts.path)
|
||||||
|
path_parts = decoded_path.split("/")
|
||||||
|
if any(part == ".." for part in path_parts):
|
||||||
|
raise PermissionError("Extension API request path cannot traverse directories.")
|
||||||
|
|
||||||
|
normalized = posixpath.normpath(decoded_path)
|
||||||
|
if normalized != decoded_path.rstrip("/") or not normalized.startswith("/api/"):
|
||||||
|
raise PermissionError("Extension API request path is invalid.")
|
||||||
|
|
||||||
|
return urlunsplit(("", "", parts.path, parts.query, ""))
|
||||||
|
|
||||||
|
|
||||||
|
async def _read_limited_response(response: httpx.Response) -> bytes:
|
||||||
|
chunks: list[bytes] = []
|
||||||
|
size = 0
|
||||||
|
async for chunk in response.aiter_bytes():
|
||||||
|
size += len(chunk)
|
||||||
|
if size > EXTENSION_API_MAX_RESPONSE_BYTES:
|
||||||
|
raise ValueError("Extension API response is too large.")
|
||||||
|
chunks.append(chunk)
|
||||||
|
return b"".join(chunks)
|
||||||
|
|
||||||
|
|
||||||
|
def _response_headers(headers: dict[str, str]) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
key: value
|
||||||
|
for key, value in headers.items()
|
||||||
|
if key.lower() not in _FORBIDDEN_RESPONSE_HEADERS
|
||||||
|
}
|
||||||
@@ -149,6 +149,20 @@ class HttpResponse(BaseModel):
|
|||||||
body: str = ""
|
body: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class ExtensionApiRequest(BaseModel):
|
||||||
|
extension_id: str = Field(..., min_length=1, max_length=128)
|
||||||
|
method: Literal["DELETE", "GET", "HEAD", "PATCH", "POST", "PUT"] = "GET"
|
||||||
|
path: str = Field(..., min_length=1, max_length=2048)
|
||||||
|
body: str | None = Field(None, max_length=65536)
|
||||||
|
|
||||||
|
@root_validator(pre=True)
|
||||||
|
def normalize_method(cls, values: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
method = values.get("method")
|
||||||
|
if isinstance(method, str):
|
||||||
|
values["method"] = method.upper()
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
class RandomIdRequest(BaseModel):
|
class RandomIdRequest(BaseModel):
|
||||||
prefix: str = Field(..., min_length=1, max_length=32)
|
prefix: str = Field(..., min_length=1, max_length=32)
|
||||||
|
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ class ExtensionAPIHost:
|
|||||||
if not isinstance(response, method.response_model):
|
if not isinstance(response, method.response_model):
|
||||||
response = method.response_model.parse_obj(response)
|
response = method.response_model.parse_obj(response)
|
||||||
payload = response.dict()
|
payload = response.dict()
|
||||||
if method.method_id == "http.request" and isinstance(
|
if method.method_id in {"http.request", "extension.api.request"} and isinstance(
|
||||||
payload.get("headers"), Mapping
|
payload.get("headers"), Mapping
|
||||||
):
|
):
|
||||||
payload["headers"] = list(payload["headers"].items())
|
payload["headers"] = list(payload["headers"].items())
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -525,6 +525,10 @@ window.localisation.en = {
|
|||||||
extension_permission_ext_storage_read: 'Read extension storage',
|
extension_permission_ext_storage_read: 'Read extension storage',
|
||||||
extension_permission_ext_storage_read_public: 'Read public extension storage',
|
extension_permission_ext_storage_read_public: 'Read public extension storage',
|
||||||
extension_permission_ext_storage_write: 'Write extension storage',
|
extension_permission_ext_storage_write: 'Write extension storage',
|
||||||
|
extension_permission_extension_api_request: 'Use other extensions',
|
||||||
|
extension_permission_extension_api_request_desc:
|
||||||
|
'Call approved installed extensions using your account permissions.',
|
||||||
|
extension_permission_extension_api_request_extensions: 'Allowed extensions',
|
||||||
extension_permission_http_request: 'Connect to external websites',
|
extension_permission_http_request: 'Connect to external websites',
|
||||||
extension_permission_http_request_desc:
|
extension_permission_http_request_desc:
|
||||||
'Make HTTP requests to approved external hosts.',
|
'Make HTTP requests to approved external hosts.',
|
||||||
|
|||||||
@@ -737,10 +737,28 @@ window.PageExtensions = {
|
|||||||
return description === key ? permission.description : description
|
return description === key ? permission.description : description
|
||||||
},
|
},
|
||||||
permissionPolicyDetails(permission) {
|
permissionPolicyDetails(permission) {
|
||||||
if (permission.id !== 'http.request') return ''
|
if (permission.id === 'http.request') {
|
||||||
const hosts = permission.policy?.hosts
|
const hosts = permission.policy?.hosts
|
||||||
if (!Array.isArray(hosts) || !hosts.length) return ''
|
if (!Array.isArray(hosts) || !hosts.length) return ''
|
||||||
return `${this.$t('extension_permission_http_request_hosts')}: ${hosts.join(', ')}`
|
return `${this.$t('extension_permission_http_request_hosts')}: ${hosts.join(', ')}`
|
||||||
|
}
|
||||||
|
if (permission.id === 'extension.api.request') {
|
||||||
|
const extensions = permission.policy?.extensions
|
||||||
|
if (!Array.isArray(extensions) || !extensions.length) return ''
|
||||||
|
const targets = extensions
|
||||||
|
.map(extension => {
|
||||||
|
if (typeof extension === 'string') return `${extension} (read)`
|
||||||
|
if (!extension?.id) return null
|
||||||
|
const access = Array.isArray(extension.access)
|
||||||
|
? extension.access.join(', ')
|
||||||
|
: 'read'
|
||||||
|
return `${extension.id} (${access})`
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
if (!targets.length) return ''
|
||||||
|
return `${this.$t('extension_permission_extension_api_request_extensions')}: ${targets.join(', ')}`
|
||||||
|
}
|
||||||
|
return ''
|
||||||
},
|
},
|
||||||
async selectAllUpdatableExtensionss() {
|
async selectAllUpdatableExtensionss() {
|
||||||
this.updatableExtensions.forEach(e => (e.selectedForUpdate = true))
|
this.updatableExtensions.forEach(e => (e.selectedForUpdate = true))
|
||||||
|
|||||||
Reference in New Issue
Block a user