diff --git a/.env.example b/.env.example index 1c478cd99..0d5b497e0 100644 --- a/.env.example +++ b/.env.example @@ -5,11 +5,15 @@ QUART_DEBUG=true HOST=127.0.0.1 PORT=5000 - LNBITS_ALLOWED_USERS="" LNBITS_ADMIN_USERS="" +# Extensions only admin can access +LNBITS_ADMIN_EXTENSIONS="nostradmin" LNBITS_DEFAULT_WALLET_NAME="LNbits wallet" +# Disable extensions for all users, use "all" to disable all extensions +LNBITS_DISABLED_EXTENSIONS="amilk" + # Database: to use SQLite, specify LNBITS_DATA_FOLDER # to use PostgreSQL, specify LNBITS_DATABASE_URL=postgres://... # to use CockroachDB, specify LNBITS_DATABASE_URL=cockroachdb://... @@ -18,8 +22,6 @@ LNBITS_DEFAULT_WALLET_NAME="LNbits wallet" LNBITS_DATA_FOLDER="./data" # LNBITS_DATABASE_URL="postgres://user:password@host:port/databasename" -# disable selected extensions, or use "all" to disable all extensions -LNBITS_DISABLED_EXTENSIONS="amilk,ngrok" LNBITS_FORCE_HTTPS=true LNBITS_SERVICE_FEE="0.0" diff --git a/lnbits/core/crud.py b/lnbits/core/crud.py index ad2d9f2cc..61710a722 100644 --- a/lnbits/core/crud.py +++ b/lnbits/core/crud.py @@ -6,7 +6,7 @@ from urllib.parse import urlparse from lnbits import bolt11 from lnbits.db import Connection, POSTGRES, COCKROACH -from lnbits.settings import DEFAULT_WALLET_NAME +from lnbits.settings import DEFAULT_WALLET_NAME, LNBITS_ADMIN_USERS from . import db from .models import User, Wallet, Payment, BalanceCheck @@ -61,6 +61,7 @@ async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[ email=user["email"], extensions=[e[0] for e in extensions], wallets=[Wallet(**w) for w in wallets], + admin=LNBITS_ADMIN_USERS and user["id"] in [x.strip() for x in LNBITS_ADMIN_USERS] ) diff --git a/lnbits/decorators.py b/lnbits/decorators.py index fc92594ed..9eee1afae 100644 --- a/lnbits/decorators.py +++ b/lnbits/decorators.py @@ -13,7 +13,7 @@ from starlette.requests import Request from lnbits.core.crud import get_user, get_wallet_for_key from lnbits.core.models import User, Wallet from lnbits.requestvars import g -from lnbits.settings import LNBITS_ALLOWED_USERS, LNBITS_ADMIN_USERS +from lnbits.settings import LNBITS_ALLOWED_USERS, LNBITS_ADMIN_USERS, LNBITS_ADMIN_EXTENSIONS class KeyChecker(SecurityBase): @@ -122,6 +122,7 @@ async def get_key_type( # 0: admin # 1: invoice # 2: invalid + pathname = r['path'].split('/')[1] if not api_key_header and not api_key_query: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST) @@ -131,7 +132,10 @@ async def get_key_type( try: checker = WalletAdminKeyChecker(api_key=token) await checker.__call__(r) - return WalletTypeInfo(0, checker.wallet) + wallet = WalletTypeInfo(0, checker.wallet) + if (LNBITS_ADMIN_USERS and wallet.wallet.user not in LNBITS_ADMIN_USERS) and (LNBITS_ADMIN_EXTENSIONS and pathname in LNBITS_ADMIN_EXTENSIONS): + raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="User not authorized.") + return wallet except HTTPException as e: if e.status_code == HTTPStatus.BAD_REQUEST: raise @@ -143,7 +147,10 @@ async def get_key_type( try: checker = WalletInvoiceKeyChecker(api_key=token) await checker.__call__(r) - return WalletTypeInfo(1, checker.wallet) + wallet = WalletTypeInfo(0, checker.wallet) + if (LNBITS_ADMIN_USERS and wallet.wallet.user not in LNBITS_ADMIN_USERS) and (LNBITS_ADMIN_EXTENSIONS and pathname in LNBITS_ADMIN_EXTENSIONS): + raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="User not authorized.") + return wallet except HTTPException as e: if e.status_code == HTTPStatus.BAD_REQUEST: raise diff --git a/lnbits/extensions/nostradmin/README.md b/lnbits/extensions/nostradmin/README.md new file mode 100644 index 000000000..596cce9de --- /dev/null +++ b/lnbits/extensions/nostradmin/README.md @@ -0,0 +1,3 @@ +# Nostr + +Opens a Nostr daemon diff --git a/lnbits/extensions/nostradmin/__init__.py b/lnbits/extensions/nostradmin/__init__.py new file mode 100644 index 000000000..797542c49 --- /dev/null +++ b/lnbits/extensions/nostradmin/__init__.py @@ -0,0 +1,16 @@ +from fastapi import APIRouter + +from lnbits.db import Database +from lnbits.helpers import template_renderer + +db = Database("ext_nostradmin") + +nostradmin_ext: APIRouter = APIRouter(prefix="/nostradmin", tags=["nostradmin"]) + + +def nostr_renderer(): + return template_renderer(["lnbits/extensions/nostradmin/templates"]) + + +from .views import * # noqa +from .views_api import * # noqa diff --git a/lnbits/extensions/nostradmin/config.json b/lnbits/extensions/nostradmin/config.json new file mode 100644 index 000000000..2c4f76d35 --- /dev/null +++ b/lnbits/extensions/nostradmin/config.json @@ -0,0 +1,6 @@ +{ + "name": "NostrAdmin", + "short_description": "Admin daemon for Nostr", + "icon": "swap_horizontal_circle", + "contributors": ["arcbtc"] +} diff --git a/lnbits/extensions/nostradmin/crud.py b/lnbits/extensions/nostradmin/crud.py new file mode 100644 index 000000000..1e1bf8106 --- /dev/null +++ b/lnbits/extensions/nostradmin/crud.py @@ -0,0 +1,146 @@ +from typing import List, Optional, Union + +from lnbits.helpers import urlsafe_short_hash +import shortuuid +from . import db +from .models import ( + nostrKeys, + nostrNotes, + nostrCreateRelays, + nostrRelays, + nostrConnections, + nostrCreateConnections, + nostrRelayList, +) +from .models import nostrKeys, nostrCreateRelays, nostrRelaySetList + +###############KEYS################## + + +async def create_nostrkeys(data: nostrKeys) -> nostrKeys: + await db.execute( + """ + INSERT INTO nostradmin.keys ( + pubkey, + privkey + ) + VALUES (?, ?) + """, + (data.pubkey, data.privkey), + ) + return await get_nostrkeys(nostrkey_id) + + +async def get_nostrkeys(pubkey: str) -> nostrKeys: + row = await db.fetchone("SELECT * FROM nostradmin.keys WHERE pubkey = ?", (pubkey,)) + return nostrKeys(**row) if row else None + + +###############NOTES################## + + +async def create_nostrnotes(data: nostrNotes) -> nostrNotes: + await db.execute( + """ + INSERT INTO nostradmin.notes ( + id, + pubkey, + created_at, + kind, + tags, + content, + sig + ) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + data.id, + data.pubkey, + data.created_at, + data.kind, + data.tags, + data.content, + data.sig, + ), + ) + return await get_nostrnotes(data.id) + + +async def get_nostrnotes(nostrnote_id: str) -> nostrNotes: + row = await db.fetchone("SELECT * FROM nostradmin.notes WHERE id = ?", (nostrnote_id,)) + return nostrNotes(**row) if row else None + + +###############RELAYS################## + + +async def create_nostrrelays(data: nostrCreateRelays) -> nostrCreateRelays: + nostrrelay_id = shortuuid.uuid(name=data.relay) + + if await get_nostrrelay(nostrrelay_id): + return "error" + await db.execute( + """ + INSERT INTO nostradmin.relays ( + id, + relay + ) + VALUES (?, ?) + """, + (nostrrelay_id, data.relay), + ) + return await get_nostrrelay(nostrrelay_id) + + +async def get_nostrrelays() -> nostrRelays: + rows = await db.fetchall("SELECT * FROM nostradmin.relays") + return [nostrRelays(**row) for row in rows] + + +async def get_nostrrelay(nostrrelay_id: str) -> nostrRelays: + row = await db.fetchone("SELECT * FROM nostradmin.relays WHERE id = ?", (nostrrelay_id,)) + return nostrRelays(**row) if row else None + +async def update_nostrrelaysetlist(data: nostrRelaySetList) -> nostrRelayList: + await db.execute( + """ + UPDATE nostradmin.relaylists SET + denylist = ?, + allowlist = ? + WHERE id = ? + """, + (data.denylist, data.allowlist, 1), + ) + return await get_nostrrelaylist() + +async def get_nostrrelaylist() -> nostrRelayList: + row = await db.fetchone("SELECT * FROM nostradmin.relaylists WHERE id = ?", (1,)) + return nostrRelayList(**row) if row else None + + +###############CONNECTIONS################## + + +async def create_nostrconnections( + data: nostrCreateConnections +) -> nostrCreateConnections: + nostrrelay_id = shortuuid.uuid(name=data.relayid + data.pubkey) + await db.execute( + """ + INSERT INTO nostradmin.connections ( + id, + pubkey, + relayid + ) + VALUES (?, ?, ?) + """, + (data.id, data.pubkey, data.relayid), + ) + return await get_nostrconnections(data.id) + + +async def get_nostrconnections(nostrconnections_id: str) -> nostrConnections: + row = await db.fetchone( + "SELECT * FROM nostradmin.connections WHERE id = ?", (nostrconnections_id,) + ) + return nostrConnections(**row) if row else None diff --git a/lnbits/extensions/nostradmin/migrations.py b/lnbits/extensions/nostradmin/migrations.py new file mode 100644 index 000000000..590f72ea7 --- /dev/null +++ b/lnbits/extensions/nostradmin/migrations.py @@ -0,0 +1,74 @@ +from lnbits.db import Database + +async def m001_initial(db): + """ + Initial nostradmin table. + """ + await db.execute( + f""" + CREATE TABLE nostradmin.keys ( + pubkey TEXT NOT NULL PRIMARY KEY, + privkey TEXT NOT NULL + ); + """ + ) + await db.execute( + f""" + CREATE TABLE nostradmin.notes ( + id TEXT NOT NULL PRIMARY KEY, + pubkey TEXT NOT NULL, + created_at TEXT NOT NULL, + kind INT NOT NULL, + tags TEXT NOT NULL, + content TEXT NOT NULL, + sig TEXT NOT NULL + ); + """ + ) + await db.execute( + f""" + CREATE TABLE nostradmin.relays ( + id TEXT NOT NULL PRIMARY KEY, + relay TEXT NOT NULL + ); + """ + ) + await db.execute( + f""" + CREATE TABLE nostradmin.relaylists ( + id TEXT NOT NULL PRIMARY KEY DEFAULT 1, + allowlist TEXT, + denylist TEXT + ); + """ + ) + + await db.execute( + """ + INSERT INTO nostradmin.relaylists ( + id, + denylist + ) + VALUES (?, ?) + """, + ("1", "wss://zucks-meta-relay.com\nwss://nostr.cia.gov",), + ) + + await db.execute( + f""" + CREATE TABLE nostradmin.connections ( + id TEXT NOT NULL PRIMARY KEY, + publickey TEXT NOT NULL, + relayid TEXT NOT NULL + ); + """ + ) + await db.execute( + f""" + CREATE TABLE nostradmin.subscribed ( + id TEXT NOT NULL PRIMARY KEY, + userPubkey TEXT NOT NULL, + subscribedPubkey TEXT NOT NULL + ); + """ + ) \ No newline at end of file diff --git a/lnbits/extensions/nostradmin/models.py b/lnbits/extensions/nostradmin/models.py new file mode 100644 index 000000000..dc99b083c --- /dev/null +++ b/lnbits/extensions/nostradmin/models.py @@ -0,0 +1,52 @@ +import json +from sqlite3 import Row +from typing import Optional + +from fastapi import Request +from pydantic import BaseModel +from pydantic.main import BaseModel +from fastapi.param_functions import Query + +class nostrKeys(BaseModel): + pubkey: str + privkey: str + +class nostrNotes(BaseModel): + id: str + pubkey: str + created_at: str + kind: int + tags: str + content: str + sig: str + +class nostrCreateRelays(BaseModel): + relay: str = Query(None) + +class nostrCreateConnections(BaseModel): + pubkey: str = Query(None) + relayid: str = Query(None) + +class nostrRelays(BaseModel): + id: Optional[str] + relay: Optional[str] + status: Optional[bool] = False + +class nostrRelayList(BaseModel): + id: str + allowlist: Optional[str] + denylist: Optional[str] + +class nostrRelaySetList(BaseModel): + allowlist: Optional[str] + denylist: Optional[str] + +class nostrConnections(BaseModel): + id: str + pubkey: Optional[str] + relayid: Optional[str] + +class nostrSubscriptions(BaseModel): + id: str + userPubkey: Optional[str] + subscribedPubkey: Optional[str] \ No newline at end of file diff --git a/lnbits/extensions/nostradmin/templates/nostradmin/index.html b/lnbits/extensions/nostradmin/templates/nostradmin/index.html new file mode 100644 index 000000000..57552bf28 --- /dev/null +++ b/lnbits/extensions/nostradmin/templates/nostradmin/index.html @@ -0,0 +1,303 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
Only Admin users can manage this extension
+ +