well-goknown/alby/well-known.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)
}