519 lines
15 KiB
Go
519 lines
15 KiB
Go
package alby
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.devvul.com/asara/gologger"
|
|
"git.devvul.com/asara/well-goknown/config"
|
|
"github.com/gorilla/schema"
|
|
"github.com/jmoiron/sqlx"
|
|
"github.com/nbd-wtf/go-nostr"
|
|
"github.com/nbd-wtf/go-nostr/nip04"
|
|
)
|
|
|
|
var (
|
|
DB *sqlx.DB
|
|
qsDecoder = schema.NewDecoder()
|
|
)
|
|
|
|
type AlbyApp struct {
|
|
Id int32 `json:"id"`
|
|
Name string `json:"name"`
|
|
AppPubkey string `json:"appPubkey"`
|
|
}
|
|
|
|
type AlbyApps []AlbyApp
|
|
|
|
type lnurlp struct {
|
|
Status string `json:"status"`
|
|
Tag string `json:"tag"`
|
|
CommentAllowed int32 `json:"commentAllowed"`
|
|
Callback string `json:"callback"`
|
|
MinSendable int64 `json:"minSendable"`
|
|
MaxSendable int64 `json:"maxSendable"`
|
|
Metadata string `json:"metadata"`
|
|
AllowsNostr bool `json:"allowsNostr"`
|
|
NostrPubkey string `json:"nostrPubkey"`
|
|
}
|
|
|
|
type lnurlpError struct {
|
|
Status string `json:"status"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
|
|
type ZapEvent struct {
|
|
Id string `json:"id"`
|
|
Pubkey string `json:"pubkey"`
|
|
CreatedAt nostr.Timestamp `json:"created_at"`
|
|
Kind int `json:"kind"`
|
|
Tags nostr.Tags `json:"tags"`
|
|
Content string `json:"content"`
|
|
Signature string `json:"sig"`
|
|
}
|
|
|
|
type NWCReq struct {
|
|
Nostr string `json:"nostr"`
|
|
Amount string `json:"amount"`
|
|
Comment string `json:"comment"`
|
|
LNUrl string `json:"lnurl"`
|
|
}
|
|
|
|
type NWCSecret struct {
|
|
Name string
|
|
Domain string
|
|
Wallet string
|
|
ClientPubkey string
|
|
AppPubkey string
|
|
Relay string
|
|
Secret string
|
|
}
|
|
|
|
func (s *NWCSecret) decodeSecret() {
|
|
l := gologger.Get(config.GetConfig().LogLevel).With().Caller().Logger()
|
|
u, err := url.Parse(s.Wallet)
|
|
if err != nil {
|
|
l.Error().Msgf("failed")
|
|
}
|
|
s.AppPubkey = u.Host
|
|
q, _ := url.ParseQuery(u.RawQuery)
|
|
s.Relay = q.Get("relay")
|
|
s.Secret = q.Get("secret")
|
|
|
|
}
|
|
|
|
type LookupInvoiceParams struct {
|
|
Invoice string `json:"invoice"`
|
|
}
|
|
|
|
type LookupInvoice struct {
|
|
Method string `json:"method"`
|
|
Params LookupInvoiceParams `json:"params"`
|
|
}
|
|
|
|
type LookupInvoiceResponseResult struct {
|
|
Type string `json:"type"`
|
|
State string `json:"state"`
|
|
Invoice string `json:"invoice"`
|
|
Description string `json:"description"`
|
|
DescriptionHash string `json:"description_hash"`
|
|
Preimage string `json:"preimage"`
|
|
PaymentHash string `json:"payment_hash"`
|
|
Amount int64 `json:"amount"`
|
|
FeesPaid int64 `json:"fees_paid"`
|
|
CreatedAt nostr.Timestamp `json:"created_at"`
|
|
ExpiresAt nostr.Timestamp `json:"expires_at"`
|
|
SettledAt nostr.Timestamp `json:"settled_at"`
|
|
Metadata string `json:"metadata"`
|
|
}
|
|
|
|
func (s *LookupInvoiceResponseResult) isExpired() bool {
|
|
if time.Now().Unix() > s.ExpiresAt.Time().Unix() {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (s *LookupInvoiceResponseResult) isSettled() bool {
|
|
if s.SettledAt.Time().Unix() != 0 {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
type LookupInvoiceResponse struct {
|
|
Result LookupInvoiceResponseResult `json:"result"`
|
|
ResultType string `json:"result_type"`
|
|
}
|
|
|
|
type MakeInvoiceParams struct {
|
|
Amount int64 `json:"amount"`
|
|
Description string `json:"description"`
|
|
DescriptionHash string `json:"description_hash"`
|
|
Expiry int32 `json:"expiry"`
|
|
}
|
|
|
|
type MakeInvoice struct {
|
|
Method string `json:"method"`
|
|
Params MakeInvoiceParams `json:"params"`
|
|
}
|
|
|
|
type MakeInvoiceResponseResult struct {
|
|
Type string `json:"type"`
|
|
Invoice string `json:"invoice"`
|
|
Description string `json:"description"`
|
|
DescriptionHash string `json:"description_hash"`
|
|
Preimage string `json:"preimage"`
|
|
PaymentHash string `json:"payment_hash"`
|
|
Amount int64 `json:"amount"`
|
|
FeesPaid int64 `json:"fees_paid"`
|
|
CreatedAt int64 `json:"created_at"`
|
|
ExpiresAt int64 `json:"expires_at"`
|
|
SettledAt int64 `json:"settled_at"`
|
|
Metadata string `json:"metadata"`
|
|
}
|
|
|
|
type MakeInvoiceResponse struct {
|
|
Result MakeInvoiceResponseResult `json:"result"`
|
|
ResultType string `json:"result_type"`
|
|
}
|
|
|
|
type InvoiceResponse struct {
|
|
Invoice string `json:"pr"`
|
|
Routes []int `json:"routes"`
|
|
}
|
|
|
|
func GetLnurlp(w http.ResponseWriter, r *http.Request) {
|
|
l := gologger.Get(config.GetConfig().LogLevel).With().Caller().Logger()
|
|
albyAdmin := config.GetConfig().AlbyAdminAuth
|
|
|
|
// setup response type
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
// normalize domain
|
|
domain, _, err := net.SplitHostPort(r.Host)
|
|
if err != nil {
|
|
domain = r.Host
|
|
}
|
|
|
|
name := r.PathValue("name")
|
|
|
|
// get all alby apps
|
|
client := http.Client{}
|
|
req, err := http.NewRequest("GET", "https://alby.devvul.com/api/apps", nil)
|
|
if err != nil {
|
|
l.Error().Msgf("unable to generate alby request for %s@%s: %s", name, domain, err.Error())
|
|
lnurlpReturnError := &lnurlpError{Status: "ERROR", Reason: "unknown error"}
|
|
retError, _ := json.Marshal(lnurlpReturnError)
|
|
w.WriteHeader(http.StatusNotFound)
|
|
w.Write(retError)
|
|
return
|
|
}
|
|
|
|
req.Header = http.Header{"Authorization": {fmt.Sprintf("Bearer %s", albyAdmin)}}
|
|
resp, err := client.Do(req)
|
|
defer resp.Body.Close()
|
|
|
|
var albyApps AlbyApps
|
|
err = json.NewDecoder(resp.Body).Decode(&albyApps)
|
|
if err != nil {
|
|
l.Error().Msgf("unable to unmarshal alby request for %s@%s: %s", name, domain, err.Error())
|
|
lnurlpReturnError := &lnurlpError{Status: "ERROR", Reason: "unknown error"}
|
|
retError, _ := json.Marshal(lnurlpReturnError)
|
|
w.WriteHeader(http.StatusNotFound)
|
|
w.Write(retError)
|
|
return
|
|
}
|
|
|
|
// check if user exists
|
|
var npk string
|
|
for _, element := range albyApps {
|
|
if element.Name == name {
|
|
npk = element.AppPubkey
|
|
}
|
|
}
|
|
|
|
if len(npk) == 0 {
|
|
l.Debug().Msgf("user doesn't exist in alby %s@%s", name, domain)
|
|
lnurlpReturnError := &lnurlpError{Status: "ERROR", Reason: "user does not exist"}
|
|
retError, _ := json.Marshal(lnurlpReturnError)
|
|
w.WriteHeader(http.StatusNotFound)
|
|
w.Write(retError)
|
|
return
|
|
}
|
|
|
|
// get server pubkey
|
|
var secret NWCSecret
|
|
err = DB.QueryRow("SELECT name, domain, wallet, pubkey FROM lnwallets WHERE name=$1 AND domain=$2", name, domain).
|
|
Scan(&secret.Name, &secret.Domain, &secret.Wallet, &secret.ClientPubkey)
|
|
if err != nil {
|
|
l.Debug().Msgf("user doesn't exist in alby %s@%s: %s", name, domain, err.Error())
|
|
lnurlpReturnError := &lnurlpError{Status: "ERROR", Reason: "user does not exist"}
|
|
retError, _ := json.Marshal(lnurlpReturnError)
|
|
w.WriteHeader(http.StatusNotFound)
|
|
w.Write(retError)
|
|
return
|
|
}
|
|
secret.decodeSecret()
|
|
|
|
lnurlpReturn := &lnurlp{
|
|
Status: "OK",
|
|
Tag: "payRequest",
|
|
CommentAllowed: 255,
|
|
Callback: fmt.Sprintf("https://%s/.well-known/lnurlp/%s/callback", domain, name),
|
|
MinSendable: 1000,
|
|
MaxSendable: 10000000,
|
|
Metadata: fmt.Sprintf("[[\"text/plain\", \"ln address payment to %s on the devvul server\"],[\"text/identifier\", \"%s@%s\"]]", name, name, domain),
|
|
AllowsNostr: true,
|
|
NostrPubkey: secret.ClientPubkey,
|
|
}
|
|
|
|
ret, err := json.Marshal(lnurlpReturn)
|
|
if err != nil {
|
|
l.Error().Msgf("unable to marshal json for %s@%s: %s", name, domain, err.Error())
|
|
lnurlpReturnError := &lnurlpError{Status: "ERROR", Reason: "User not found"}
|
|
retError, _ := json.Marshal(lnurlpReturnError)
|
|
w.WriteHeader(http.StatusNotFound)
|
|
w.Write(retError)
|
|
return
|
|
}
|
|
|
|
l.Debug().Msgf("returning pay request callback for %s@%s", name, domain)
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write(ret)
|
|
return
|
|
}
|
|
|
|
func GetLnurlpCallback(w http.ResponseWriter, r *http.Request) {
|
|
l := gologger.Get(config.GetConfig().LogLevel).With().Caller().Logger()
|
|
var nwc NWCReq
|
|
var zapEvent ZapEvent
|
|
|
|
// normalize domain
|
|
domain, _, err := net.SplitHostPort(r.Host)
|
|
if err != nil {
|
|
domain = r.Host
|
|
}
|
|
name := r.PathValue("name")
|
|
|
|
err = qsDecoder.Decode(&nwc, r.URL.Query())
|
|
if err != nil {
|
|
l.Error().Msgf("unable to marshal json for %s@%s: %s", name, domain, err.Error())
|
|
}
|
|
|
|
// get NWC secret
|
|
var secret NWCSecret
|
|
err = DB.QueryRow("SELECT name, domain, wallet, pubkey FROM lnwallets WHERE name=$1 AND domain=$2", name, domain).
|
|
Scan(&secret.Name, &secret.Domain, &secret.Wallet, &secret.ClientPubkey)
|
|
if err != nil {
|
|
l.Debug().Msgf("user doesn't exist in alby %s@%s: %s", name, domain, err.Error())
|
|
lnurlpReturnError := &lnurlpError{Status: "ERROR", Reason: "user does not exist"}
|
|
retError, _ := json.Marshal(lnurlpReturnError)
|
|
w.WriteHeader(http.StatusNotFound)
|
|
w.Write(retError)
|
|
return
|
|
}
|
|
secret.decodeSecret()
|
|
|
|
// if there is a nostr payload unmarshal it
|
|
if nwc.Nostr != "" {
|
|
err = json.Unmarshal([]byte(nwc.Nostr), &zapEvent)
|
|
if err != nil {
|
|
l.Debug().Msgf("unable to unmarshal nwc value: %s", err.Error())
|
|
lnurlpReturnError := &lnurlpError{Status: "ERROR", Reason: "unable to connect to relay"}
|
|
retError, _ := json.Marshal(lnurlpReturnError)
|
|
w.WriteHeader(http.StatusNotFound)
|
|
w.Write(retError)
|
|
return
|
|
}
|
|
ok := checkEvent(nwc.Nostr)
|
|
if !ok {
|
|
l.Debug().Msgf("nostr event is not valid", err.Error())
|
|
lnurlpReturnError := &lnurlpError{Status: "ERROR", Reason: "check your request and try again"}
|
|
retError, _ := json.Marshal(lnurlpReturnError)
|
|
w.WriteHeader(http.StatusNotFound)
|
|
w.Write(retError)
|
|
return
|
|
}
|
|
}
|
|
|
|
// connect to the relay
|
|
relayCtx, relayCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer relayCancel()
|
|
relay, err := nostr.RelayConnect(relayCtx, secret.Relay)
|
|
if err != nil {
|
|
l.Debug().Msgf("unable to connect to relay: %s", err.Error())
|
|
lnurlpReturnError := &lnurlpError{Status: "ERROR", Reason: "unable to connect to relay"}
|
|
retError, _ := json.Marshal(lnurlpReturnError)
|
|
w.WriteHeader(http.StatusNotFound)
|
|
w.Write(retError)
|
|
return
|
|
}
|
|
|
|
// create make_invoice request
|
|
amt, err := strconv.ParseInt(nwc.Amount, 10, 64)
|
|
if err != nil {
|
|
l.Debug().Msgf("unable to parse amount: %s", err.Error())
|
|
lnurlpReturnError := &lnurlpError{Status: "ERROR", Reason: "unable to create an invoice"}
|
|
retError, _ := json.Marshal(lnurlpReturnError)
|
|
w.WriteHeader(http.StatusNotFound)
|
|
w.Write(retError)
|
|
return
|
|
}
|
|
|
|
var hash string
|
|
if nwc.Nostr != "" {
|
|
sha := sha256.Sum256([]byte(nwc.Nostr))
|
|
hash = hex.EncodeToString(sha[:])
|
|
}
|
|
if nwc.Nostr == "" {
|
|
hash = ""
|
|
}
|
|
|
|
invoiceParams := MakeInvoiceParams{
|
|
Amount: amt,
|
|
Description: nwc.Nostr,
|
|
DescriptionHash: hash,
|
|
Expiry: 300,
|
|
}
|
|
|
|
invoice := MakeInvoice{
|
|
Method: "make_invoice",
|
|
Params: invoiceParams,
|
|
}
|
|
|
|
invoiceJson, err := json.Marshal(invoice)
|
|
if err != nil {
|
|
l.Debug().Msgf("unable to marshal invoice: %s", err.Error())
|
|
lnurlpReturnError := &lnurlpError{Status: "ERROR", Reason: "unable to create an invoice"}
|
|
retError, _ := json.Marshal(lnurlpReturnError)
|
|
w.WriteHeader(http.StatusNotFound)
|
|
w.Write(retError)
|
|
return
|
|
}
|
|
|
|
// generate nip-04 shared secret
|
|
sharedSecret, err := nip04.ComputeSharedSecret(secret.AppPubkey, secret.Secret)
|
|
if err != nil {
|
|
l.Debug().Msgf("unable to marshal invoice: %s", err.Error())
|
|
lnurlpReturnError := &lnurlpError{Status: "ERROR", Reason: "unable to create an invoice"}
|
|
retError, _ := json.Marshal(lnurlpReturnError)
|
|
w.WriteHeader(http.StatusNotFound)
|
|
w.Write(retError)
|
|
return
|
|
}
|
|
|
|
// create the encrypted content payload
|
|
encryptedContent, err := nip04.Encrypt(string(invoiceJson), sharedSecret)
|
|
if err != nil {
|
|
l.Debug().Msgf("unable to marshal invoice: %s", err.Error())
|
|
lnurlpReturnError := &lnurlpError{Status: "ERROR", Reason: "unable to create an invoice"}
|
|
retError, _ := json.Marshal(lnurlpReturnError)
|
|
w.WriteHeader(http.StatusNotFound)
|
|
w.Write(retError)
|
|
return
|
|
}
|
|
|
|
recipient := nostr.Tag{"p", secret.AppPubkey}
|
|
nwcEv := nostr.Event{
|
|
PubKey: secret.ClientPubkey,
|
|
CreatedAt: nostr.Now(),
|
|
Kind: nostr.KindNWCWalletRequest,
|
|
Tags: nostr.Tags{recipient},
|
|
Content: encryptedContent,
|
|
}
|
|
|
|
// sign the message with the app token
|
|
nwcEv.Sign(secret.Secret)
|
|
|
|
var filters nostr.Filters
|
|
t := make(map[string][]string)
|
|
t["e"] = []string{nwcEv.GetID()}
|
|
filters = []nostr.Filter{
|
|
{
|
|
Kinds: []int{
|
|
nostr.KindNWCWalletResponse,
|
|
},
|
|
Tags: t,
|
|
},
|
|
}
|
|
|
|
subCtx, subCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer subCancel()
|
|
sub, err := relay.Subscribe(subCtx, filters)
|
|
if err != nil {
|
|
l.Debug().Msgf("unable to connect to relay: %s", err.Error())
|
|
lnurlpReturnError := &lnurlpError{Status: "ERROR", Reason: "unable to connect to relay"}
|
|
retError, _ := json.Marshal(lnurlpReturnError)
|
|
w.WriteHeader(http.StatusNotFound)
|
|
w.Write(retError)
|
|
return
|
|
}
|
|
|
|
var wg sync.WaitGroup
|
|
wg.Add(1)
|
|
|
|
// watch for the invoice
|
|
evs := make([]nostr.Event, 0, 1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for {
|
|
select {
|
|
case ev, ok := <-sub.Events:
|
|
if !ok {
|
|
l.Debug().Msgf("subscription events channel is closed")
|
|
return
|
|
}
|
|
if ev.Kind != 0 {
|
|
evs = append(evs, *ev)
|
|
}
|
|
if len(evs) > 0 {
|
|
return
|
|
}
|
|
case <-sub.EndOfStoredEvents:
|
|
l.Trace().Msgf("end of stored events received")
|
|
case <-subCtx.Done():
|
|
l.Debug().Msgf("subscription context cancelled or done: %v", subCtx.Err())
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
// publish the invoice request
|
|
if err := relay.Publish(relayCtx, nwcEv); err != nil {
|
|
l.Debug().Msgf("unable to marshal invoice: %s", err.Error())
|
|
lnurlpReturnError := &lnurlpError{Status: "ERROR", Reason: "unable to create an invoice"}
|
|
retError, _ := json.Marshal(lnurlpReturnError)
|
|
w.WriteHeader(http.StatusNotFound)
|
|
w.Write(retError)
|
|
return
|
|
}
|
|
// wait for the invoice to get returned
|
|
wg.Wait()
|
|
|
|
// decrypt the invoice
|
|
response, err := nip04.Decrypt(evs[0].Content, sharedSecret)
|
|
resStruct := MakeInvoiceResponse{}
|
|
err = json.Unmarshal([]byte(response), &resStruct)
|
|
if err != nil {
|
|
l.Debug().Msgf("unable to create invoice: %s", err.Error())
|
|
lnurlpReturnError := &lnurlpError{Status: "ERROR", Reason: "unable to connect to relay"}
|
|
retError, _ := json.Marshal(lnurlpReturnError)
|
|
w.WriteHeader(http.StatusNotFound)
|
|
w.Write(retError)
|
|
return
|
|
}
|
|
|
|
retStruct := InvoiceResponse{
|
|
Invoice: resStruct.Result.Invoice,
|
|
Routes: []int{},
|
|
}
|
|
|
|
// return the invoice to the requester
|
|
ret, err := json.Marshal(retStruct)
|
|
if err != nil {
|
|
l.Error().Msgf("unable to marshal json for invoice: %s", err.Error())
|
|
lnurlpReturnError := &lnurlpError{Status: "ERROR", Reason: "unable to create invoice"}
|
|
retError, _ := json.Marshal(lnurlpReturnError)
|
|
w.WriteHeader(http.StatusNotFound)
|
|
w.Write(retError)
|
|
return
|
|
}
|
|
if nwc.Nostr != "" {
|
|
l.Debug().Msgf("starting background job for invoice")
|
|
go watchForReceipt(nwc.Nostr, secret, retStruct.Invoice)
|
|
}
|
|
|
|
l.Info().Msg("returning lnurl-p payload")
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write(ret)
|
|
}
|