├── .travis.yml ├── backends ├── log.go ├── backend.go └── lnd.go ├── database ├── log.go └── database.go ├── notifications ├── log.go └── mail.go ├── version └── version.go ├── .github └── ISSUE_TEMPLATE.md ├── .gitignore ├── frontend ├── lightningTip_light.css ├── lightningTip.html ├── lightningTip.css └── lightningTip.js ├── go.mod ├── log.go ├── LICENSE ├── Makefile ├── cmd └── tipreport │ ├── tipreport.go │ └── commands.go ├── release.sh ├── go.sum ├── sample-lightningTip.conf ├── README.md ├── config.go └── lightningtip.go /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | go: 4 | - "1.11.4" 5 | 6 | script: 7 | - make build 8 | - make lint 9 | -------------------------------------------------------------------------------- /backends/log.go: -------------------------------------------------------------------------------- 1 | package backends 2 | 3 | import "github.com/op/go-logging" 4 | 5 | var log logging.Logger 6 | 7 | // UseLogger tells the backends package which logger to use 8 | func UseLogger(logger logging.Logger) { 9 | log = logger 10 | } 11 | -------------------------------------------------------------------------------- /database/log.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import "github.com/op/go-logging" 4 | 5 | var log logging.Logger 6 | 7 | // UseLogger tells the database package which logger to use 8 | func UseLogger(logger logging.Logger) { 9 | log = logger 10 | } 11 | -------------------------------------------------------------------------------- /notifications/log.go: -------------------------------------------------------------------------------- 1 | package notifications 2 | 3 | import "github.com/op/go-logging" 4 | 5 | var log logging.Logger 6 | 7 | // UseLogger tells the notifications package which logger to use 8 | func UseLogger(logger logging.Logger) { 9 | log = logger 10 | } 11 | -------------------------------------------------------------------------------- /version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import "fmt" 4 | 5 | // Version is the version of LightningTip 6 | const Version = "1.1.0-dev" 7 | 8 | // PrintVersion prints the version of LightningTip to the console 9 | func PrintVersion() { 10 | fmt.Println("LightningTip version " + Version) 11 | } 12 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Background 2 | 3 | Describe your issue here. 4 | 5 | ### Your environment 6 | 7 | * which operating system? 8 | * any other relevant environment details? 9 | * are you running LightningTip behind a reverse proxy? 10 | 11 | ### Steps to reproduce 12 | 13 | Tell us how to reproduce this issue. Please provide stacktraces and links to code in question. 14 | 15 | ### Expected behaviour 16 | 17 | Tell us what should happen. 18 | 19 | ### Actual behaviour 20 | 21 | Tell us what happens instead. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.dll 4 | *.so 5 | *.dylib 6 | /lightningTip 7 | /lightningtip 8 | /tipreport 9 | lightningTip*/ 10 | 11 | # Test binary, build with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736 18 | .glide/ 19 | 20 | # Jetbrain GoLand 21 | .idea 22 | 23 | # Log file 24 | lightningTip.log 25 | 26 | # Config file 27 | lightningTip.conf 28 | 29 | # Dep vendor directory 30 | vendor 31 | _vendor* -------------------------------------------------------------------------------- /frontend/lightningTip_light.css: -------------------------------------------------------------------------------- 1 | #lightningTip > a { 2 | font-weight: 600 !important; 3 | } 4 | 5 | #lightningTip { 6 | background-color: #FFFFFF !important; 7 | 8 | border: 2px #BDBDBD solid !important; 9 | 10 | color: #FFB300 !important; 11 | 12 | } 13 | 14 | .lightningTipInput { 15 | border: 1px #BDBDBD solid !important; 16 | 17 | background-color: #FFFFFF !important; 18 | } 19 | 20 | .lightningTipButton { 21 | color: #FFFFFF !important; 22 | 23 | background-color: #FFB300 !important; 24 | } 25 | 26 | #lightningTipError { 27 | color: #D50000 !important; 28 | 29 | font-weight: 600 !important; 30 | } 31 | 32 | #lightningTipCopy { 33 | border-right: 1px solid #FFFFFF !important; 34 | } 35 | 36 | #lightningTipExpiry { 37 | font-weight: 600 !important; 38 | } -------------------------------------------------------------------------------- /backends/backend.go: -------------------------------------------------------------------------------- 1 | package backends 2 | 3 | // PublishInvoiceSettled is a callback for a settled invoice 4 | type PublishInvoiceSettled func(invoice string) 5 | 6 | // RescanPendingInvoices is a callbacks when reconnecting 7 | type RescanPendingInvoices func() 8 | 9 | // Backend is an interface that would allow for different implementations of Lightning to be used as backend 10 | type Backend interface { 11 | Connect() error 12 | 13 | // The amount is denominated in satoshis and the expiry in seconds 14 | GetInvoice(description string, amount int64, expiry int64) (invoice string, rHash string, err error) 15 | 16 | InvoiceSettled(rHash string) (settled bool, err error) 17 | 18 | SubscribeInvoices(publish PublishInvoiceSettled, rescan RescanPendingInvoices) error 19 | 20 | KeepAliveRequest() error 21 | } 22 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/michael1011/lightningtip 2 | 3 | require ( 4 | github.com/donovanhide/eventsource v0.0.0-20171031113327-3ed64d21fb0b 5 | github.com/golang/protobuf v1.2.0 // indirect 6 | github.com/grpc-ecosystem/grpc-gateway v0.0.0-20170724004829-f2862b476edc // indirect 7 | github.com/jessevdk/go-flags v1.4.0 8 | github.com/lightningnetwork/lnd v0.0.0-20180827212353-73af09a06ae9 9 | github.com/mattn/go-sqlite3 v1.9.0 10 | github.com/op/go-logging v0.0.0-20160211212156-b2cb9fa56473 11 | github.com/urfave/cli v1.20.0 12 | golang.org/x/net v0.0.0-20180311174755-ae89d30ce0c6 // indirect 13 | golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4 // indirect 14 | golang.org/x/text v0.3.0 // indirect 15 | google.golang.org/genproto v0.0.0-20180306020942-df60624c1e9b // indirect 16 | google.golang.org/grpc v1.5.2 17 | ) 18 | -------------------------------------------------------------------------------- /log.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/op/go-logging" 7 | ) 8 | 9 | var log = logging.MustGetLogger("") 10 | var logFormat = logging.MustStringFormatter("%{time:2006-01-02 15:04:05} [%{level}] %{message}") 11 | 12 | var backendConsole = logging.NewLogBackend(os.Stdout, "", 0) 13 | 14 | func initLog() { 15 | logging.SetFormatter(logFormat) 16 | 17 | logging.SetBackend(backendConsole) 18 | } 19 | 20 | func initLogger(logFile string, level logging.Level) error { 21 | file, err := os.OpenFile(logFile, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0600) 22 | 23 | if err != nil { 24 | return err 25 | } 26 | 27 | backendFile := logging.NewLogBackend(file, "", 0) 28 | 29 | backendFileLeveled := logging.AddModuleLevel(backendFile) 30 | backendFileLeveled.SetLevel(level, "") 31 | 32 | backendConsoleLeveled := logging.AddModuleLevel(backendConsole) 33 | backendConsoleLeveled.SetLevel(level, "") 34 | 35 | logging.SetBackend(backendConsoleLeveled, backendFileLeveled) 36 | 37 | return nil 38 | } 39 | -------------------------------------------------------------------------------- /database/database.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "time" 7 | 8 | // The sqlite drivers have to be imported to establish a connection to the database 9 | _ "github.com/mattn/go-sqlite3" 10 | ) 11 | 12 | var db *sql.DB 13 | 14 | // InitDatabase is initializing the database 15 | func InitDatabase(databaseFile string) (err error) { 16 | db, err = sql.Open("sqlite3", databaseFile) 17 | 18 | if err == nil { 19 | db.Exec("CREATE TABLE IF NOT EXISTS `tips` (`date` INTEGER, `amount` INTEGER, `message` VARCHAR)") 20 | } 21 | 22 | return err 23 | } 24 | 25 | // AddSettledInvoice is adding a settled invoice to the database 26 | func AddSettledInvoice(amount int64, message string) { 27 | stmt, err := db.Prepare("INSERT INTO tips(date, amount, message) values(?, ?, ?)") 28 | 29 | if err == nil { 30 | _, err = stmt.Exec(time.Now().Unix(), amount, message) 31 | } 32 | 33 | if err == nil { 34 | stmt.Close() 35 | 36 | } else { 37 | log.Error("Could not insert into database: " + fmt.Sprint(err)) 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /frontend/lightningTip.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | Send a tip via Lightning 13 | 14 |
15 | 16 |
17 |
18 | 19 | 20 | 21 |
22 | 23 |
24 | 25 |
26 | 27 |
28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 michael1011 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PKG := github.com/michael1011/lightningtip 2 | 3 | GOBUILD := GO111MODULE=on go build -v 4 | GOINSTALL := GO111MODULE=on go install -v 5 | 6 | GO_BIN := ${GOPATH}/bin 7 | LINT_BIN := $(GO_BIN)/gometalinter.v2 8 | 9 | HAVE_LINTER := $(shell command -v $(LINT_BIN) 2> /dev/null) 10 | 11 | default: build 12 | 13 | $(LINT_BIN): 14 | @$(call print, "Fetching gometalinter.v2") 15 | go get -u gopkg.in/alecthomas/gometalinter.v2 16 | 17 | GREEN := "\\033[0;32m" 18 | NC := "\\033[0m" 19 | 20 | define print 21 | echo $(GREEN)$1$(NC) 22 | endef 23 | 24 | LINT_LIST = $(shell go list -f '{{.Dir}}' ./...) 25 | 26 | LINT = $(LINT_BIN) \ 27 | --disable-all \ 28 | --enable=gofmt \ 29 | --enable=vet \ 30 | --enable=golint \ 31 | --line-length=72 \ 32 | --deadline=4m $(LINT_LIST) 2>&1 | \ 33 | grep -v 'ALL_CAPS\|OP_' 2>&1 | \ 34 | tee /dev/stderr 35 | 36 | # Building 37 | 38 | build: 39 | @$(call print, "Building lightningtip and tipreport") 40 | $(GOBUILD) -o lightningtip $(PKG) 41 | $(GOBUILD) -o tipreport $(PKG)/cmd/tipreport 42 | 43 | install: 44 | @$(call print, "Installing lightningtip and tipreport") 45 | $(GOINSTALL) $(PKG) 46 | $(GOINSTALL) $(PKG)/cmd/tipreport 47 | 48 | # Utils 49 | 50 | fmt: 51 | @$(call print, "Formatting source") 52 | gofmt -s -w . 53 | 54 | lint: $(LINT_BIN) 55 | @$(call print, "Linting source") 56 | GO111MODULE=on go mod vendor 57 | GO111MODULE=off $(LINT_BIN) --install 1> /dev/null 58 | test -z "$$($(LINT))" 59 | -------------------------------------------------------------------------------- /cmd/tipreport/tipreport.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "os" 7 | "os/user" 8 | "path" 9 | "path/filepath" 10 | "runtime" 11 | "strings" 12 | 13 | _ "github.com/mattn/go-sqlite3" 14 | "github.com/michael1011/lightningtip/version" 15 | "github.com/urfave/cli" 16 | ) 17 | 18 | const ( 19 | defaultDataDir = "LightningTip" 20 | defaultDatabaseFile = "tips.db" 21 | ) 22 | 23 | func main() { 24 | app := cli.NewApp() 25 | 26 | app.Name = "tipreport" 27 | app.Usage = "display received tips" 28 | 29 | app.Version = version.Version 30 | 31 | app.Flags = []cli.Flag{ 32 | cli.StringFlag{ 33 | Name: "databasefile", 34 | Value: getDefaultDatabaseFile(), 35 | Usage: "path to database file", 36 | }, 37 | } 38 | 39 | app.Commands = []cli.Command{ 40 | summaryCommand, 41 | listCommand, 42 | } 43 | 44 | err := app.Run(os.Args) 45 | 46 | if err != nil { 47 | fmt.Println(err) 48 | } 49 | 50 | } 51 | 52 | func openDatabase(ctx *cli.Context) (db *sql.DB, err error) { 53 | db, err = sql.Open("sqlite3", ctx.GlobalString("databasefile")) 54 | 55 | return db, err 56 | } 57 | 58 | func getDefaultDatabaseFile() (dir string) { 59 | usr, _ := user.Current() 60 | 61 | switch runtime.GOOS { 62 | case "darwin": 63 | dir = path.Join(usr.HomeDir, "Library/Application Support", defaultDataDir, defaultDatabaseFile) 64 | 65 | case "windows": 66 | dir = path.Join(usr.HomeDir, "AppData/Local", defaultDataDir, defaultDatabaseFile) 67 | 68 | default: 69 | dir = path.Join(usr.HomeDir, "."+strings.ToLower(defaultDataDir), defaultDatabaseFile) 70 | } 71 | 72 | return cleanPath(dir) 73 | } 74 | 75 | func cleanPath(path string) string { 76 | path = filepath.Clean(os.ExpandEnv(path)) 77 | 78 | return strings.Replace(path, "\\", "/", -1) 79 | } 80 | -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Simple bash script to build basic lnd tools for all the platforms 4 | # we support with the golang cross-compiler. 5 | # 6 | # Copyright (c) 2016 Company 0, LLC. 7 | # Use of this source code is governed by the ISC 8 | # license. 9 | 10 | # If no tag specified, use date + version otherwise use tag. 11 | if [[ $1x = x ]]; then 12 | DATE=`date +%Y%m%d` 13 | VERSION="01" 14 | TAG=$DATE-$VERSION 15 | else 16 | TAG=$1 17 | fi 18 | 19 | PACKAGE=lightningTip 20 | MAINDIR=$PACKAGE-$TAG 21 | mkdir -p $MAINDIR 22 | cd $MAINDIR 23 | 24 | SYS=( "windows-386" "windows-amd64" "linux-386" "linux-amd64" "linux-arm" "linux-arm64") 25 | 26 | # GCC cross compiler for the SYS above 27 | # These are necessary for https://github.com/mattn/go-sqlite3 28 | # It is assumed that the machine you are compiling on is running Linux amd64 29 | GCC=( "i686-w64-mingw32-gcc" "x86_64-w64-mingw32-gcc" "gcc" "gcc" "arm-linux-gnueabihf-gcc" "aarch64-linux-gnu-gcc") 30 | 31 | # Additional flag to allow cross compiling from 64 to 32 bit on Linux 32 | GCC_LINUX_32BIT="-m32" 33 | 34 | cp -r ../frontend/ . 35 | 36 | # Use the first element of $GOPATH in the case where GOPATH is a list 37 | # (something that is totally allowed). 38 | GPATH=$(echo $GOPATH | cut -f1 -d:) 39 | 40 | for index in ${!SYS[@]}; do 41 | OS=$(echo ${SYS[index]} | cut -f1 -d-) 42 | ARCH=$(echo ${SYS[index]} | cut -f2 -d-) 43 | 44 | CC=${GCC[index]} 45 | CFLAGS="" 46 | 47 | mkdir $PACKAGE-${SYS[index]}-$TAG 48 | cd $PACKAGE-${SYS[index]}-$TAG 49 | 50 | echo "Building:" $OS $ARCH 51 | 52 | # Add flag to allow cross compilation to 32 bit Linux 53 | if [[ $OS = "linux" ]]; then 54 | if [[ $ARCH = "386" ]]; then 55 | CFLAGS="$GCC_LINUX_32BIT" 56 | fi 57 | fi 58 | 59 | env GOOS=$OS GOARCH=$ARCH CGO_ENABLED=1 CC=$CC CFLAGS=$CFLAGS LDFLAGS=$CFLAGS go build github.com/michael1011/lightningtip 60 | env GOOS=$OS GOARCH=$ARCH CGO_ENABLED=1 CC=$CC CFLAGS=$CFLAGS LDFLAGS=$CFLAGS go build github.com/michael1011/lightningtip/cmd/tipreport 61 | 62 | cd .. 63 | 64 | if [[ $OS = "windows" ]]; then 65 | zip -r $PACKAGE-${SYS[index]}-$TAG.zip $PACKAGE-${SYS[index]}-$TAG frontend/ 66 | else 67 | tar -cvzf $PACKAGE-${SYS[index]}-$TAG.tar.gz $PACKAGE-${SYS[index]}-$TAG frontend/ 68 | fi 69 | 70 | rm -r $PACKAGE-${SYS[index]}-$TAG 71 | 72 | done 73 | 74 | rm -rf frontend -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/donovanhide/eventsource v0.0.0-20171031113327-3ed64d21fb0b h1:eR1P/A4QMYF2/LpHRhYAts9wyYEtF7qNk/tVNiYCWc8= 2 | github.com/donovanhide/eventsource v0.0.0-20171031113327-3ed64d21fb0b/go.mod h1:56wL82FO0bfMU5RvfXoIwSOP2ggqqxT+tAfNEIyxuHw= 3 | github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM= 4 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 5 | github.com/grpc-ecosystem/grpc-gateway v0.0.0-20170724004829-f2862b476edc h1:3NXdOHZ1YlN6SGP3FPbn4k73O2MeEp065abehRwGFxI= 6 | github.com/grpc-ecosystem/grpc-gateway v0.0.0-20170724004829-f2862b476edc/go.mod h1:RSKVYQBd5MCa4OVpNdGskqpgL2+G+NZTnrVHpWWfpdw= 7 | github.com/jessevdk/go-flags v1.4.0 h1:4IU2WS7AumrZ/40jfhf4QVDMsQwqA7VEHozFRrGARJA= 8 | github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= 9 | github.com/lightningnetwork/lnd v0.0.0-20180827212353-73af09a06ae9 h1:6MKdvuQgZ4UOVJ1h9xeASz8oSUySybblkgjQq4Ebu+w= 10 | github.com/lightningnetwork/lnd v0.0.0-20180827212353-73af09a06ae9/go.mod h1:wpCSmoRQxoM/vXLtTETeBp08XnB/9/f+sjPvCJZPyA0= 11 | github.com/mattn/go-sqlite3 v1.9.0 h1:pDRiWfl+++eC2FEFRy6jXmQlvp4Yh3z1MJKg4UeYM/4= 12 | github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= 13 | github.com/op/go-logging v0.0.0-20160211212156-b2cb9fa56473 h1:J1QZwDXgZ4dJD2s19iqR9+U00OWM2kDzbf1O/fmvCWg= 14 | github.com/op/go-logging v0.0.0-20160211212156-b2cb9fa56473/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk= 15 | github.com/urfave/cli v1.20.0 h1:fDqGv3UG/4jbVl/QkFwEdddtEDjh/5Ov6X+0B/3bPaw= 16 | github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= 17 | golang.org/x/net v0.0.0-20180311174755-ae89d30ce0c6 h1:VNwI0l6D6+cM79+3XBbvypTLyFJtQP1GEgUNsEadLdY= 18 | golang.org/x/net v0.0.0-20180311174755-ae89d30ce0c6/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 19 | golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4 h1:YUO/7uOKsKeq9UokNS62b8FYywz3ker1l1vDZRCRefw= 20 | golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 21 | golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= 22 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 23 | google.golang.org/genproto v0.0.0-20180306020942-df60624c1e9b h1:XeiFoG4FHSBJUL3qKCkMrkwBFRXB+hyQiTPg82JUssI= 24 | google.golang.org/genproto v0.0.0-20180306020942-df60624c1e9b/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= 25 | google.golang.org/grpc v1.5.2 h1:b6oAqMSH36Omv3KU5KuN6qB2jaJClahvIWSmfQtfyFw= 26 | google.golang.org/grpc v1.5.2/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= 27 | -------------------------------------------------------------------------------- /cmd/tipreport/commands.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "github.com/urfave/cli" 7 | "strconv" 8 | "time" 9 | ) 10 | 11 | type tip struct { 12 | Date string 13 | Amount string 14 | Message string 15 | } 16 | 17 | // TODO: add description? 18 | var summaryCommand = cli.Command{ 19 | Name: "summary", 20 | Usage: "Shows a summary of received tips", 21 | Action: summary, 22 | } 23 | 24 | func summary(ctx *cli.Context) error { 25 | db, err := openDatabase(ctx) 26 | 27 | if err == nil { 28 | rows, err := getTips(db) 29 | 30 | if err == nil { 31 | var tips int64 32 | var sum int64 33 | 34 | var unixDate int64 35 | var amount int64 36 | var message string 37 | 38 | for rows.Next() { 39 | err = rows.Scan(&unixDate, &amount, &message) 40 | 41 | tips++ 42 | sum += amount 43 | } 44 | 45 | if err == nil { 46 | date := formatUnixDate(unixDate) 47 | 48 | // Trim hours and minutes 49 | date = date[:len(date)-6] 50 | 51 | fmt.Println("Received " + formatInt(tips) + " tips since " + date + 52 | " totalling " + formatInt(sum) + " satoshis") 53 | } 54 | 55 | } 56 | 57 | } 58 | 59 | return err 60 | } 61 | 62 | // TODO: show sender of tips? 63 | var listCommand = cli.Command{ 64 | Name: "list", 65 | Usage: "Shows all received tips", 66 | Action: list, 67 | } 68 | 69 | func list(ctx *cli.Context) error { 70 | db, err := openDatabase(ctx) 71 | 72 | if err == nil { 73 | rows, err := getTips(db) 74 | 75 | if err == nil { 76 | var tips []tip 77 | 78 | // To ensure that the grid looks right 79 | maxAmountSize := 6 80 | 81 | var unixDate int64 82 | var amount int64 83 | var message string 84 | 85 | for rows.Next() { 86 | err = rows.Scan(&unixDate, &amount, &message) 87 | 88 | amountString := formatInt(amount) 89 | 90 | tips = append(tips, tip{ 91 | Date: formatUnixDate(unixDate), 92 | Amount: amountString, 93 | Message: message, 94 | }) 95 | 96 | if amountSize := len(amountString); amountSize > maxAmountSize { 97 | maxAmountSize = amountSize 98 | } 99 | 100 | } 101 | 102 | fmt.Println("Date Amount" + getSpacing(6, maxAmountSize) + "Message") 103 | 104 | for _, tip := range tips { 105 | tipSpacing := getSpacing(len(tip.Amount), maxAmountSize) 106 | 107 | fmt.Println(tip.Date + " " + tip.Amount + tipSpacing + tip.Message) 108 | } 109 | 110 | } 111 | 112 | } 113 | 114 | return err 115 | } 116 | 117 | func getSpacing(entrySize int, maxSize int) string { 118 | spacing := " " 119 | 120 | spacingSize := maxSize - entrySize 121 | 122 | for spacingSize > 0 { 123 | spacing += " " 124 | 125 | spacingSize-- 126 | } 127 | 128 | return spacing 129 | } 130 | 131 | func formatUnixDate(unixDate int64) string { 132 | date := time.Unix(unixDate, 0) 133 | 134 | return date.Format("02-01-2006 15:04") 135 | } 136 | 137 | func formatInt(i int64) string { 138 | return strconv.FormatInt(i, 10) 139 | } 140 | 141 | func getTips(db *sql.DB) (rows *sql.Rows, err error) { 142 | return db.Query("SELECT * FROM tips ORDER BY date DESC") 143 | } 144 | -------------------------------------------------------------------------------- /frontend/lightningTip.css: -------------------------------------------------------------------------------- 1 | #lightningTip { 2 | width: 12em; 3 | 4 | padding: 1em; 5 | 6 | background-color: #212121; 7 | 8 | border-radius: 4px; 9 | 10 | color: #F5F5F5; 11 | 12 | font-size: 20px; 13 | font-family: Arial, Helvetica, sans-serif; 14 | 15 | text-align: center; 16 | } 17 | 18 | .lightningTipInput { 19 | width: 100%; 20 | 21 | display: inline-block; 22 | 23 | padding: 6px 10px; 24 | 25 | border: none; 26 | border-radius: 4px; 27 | 28 | font-size: 15px; 29 | 30 | color: #212121; 31 | 32 | background-color: #F5F5F5; 33 | 34 | outline: none; 35 | resize: none; 36 | 37 | overflow-y: hidden; 38 | } 39 | 40 | .lightningTipButton { 41 | padding: 0.4em 1em; 42 | 43 | font-size: 17px; 44 | 45 | color: #212121; 46 | 47 | background-color: #FFC83D; 48 | 49 | border: none; 50 | border-radius: 4px; 51 | 52 | outline: none; 53 | cursor: pointer; 54 | } 55 | 56 | .lightningTipButton:focus { 57 | outline: none; 58 | } 59 | 60 | .lightningTipButton::-moz-focus-inner { 61 | outline: none; 62 | 63 | border: 0; 64 | } 65 | 66 | #lightningTipLogo { 67 | margin-top: 0; 68 | margin-bottom: 0.6em; 69 | 70 | font-size: 25px; 71 | } 72 | 73 | #lightningTipInputs { 74 | margin-top: 0.8em; 75 | } 76 | 77 | #lightningTipMessage { 78 | min-height: 55px; 79 | 80 | margin-top: 0.5em; 81 | padding: 8px 10px; 82 | 83 | display: inline-block; 84 | box-sizing: border-box; 85 | 86 | text-align: left; 87 | 88 | font-family: Arial, Helvetica, sans-serif; 89 | } 90 | 91 | /* Hack for using placeholders on divs */ 92 | #lightningTipMessage:empty:before { 93 | content: attr(placeholder); 94 | color: gray; 95 | } 96 | 97 | #lightningTipGetInvoice { 98 | margin-top: 1em; 99 | } 100 | 101 | #lightningTipError { 102 | font-size: 17px; 103 | 104 | color: #F44336; 105 | } 106 | 107 | #lightningTipInvoice { 108 | margin-top: 1em; 109 | margin-bottom: 0.5em; 110 | } 111 | 112 | #lightningTipQR { 113 | margin-bottom: 0.8em; 114 | } 115 | 116 | #lightningTipTools { 117 | height: 100px; 118 | } 119 | 120 | #lightningTipCopy { 121 | border-right: 1px solid #F5F5F5; 122 | 123 | border-top-right-radius: 0; 124 | border-bottom-right-radius: 0; 125 | 126 | float: left; 127 | } 128 | 129 | #lightningTipOpen { 130 | border-top-left-radius: 0; 131 | border-bottom-left-radius: 0; 132 | 133 | float: left; 134 | } 135 | 136 | #lightningTipExpiry { 137 | padding: 0.3em 0; 138 | 139 | float: right; 140 | } 141 | 142 | #lightningTipFinished { 143 | margin-bottom: 0.2em; 144 | 145 | display: block; 146 | } 147 | 148 | .spinner { 149 | width: 12px; 150 | height: 12px; 151 | 152 | display: inline-block; 153 | 154 | border: 3px solid #F5F5F5; 155 | border-top: 3px solid #212121; 156 | border-radius: 50%; 157 | 158 | animation: spin 1.5s linear infinite; 159 | } 160 | 161 | @keyframes spin { 162 | 0% { 163 | transform: rotate(0deg); 164 | } 165 | 100% { 166 | transform: rotate(360deg); 167 | } 168 | } -------------------------------------------------------------------------------- /sample-lightningTip.conf: -------------------------------------------------------------------------------- 1 | [Application Options] 2 | # Directory for all data stored by LightningTip (config and log file) 3 | # Gets overwritten by individual settings and flags like: "logfile" and "config" 4 | # 5 | # Defaults values: 6 | # Darwin (macOS): /Users//Library/Application Support/LightningTip 7 | # Linux: /home//.lightningtip 8 | # Windows: C:\Users\\AppData\Local\LightningTip 9 | # 10 | # datadir = 11 | 12 | # Location of the log file 13 | # logfile = lightningTip.log 14 | 15 | # Log level for log file and console. Options are: debug, info, warning and error 16 | # loglevel = info 17 | 18 | # Location of the database file to store settled invoices 19 | # databasefile = 20 | 21 | 22 | # Host for the REST interface of LightningTip 23 | # resthost = localhost:8081 24 | 25 | # The domain (or IP address) you are using LightningTip from 26 | # Only needed if LightningTip is running on another port than the web server and no reverse proxy is used 27 | # Set the value to "*" to allow all domains and IP addresses 28 | # This value is used for the HTTP header: "Access-Control-Allow-Origin" 29 | # accessdomain = 30 | 31 | # It is possible to use LightningTip over HTTPS instead of HTTP 32 | # If you want to: edit "tlscertfile" and "tlskeyfile" accordingly 33 | # tlscertfile = 34 | # tlskeyfile = 35 | 36 | 37 | # After how many seconds invoices should expire 38 | # tipexpiry = 3600 39 | 40 | 41 | # If the connection to LND gets lost LightningTip will try to reconnect at the interval (in seconds) below 42 | # Set to 0 or comment out to disable 43 | # reconnectinterval = 0 44 | 45 | # If you are using LightningTip behind a firewall it could happen the connection to LND times out after a few minutes 46 | # To prevent this keepalive requests (requests which contain meaningless data) will be sent at the interval (in seconds) below 47 | # Set to 0 or comment out to disable 48 | # keepaliveinterval = 0 49 | 50 | 51 | [LND] 52 | # LightningTip should work out of the box with LND 53 | # You only have to change this settings if you edited the according settings in the LND config 54 | 55 | # Host of the gRPC interface of LND 56 | # lnd.grpchost = localhost:10009 57 | 58 | # TLS certificate for the LND gRPC and REST services 59 | # lnd.certfile = .lnd/tls.cert 60 | 61 | # Invoice macaroon file for authentication 62 | # If you are using LND version 0.4.0 or lower use the file admin.macaroon 63 | # For LND version 0.5.0 or higher the macaroon for the mainnet is preferred if it exists 64 | # Set an empty string if you disabled the usage of macaroons (not recommended) 65 | # lnd.macaroonfile = .lnd/data/chain/bitcoin/testnet/invoice.macaroon 66 | 67 | 68 | [Mail] 69 | # LightningTip can send you a notification via email when you get a tip 70 | 71 | # Email address to which notifications get sent 72 | # If an email address is not set here, no notifications will be sent 73 | # 74 | # mail.recipient = 75 | 76 | # Sender address of the notification emails 77 | # mail.sender = 78 | 79 | 80 | # SMTP server with corresponding port 81 | # 82 | # If no server is set LightningTip will try to send the mail with the command "mail" 83 | # (this command will work only on Linux with mail tools installed) 84 | # 85 | # Format is :, e.g. 86 | # mail.server = localhost:25 87 | # mail.server = remote.server.tld:465 88 | # 89 | # mail.server = 90 | 91 | # Whether SSL should be used for connecting to the SMTP server or not 92 | # mail.ssl = false 93 | 94 | # User for authenticating the SMTP connection 95 | # mail.user = 96 | 97 | # Password for authenticating the SMTP connection 98 | # mail.password = 99 | -------------------------------------------------------------------------------- /backends/lnd.go: -------------------------------------------------------------------------------- 1 | package backends 2 | 3 | import ( 4 | "context" 5 | "encoding/hex" 6 | "errors" 7 | "io" 8 | "io/ioutil" 9 | 10 | "github.com/lightningnetwork/lnd/lnrpc" 11 | "google.golang.org/grpc" 12 | "google.golang.org/grpc/credentials" 13 | "google.golang.org/grpc/metadata" 14 | ) 15 | 16 | // LND contains all values needed to be able to connect to a node 17 | type LND struct { 18 | GRPCHost string `long:"grpchost" Description:"Host of the gRPC interface of LND"` 19 | CertFile string `long:"certfile" Description:"TLS certificate for the LND gRPC and REST services"` 20 | MacaroonFile string `long:"macaroonfile" Description:"Macaroon file for authentication. Set to an empty string for no macaroon"` 21 | 22 | ctx context.Context 23 | client lnrpc.LightningClient 24 | } 25 | 26 | // Connect to a node 27 | func (lnd *LND) Connect() error { 28 | creds, err := credentials.NewClientTLSFromFile(lnd.CertFile, "") 29 | 30 | if err != nil { 31 | log.Error("Failed to read certificate for LND gRPC") 32 | 33 | return err 34 | } 35 | 36 | con, err := grpc.Dial(lnd.GRPCHost, grpc.WithTransportCredentials(creds)) 37 | 38 | if err != nil { 39 | log.Error("Failed to connect to LND gRPC server") 40 | 41 | return err 42 | } 43 | 44 | if lnd.ctx == nil { 45 | lnd.ctx = context.Background() 46 | 47 | if lnd.MacaroonFile != "" { 48 | macaroon, err := getMacaroon(lnd.MacaroonFile) 49 | 50 | if macaroon == nil && err != nil { 51 | log.Error("Failed to read macaroon file of LND: ", err.Error()) 52 | 53 | } else { 54 | lnd.ctx = metadata.NewOutgoingContext(lnd.ctx, macaroon) 55 | } 56 | 57 | } 58 | 59 | } 60 | 61 | lnd.client = lnrpc.NewLightningClient(con) 62 | 63 | return err 64 | } 65 | 66 | // GetInvoice gets and invoice from a node 67 | func (lnd *LND) GetInvoice(message string, amount int64, expiry int64) (invoice string, rHash string, err error) { 68 | var response *lnrpc.AddInvoiceResponse 69 | 70 | response, err = lnd.client.AddInvoice(lnd.ctx, &lnrpc.Invoice{ 71 | Memo: message, 72 | Value: amount, 73 | Expiry: expiry, 74 | }) 75 | 76 | if err != nil { 77 | return "", "", err 78 | } 79 | 80 | return response.PaymentRequest, hex.EncodeToString(response.RHash), err 81 | } 82 | 83 | // InvoiceSettled checks if an invoice is settled by looking it up 84 | func (lnd *LND) InvoiceSettled(rHash string) (settled bool, err error) { 85 | var invoice *lnrpc.Invoice 86 | 87 | rpcPaymentHash := lnrpc.PaymentHash{ 88 | RHash: []byte(rHash), 89 | } 90 | 91 | invoice, err = lnd.client.LookupInvoice(lnd.ctx, &rpcPaymentHash) 92 | 93 | if err != nil { 94 | return false, err 95 | } 96 | 97 | return invoice.Settled, err 98 | } 99 | 100 | // SubscribeInvoices subscribe to the invoice events of LND and calls a callback when one is settled 101 | func (lnd *LND) SubscribeInvoices(publish PublishInvoiceSettled, rescan RescanPendingInvoices) error { 102 | stream, err := lnd.client.SubscribeInvoices(lnd.ctx, &lnrpc.InvoiceSubscription{}) 103 | 104 | if err != nil { 105 | return err 106 | } 107 | 108 | wait := make(chan struct{}) 109 | 110 | go func() { 111 | for { 112 | invoice, streamErr := stream.Recv() 113 | 114 | if streamErr == io.EOF { 115 | err = errors.New("lost connection to LND gRPC") 116 | 117 | close(wait) 118 | 119 | return 120 | } 121 | 122 | if streamErr != nil { 123 | err = streamErr 124 | 125 | close(wait) 126 | 127 | return 128 | } 129 | 130 | if invoice.Settled { 131 | go publish(invoice.PaymentRequest) 132 | } 133 | 134 | } 135 | 136 | }() 137 | 138 | // Connected successfully to LND 139 | // If there are pending invoices after reconnecting they should get rescanned now 140 | rescan() 141 | 142 | <-wait 143 | 144 | return err 145 | } 146 | 147 | // KeepAliveRequest is a dummy request to make sure the connection to LND doesn't time out if 148 | // LND and LightningTip are separated with a firewall 149 | func (lnd *LND) KeepAliveRequest() error { 150 | _, err := lnd.client.GetInfo(lnd.ctx, &lnrpc.GetInfoRequest{}) 151 | 152 | return err 153 | } 154 | 155 | func getMacaroon(macaroonFile string) (macaroon metadata.MD, err error) { 156 | data, err := ioutil.ReadFile(macaroonFile) 157 | 158 | if err == nil { 159 | macaroon = metadata.Pairs("macaroon", hex.EncodeToString(data)) 160 | } 161 | 162 | return macaroon, err 163 | } 164 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LightningTip 2 | 3 | A simple way to accept tips via the Lightning Network on your website. If want to tip me you can find my instance of LightningTip [here](https://michael1011.at/lightning.html). 4 | 5 | [robclark56](https://github.com/robclark56) forked LightningTip and rewrote the backend in **PHP**. His fork is called [LightningTip-PHP](https://github.com/robclark56/lightningtip) and is a great alternative if you are not able to run the executable. 6 | 7 | 8 | 9 | ## How to install 10 | 11 | To get all necessary files for setting up LightningTip you can either [download a prebuilt version](https://github.com/michael1011/lightningtip/releases) or [compile from source](#how-to-build). 12 | 13 | LightningTip is using [LND](https://github.com/lightningnetwork/lnd) as backend. Please make sure it is installed and fully synced before you install LightningTip. 14 | 15 | The default config file location is `$HOME/.lightningtip/lightningTip.conf`. The [sample config](https://github.com/michael1011/lightningtip/blob/master/sample-lightningTip.conf) contains everything you need to know about the configuration. To use a custom config file location use the flag `--config filename`. You can use all keys in the config as command line flag. Command line flags *always* override values in the config. 16 | 17 | The next step is embedding LightningTip on your website. Upload all files excluding `lightningTip.html` to your webserver. Copy the contents of the head tag from `lightningTip.html` into the head section of the HTML file you want to show LightningTip in. The div below the head tag is LightningTip itself. Paste it into any place in the already edited HTML file on your server. 18 | 19 | There is a light theme available for LightningTip. If you want to use it **add** this to the head tag of your HTML file: 20 | 21 | ```html 22 | 23 | ``` 24 | 25 | **Do not use LightningTip on XHTML** sites. That causes some weird scaling issues. 26 | 27 | Make sure that the executable of **LightningTip is always running** in the background. It is an [API](https://github.com/michael1011/lightningtip/wiki/API-documentation) to connect LND and the widget on your website. **Do not open the URL you are running LightingTip on in your browser.** All this will do is show an error. 28 | 29 | If you are not running LightningTip on the same domain or IP address as your webserver, or not on port 8081, change the variable `requestUrl` (which is in the first line) in the file `lightningTip.js` accordingly. 30 | 31 | When using LightningTip behind a proxy make sure the proxy supports [EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource). Without support for it the users will not see the "Thank you for your tip!" screen. 32 | 33 | That's it! The only two things you need to take care about is keeping the LND node online and making sure that your incoming channels are sufficiently funded to receive tips. LightningTip will take care of everything else. 34 | 35 | ## How to build 36 | 37 | First of all make sure [Golang](https://golang.org/) version 1.11 or newer is correctly installed. 38 | 39 | ```bash 40 | go get -d github.com/michael1011/lightningtip 41 | cd $GOPATH/src/github.com/michael1011/lightningtip 42 | 43 | make && make install 44 | ``` 45 | 46 | To start run `$GOPATH/bin/lightningtip` or follow the instructions below to setup a service to run LightningTip automatically. 47 | 48 | ## Upgrading 49 | 50 | Make sure you stop any running LightningTip process before upgrading, then pull from source as follows: 51 | 52 | ```bash 53 | cd $GOPATH/src/github.com/michael1011/lightningtip 54 | git pull 55 | 56 | make && make install 57 | ``` 58 | 59 | ## Starting LightningTip Automatically 60 | 61 | LightningTip can be started automatically via Systemd, or Supervisord, as outlined in the following wiki documentation: 62 | 63 | * [Running LightningTip with systemd](https://github.com/michael1011/lightningtip/wiki/Running-LightningTip-with-systemd) 64 | * [Running LightningTip with supervisord](https://github.com/michael1011/lightningtip/wiki/Running-LightningTip-with-supervisord) 65 | 66 | ## Reverse Proxy Recipes 67 | 68 | In instances where the default LightningTip SSL configuration options are not working, you may want to explore running a reverse proxy to LightningTip as outlined in the following wiki documentation: 69 | 70 | * [LightningTip via Apache2 reverse proxy](https://github.com/michael1011/lightningtip/wiki/LightningTip-via-Apache2-reverse-proxy) 71 | * [LightningTip via Nginx reverse proxy](https://github.com/michael1011/lightningtip/wiki/LightningTip-via-Nginx-reverse-proxy) 72 | -------------------------------------------------------------------------------- /notifications/mail.go: -------------------------------------------------------------------------------- 1 | package notifications 2 | 3 | import ( 4 | "crypto/tls" 5 | "fmt" 6 | "net" 7 | "net/smtp" 8 | "os/exec" 9 | "strconv" 10 | ) 11 | 12 | // Mail contains all values needed to be able to send a mail 13 | type Mail struct { 14 | Recipient string `long:"recipient" Description:"Email address to which notifications get sent"` 15 | Sender string `long:"sender" Description:"Email address from which notifications get sent"` 16 | 17 | SMTPServer string `long:"server" Description:"SMTP server with port for sending mails"` 18 | 19 | SMTPSSL bool `long:"ssl" Description:"Whether SSL should be used or not"` 20 | SMTPUser string `long:"user" Description:"User for authenticating the SMTP connection"` 21 | SMTPPassword string `long:"password" Description:"Password for authenticating the SMTP connection"` 22 | } 23 | 24 | const sendmail = "/usr/bin/mail" 25 | 26 | const newLine = "\r\n" 27 | 28 | const subject = "You received a tip" 29 | 30 | // SendMail sends a mail 31 | func (mail *Mail) SendMail(amount int64, message string) { 32 | body := "You received a tip of " + strconv.FormatInt(amount, 10) + " satoshis" 33 | 34 | if message != "" { 35 | body += " with the message \"" + message + "\"" 36 | } 37 | 38 | if mail.SMTPServer == "" { 39 | // "mail" command will be used for sending 40 | mail.sendMailCommand(body) 41 | 42 | } else { 43 | // SMTP server will be used 44 | mail.sendMailSMTP(body) 45 | } 46 | 47 | } 48 | 49 | // Sends a mail with the "mail" command 50 | func (mail *Mail) sendMailCommand(body string) { 51 | var cmd *exec.Cmd 52 | 53 | if mail.Sender == "" { 54 | cmd = exec.Command(sendmail, "-s", subject, mail.Recipient) 55 | 56 | } else { 57 | // Append "From" header 58 | cmd = exec.Command(sendmail, "-s", subject, "-a", "From: "+mail.Sender, mail.Recipient) 59 | } 60 | 61 | writer, err := cmd.StdinPipe() 62 | 63 | if err == nil { 64 | err = cmd.Start() 65 | 66 | if err == nil { 67 | _, err = writer.Write([]byte(body)) 68 | 69 | if err == nil { 70 | err = writer.Close() 71 | 72 | if err == nil { 73 | err = cmd.Wait() 74 | 75 | if err == nil { 76 | logSent() 77 | 78 | return 79 | } 80 | 81 | } 82 | 83 | } 84 | 85 | } 86 | 87 | } 88 | 89 | logSendingFailed(err) 90 | } 91 | 92 | func (mail *Mail) sendMailSMTP(body string) { 93 | // Because the SMTP method doesn't have a dedicated field for the subject 94 | // it will be in the body of the message 95 | body = "Subject: " + subject + newLine + body 96 | 97 | var auth smtp.Auth 98 | 99 | host, _, err := net.SplitHostPort(mail.SMTPServer) 100 | 101 | if err != nil { 102 | log.Error("Failed to parse host of SMTP server: " + mail.SMTPServer) 103 | 104 | return 105 | } 106 | 107 | if mail.SMTPUser != "" { 108 | // If there are credentials they are used 109 | auth = smtp.PlainAuth( 110 | "", 111 | mail.SMTPUser, 112 | mail.SMTPPassword, 113 | host, 114 | ) 115 | 116 | } 117 | 118 | if mail.SMTPSSL { 119 | body = "From: " + mail.Sender + newLine + "To: " + mail.Recipient + newLine + body 120 | 121 | tlsConfig := &tls.Config{ 122 | ServerName: host, 123 | InsecureSkipVerify: true, 124 | } 125 | 126 | con, err := tls.Dial("tcp", mail.SMTPServer, tlsConfig) 127 | 128 | defer con.Close() 129 | 130 | if err != nil { 131 | log.Error("Failed to connect to connect to SMTP server: " + fmt.Sprint(err)) 132 | 133 | return 134 | } 135 | 136 | client, err := smtp.NewClient(con, host) 137 | 138 | defer client.Close() 139 | 140 | if err != nil { 141 | log.Error("Failed to create SMTP client: " + fmt.Sprint(err)) 142 | 143 | return 144 | } 145 | 146 | err = client.Auth(auth) 147 | 148 | if err != nil { 149 | log.Error("Failed to authenticate SMTP client: " + fmt.Sprint(err)) 150 | 151 | return 152 | } 153 | 154 | err = client.Mail(mail.Sender) 155 | err = client.Rcpt(mail.Recipient) 156 | 157 | writer, err := client.Data() 158 | 159 | defer writer.Close() 160 | 161 | if err == nil { 162 | _, err = writer.Write([]byte(body)) 163 | } 164 | 165 | if err == nil { 166 | logSent() 167 | 168 | } else { 169 | logSendingFailed(err) 170 | } 171 | 172 | } else { 173 | err := smtp.SendMail( 174 | mail.SMTPServer, 175 | auth, 176 | mail.Sender, 177 | []string{mail.Recipient}, 178 | []byte(body), 179 | ) 180 | 181 | if err == nil { 182 | logSent() 183 | 184 | } else { 185 | logSendingFailed(err) 186 | } 187 | 188 | } 189 | 190 | } 191 | 192 | func logSent() { 193 | log.Debug("Sent email") 194 | } 195 | 196 | func logSendingFailed(err error) { 197 | log.Error("Failed to send mail: " + fmt.Sprint(err)) 198 | } 199 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "os" 7 | "os/user" 8 | "path" 9 | "path/filepath" 10 | "runtime" 11 | "strings" 12 | 13 | flags "github.com/jessevdk/go-flags" 14 | "github.com/michael1011/lightningtip/backends" 15 | "github.com/michael1011/lightningtip/database" 16 | "github.com/michael1011/lightningtip/notifications" 17 | "github.com/michael1011/lightningtip/version" 18 | logging "github.com/op/go-logging" 19 | ) 20 | 21 | const ( 22 | defaultConfigFile = "lightningTip.conf" 23 | 24 | defaultDataDir = "LightningTip" 25 | 26 | defaultLogFile = "lightningTip.log" 27 | defaultLogLevel = "info" 28 | 29 | defaultDatabaseFile = "tips.db" 30 | 31 | defaultRESTHost = "0.0.0.0:8081" 32 | defaultTLSCertFile = "" 33 | defaultTLSKeyFile = "" 34 | 35 | defaultAccessDomain = "" 36 | 37 | defaultTipExpiry = 3600 38 | 39 | defaultReconnectInterval = 0 40 | defaultKeepaliveInterval = 0 41 | 42 | defaultLndGRPCHost = "localhost:10009" 43 | defaultLndCertFile = "tls.cert" 44 | defaultMacaroonFile = "invoice.macaroon" 45 | 46 | defaultRecipient = "" 47 | defaultSender = "" 48 | 49 | defaultSTMPServer = "" 50 | defaultSTMPSSL = false 51 | defaultSTMPUser = "" 52 | defaultSTMPPassword = "" 53 | ) 54 | 55 | type helpOptions struct { 56 | ShowHelp bool `long:"help" short:"h" description:"Display this help message"` 57 | ShowVersion bool `long:"version" short:"v" description:"Display version and exit"` 58 | } 59 | 60 | type config struct { 61 | ConfigFile string `long:"config" description:"Location of the config file"` 62 | 63 | DataDir string `long:"datadir" description:"Location of the data stored by LightningTip"` 64 | 65 | LogFile string `long:"logfile" description:"Location of the log file"` 66 | LogLevel string `long:"loglevel" description:"Log level: debug, info, warning, error"` 67 | 68 | DatabaseFile string `long:"databasefile" description:"Location of the database file to store settled invoices"` 69 | 70 | RESTHost string `long:"resthost" description:"Host for the REST interface of LightningTip"` 71 | TLSCertFile string `long:"tlscertfile" description:"Certificate for using LightningTip via HTTPS"` 72 | TLSKeyFile string `long:"tlskeyfile" description:"Certificate for using LightningTip via HTTPS"` 73 | 74 | AccessDomain string `long:"accessdomain" description:"The domain you are using LightningTip from"` 75 | 76 | TipExpiry int64 `long:"tipexpiry" description:"Invoice expiry time in seconds"` 77 | 78 | ReconnectInterval int64 `long:"reconnectinterval" description:"Reconnect interval to LND in seconds"` 79 | KeepAliveInterval int64 `long:"keepaliveinterval" description:"Send a dummy request to LND to prevent timeouts "` 80 | 81 | LND *backends.LND `group:"LND" namespace:"lnd"` 82 | 83 | Mail *notifications.Mail `group:"Mail" namespace:"mail"` 84 | 85 | Help *helpOptions `group:"Help Options"` 86 | } 87 | 88 | var cfg config 89 | 90 | var backend backends.Backend 91 | 92 | func initConfig() { 93 | cfg = config{ 94 | ConfigFile: path.Join(getDefaultDataDir(), defaultConfigFile), 95 | 96 | DataDir: getDefaultDataDir(), 97 | 98 | LogFile: path.Join(getDefaultDataDir(), defaultLogFile), 99 | LogLevel: defaultLogLevel, 100 | 101 | DatabaseFile: path.Join(getDefaultDataDir(), defaultDatabaseFile), 102 | 103 | RESTHost: defaultRESTHost, 104 | TLSCertFile: defaultTLSCertFile, 105 | TLSKeyFile: defaultTLSKeyFile, 106 | 107 | AccessDomain: defaultAccessDomain, 108 | 109 | TipExpiry: defaultTipExpiry, 110 | 111 | ReconnectInterval: defaultReconnectInterval, 112 | KeepAliveInterval: defaultKeepaliveInterval, 113 | 114 | LND: &backends.LND{ 115 | GRPCHost: defaultLndGRPCHost, 116 | CertFile: path.Join(getDefaultLndDir(), defaultLndCertFile), 117 | MacaroonFile: getDefaultMacaroon(), 118 | }, 119 | 120 | Mail: ¬ifications.Mail{ 121 | Recipient: defaultRecipient, 122 | Sender: defaultSender, 123 | 124 | SMTPServer: defaultSTMPServer, 125 | SMTPSSL: defaultSTMPSSL, 126 | SMTPUser: defaultSTMPUser, 127 | SMTPPassword: defaultSTMPPassword, 128 | }, 129 | } 130 | 131 | // Ignore unknown flags the first time parsing command line flags to prevent showing the unknown flag error twice 132 | parser := flags.NewParser(&cfg, flags.IgnoreUnknown) 133 | parser.Parse() 134 | 135 | errFile := flags.IniParse(cfg.ConfigFile, &cfg) 136 | 137 | // If the user just wants to see the version initializing everything else is irrelevant 138 | if cfg.Help.ShowVersion { 139 | version.PrintVersion() 140 | os.Exit(0) 141 | } 142 | 143 | // If the user just wants to see the help message 144 | if cfg.Help.ShowHelp { 145 | parser.WriteHelp(os.Stdout) 146 | os.Exit(0) 147 | } 148 | 149 | // Parse flags again to override config file 150 | _, err := flags.Parse(&cfg) 151 | 152 | // Default log level if parsing fails 153 | logLevel := logging.DEBUG 154 | 155 | switch strings.ToLower(cfg.LogLevel) { 156 | case "info": 157 | logLevel = logging.INFO 158 | 159 | case "warning": 160 | logLevel = logging.WARNING 161 | 162 | case "error": 163 | logLevel = logging.ERROR 164 | } 165 | 166 | // Create data directory 167 | var errDataDir error 168 | var dataDirCreated bool 169 | 170 | if _, err := os.Stat(getDefaultDataDir()); os.IsNotExist(err) { 171 | errDataDir = os.Mkdir(getDefaultDataDir(), 0700) 172 | 173 | dataDirCreated = true 174 | } 175 | 176 | errLogFile := initLogger(cfg.LogFile, logLevel) 177 | 178 | // Show error messages 179 | if err != nil { 180 | log.Error("Failed to parse command line flags") 181 | } 182 | 183 | if errDataDir != nil { 184 | log.Error("Could not create data directory") 185 | log.Debug("Data directory path: " + getDefaultDataDir()) 186 | 187 | } else if dataDirCreated { 188 | log.Debug("Created data directory: " + getDefaultDataDir()) 189 | } 190 | 191 | if errFile != nil { 192 | log.Warning("Failed to parse config file: " + fmt.Sprint(errFile)) 193 | } else { 194 | log.Debug("Parsed config file: " + cfg.ConfigFile) 195 | } 196 | 197 | if errLogFile != nil { 198 | log.Error("Failed to initialize log file: " + fmt.Sprint(err)) 199 | 200 | } else { 201 | log.Debug("Initialized log file: " + cfg.LogFile) 202 | } 203 | 204 | database.UseLogger(*log) 205 | backends.UseLogger(*log) 206 | notifications.UseLogger(*log) 207 | 208 | backend = cfg.LND 209 | } 210 | 211 | func getDefaultDataDir() (dir string) { 212 | homeDir := getHomeDir() 213 | 214 | switch runtime.GOOS { 215 | case "windows": 216 | fallthrough 217 | 218 | case "darwin": 219 | dir = path.Join(homeDir, defaultDataDir) 220 | 221 | default: 222 | dir = path.Join(homeDir, "."+strings.ToLower(defaultDataDir)) 223 | } 224 | 225 | return cleanPath(dir) 226 | } 227 | 228 | // If the mainnet macaroon does exists it is preffered over all others 229 | func getDefaultMacaroon() string { 230 | networksDir := filepath.Join(getDefaultLndDir(), "/data/chain/bitcoin/") 231 | mainnetMacaroon := filepath.Join(networksDir, "mainnet/", defaultMacaroonFile) 232 | 233 | if _, err := os.Stat(mainnetMacaroon); err == nil { 234 | return mainnetMacaroon 235 | } 236 | 237 | networks, err := ioutil.ReadDir(networksDir) 238 | 239 | if err == nil && len(networks) != 0 { 240 | for _, subDir := range networks { 241 | if subDir.IsDir() { 242 | return filepath.Join(networksDir, networks[0].Name(), defaultMacaroonFile) 243 | } 244 | } 245 | } 246 | 247 | return "" 248 | } 249 | 250 | func getDefaultLndDir() (dir string) { 251 | homeDir := getHomeDir() 252 | 253 | switch runtime.GOOS { 254 | case "darwin": 255 | fallthrough 256 | 257 | case "windows": 258 | dir = path.Join(homeDir, "Lnd") 259 | 260 | default: 261 | dir = path.Join(homeDir, ".lnd") 262 | } 263 | 264 | return cleanPath(dir) 265 | } 266 | 267 | func getHomeDir() (dir string) { 268 | usr, err := user.Current() 269 | 270 | if err == nil { 271 | switch runtime.GOOS { 272 | case "darwin": 273 | dir = path.Join(usr.HomeDir, "Library/Application Support") 274 | 275 | case "windows": 276 | dir = path.Join(usr.HomeDir, "AppData/Local") 277 | 278 | default: 279 | dir = usr.HomeDir 280 | } 281 | 282 | } 283 | 284 | return cleanPath(dir) 285 | } 286 | 287 | func cleanPath(path string) string { 288 | path = filepath.Clean(os.ExpandEnv(path)) 289 | 290 | return strings.Replace(path, "\\", "/", -1) 291 | } 292 | -------------------------------------------------------------------------------- /lightningtip.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "net/http" 8 | "os" 9 | "strconv" 10 | "strings" 11 | "time" 12 | 13 | "github.com/donovanhide/eventsource" 14 | "github.com/michael1011/lightningtip/database" 15 | ) 16 | 17 | // PendingInvoice is for keeping alist of unpaid invoices 18 | type PendingInvoice struct { 19 | Invoice string 20 | Amount int64 21 | Message string 22 | RHash string 23 | Expiry time.Time 24 | } 25 | 26 | const eventChannel = "invoiceSettled" 27 | 28 | const couldNotParseError = "Could not parse values from request" 29 | 30 | var eventSrv *eventsource.Server 31 | 32 | var pendingInvoices []PendingInvoice 33 | 34 | // To use the pendingInvoice type as event for the EventSource stream 35 | 36 | // Id gets the ID of the event which is not neede in our scenario 37 | func (pending PendingInvoice) Id() string { return "" } // nolint: golint 38 | 39 | // Event is for using a different type of event than "data" 40 | func (pending PendingInvoice) Event() string { return "" } 41 | 42 | // Data tells EventSource what data to write 43 | func (pending PendingInvoice) Data() string { return pending.RHash } 44 | 45 | type invoiceRequest struct { 46 | Amount int64 47 | Message string 48 | } 49 | 50 | type invoiceResponse struct { 51 | Invoice string 52 | RHash string 53 | Expiry int64 54 | } 55 | 56 | type invoiceSettledRequest struct { 57 | RHash string 58 | } 59 | 60 | type invoiceSettledResponse struct { 61 | Settled bool 62 | } 63 | 64 | type errorResponse struct { 65 | Error string 66 | } 67 | 68 | // TODO: add version flag 69 | // TODO: don't start when "--help" flag is provided 70 | func main() { 71 | initLog() 72 | 73 | initConfig() 74 | 75 | dbErr := database.InitDatabase(cfg.DatabaseFile) 76 | 77 | if dbErr != nil { 78 | log.Error("Failed to initialize database: " + fmt.Sprint(dbErr)) 79 | 80 | os.Exit(1) 81 | 82 | } else { 83 | log.Debug("Opened SQLite database: " + cfg.DatabaseFile) 84 | } 85 | 86 | err := backend.Connect() 87 | 88 | if err == nil { 89 | log.Info("Starting EventSource stream") 90 | 91 | eventSrv = eventsource.NewServer() 92 | 93 | defer eventSrv.Close() 94 | 95 | http.Handle("/", handleHeaders(notFoundHandler)) 96 | http.Handle("/getinvoice", handleHeaders(getInvoiceHandler)) 97 | http.Handle("/eventsource", handleHeaders(eventSrv.Handler(eventChannel))) 98 | 99 | // Alternative for browsers which don't support EventSource (Internet Explorer and Edge) 100 | http.Handle("/invoicesettled", handleHeaders(invoiceSettledHandler)) 101 | 102 | log.Debug("Starting ticker to clear expired invoices") 103 | 104 | // A bit longer than the expiry time to make sure the invoice doesn't show as settled if it isn't (affects just invoiceSettledHandler) 105 | expiryInterval := time.Duration(cfg.TipExpiry + 10) 106 | expiryTicker := time.Tick(expiryInterval * time.Second) 107 | 108 | go func() { 109 | for { 110 | select { 111 | case <-expiryTicker: 112 | now := time.Now() 113 | 114 | for index := len(pendingInvoices) - 1; index >= 0; index-- { 115 | invoice := pendingInvoices[index] 116 | 117 | if now.Sub(invoice.Expiry) > 0 { 118 | log.Debug("Invoice expired: " + invoice.Invoice) 119 | 120 | pendingInvoices = append(pendingInvoices[:index], pendingInvoices[index+1:]...) 121 | } 122 | 123 | } 124 | 125 | } 126 | 127 | } 128 | 129 | }() 130 | 131 | go func() { 132 | subscribeToInvoices() 133 | }() 134 | 135 | if cfg.KeepAliveInterval > 0 { 136 | log.Debug("Starting ticker to send keepalive requests") 137 | 138 | interval := time.Duration(cfg.KeepAliveInterval) 139 | keepAliveTicker := time.Tick(interval * time.Second) 140 | 141 | go func() { 142 | for { 143 | select { 144 | case <-keepAliveTicker: 145 | backend.KeepAliveRequest() 146 | } 147 | } 148 | }() 149 | 150 | } 151 | 152 | log.Info("Starting HTTP server") 153 | 154 | go func() { 155 | var err error 156 | 157 | if cfg.TLSCertFile != "" && cfg.TLSKeyFile != "" { 158 | err = http.ListenAndServeTLS(cfg.RESTHost, cfg.TLSCertFile, cfg.TLSKeyFile, nil) 159 | 160 | } else { 161 | err = http.ListenAndServe(cfg.RESTHost, nil) 162 | } 163 | 164 | if err != nil { 165 | log.Errorf("Failed to start HTTP server: " + fmt.Sprint(err)) 166 | 167 | os.Exit(1) 168 | } 169 | 170 | }() 171 | 172 | select {} 173 | 174 | } 175 | 176 | } 177 | 178 | func subscribeToInvoices() { 179 | log.Info("Subscribing to invoices") 180 | 181 | err := backend.SubscribeInvoices(publishInvoiceSettled, rescanPendingInvoices) 182 | 183 | log.Error("Failed to subscribe to invoices: " + fmt.Sprint(err)) 184 | 185 | if err != nil { 186 | if cfg.ReconnectInterval != 0 { 187 | reconnectToBackend() 188 | 189 | } else { 190 | os.Exit(1) 191 | } 192 | 193 | } 194 | 195 | } 196 | 197 | func reconnectToBackend() { 198 | time.Sleep(time.Duration(cfg.ReconnectInterval) * time.Second) 199 | 200 | log.Info("Trying to reconnect to LND") 201 | 202 | backend = cfg.LND 203 | 204 | err := backend.Connect() 205 | 206 | if err == nil { 207 | err = backend.KeepAliveRequest() 208 | 209 | // The default macaroon file used by LightningTip "invoice.macaroon" allows only creating and checking status of invoices 210 | // The keep alive request doesn't have to be successful as long as it can establish a connection to LND 211 | if err == nil || fmt.Sprint(err) == "rpc error: code = Unknown desc = permission denied" { 212 | log.Info("Reconnected to LND") 213 | 214 | subscribeToInvoices() 215 | } 216 | 217 | } 218 | 219 | log.Info("Connection failed") 220 | 221 | log.Debug(fmt.Sprint(err)) 222 | 223 | reconnectToBackend() 224 | } 225 | 226 | func rescanPendingInvoices() { 227 | if len(pendingInvoices) > 0 { 228 | log.Debug("Rescanning pending invoices") 229 | 230 | for _, invoice := range pendingInvoices { 231 | settled, err := backend.InvoiceSettled(invoice.RHash) 232 | 233 | if err == nil { 234 | if settled { 235 | publishInvoiceSettled(invoice.Invoice) 236 | } 237 | 238 | } else { 239 | log.Warning("Failed to check if invoice settled: " + fmt.Sprint(err)) 240 | } 241 | 242 | } 243 | 244 | } 245 | 246 | } 247 | 248 | func publishInvoiceSettled(invoice string) { 249 | for index, settled := range pendingInvoices { 250 | if settled.Invoice == invoice { 251 | log.Info("Invoice settled: " + invoice) 252 | 253 | eventSrv.Publish([]string{eventChannel}, settled) 254 | 255 | database.AddSettledInvoice(settled.Amount, settled.Message) 256 | 257 | if cfg.Mail.Recipient != "" { 258 | cfg.Mail.SendMail(settled.Amount, settled.Message) 259 | } 260 | 261 | pendingInvoices = append(pendingInvoices[:index], pendingInvoices[index+1:]...) 262 | 263 | break 264 | } 265 | 266 | } 267 | 268 | } 269 | 270 | func invoiceSettledHandler(writer http.ResponseWriter, request *http.Request) { 271 | errorMessage := couldNotParseError 272 | 273 | if request.Method == http.MethodPost { 274 | var body invoiceSettledRequest 275 | 276 | data, _ := ioutil.ReadAll(request.Body) 277 | 278 | err := json.Unmarshal(data, &body) 279 | 280 | if err == nil { 281 | if body.RHash != "" { 282 | settled := true 283 | 284 | for _, pending := range pendingInvoices { 285 | if pending.RHash == body.RHash { 286 | settled = false 287 | 288 | break 289 | } 290 | 291 | } 292 | 293 | writer.Write(marshalJSON(invoiceSettledResponse{ 294 | Settled: settled, 295 | })) 296 | 297 | return 298 | 299 | } 300 | 301 | } 302 | 303 | } 304 | 305 | log.Error(errorMessage) 306 | 307 | writeError(writer, errorMessage) 308 | } 309 | 310 | func getInvoiceHandler(writer http.ResponseWriter, request *http.Request) { 311 | errorMessage := couldNotParseError 312 | 313 | if request.Method == http.MethodPost { 314 | var body invoiceRequest 315 | 316 | data, _ := ioutil.ReadAll(request.Body) 317 | 318 | err := json.Unmarshal(data, &body) 319 | 320 | if err == nil { 321 | if body.Amount != 0 { 322 | invoice, paymentHash, err := backend.GetInvoice(body.Message, body.Amount, cfg.TipExpiry) 323 | 324 | if err == nil { 325 | logMessage := "Created invoice with amount of " + strconv.FormatInt(body.Amount, 10) + " satoshis" 326 | 327 | if body.Message != "" { 328 | // Deletes new lines at the end of the messages 329 | body.Message = strings.TrimSuffix(body.Message, "\n") 330 | 331 | logMessage += " with message \"" + body.Message + "\"" 332 | } 333 | 334 | expiryDuration := time.Duration(cfg.TipExpiry) * time.Second 335 | 336 | log.Info(logMessage) 337 | 338 | pendingInvoices = append(pendingInvoices, PendingInvoice{ 339 | Invoice: invoice, 340 | Amount: body.Amount, 341 | Message: body.Message, 342 | RHash: string(paymentHash), 343 | Expiry: time.Now().Add(expiryDuration), 344 | }) 345 | 346 | writer.Write(marshalJSON(invoiceResponse{ 347 | Invoice: invoice, 348 | RHash: paymentHash, 349 | Expiry: cfg.TipExpiry, 350 | })) 351 | 352 | return 353 | } 354 | 355 | errorMessage = "Failed to create invoice" 356 | 357 | // This is way too hacky 358 | // Maybe a cast to the gRPC error and get its error message directly 359 | if fmt.Sprint(err)[:47] == "rpc error: code = Unknown desc = memo too large" { 360 | errorMessage += ": message too long" 361 | } 362 | 363 | } 364 | 365 | } 366 | 367 | } 368 | 369 | log.Error(errorMessage) 370 | 371 | writeError(writer, errorMessage) 372 | } 373 | 374 | func notFoundHandler(writer http.ResponseWriter, request *http.Request) { 375 | if request.RequestURI == "/" { 376 | writeError(writer, "This is an API to connect LND and your website. You should not open this in your browser") 377 | 378 | } else { 379 | writeError(writer, "Not found") 380 | } 381 | } 382 | 383 | func handleHeaders(handler func(w http.ResponseWriter, r *http.Request)) http.Handler { 384 | return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { 385 | if cfg.AccessDomain != "" { 386 | writer.Header().Add("Access-Control-Allow-Origin", cfg.AccessDomain) 387 | } 388 | 389 | handler(writer, request) 390 | }) 391 | } 392 | 393 | func writeError(writer http.ResponseWriter, message string) { 394 | writer.WriteHeader(http.StatusBadRequest) 395 | 396 | writer.Write(marshalJSON(errorResponse{ 397 | Error: message, 398 | })) 399 | } 400 | 401 | func marshalJSON(data interface{}) []byte { 402 | response, _ := json.MarshalIndent(data, "", " ") 403 | 404 | return response 405 | } 406 | -------------------------------------------------------------------------------- /frontend/lightningTip.js: -------------------------------------------------------------------------------- 1 | // Edit this variable if you are not running LightningTip on the same domain or IP address as your webserver or not on port 8081 2 | // Don't forget the "/" at the end! 3 | var requestUrl = window.location.protocol + "//" + window.location.hostname + ":8081/"; 4 | 5 | // Used for development 6 | if (window.location.protocol === "file:") { 7 | requestUrl = "http://localhost:8081/" 8 | } 9 | 10 | // To prohibit multiple requests at the same time 11 | var requestPending = false; 12 | 13 | var invoice; 14 | var qrCode; 15 | 16 | var defaultGetInvoice; 17 | 18 | // Data capacities for QR codes with mode byte and error correction level L (7%) 19 | // Shortest invoice: 194 characters 20 | // Longest invoice: 1223 characters (as far as I know) 21 | var qrCodeDataCapacities = [ 22 | {"typeNumber": 9, "capacity": 230}, 23 | {"typeNumber": 10, "capacity": 271}, 24 | {"typeNumber": 11, "capacity": 321}, 25 | {"typeNumber": 12, "capacity": 367}, 26 | {"typeNumber": 13, "capacity": 425}, 27 | {"typeNumber": 14, "capacity": 458}, 28 | {"typeNumber": 15, "capacity": 520}, 29 | {"typeNumber": 16, "capacity": 586}, 30 | {"typeNumber": 17, "capacity": 644}, 31 | {"typeNumber": 18, "capacity": 718}, 32 | {"typeNumber": 19, "capacity": 792}, 33 | {"typeNumber": 20, "capacity": 858}, 34 | {"typeNumber": 21, "capacity": 929}, 35 | {"typeNumber": 22, "capacity": 1003}, 36 | {"typeNumber": 23, "capacity": 1091}, 37 | {"typeNumber": 24, "capacity": 1171}, 38 | {"typeNumber": 25, "capacity": 1273} 39 | ]; 40 | 41 | // TODO: solve this without JavaScript 42 | // Fixes weird bug which moved the button up one pixel when its content was changed 43 | window.onload = function () { 44 | var button = document.getElementById("lightningTipGetInvoice"); 45 | 46 | button.style.height = (button.clientHeight + 1) + "px"; 47 | button.style.width = (button.clientWidth + 1) + "px"; 48 | }; 49 | 50 | // TODO: show invoice even if JavaScript is disabled 51 | // TODO: fix scaling on phones 52 | // TODO: show price in dollar? 53 | function getInvoice() { 54 | if (!requestPending) { 55 | requestPending = true; 56 | 57 | var tipValue = document.getElementById("lightningTipAmount"); 58 | 59 | if (tipValue.value !== "") { 60 | if (!isNaN(tipValue.value)) { 61 | var data = JSON.stringify({"Amount": parseInt(tipValue.value), "Message": document.getElementById("lightningTipMessage").innerText}); 62 | 63 | var request = new XMLHttpRequest(); 64 | 65 | request.onreadystatechange = function () { 66 | if (request.readyState === 4) { 67 | try { 68 | var json = JSON.parse(request.responseText); 69 | 70 | if (request.status === 200) { 71 | console.log("Got invoice: " + json.Invoice); 72 | console.log("Invoice expires in: " + json.Expiry); 73 | console.log("Got rHash of invoice: " + json.RHash); 74 | 75 | console.log("Starting listening for invoice to get settled"); 76 | 77 | listenInvoiceSettled(json.RHash); 78 | 79 | invoice = json.Invoice; 80 | 81 | // Update UI 82 | var wrapper = document.getElementById("lightningTip"); 83 | 84 | wrapper.innerHTML = "Your tip request"; 85 | wrapper.innerHTML += ""; 86 | wrapper.innerHTML += "
"; 87 | 88 | wrapper.innerHTML += "
" + 89 | "" + 90 | "" + 91 | "" + 92 | "
"; 93 | 94 | starTimer(json.Expiry, document.getElementById("lightningTipExpiry")); 95 | 96 | // Fixes bug which caused the content of #lightningTipTools to be visually outside of #lightningTip 97 | document.getElementById("lightningTipTools").style.height = document.getElementById("lightningTipCopy").clientHeight + "px"; 98 | 99 | document.getElementById("lightningTipOpen").onclick = function () { 100 | location.href = "lightning:" + json.Invoice; 101 | }; 102 | 103 | showQRCode(); 104 | 105 | } else { 106 | showErrorMessage(json.Error); 107 | } 108 | 109 | } catch (exception) { 110 | console.error(exception); 111 | 112 | showErrorMessage("Failed to reach backend"); 113 | } 114 | 115 | requestPending = false; 116 | } 117 | 118 | }; 119 | 120 | request.open("POST", requestUrl + "getinvoice", true); 121 | request.send(data); 122 | 123 | var button = document.getElementById("lightningTipGetInvoice"); 124 | 125 | defaultGetInvoice = button.innerHTML; 126 | 127 | button.innerHTML = "
"; 128 | 129 | } else { 130 | showErrorMessage("Tip amount must be a number"); 131 | } 132 | 133 | } else { 134 | showErrorMessage("No tip amount set"); 135 | } 136 | 137 | } else { 138 | console.warn("Last request still pending"); 139 | } 140 | 141 | } 142 | 143 | function listenInvoiceSettled(rHash) { 144 | try { 145 | var eventSrc = new EventSource(requestUrl + "eventsource"); 146 | 147 | eventSrc.onmessage = function (event) { 148 | if (event.data === rHash) { 149 | console.log("Invoice settled"); 150 | 151 | eventSrc.close(); 152 | 153 | showThankYouScreen(); 154 | } 155 | 156 | }; 157 | 158 | } catch (e) { 159 | console.error(e); 160 | console.warn("Your browser does not support EventSource. Sending a request to the server every two second to check if the invoice settled"); 161 | 162 | var interval = setInterval(function () { 163 | if (!requestPending) { 164 | requestPending = true; 165 | 166 | var request = new XMLHttpRequest(); 167 | 168 | request.onreadystatechange = function () { 169 | if (request.readyState === 4) { 170 | if (request.status === 200) { 171 | var json = JSON.parse(request.responseText); 172 | 173 | if (json.Settled) { 174 | console.log("Invoice settled"); 175 | 176 | clearInterval(interval); 177 | 178 | showThankYouScreen(); 179 | } 180 | 181 | } 182 | 183 | requestPending = false; 184 | } 185 | 186 | }; 187 | 188 | request.open("POST", requestUrl + "invoicesettled", true); 189 | request.send(JSON.stringify({"RHash": rHash})); 190 | } 191 | 192 | }, 2000); 193 | 194 | } 195 | 196 | } 197 | 198 | function showThankYouScreen() { 199 | var wrapper = document.getElementById("lightningTip"); 200 | 201 | wrapper.innerHTML = "

