diff --git a/config.yaml.example b/config.yaml.example index ab8c4080..e2a4e965 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -18,7 +18,11 @@ telegram: lnbits: url: "http://127.0.0.1:5000" admin_key: "1234" - admin_id: "1234" + admin_id: "1234" # unused on lnbits 1.x, kept for older deployments + # ACL token scoped to /users/api/v1, required on lnbits 1.x to create users. + # Mint one under your lnbits account's access-control settings; a wallet api + # key does not grant account management there. + admin_token: "1234" webhook_server: "http://0.0.0.0:5588" # Local webhook server address webhook_public_url: "https://yourdomain.com/webhook" # Public URL for webhooks (for reverse proxy) lnbits_public_url: "link.mylnurl.com" diff --git a/internal/config.go b/internal/config.go index 36be4668..6d34209b 100644 --- a/internal/config.go +++ b/internal/config.go @@ -77,6 +77,10 @@ type DatabaseConfiguration struct { type LnbitsConfiguration struct { AdminId string `yaml:"admin_id"` AdminKey string `yaml:"admin_key"` + // AdminToken is an lnbits ACL token, scoped to /users/api/v1. Account + // management moved behind account-level auth in lnbits 1.x, where a wallet + // api key no longer grants it, so creating users needs a bearer token. + AdminToken string `yaml:"admin_token"` Url string `yaml:"url"` LnbitsPublicUrl string `yaml:"lnbits_public_url"` WebhookServer string `yaml:"webhook_server"` diff --git a/internal/lnbits/lnbits.go b/internal/lnbits/lnbits.go index 63763950..deb397f7 100644 --- a/internal/lnbits/lnbits.go +++ b/internal/lnbits/lnbits.go @@ -21,8 +21,9 @@ func parseLNbitsError(resp *req.Resp) Error { return reqErr } -// NewClient returns a new lnbits api client. Pass your API key and url here. -func NewClient(key, url string) *Client { +// NewClient returns a new lnbits api client. Pass your API key, the ACL token +// used for account management, and the url here. +func NewClient(key, adminToken, url string) *Client { return &Client{ url: url, // info: this header holds the ADMIN key for the entire API @@ -34,12 +35,17 @@ func NewClient(key, url string) *Client { "Accept": "application/json", "X-Api-Key": key, }, + adminHeader: req.Header{ + "Content-Type": "application/json", + "Accept": "application/json", + "Authorization": "Bearer " + adminToken, + }, } } // GetUser returns user information func (c *Client) GetUser(userId string) (user User, err error) { - resp, err := req.Post(c.url+"/usermanager/api/v1/users/"+userId, c.header, nil) + resp, err := req.Get(c.url+"/users/api/v1/user/"+userId, c.adminHeader, nil) if err != nil { return } @@ -53,14 +59,21 @@ func (c *Client) GetUser(userId string) (user User, err error) { return } -// CreateUserWithInitialWallet creates new user with initial wallet +// CreateUserWithInitialWallet creates new user with initial wallet. +// +// lnbits 1.x creates the account's first wallet itself, named after +// lnbits_default_wallet_name, and its create-user response omits the wallets. +// So the wallet is fetched separately and renamed to walletName, keeping the +// " ()" convention the existing wallets use. +// +// adminId is unused: lnbits 1.x has no admin_id concept. It is kept so callers +// do not have to change. func (c *Client) CreateUserWithInitialWallet(userName, walletName, adminId string, email string) (wal User, err error) { - resp, err := req.Post(c.url+"/usermanager/api/v1/users", c.header, req.BodyJSON(struct { - WalletName string `json:"wallet_name"` - AdminId string `json:"admin_id"` - UserName string `json:"user_name"` - Email string `json:"email"` - }{walletName, adminId, userName, email})) + // email is deliberately not sent: the bot passes a telegram handle here, + // which lnbits 1.x rejects via is_valid_email_address. + resp, err := req.Post(c.url+"/users/api/v1/user", c.adminHeader, req.BodyJSON(struct { + UserName string `json:"username"` + }{userName})) if err != nil { return } @@ -69,17 +82,55 @@ func (c *Client) CreateUserWithInitialWallet(userName, walletName, adminId strin err = parseLNbitsError(resp) return } - err = resp.ToJSON(&wal) + if err = resp.ToJSON(&wal); err != nil { + return + } + + wallets, err := c.Wallets(wal) + if err != nil { + return + } + if len(wallets) == 0 { + err = fmt.Errorf("lnbits created user %s without a wallet", wal.ID) + return + } + if err = c.renameWallet(wallets[0], walletName); err != nil { + return + } + + // the create-user response echoes back only what it was given, so the name + // the bot stores is the username it asked for. + wal.Name = userName return } +// renameWallet sets a wallet's name. It authenticates with the wallet's own +// admin key rather than the ACL token, so it stays outside that token's scope. +func (c *Client) renameWallet(w Wallet, name string) error { + resp, err := req.Patch(c.url+"/api/v1/wallet", req.Header{ + "Content-Type": "application/json", + "Accept": "application/json", + "X-Api-Key": w.Adminkey, + }, req.BodyJSON(struct { + Name string `json:"name"` + }{name})) + if err != nil { + return err + } + if resp.Response().StatusCode >= 300 { + return parseLNbitsError(resp) + } + return nil +} + // CreateWallet creates a new wallet. +// +// adminId is unused, see CreateUserWithInitialWallet. func (c *Client) CreateWallet(userId, walletName, adminId string) (wal Wallet, err error) { - resp, err := req.Post(c.url+"/usermanager/api/v1/wallets", c.header, req.BodyJSON(struct { - UserId string `json:"user_id"` - WalletName string `json:"wallet_name"` - AdminId string `json:"admin_id"` - }{userId, walletName, adminId})) + resp, err := req.Post(c.url+"/users/api/v1/user/"+userId+"/wallet", c.adminHeader, + req.BodyJSON(struct { + Name string `json:"name"` + }{walletName})) if err != nil { return } @@ -188,7 +239,7 @@ func (c Client) Payment(w Wallet, payment_hash string) (payment LNbitsPayment, e // Wallets returns all wallets belonging to an user func (c Client) Wallets(w User) (wtx []Wallet, err error) { - resp, err := req.Get(c.url+"/usermanager/api/v1/wallets/"+w.ID, c.header, nil) + resp, err := req.Get(c.url+"/users/api/v1/user/"+w.ID+"/wallet", c.adminHeader, nil) if err != nil { return } diff --git a/internal/lnbits/types.go b/internal/lnbits/types.go index 6cdb15e3..7599c683 100644 --- a/internal/lnbits/types.go +++ b/internal/lnbits/types.go @@ -15,10 +15,14 @@ import ( ) type Client struct { - header req.Header - url string - AdminKey string - InvoiceKey string + header req.Header + // adminHeader carries the ACL bearer token for /users/api/v1. It is kept + // separate from header so wallet calls cannot present it, and so the token + // cannot be used to spend: it is scoped to account management only. + adminHeader req.Header + url string + AdminKey string + InvoiceKey string } type User struct { diff --git a/internal/telegram/bot.go b/internal/telegram/bot.go index 447ca68a..583bf8ed 100644 --- a/internal/telegram/bot.go +++ b/internal/telegram/bot.go @@ -51,7 +51,11 @@ func NewBot() TipBot { bot := TipBot{ DB: dbs, - Client: lnbits.NewClient(internal.Configuration.Lnbits.AdminKey, internal.Configuration.Lnbits.Url), + Client: lnbits.NewClient( + internal.Configuration.Lnbits.AdminKey, + internal.Configuration.Lnbits.AdminToken, + internal.Configuration.Lnbits.Url, + ), Bunt: createBunt(internal.Configuration.Database.BuntDbPath), ShopBunt: createBunt(internal.Configuration.Database.ShopBuntDbPath), Telegram: newTelegramBot(),