diff --git a/internal/api/invoice.go b/internal/api/invoice.go new file mode 100644 index 0000000..0bd4201 --- /dev/null +++ b/internal/api/invoice.go @@ -0,0 +1,27 @@ +package api + +type BalanceResponse struct { + Balance int64 `json:"balance"` +} + +type InvoiceStatusResponse struct { + State string `json:"state,omitempty"` + PaymentHash string `json:"payment_hash"` + Preimage int64 `json:"preimage"` +} + +type CreateInvoiceResponse struct { + PaymentHash string `json:"payment_hash"` + PayRequest string `json:"pay_request"` + Preimage string `json:"preimage,omitempty"` +} +type CreateInvoiceRequest struct { + Memo string `json:"memo"` + Amount int64 `json:"amount"` + DescriptionHash string `json:"description_hash"` + UnhashedDescription string `json:"unhashed_description"` +} + +type PayInvoiceRequest struct { + PayRequest string `json:"pay_req"` +} diff --git a/internal/api/lightning.go b/internal/api/lightning.go new file mode 100644 index 0000000..47f5cbe --- /dev/null +++ b/internal/api/lightning.go @@ -0,0 +1,121 @@ +package api + +import ( + "encoding/json" + "net/http" + + "github.com/LightningTipBot/LightningTipBot/internal" + "github.com/LightningTipBot/LightningTipBot/internal/lnbits" + "github.com/LightningTipBot/LightningTipBot/internal/telegram" + "github.com/gorilla/mux" +) + +type Service struct { + Bot *telegram.TipBot +} + +type ErrorResponse struct { + Message string `json:"error"` +} + +func RespondError(w http.ResponseWriter, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(ErrorResponse{Message: message}) +} + +func (s Service) Balance(w http.ResponseWriter, r *http.Request) { + user := telegram.LoadUser(r.Context()) + balance, err := s.Bot.GetUserBalance(user) + if err != nil { + RespondError(w, "balance check failed") + return + } + + balanceResponse := BalanceResponse{ + Balance: balance, + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(balanceResponse) +} + +func (s Service) CreateInvoice(w http.ResponseWriter, r *http.Request) { + user := telegram.LoadUser(r.Context()) + var createInvoiceRequest CreateInvoiceRequest + err := json.NewDecoder(r.Body).Decode(&createInvoiceRequest) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + invoice, err := user.Wallet.Invoice( + lnbits.InvoiceParams{ + Amount: createInvoiceRequest.Amount, + Out: false, + DescriptionHash: createInvoiceRequest.DescriptionHash, + UnhashedDescription: createInvoiceRequest.UnhashedDescription, + Webhook: internal.Configuration.Lnbits.WebhookServer}, + s.Bot.Client) + if err != nil { + RespondError(w, "could not create invoice") + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(invoice) +} + +func (s Service) PayInvoice(w http.ResponseWriter, r *http.Request) { + user := telegram.LoadUser(r.Context()) + var payInvoiceRequest PayInvoiceRequest + err := json.NewDecoder(r.Body).Decode(&payInvoiceRequest) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + invoice, err := user.Wallet.Pay(lnbits.PaymentParams{Out: true, Bolt11: payInvoiceRequest.PayRequest}, s.Bot.Client) + if err != nil { + RespondError(w, "could not pay invoice: "+err.Error()) + return + } + + payment, _ := s.Bot.Client.Payment(*user.Wallet, invoice.PaymentHash) + if err != nil { + // we assume that it's paid since thre was no error earlier + payment.Paid = true + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(payment) +} + +func (s Service) PaymentStatus(w http.ResponseWriter, r *http.Request) { + user := telegram.LoadUser(r.Context()) + payment_hash := mux.Vars(r)["payment_hash"] + payment, err := s.Bot.Client.Payment(*user.Wallet, payment_hash) + if err != nil { + RespondError(w, "could not get payment") + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(payment) +} + +// InvoiceStatus +func (s Service) InvoiceStatus(w http.ResponseWriter, r *http.Request) { + user := telegram.LoadUser(r.Context()) + payment_hash := mux.Vars(r)["payment_hash"] + user.Wallet = &lnbits.Wallet{} + payment, err := s.Bot.Client.Payment(*user.Wallet, payment_hash) + if err != nil { + RespondError(w, "could not get invoice") + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(payment) +} diff --git a/internal/api/middleware.go b/internal/api/middleware.go index 3bc7670..909f492 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -1,8 +1,16 @@ package api import ( + "context" + "encoding/base64" + "fmt" "net/http" "net/http/httputil" + "strings" + + "github.com/LightningTipBot/LightningTipBot/internal/lnbits" + "github.com/LightningTipBot/LightningTipBot/internal/telegram" + "gorm.io/gorm" log "github.com/sirupsen/logrus" ) @@ -16,6 +24,82 @@ func LoggingMiddleware(prefix string, next http.HandlerFunc) http.HandlerFunc { } } +type AuthType struct { + Type string + Decoder func(s string) ([]byte, error) +} + +var AuthTypeBasic = AuthType{Type: "Basic"} +var AuthTypeBearerBase64 = AuthType{Type: "Basic", Decoder: base64.StdEncoding.DecodeString} + +func AuthorizationMiddleware(database *gorm.DB, authType AuthType, next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + // check if the user is banned + if auth == "" { + w.WriteHeader(401) + log.Warn("[AuthorizationMiddleware] no auth") + return + } + _, password, ok := parseAuth(authType, auth) + if !ok { + w.WriteHeader(401) + return + } + // first we make sure that the password is not already "banned_" + if strings.Contains(password, "_") || strings.HasPrefix(password, "banned_") { + w.WriteHeader(401) + log.Warnf("[AuthorizationMiddleware] Banned user %s. Not forwarding request", password) + return + } + // then we check whether the "normal" password provided is in the database (it should be not if the user is banned) + user := &lnbits.User{} + tx := database.Where("wallet_adminkey = ? COLLATE NOCASE", password).First(user) + if tx.Error != nil { + tx = database.Where("wallet_inkey = ? COLLATE NOCASE", password).First(user) + log.Warnf("[AuthorizationMiddleware] Could not get wallet admin key %s: %v", password, tx.Error) + if tx.Error != nil { + log.Warnf("[AuthorizationMiddleware] need admin key to pay invoice %s: %v", password, tx.Error) + return + } + if r.URL.Path == "/api/v1/payinvoice" { + log.Warnf("[AuthorizationMiddleware] need admin key to pay invoice %s: %v", password, tx.Error) + return + } + } + log.Debugf("[AuthorizationMiddleware] User: %s", telegram.GetUserStr(user.Telegram)) + r = r.WithContext(context.WithValue(r.Context(), "user", user)) + next.ServeHTTP(w, r) + } +} + +// parseAuth parses an HTTP Basic Authentication string. +// "Bearer QWxhZGRpbjpvcGVuIHNlc2FtZQ==" returns ("Aladdin", "open sesame", true). +func parseAuth(authType AuthType, auth string) (username, password string, ok bool) { + parse := func(prefix string) (username, password string, ok bool) { + // Case insensitive prefix match. See Issue 22736. + if len(auth) < len(prefix) || !strings.EqualFold(auth[:len(prefix)], prefix) { + return + } + if authType.Decoder != nil { + c, err := base64.StdEncoding.DecodeString(auth[len(prefix):]) + if err != nil { + return + } + cs := string(c) + s := strings.IndexByte(cs, ':') + if s < 0 { + return + } + return cs[:s], cs[s+1:], true + } + return auth[len(prefix):], auth[len(prefix):], true + + } + return parse(fmt.Sprintf("%s ", authType.Type)) + +} + func dump(r *http.Request) string { x, err := httputil.DumpRequest(r, true) if err != nil { diff --git a/internal/api/server.go b/internal/api/server.go index da35696..31ac499 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -2,6 +2,7 @@ package api import ( "encoding/json" + "gorm.io/gorm" "net/http" "time" @@ -42,6 +43,12 @@ func (w *Server) ListenAndServe() { func (w *Server) PathPrefix(path string, handler http.Handler) { w.router.PathPrefix(path).Handler(handler) } +func (w *Server) AppendAuthorizedRoute(path string, authType AuthType, database *gorm.DB, handler func(http.ResponseWriter, *http.Request), methods ...string) { + r := w.router.HandleFunc(path, LoggingMiddleware("API", AuthorizationMiddleware(database, authType, handler))) + if len(methods) > 0 { + r.Methods(methods...) + } +} func (w *Server) AppendRoute(path string, handler func(http.ResponseWriter, *http.Request), methods ...string) { r := w.router.HandleFunc(path, LoggingMiddleware("API", handler)) if len(methods) > 0 { diff --git a/internal/lnbits/lnbits.go b/internal/lnbits/lnbits.go index c446b9a..1419af7 100644 --- a/internal/lnbits/lnbits.go +++ b/internal/lnbits/lnbits.go @@ -1,6 +1,7 @@ package lnbits import ( + "fmt" "time" "github.com/imroc/req" @@ -84,7 +85,7 @@ func (c *Client) CreateWallet(userId, walletName, adminId string) (wal Wallet, e } // Invoice creates an invoice associated with this wallet. -func (w Wallet) Invoice(params InvoiceParams, c *Client) (lntx BitInvoice, err error) { +func (w Wallet) Invoice(params InvoiceParams, c *Client) (lntx Invoice, err error) { // custom header with invoice key invoiceHeader := req.Header{ "Content-Type": "application/json", @@ -131,7 +132,7 @@ func (c Client) Info(w Wallet) (wtx Wallet, err error) { return } -// Info returns wallet payments +// Payments returns wallet payments func (c Client) Payments(w Wallet) (wtx Payments, err error) { // custom header with invoice key invoiceHeader := req.Header{ @@ -155,6 +156,30 @@ func (c Client) Payments(w Wallet) (wtx Payments, err error) { return } +// Payment state of a payment +func (c Client) Payment(w Wallet, payment_hash string) (payment LNbitsPayment, err error) { + // custom header with invoice key + invoiceHeader := req.Header{ + "Content-Type": "application/json", + "Accept": "application/json", + "X-Api-Key": w.Inkey, + } + resp, err := req.Get(c.url+fmt.Sprintf("/api/v1/payments/%s", payment_hash), invoiceHeader, nil) + if err != nil { + return + } + + if resp.Response().StatusCode >= 300 { + var reqErr Error + resp.ToJSON(&reqErr) + err = reqErr + return + } + + err = resp.ToJSON(&payment) + return +} + // 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) @@ -174,7 +199,7 @@ func (c Client) Wallets(w User) (wtx []Wallet, err error) { } // Pay pays a given invoice with funds from the wallet. -func (w Wallet) Pay(params PaymentParams, c *Client) (wtx BitInvoice, err error) { +func (w Wallet) Pay(params PaymentParams, c *Client) (wtx Invoice, err error) { // custom header with admin key adminHeader := req.Header{ "Content-Type": "application/json", diff --git a/internal/lnbits/types.go b/internal/lnbits/types.go index c15a3a0..770359a 100644 --- a/internal/lnbits/types.go +++ b/internal/lnbits/types.go @@ -75,11 +75,12 @@ func (u *User) ResetState() { } type InvoiceParams struct { - Out bool `json:"out"` // must be True if invoice is payed, False if invoice is received - Amount int64 `json:"amount"` // amount in Satoshi - Memo string `json:"memo,omitempty"` // the invoice memo. - Webhook string `json:"webhook,omitempty"` // the webhook to fire back to when payment is received. - DescriptionHash string `json:"description_hash,omitempty"` // the invoice description hash. + Out bool `json:"out"` // must be True if invoice is payed, False if invoice is received + Amount int64 `json:"amount"` // amount in Satoshi + Memo string `json:"memo,omitempty"` // the invoice memo. + Webhook string `json:"webhook,omitempty"` // the webhook to fire back to when payment is received. + DescriptionHash string `json:"description_hash,omitempty"` // the invoice description hash. + UnhashedDescription string `json:"unhashed_description,omitempty"` // the unhashed invoice description. } type PaymentParams struct { @@ -117,7 +118,7 @@ type Wallet struct { User string `json:"user"` } -type Payments []struct { +type Payment struct { CheckingID string `json:"checking_id"` Pending bool `json:"pending"` Amount int64 `json:"amount"` @@ -133,7 +134,15 @@ type Payments []struct { WebhookStatus interface{} `json:"webhook_status"` } -type BitInvoice struct { +type LNbitsPayment struct { + Paid bool `json:"paid"` + Preimage string `json:"preimage"` + Details Payment `json:"details,omitempty"` +} + +type Payments []Payment + +type Invoice struct { PaymentHash string `json:"payment_hash"` PaymentRequest string `json:"payment_request"` } diff --git a/internal/telegram/lnurl-withdraw.go b/internal/telegram/lnurl-withdraw.go index 21e88f9..c1d2f60 100644 --- a/internal/telegram/lnurl-withdraw.go +++ b/internal/telegram/lnurl-withdraw.go @@ -42,7 +42,7 @@ type LnurlWithdrawState struct { Comment string `json:"comment"` LanguageCode string `json:"languagecode"` Success bool `json:"success"` - Invoice lnbits.BitInvoice `json:"invoice"` + Invoice lnbits.Invoice `json:"invoice"` Message string `json:"message"` } diff --git a/internal/telegram/transaction.go b/internal/telegram/transaction.go index fe2d032..afadd4f 100644 --- a/internal/telegram/transaction.go +++ b/internal/telegram/transaction.go @@ -11,26 +11,26 @@ import ( ) type Transaction struct { - ID uint `gorm:"primarykey"` - Time time.Time `json:"time"` - Bot *TipBot `gorm:"-"` - From *lnbits.User `json:"from" gorm:"-"` - To *lnbits.User `json:"to" gorm:"-"` - FromId int64 `json:"from_id" ` - ToId int64 `json:"to_id" ` - FromUser string `json:"from_user"` - ToUser string `json:"to_user"` - Type string `json:"type"` - Amount int64 `json:"amount"` - ChatID int64 `json:"chat_id"` - ChatName string `json:"chat_name"` - Memo string `json:"memo"` - Success bool `json:"success"` - FromWallet string `json:"from_wallet"` - ToWallet string `json:"to_wallet"` - FromLNbitsID string `json:"from_lnbits"` - ToLNbitsID string `json:"to_lnbits"` - Invoice lnbits.BitInvoice `gorm:"embedded;embeddedPrefix:invoice_"` + ID uint `gorm:"primarykey"` + Time time.Time `json:"time"` + Bot *TipBot `gorm:"-"` + From *lnbits.User `json:"from" gorm:"-"` + To *lnbits.User `json:"to" gorm:"-"` + FromId int64 `json:"from_id" ` + ToId int64 `json:"to_id" ` + FromUser string `json:"from_user"` + ToUser string `json:"to_user"` + Type string `json:"type"` + Amount int64 `json:"amount"` + ChatID int64 `json:"chat_id"` + ChatName string `json:"chat_name"` + Memo string `json:"memo"` + Success bool `json:"success"` + FromWallet string `json:"from_wallet"` + ToWallet string `json:"to_wallet"` + FromLNbitsID string `json:"from_lnbits"` + ToLNbitsID string `json:"to_lnbits"` + Invoice lnbits.Invoice `gorm:"embedded;embeddedPrefix:invoice_"` } type TransactionOption func(t *Transaction) diff --git a/main.go b/main.go index 49384c6..b6a61c5 100644 --- a/main.go +++ b/main.go @@ -54,7 +54,6 @@ func startApiServer(bot *telegram.TipBot) { // append lnurl ctx functions lnUrl := lnurl.New(bot) s.AppendRoute("/.well-known/lnurlp/{username}", lnUrl.Handle, http.MethodGet) - // userpage server userpage := userpage.New(bot) s.AppendRoute("/@{username}", userpage.UserPageHandler, http.MethodGet) @@ -63,6 +62,16 @@ func startApiServer(bot *telegram.TipBot) { hub := lndhub.New(bot) s.AppendRoute(`/lndhub/ext/{.*}`, hub.Handle) s.AppendRoute(`/lndhub/ext`, hub.Handle) + //s.AppendAuthorizedRoute(`/lndhub/ext/{.*}`, api.AuthTypeBearer, bot.DB.Users, hub.Handle) + //s.AppendAuthorizedRoute(`/lndhub/ext`, api.AuthTypeBearer, bot.DB.Users, hub.Handle) + + // starting api service + apiService := api.Service{Bot: bot} + s.AppendAuthorizedRoute(`/api/v1/paymentstatus/{payment_hash}`, api.AuthTypeBasic, bot.DB.Users, apiService.PaymentStatus, http.MethodPost) + s.AppendAuthorizedRoute(`/api/v1/invoicestatus/{payment_hash}`, api.AuthTypeBasic, bot.DB.Users, apiService.InvoiceStatus, http.MethodPost) + s.AppendAuthorizedRoute(`/api/v1/payinvoice`, api.AuthTypeBasic, bot.DB.Users, apiService.PayInvoice, http.MethodPost) + s.AppendAuthorizedRoute(`/api/v1/createinvoice`, api.AuthTypeBasic, bot.DB.Users, apiService.CreateInvoice, http.MethodPost) + s.AppendAuthorizedRoute(`/api/v1/balance`, api.AuthTypeBasic, bot.DB.Users, apiService.Balance, http.MethodGet) // start internal admin server adminService := admin.New(bot) @@ -75,6 +84,9 @@ func startApiServer(bot *telegram.TipBot) { } +func withMiddleware(mw func(w http.ResponseWriter, r *http.Request)) http.HandlerFunc { + return mw +} func withRecovery() { if r := recover(); r != nil { log.Errorln("Recovered panic: ", r)