"; 202 | wrapper.innerHTML += "Thank you for your tip!"; 203 | } 204 | 205 | function starTimer(duration, element) { 206 | showTimer(duration, element); 207 | 208 | var interval = setInterval(function () { 209 | if (duration > 1) { 210 | duration--; 211 | 212 | showTimer(duration, element); 213 | 214 | } else { 215 | showExpired(); 216 | 217 | clearInterval(interval); 218 | } 219 | 220 | }, 1000); 221 | 222 | } 223 | 224 | function showTimer(duration, element) { 225 | var seconds = Math.floor(duration % 60); 226 | var minutes = Math.floor((duration / 60) % 60); 227 | var hours = Math.floor((duration / (60 * 60)) % 24); 228 | 229 | seconds = addLeadingZeros(seconds); 230 | minutes = addLeadingZeros(minutes); 231 | 232 | if (hours > 0) { 233 | element.innerHTML = hours + ":" + minutes + ":" + seconds; 234 | 235 | } else { 236 | element.innerHTML = minutes + ":" + seconds; 237 | } 238 | 239 | } 240 | 241 | function showExpired() { 242 | var wrapper = document.getElementById("lightningTip"); 243 | 244 | wrapper.innerHTML = "

"; 245 | wrapper.innerHTML += "Your tip request expired!"; 246 | } 247 | 248 | function addLeadingZeros(value) { 249 | return ("0" + value).slice(-2); 250 | } 251 | 252 | function showQRCode() { 253 | var element = document.getElementById("lightningTipQR"); 254 | 255 | createQRCode(); 256 | 257 | element.innerHTML = qrCode; 258 | 259 | var size = document.getElementById("lightningTipInvoice").clientWidth + "px"; 260 | 261 | var qrElement = element.children[0]; 262 | 263 | qrElement.style.height = size; 264 | qrElement.style.width = size; 265 | } 266 | 267 | function createQRCode() { 268 | var invoiceLength = invoice.length; 269 | 270 | // Just in case an invoice bigger than expected gets created 271 | var typeNumber = 26; 272 | 273 | for (var i = 0; i < qrCodeDataCapacities.length; i++) { 274 | var dataCapacity = qrCodeDataCapacities[i]; 275 | 276 | if (invoiceLength < dataCapacity.capacity) { 277 | typeNumber = dataCapacity.typeNumber; 278 | 279 | break; 280 | } 281 | 282 | } 283 | 284 | console.log("Creating QR code with type number: " + typeNumber); 285 | 286 | var qr = qrcode(typeNumber, "L"); 287 | 288 | qr.addData(invoice); 289 | qr.make(); 290 | 291 | qrCode = qr.createImgTag(6, 6); 292 | } 293 | 294 | function copyInvoiceToClipboard() { 295 | var element = document.getElementById("lightningTipInvoice"); 296 | 297 | element.select(); 298 | 299 | document.execCommand('copy'); 300 | 301 | console.log("Copied invoice to clipboard"); 302 | } 303 | 304 | function showErrorMessage(message) { 305 | requestPending = false; 306 | 307 | console.error(message); 308 | 309 | var error = document.getElementById("lightningTipError"); 310 | 311 | error.parentElement.style.marginTop = "0.5em"; 312 | error.innerHTML = message; 313 | 314 | var button = document.getElementById("lightningTipGetInvoice"); 315 | 316 | // Only necessary if it has a child (div with class spinner) 317 | if (button.children.length !== 0) { 318 | button.innerHTML = defaultGetInvoice; 319 | } 320 | 321 | } 322 | 323 | function divRestorePlaceholder(element) { 324 | //
and

mean that there is no user input 325 | if (element.innerHTML === "
" || element.innerHTML === "

") { 326 | element.innerHTML = ""; 327 | } 328 | } 329 | --------------------------------------------------------------------------------