lightningtip/lightningtip.go

294 lines
6.0 KiB
Go

package main
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"github.com/donovanhide/eventsource"
"io/ioutil"
"net/http"
"os"
"strconv"
"time"
)
const eventChannel = "invoiceSettled"
const couldNotParseError = "Could not parse values from request"
var eventSrv *eventsource.Server
var pendingInvoices []PendingInvoice
type PendingInvoice struct {
Invoice string
Hash string
Expiry time.Time
}
// To use the pendingInvoice type as event for the EventSource stream
func (pending PendingInvoice) Id() string { return "" }
func (pending PendingInvoice) Event() string { return "" }
func (pending PendingInvoice) Data() string { return pending.Hash }
type invoiceRequest struct {
Amount int64
Message string
}
type invoiceResponse struct {
Invoice string
Expiry int64
}
type invoiceSettledRequest struct {
InvoiceHash string
}
type invoiceSettledResponse struct {
Settled bool
}
type errorResponse struct {
Error string
}
// TODO: add option to show URI of Lightning node
func main() {
initLog()
initConfig()
err := backend.Connect()
if err == nil {
log.Info("Starting EventSource stream")
eventSrv = eventsource.NewServer()
http.Handle("/", handleHeaders(notFoundHandler))
http.Handle("/getinvoice", handleHeaders(getInvoiceHandler))
http.Handle("/eventsource", handleHeaders(eventSrv.Handler(eventChannel)))
// Alternative for browsers which don't support EventSource (Internet Explorer and Edge)
http.Handle("/invoicesettled", handleHeaders(invoiceSettledHandler))
log.Debug("Starting ticker to clear expired invoices")
// A bit longer than the expiry time to make sure the invoice doesn't show as settled if it isn't (affects just invoiceSettledHandler)
duration := time.Duration(cfg.TipExpiry + 10)
ticker := time.Tick(duration * time.Second)
go func() {
for {
select {
case <-ticker:
now := time.Now()
for index := len(pendingInvoices) - 1; index >= 0; index-- {
invoice := pendingInvoices[index]
if now.Sub(invoice.Expiry) > 0 {
log.Debug("Invoice expired: " + invoice.Invoice)
pendingInvoices = append(pendingInvoices[:index], pendingInvoices[index+1:]...)
}
}
}
}
}()
log.Info("Subscribing to invoices")
go func() {
err = backend.SubscribeInvoices(publishInvoiceSettled, eventSrv)
if err != nil {
log.Error("Failed to subscribe to invoices: " + fmt.Sprint(err))
os.Exit(1)
}
}()
log.Info("Starting HTTP server")
go func() {
var err error
if cfg.TLSCertFile != "" && cfg.TLSKeyFile != "" {
err = http.ListenAndServeTLS(cfg.RESTHost, cfg.TLSCertFile, cfg.TLSKeyFile, nil)
} else {
err = http.ListenAndServe(cfg.RESTHost, nil)
}
if err != nil {
log.Errorf("Failed to start HTTP server: " + fmt.Sprint(err))
os.Exit(1)
}
}()
select {}
}
}
// Callback for backend
func publishInvoiceSettled(invoice string, eventSrv *eventsource.Server) {
for index, pending := range pendingInvoices {
if pending.Invoice == invoice {
log.Info("Invoice settled: " + invoice)
eventSrv.Publish([]string{eventChannel}, pending)
pendingInvoices = append(pendingInvoices[:index], pendingInvoices[index+1:]...)
break
}
}
}
func invoiceSettledHandler(writer http.ResponseWriter, request *http.Request) {
errorMessage := couldNotParseError
if request.Method == http.MethodPost {
var body invoiceSettledRequest
data, _ := ioutil.ReadAll(request.Body)
err := json.Unmarshal(data, &body)
if err == nil {
if body.InvoiceHash != "" {
settled := true
for _, pending := range pendingInvoices {
if pending.Hash == body.InvoiceHash {
settled = false
break
}
}
writer.Write(marshalJson(invoiceSettledResponse{
Settled: settled,
}))
return
}
}
}
log.Error(errorMessage)
writeError(writer, errorMessage)
}
func getInvoiceHandler(writer http.ResponseWriter, request *http.Request) {
errorMessage := couldNotParseError
if request.Method == http.MethodPost {
var body invoiceRequest
data, _ := ioutil.ReadAll(request.Body)
err := json.Unmarshal(data, &body)
if err == nil {
if body.Amount != 0 {
invoice, err := backend.GetInvoice(body.Message, body.Amount, cfg.TipExpiry)
if err == nil {
logMessage := "Created invoice with amount of " + strconv.FormatInt(body.Amount, 10) + " satoshis"
if body.Message != "" {
logMessage += " with message \"" + body.Message + "\""
}
sha := sha256.New()
sha.Write([]byte(invoice))
hash := hex.EncodeToString(sha.Sum(nil))
expiryDuration := time.Duration(cfg.TipExpiry) * time.Second
log.Info(logMessage)
pendingInvoices = append(pendingInvoices, PendingInvoice{
Invoice: invoice,
Hash: hash,
Expiry: time.Now().Add(expiryDuration),
})
writer.Write(marshalJson(invoiceResponse{
Invoice: invoice,
Expiry: cfg.TipExpiry,
}))
return
} else {
errorMessage = "Failed to create invoice"
// This is way to hacky
// Maybe a cast to the gRPC error and get its error message directly
if fmt.Sprint(err)[:47] == "rpc error: code = Unknown desc = memo too large" {
errorMessage += ": message too long"
}
}
}
}
}
log.Error(errorMessage)
writeError(writer, errorMessage)
}
func notFoundHandler(writer http.ResponseWriter, request *http.Request) {
writeError(writer, "Not found")
}
func handleHeaders(handler func(w http.ResponseWriter, r *http.Request)) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if cfg.AccessDomain != "" {
writer.Header().Add("Access-Control-Allow-Origin", cfg.AccessDomain)
}
handler(writer, request)
})
}
func writeError(writer http.ResponseWriter, message string) {
writer.WriteHeader(http.StatusBadRequest)
writer.Write(marshalJson(errorResponse{
Error: message,
}))
}
func marshalJson(data interface{}) []byte {
response, _ := json.MarshalIndent(data, "", " ")
return response
}