Compare commits
14 Commits
3ac346df4f
...
ec81068b1c
Author | SHA1 | Date | |
---|---|---|---|
ec81068b1c | |||
9b8734f5a9 | |||
db23fbb64a | |||
3957d4d774 | |||
26485a2307 | |||
642e7785a1 | |||
897d973b8d | |||
dfd5bb728a | |||
782bfec93a | |||
8d84e29d34 | |||
738d511f01 | |||
23ab67e8fc | |||
2ec59e96eb | |||
2b8ab8b51c |
@ -24,10 +24,9 @@ CREATE TABLE invoices(
|
|||||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
msats BIGINT NOT NULL,
|
msats BIGINT NOT NULL,
|
||||||
msats_received BIGINT,
|
msats_received BIGINT,
|
||||||
preimage TEXT NOT NULL UNIQUE,
|
|
||||||
hash TEXT NOT NULL UNIQUE,
|
hash TEXT NOT NULL UNIQUE,
|
||||||
bolt11 TEXT NOT NULL,
|
bolt11 TEXT NOT NULL,
|
||||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
confirmed_at TIMESTAMP WITH TIME ZONE,
|
confirmed_at TIMESTAMP WITH TIME ZONE,
|
||||||
held_since TIMESTAMP WITH TIME ZONE,
|
held_since TIMESTAMP WITH TIME ZONE,
|
||||||
@ -37,7 +36,8 @@ CREATE TABLE invoices(
|
|||||||
CREATE TABLE markets(
|
CREATE TABLE markets(
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
description TEXT NOT NULL,
|
question TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
end_date TIMESTAMP WITH TIME ZONE NOT NULL,
|
end_date TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
settled_at TIMESTAMP WITH TIME ZONE,
|
settled_at TIMESTAMP WITH TIME ZONE,
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
@ -12,7 +12,7 @@ function restart_server() {
|
|||||||
tailwindcss -i public/css/_tw-input.css -o public/css/tailwind.css
|
tailwindcss -i public/css/_tw-input.css -o public/css/tailwind.css
|
||||||
templ generate -path server/router/pages
|
templ generate -path server/router/pages
|
||||||
go build -o delphi.market .
|
go build -o delphi.market .
|
||||||
./delphi.market >> server.log 2>&1 &
|
./delphi.market 2>&1 &
|
||||||
templ generate -path server/router/pages
|
templ generate -path server/router/pages
|
||||||
PID=$(pidof delphi.market)
|
PID=$(pidof delphi.market)
|
||||||
}
|
}
|
||||||
@ -29,7 +29,6 @@ function cleanup() {
|
|||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
|
|
||||||
restart
|
restart
|
||||||
tail -f server.log &
|
|
||||||
|
|
||||||
while inotifywait -r -e modify db/ env/ lib/ lnd/ public/ server/; do
|
while inotifywait -r -e modify db/ env/ lib/ lnd/ public/ server/; do
|
||||||
restart
|
restart
|
||||||
|
16
lib/qr.go
16
lib/qr.go
@ -1,16 +0,0 @@
|
|||||||
package lib
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/base64"
|
|
||||||
|
|
||||||
"github.com/skip2/go-qrcode"
|
|
||||||
)
|
|
||||||
|
|
||||||
func ToQR(s string) (string, error) {
|
|
||||||
png, err := qrcode.Encode(s, qrcode.Medium, 256)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
qr := base64.StdEncoding.EncodeToString([]byte(png))
|
|
||||||
return qr, nil
|
|
||||||
}
|
|
17
main.go
17
main.go
@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
@ -23,7 +24,8 @@ func init() {
|
|||||||
dbUrl string
|
dbUrl string
|
||||||
lndAddress string
|
lndAddress string
|
||||||
lndCert string
|
lndCert string
|
||||||
lndMacaroonDir string
|
tlsData []byte
|
||||||
|
lndMacaroon string
|
||||||
lndNetwork string
|
lndNetwork string
|
||||||
db_ *db.DB
|
db_ *db.DB
|
||||||
lnd_ *lnd.LNDClient
|
lnd_ *lnd.LNDClient
|
||||||
@ -37,8 +39,8 @@ func init() {
|
|||||||
|
|
||||||
flag.StringVar(&dbUrl, "DATABASE_URL", "delphi.market", "Public URL of website")
|
flag.StringVar(&dbUrl, "DATABASE_URL", "delphi.market", "Public URL of website")
|
||||||
flag.StringVar(&lndAddress, "LND_ADDRESS", "localhost:10001", "LND gRPC server address")
|
flag.StringVar(&lndAddress, "LND_ADDRESS", "localhost:10001", "LND gRPC server address")
|
||||||
flag.StringVar(&lndCert, "LND_CERT", "", "Path to LND TLS certificate")
|
flag.StringVar(&lndCert, "LND_CERT", "", "LND TLS certificate in hex")
|
||||||
flag.StringVar(&lndMacaroonDir, "LND_MACAROON_DIR", "", "LND macaroon directory")
|
flag.StringVar(&lndMacaroon, "LND_MACAROON", "", "LND macaroon in hex")
|
||||||
flag.StringVar(&lndNetwork, "LND_NETWORK", "regtest", "LND network")
|
flag.StringVar(&lndNetwork, "LND_NETWORK", "regtest", "LND network")
|
||||||
env.Parse()
|
env.Parse()
|
||||||
|
|
||||||
@ -52,11 +54,16 @@ func init() {
|
|||||||
log.Fatalf("error connecting to database: %v", err)
|
log.Fatalf("error connecting to database: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if tlsData, err = hex.DecodeString(lndCert); err != nil {
|
||||||
|
log.Printf("[warn] error decoding LND TLS certificate: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
if lnd_, err = lnd.New(&lnd.LNDConfig{
|
if lnd_, err = lnd.New(&lnd.LNDConfig{
|
||||||
LndAddress: lndAddress,
|
LndAddress: lndAddress,
|
||||||
TLSPath: lndCert,
|
CustomMacaroonHex: lndMacaroon,
|
||||||
MacaroonDir: lndMacaroonDir,
|
TLSData: string(tlsData),
|
||||||
Network: lndclient.Network(lndNetwork),
|
Network: lndclient.Network(lndNetwork),
|
||||||
|
Insecure: false,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Printf("[warn] error connecting to LND: %v\n", err)
|
log.Printf("[warn] error connecting to LND: %v\n", err)
|
||||||
lnd_ = nil
|
lnd_ = nil
|
||||||
|
@ -3,49 +3,61 @@
|
|||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background-color: #191d21;
|
--background: #191d21;
|
||||||
--color: #d3d3d3;
|
--color: #d3d3d3;
|
||||||
--muted-color: #6c757d;
|
--muted: #6c757d;
|
||||||
--lightning-color: #fada5e;
|
--lightning: #fada5e;
|
||||||
--nostr-color: #8d45dd;
|
--nostr: #8d45dd;
|
||||||
--black: #000;
|
--black: #000;
|
||||||
--white: #fff;
|
--white: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
body {
|
body {
|
||||||
background-color: var(--background-color);
|
background-color: var(--background);
|
||||||
color: var(--color);
|
color: var(--color);
|
||||||
transition: background-color 150ms ease-in;
|
transition: background-color 150ms ease-in;
|
||||||
}
|
}
|
||||||
|
|
||||||
footer {
|
footer {
|
||||||
color: var(--muted-color);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
hr {
|
hr {
|
||||||
border-color: var(--muted-color);
|
border-color: var(--muted);
|
||||||
border-style: dashed;
|
border-style: dashed;
|
||||||
@apply pb-1;
|
@apply pb-1;
|
||||||
}
|
}
|
||||||
|
|
||||||
a:not(.no-link),
|
a:not(.no-link),
|
||||||
button[hx-get],
|
button[hx-get],
|
||||||
button[hx-post] {
|
button[hx-post],
|
||||||
text-decoration: underline;
|
button[type="submit"] {
|
||||||
transition: background-color 150ms ease-in, color 150ms ease-in;
|
transition: background-color 150ms ease-in, color 150ms ease-in;
|
||||||
}
|
}
|
||||||
|
|
||||||
a:not(.no-link):hover,
|
a:not(.no-link):hover,
|
||||||
button[hx-get]:hover,
|
button[hx-get]:hover,
|
||||||
button[hx-post]:hover {
|
button[hx-post]:hover,
|
||||||
|
button[type="submit"]:hover {
|
||||||
background-color: var(--color);
|
background-color: var(--color);
|
||||||
color: var(--background-color);
|
color: var(--background);
|
||||||
|
}
|
||||||
|
|
||||||
|
a:not(.no-link),
|
||||||
|
button[hx-get] {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
button[hx-post],
|
||||||
|
button[type="submit"] {
|
||||||
|
border-width: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
nav a,
|
nav a,
|
||||||
button[hx-get],
|
button[hx-get],
|
||||||
button[hx-post] {
|
button[hx-post],
|
||||||
|
button[type="submit"] {
|
||||||
padding: 0 0.25em;
|
padding: 0 0.25em;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -59,7 +71,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.text-muted {
|
.text-muted {
|
||||||
color: var(--muted-color);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.figlet {
|
.figlet {
|
||||||
@ -81,20 +93,24 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.lightning {
|
.lightning {
|
||||||
background-color: var(--lightning-color);
|
background-color: var(--lightning) !important;
|
||||||
color: var(--black);
|
color: var(--black) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lightning:hover {
|
.lightning:hover {
|
||||||
filter: brightness(125%) drop-shadow(0 0 0.33rem var(--lightning-color));
|
filter: brightness(125%) drop-shadow(0 0 0.33rem var(--lightning));
|
||||||
}
|
}
|
||||||
|
|
||||||
.nostr {
|
.nostr {
|
||||||
background-color: var(--nostr-color);
|
background-color: var(--nostr) !important;
|
||||||
color: var(--white);
|
color: var(--white) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nostr:hover {
|
.nostr:hover {
|
||||||
filter: brightness(125%) drop-shadow(0 0 0.33rem var(--nostr-color));
|
filter: brightness(125%) drop-shadow(0 0 0.33rem var(--nostr));
|
||||||
|
}
|
||||||
|
|
||||||
|
#modal {
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -38,6 +38,10 @@ func httpErrorHandler(sc context.Context) echo.HTTPErrorHandler {
|
|||||||
code = http.StatusInternalServerError
|
code = http.StatusInternalServerError
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// make sure that HTMX selects and targets correct element
|
||||||
|
c.Response().Header().Add("HX-Retarget", "#content")
|
||||||
|
c.Response().Header().Add("HX-Reselect", "#content")
|
||||||
|
|
||||||
if err = c.HTML(code, buf.String()); err != nil {
|
if err = c.HTML(code, buf.String()); err != nil {
|
||||||
c.Logger().Error(err)
|
c.Logger().Error(err)
|
||||||
}
|
}
|
||||||
|
@ -5,7 +5,6 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.ekzyis.com/ekzyis/delphi.market/lib"
|
|
||||||
"git.ekzyis.com/ekzyis/delphi.market/server/auth"
|
"git.ekzyis.com/ekzyis/delphi.market/server/auth"
|
||||||
"git.ekzyis.com/ekzyis/delphi.market/server/router/context"
|
"git.ekzyis.com/ekzyis/delphi.market/server/router/context"
|
||||||
"git.ekzyis.com/ekzyis/delphi.market/server/router/pages"
|
"git.ekzyis.com/ekzyis/delphi.market/server/router/pages"
|
||||||
@ -20,6 +19,15 @@ func HandleAuth(sc context.Context, action string) echo.HandlerFunc {
|
|||||||
return LnAuth(sc, c, action)
|
return LnAuth(sc, c, action)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if c.Param("method") == "nostr" {
|
||||||
|
return NostrAuth(sc, c, action)
|
||||||
|
}
|
||||||
|
|
||||||
|
// on session guard redirects to /login,
|
||||||
|
// we need to make sure that HTMX selects and targets correct element
|
||||||
|
c.Response().Header().Add("HX-Retarget", "#content")
|
||||||
|
c.Response().Header().Add("HX-Reselect", "#content")
|
||||||
|
|
||||||
return pages.Auth(mapAction(action)).Render(context.RenderContext(sc, c), c.Response().Writer)
|
return pages.Auth(mapAction(action)).Render(context.RenderContext(sc, c), c.Response().Writer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -32,7 +40,6 @@ func LnAuth(sc context.Context, c echo.Context, action string) error {
|
|||||||
sessionId string
|
sessionId string
|
||||||
// sessions expire in 30 days. TODO: refresh sessions
|
// sessions expire in 30 days. TODO: refresh sessions
|
||||||
expires = time.Now().Add(60 * 60 * 24 * 30 * time.Second)
|
expires = time.Now().Add(60 * 60 * 24 * 30 * time.Second)
|
||||||
qr string
|
|
||||||
err error
|
err error
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -47,10 +54,6 @@ func LnAuth(sc context.Context, c echo.Context, action string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if qr, err = lib.ToQR(lnAuth.LNURL); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
c.SetCookie(&http.Cookie{
|
c.SetCookie(&http.Cookie{
|
||||||
Name: "session",
|
Name: "session",
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
@ -60,7 +63,7 @@ func LnAuth(sc context.Context, c echo.Context, action string) error {
|
|||||||
Expires: expires,
|
Expires: expires,
|
||||||
})
|
})
|
||||||
|
|
||||||
return pages.LnAuth(qr, lnAuth.LNURL, mapAction(action)).Render(context.RenderContext(sc, c), c.Response().Writer)
|
return pages.LnAuth(lnAuth.LNURL, mapAction(action)).Render(context.RenderContext(sc, c), c.Response().Writer)
|
||||||
}
|
}
|
||||||
|
|
||||||
func HandleLnAuthCallback(sc context.Context) echo.HandlerFunc {
|
func HandleLnAuthCallback(sc context.Context) echo.HandlerFunc {
|
||||||
@ -78,8 +81,11 @@ func HandleLnAuthCallback(sc context.Context) echo.HandlerFunc {
|
|||||||
)
|
)
|
||||||
|
|
||||||
bail := func(code int, reason string) error {
|
bail := func(code int, reason string) error {
|
||||||
c.JSON(code, map[string]string{"status": "ERROR", "reason": reason})
|
if tx != nil {
|
||||||
return nil
|
// manual rollback is only required for tests afaik
|
||||||
|
tx.Rollback()
|
||||||
|
}
|
||||||
|
return c.JSON(code, map[string]string{"status": "ERROR", "reason": reason})
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = c.Bind(&query); err != nil {
|
if err = c.Bind(&query); err != nil {
|
||||||
@ -94,19 +100,15 @@ func HandleLnAuthCallback(sc context.Context) echo.HandlerFunc {
|
|||||||
|
|
||||||
err = tx.QueryRow("SELECT session_id FROM lnauth WHERE k1 = $1 LIMIT 1", query.K1).Scan(&sessionId)
|
err = tx.QueryRow("SELECT session_id FROM lnauth WHERE k1 = $1 LIMIT 1", query.K1).Scan(&sessionId)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
tx.Rollback()
|
|
||||||
return bail(http.StatusNotFound, "session not found")
|
return bail(http.StatusNotFound, "session not found")
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
tx.Rollback()
|
|
||||||
return bail(http.StatusInternalServerError, err.Error())
|
return bail(http.StatusInternalServerError, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
ok, err = auth.VerifyLNAuth(&query)
|
ok, err = auth.VerifyLNAuth(&query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
tx.Rollback()
|
|
||||||
return bail(http.StatusInternalServerError, err.Error())
|
return bail(http.StatusInternalServerError, err.Error())
|
||||||
} else if !ok {
|
} else if !ok {
|
||||||
tx.Rollback()
|
|
||||||
return bail(http.StatusBadRequest, "bad signature")
|
return bail(http.StatusBadRequest, "bad signature")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -116,7 +118,6 @@ func HandleLnAuthCallback(sc context.Context) echo.HandlerFunc {
|
|||||||
"ON CONFLICT(ln_pubkey) DO UPDATE SET ln_pubkey = $1 "+
|
"ON CONFLICT(ln_pubkey) DO UPDATE SET ln_pubkey = $1 "+
|
||||||
"RETURNING id", query.Key).Scan(&userId)
|
"RETURNING id", query.Key).Scan(&userId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
tx.Rollback()
|
|
||||||
pqErr, ok = err.(*pq.Error)
|
pqErr, ok = err.(*pq.Error)
|
||||||
if ok && pqErr.Code == "23505" {
|
if ok && pqErr.Code == "23505" {
|
||||||
return bail(http.StatusBadRequest, "user already exists")
|
return bail(http.StatusBadRequest, "user already exists")
|
||||||
@ -126,10 +127,8 @@ func HandleLnAuthCallback(sc context.Context) echo.HandlerFunc {
|
|||||||
} else if query.Action == "login" {
|
} else if query.Action == "login" {
|
||||||
err = tx.QueryRow("SELECT id FROM users WHERE ln_pubkey = $1", query.Key).Scan(&userId)
|
err = tx.QueryRow("SELECT id FROM users WHERE ln_pubkey = $1", query.Key).Scan(&userId)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
tx.Rollback()
|
|
||||||
return bail(http.StatusNotFound, "user not found")
|
return bail(http.StatusNotFound, "user not found")
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
tx.Rollback()
|
|
||||||
return bail(http.StatusInternalServerError, err.Error())
|
return bail(http.StatusInternalServerError, err.Error())
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -137,17 +136,14 @@ func HandleLnAuthCallback(sc context.Context) echo.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if _, err = tx.Exec("INSERT INTO sessions(id, user_id) VALUES($1, $2)", sessionId, userId); err != nil {
|
if _, err = tx.Exec("INSERT INTO sessions(id, user_id) VALUES($1, $2)", sessionId, userId); err != nil {
|
||||||
tx.Rollback()
|
|
||||||
return bail(http.StatusInternalServerError, err.Error())
|
return bail(http.StatusInternalServerError, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err = tx.Exec("DELETE FROM lnauth WHERE k1 = $1", query.K1); err != nil {
|
if _, err = tx.Exec("DELETE FROM lnauth WHERE k1 = $1", query.K1); err != nil {
|
||||||
tx.Rollback()
|
|
||||||
return bail(http.StatusInternalServerError, err.Error())
|
return bail(http.StatusInternalServerError, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = tx.Commit(); err != nil {
|
if err = tx.Commit(); err != nil {
|
||||||
tx.Rollback()
|
|
||||||
return bail(http.StatusInternalServerError, err.Error())
|
return bail(http.StatusInternalServerError, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -155,6 +151,10 @@ func HandleLnAuthCallback(sc context.Context) echo.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NostrAuth(sc context.Context, c echo.Context, action string) error {
|
||||||
|
return echo.NewHTTPError(http.StatusNotImplemented, nil)
|
||||||
|
}
|
||||||
|
|
||||||
func HandleSessionCheck(sc context.Context) echo.HandlerFunc {
|
func HandleSessionCheck(sc context.Context) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
var (
|
var (
|
||||||
@ -175,10 +175,11 @@ func HandleSessionCheck(sc context.Context) echo.HandlerFunc {
|
|||||||
return c.JSON(http.StatusNotFound, "session not found")
|
return c.JSON(http.StatusNotFound, "session not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Response().Header().Set("HX-Location", "/")
|
// HTMX can't follow 302 redirects with a Location header
|
||||||
// htmx requires a 200 response to follow redirects
|
// it requires a HX-Location header and 200 response instead
|
||||||
// see https://github.com/bigskysoftware/htmx/issues/2052
|
// see https://github.com/bigskysoftware/htmx/issues/2052
|
||||||
return c.HTML(http.StatusOK, "/")
|
c.Response().Header().Set("HX-Location", "/")
|
||||||
|
return c.JSON(http.StatusOK, nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -192,3 +193,33 @@ func mapAction(action string) string {
|
|||||||
return action
|
return action
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func HandleLogout(sc context.Context) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
var (
|
||||||
|
db = sc.Db
|
||||||
|
ctx = c.Request().Context()
|
||||||
|
cookie *http.Cookie
|
||||||
|
sessionId string
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
|
||||||
|
if cookie, err = c.Cookie("session"); err != nil {
|
||||||
|
// cookie not found
|
||||||
|
return c.JSON(http.StatusNotFound, "session not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionId = cookie.Value
|
||||||
|
if _, err = db.ExecContext(ctx,
|
||||||
|
"DELETE FROM sessions WHERE id = $1", sessionId); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// tell browser that cookie is expired and thus can be deleted
|
||||||
|
c.SetCookie(&http.Cookie{Name: "session", HttpOnly: true, Path: "/", Value: sessionId, Secure: true, Expires: time.Now()})
|
||||||
|
|
||||||
|
return c.Redirect(http.StatusSeeOther, "/")
|
||||||
|
// c.Response().Header().Set("HX-Location", "/")
|
||||||
|
// return c.JSON(http.StatusOK, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
74
server/router/handler/market.go
Normal file
74
server/router/handler/market.go
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.ekzyis.com/ekzyis/delphi.market/server/router/context"
|
||||||
|
"git.ekzyis.com/ekzyis/delphi.market/server/router/pages/components"
|
||||||
|
"git.ekzyis.com/ekzyis/delphi.market/types"
|
||||||
|
"github.com/a-h/templ"
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"github.com/lightningnetwork/lnd/lnrpc/invoicesrpc"
|
||||||
|
"github.com/lightningnetwork/lnd/lntypes"
|
||||||
|
"github.com/lightningnetwork/lnd/lnwire"
|
||||||
|
)
|
||||||
|
|
||||||
|
func HandleCreate(sc context.Context) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
var (
|
||||||
|
db = sc.Db
|
||||||
|
lnd = sc.Lnd
|
||||||
|
tx *sql.Tx
|
||||||
|
ctx = c.Request().Context()
|
||||||
|
u = c.Get("session").(types.User)
|
||||||
|
question = c.FormValue("question")
|
||||||
|
description = c.FormValue("description")
|
||||||
|
endDate = c.FormValue("end_date")
|
||||||
|
hash lntypes.Hash
|
||||||
|
paymentRequest string
|
||||||
|
amount = lnwire.MilliSatoshi(1000)
|
||||||
|
expiry = int64(600)
|
||||||
|
expiresAt = time.Now().Add(time.Second * time.Duration(expiry))
|
||||||
|
invoiceId int
|
||||||
|
qr templ.Component
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
// TODO: validation
|
||||||
|
|
||||||
|
if tx, err = db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if hash, paymentRequest, err = lnd.Client.AddInvoice(ctx,
|
||||||
|
&invoicesrpc.AddInvoiceData{
|
||||||
|
Value: amount,
|
||||||
|
Expiry: expiry,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = db.QueryRowContext(ctx, ""+
|
||||||
|
"INSERT INTO invoices (user_id, msats, hash, bolt11, expires_at) "+
|
||||||
|
"VALUES ($1, $2, $3, $4, $5) "+
|
||||||
|
"RETURNING id",
|
||||||
|
u.Id, amount, hash.String(), paymentRequest, expiresAt).Scan(&invoiceId); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err = tx.ExecContext(ctx, ""+
|
||||||
|
"INSERT INTO markets (question, description, end_date, user_id, invoice_id) "+
|
||||||
|
"VALUES ($1, $2, $3, $4, $5)",
|
||||||
|
question, description, endDate, u.Id, invoiceId); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = tx.Commit(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
qr = components.Qr(paymentRequest, "lightning:"+paymentRequest)
|
||||||
|
|
||||||
|
return components.Modal(qr).Render(context.RenderContext(sc, c), c.Response().Writer)
|
||||||
|
}
|
||||||
|
}
|
15
server/router/handler/user.go
Normal file
15
server/router/handler/user.go
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.ekzyis.com/ekzyis/delphi.market/server/router/context"
|
||||||
|
"git.ekzyis.com/ekzyis/delphi.market/server/router/pages"
|
||||||
|
"git.ekzyis.com/ekzyis/delphi.market/types"
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
func HandleUser(sc context.Context) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
u := c.Get("session").(types.User)
|
||||||
|
return pages.User(&u).Render(context.RenderContext(sc, c), c.Response().Writer)
|
||||||
|
}
|
||||||
|
}
|
@ -46,7 +46,8 @@ func SessionGuard(sc context.Context) echo.MiddlewareFunc {
|
|||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
session := c.Get("session")
|
session := c.Get("session")
|
||||||
if session == nil {
|
if session == nil {
|
||||||
return c.Redirect(http.StatusTemporaryRedirect, "/login")
|
// this seems to work for non-interactive and htmx requests
|
||||||
|
return c.Redirect(http.StatusSeeOther, "/login")
|
||||||
}
|
}
|
||||||
return next(c)
|
return next(c)
|
||||||
}
|
}
|
||||||
|
@ -34,7 +34,10 @@ templ Auth(action string) {
|
|||||||
</svg>
|
</svg>
|
||||||
{ action } with lightning
|
{ action } with lightning
|
||||||
</button>
|
</button>
|
||||||
<button class="flex signup nostr my-3 items-center">
|
<button
|
||||||
|
class="flex signup nostr my-3 items-center"
|
||||||
|
hx-get={ string(templ.SafeURL(fmt.Sprintf("/%s/nostr", action))) }
|
||||||
|
>
|
||||||
<svg
|
<svg
|
||||||
class="me-1"
|
class="me-1"
|
||||||
width="16"
|
width="16"
|
||||||
|
@ -12,7 +12,7 @@ templ Head() {
|
|||||||
<link rel="manifest" href="/site.webmanifest"/>
|
<link rel="manifest" href="/site.webmanifest"/>
|
||||||
<link rel="stylesheet" href="/css/tailwind.css"/>
|
<link rel="stylesheet" href="/css/tailwind.css"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
<meta name="theme-color" content="#091833"/>
|
<meta name="theme-color" content="#191d21"/>
|
||||||
<meta
|
<meta
|
||||||
name="htmx-config"
|
name="htmx-config"
|
||||||
content='{
|
content='{
|
||||||
|
21
server/router/pages/components/modal.templ
Normal file
21
server/router/pages/components/modal.templ
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
templ Modal(component templ.Component) {
|
||||||
|
if component != nil {
|
||||||
|
<div
|
||||||
|
id="modal"
|
||||||
|
class="fixed left-0 top-0 w-screen h-screen"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"
|
||||||
|
>
|
||||||
|
<!-- TODO: add background -->
|
||||||
|
<div class="m-3">
|
||||||
|
@component
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div id="modal" class="hidden"></div>
|
||||||
|
}
|
||||||
|
}
|
@ -4,7 +4,14 @@ import c "git.ekzyis.com/ekzyis/delphi.market/server/router/context"
|
|||||||
|
|
||||||
templ Nav() {
|
templ Nav() {
|
||||||
<header class="mt-3">
|
<header class="mt-3">
|
||||||
<nav class="flex flex-row" hx-target="#content" hx-swap="outerHTML" hx-select="#content" hx-push-url="true">
|
<nav
|
||||||
|
id="nav"
|
||||||
|
class="flex flex-row"
|
||||||
|
hx-target="#content"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-select="#content"
|
||||||
|
hx-push-url="true"
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
<button hx-get="/">home</button>
|
<button hx-get="/">home</button>
|
||||||
</div>
|
</div>
|
||||||
|
27
server/router/pages/components/qr.templ
Normal file
27
server/router/pages/components/qr.templ
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"github.com/skip2/go-qrcode"
|
||||||
|
)
|
||||||
|
|
||||||
|
templ Qr(value string, href string) {
|
||||||
|
if href != "" {
|
||||||
|
<a
|
||||||
|
class="mx-auto no-link"
|
||||||
|
href={ templ.SafeURL(href) }
|
||||||
|
>
|
||||||
|
<img src={ "data:image/jpeg;base64," + qrEncode(value) }/>
|
||||||
|
</a>
|
||||||
|
} else {
|
||||||
|
<img src={ "data:image/jpeg;base64," + qrEncode(value) }/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func qrEncode(value string) string {
|
||||||
|
png, err := qrcode.Encode(value, qrcode.Medium, 256)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return base64.StdEncoding.EncodeToString([]byte(png))
|
||||||
|
}
|
@ -1,6 +1,9 @@
|
|||||||
package pages
|
package pages
|
||||||
|
|
||||||
import "git.ekzyis.com/ekzyis/delphi.market/server/router/pages/components"
|
import (
|
||||||
|
c "git.ekzyis.com/ekzyis/delphi.market/server/router/context"
|
||||||
|
"git.ekzyis.com/ekzyis/delphi.market/server/router/pages/components"
|
||||||
|
)
|
||||||
|
|
||||||
templ Index() {
|
templ Index() {
|
||||||
<html>
|
<html>
|
||||||
@ -9,9 +12,81 @@ templ Index() {
|
|||||||
@components.Nav()
|
@components.Nav()
|
||||||
<div id="content" class="flex flex-col text-center">
|
<div id="content" class="flex flex-col text-center">
|
||||||
@components.Figlet("random", "delphi")
|
@components.Figlet("random", "delphi")
|
||||||
<div class="font-mono my-3">A prediction market using the lightning network</div>
|
<div class="font-mono my-3"><small>A prediction market using the lightning network</small></div>
|
||||||
|
<div
|
||||||
|
id="grid-container"
|
||||||
|
class="border border-[var(--muted)] text-start"
|
||||||
|
hx-target="#grid-container"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-select="#grid-container"
|
||||||
|
hx-push-url="true"
|
||||||
|
>
|
||||||
|
<div class="border-b border-[var(--muted)]">
|
||||||
|
<button hx-get="/" class={ tabStyle(ctx.Value(c.ReqPathContextKey).(string), "/") }>markets</button>
|
||||||
|
<button hx-get="/create" class={ tabStyle(ctx.Value(c.ReqPathContextKey).(string), "/create") }>create</button>
|
||||||
</div>
|
</div>
|
||||||
|
if ctx.Value(c.ReqPathContextKey).(string) == "/" {
|
||||||
|
<div class="grid grid-cols-[auto_fit-content(10%)_fit-content(10%)] gap-1">
|
||||||
|
<!--
|
||||||
|
<span class="mt-1 ms-3">Will X happen?</span>
|
||||||
|
<span class="mt-1">51%</span>
|
||||||
|
<span class="mt-1 me-3 text-nowrap">1504 sats</span>
|
||||||
|
<span class="mb-1 ms-3">Will X happen?</span>
|
||||||
|
<span class="mb-1">51%</span>
|
||||||
|
<span class="mb-1 me-3 text-nowrap">1504 sats</span>
|
||||||
|
-->
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<form
|
||||||
|
hx-post="/create"
|
||||||
|
hx-target="#modal"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-select="#modal"
|
||||||
|
class="flex flex-col mx-3"
|
||||||
|
>
|
||||||
|
<label class="my-1" for="question">question</label>
|
||||||
|
<input
|
||||||
|
id="question"
|
||||||
|
name="question"
|
||||||
|
type="text"
|
||||||
|
class="my-1 p-1 text-black"
|
||||||
|
autocomplete="off"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<div class="mt-3 mb-1">
|
||||||
|
<label for="description">description</label>
|
||||||
|
<span class="px-1 text-small text-muted">optional</span>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
type="text"
|
||||||
|
class="my-1 p-1 text-black"
|
||||||
|
></textarea>
|
||||||
|
<label class="mt-3" for="end_date">end date</label>
|
||||||
|
<input
|
||||||
|
id="end_date"
|
||||||
|
name="end_date"
|
||||||
|
type="date"
|
||||||
|
class="my-1 p-1 text-black"
|
||||||
|
autocomplete="off"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button type="submit" class="mt-3">submit</button>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@components.Modal(nil)
|
||||||
@components.Footer()
|
@components.Footer()
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func tabStyle(path string, tab string) string {
|
||||||
|
class := "!no-underline"
|
||||||
|
if path == tab {
|
||||||
|
class += " font-bold border-b-none"
|
||||||
|
}
|
||||||
|
return class
|
||||||
|
}
|
||||||
|
@ -2,7 +2,7 @@ package pages
|
|||||||
|
|
||||||
import "git.ekzyis.com/ekzyis/delphi.market/server/router/pages/components"
|
import "git.ekzyis.com/ekzyis/delphi.market/server/router/pages/components"
|
||||||
|
|
||||||
templ LnAuth(qr string, lnurl string, action string) {
|
templ LnAuth(lnurl string, action string) {
|
||||||
<html>
|
<html>
|
||||||
@components.Head()
|
@components.Head()
|
||||||
<body class="container">
|
<body class="container">
|
||||||
@ -17,12 +17,7 @@ templ LnAuth(qr string, lnurl string, action string) {
|
|||||||
hx-select="#content"
|
hx-select="#content"
|
||||||
hx-push-url="true"
|
hx-push-url="true"
|
||||||
>
|
>
|
||||||
<a
|
@components.Qr(lnurl, "lightning:"+lnurl)
|
||||||
class="mx-auto no-link"
|
|
||||||
href={ templ.SafeURL("lightning:" + lnurl) }
|
|
||||||
>
|
|
||||||
<img src={ "data:image/jpeg;base64," + qr }/>
|
|
||||||
</a>
|
|
||||||
<small class="mx-auto w-[256px] my-1 break-words">{ lnurl }</small>
|
<small class="mx-auto w-[256px] my-1 break-words">{ lnurl }</small>
|
||||||
</div>
|
</div>
|
||||||
<div hx-get="/session" hx-trigger="every 1s" hx-swap="none"></div>
|
<div hx-get="/session" hx-trigger="every 1s" hx-swap="none"></div>
|
||||||
|
37
server/router/pages/user.templ
Normal file
37
server/router/pages/user.templ
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
package pages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.ekzyis.com/ekzyis/delphi.market/server/router/pages/components"
|
||||||
|
"git.ekzyis.com/ekzyis/delphi.market/types"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
templ User(user *types.User) {
|
||||||
|
<html>
|
||||||
|
@components.Head()
|
||||||
|
<body class="container">
|
||||||
|
@components.Nav()
|
||||||
|
<div id="content" class="flex flex-col">
|
||||||
|
@components.Figlet("random", "user")
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-2 gap-4 my-3 mx-auto"
|
||||||
|
hx-target="#content"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-select="#content"
|
||||||
|
hx-push-url="true"
|
||||||
|
hx-select-oob="#nav"
|
||||||
|
>
|
||||||
|
<div class="font-bold">id</div>
|
||||||
|
<div>{ strconv.Itoa(user.Id) }</div>
|
||||||
|
<div class="font-bold">joined</div>
|
||||||
|
<div>{ user.CreatedAt.Format(time.DateOnly) }</div>
|
||||||
|
<div class="font-bold">sats</div>
|
||||||
|
<div>{ strconv.Itoa(int(user.Msats) / 1000) }</div>
|
||||||
|
<button hx-post="/logout" class="col-span-2">logout</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@components.Footer()
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
}
|
@ -14,6 +14,8 @@ func Init(e *echo.Echo, sc Context) {
|
|||||||
e.Use(middleware.Session(sc))
|
e.Use(middleware.Session(sc))
|
||||||
|
|
||||||
e.GET("/", handler.HandleIndex(sc))
|
e.GET("/", handler.HandleIndex(sc))
|
||||||
|
e.GET("/create", handler.HandleIndex(sc))
|
||||||
|
e.POST("/create", handler.HandleCreate(sc), middleware.SessionGuard(sc))
|
||||||
e.GET("/about", handler.HandleAbout(sc))
|
e.GET("/about", handler.HandleAbout(sc))
|
||||||
|
|
||||||
e.GET("/login", handler.HandleAuth(sc, "login"))
|
e.GET("/login", handler.HandleAuth(sc, "login"))
|
||||||
@ -22,4 +24,7 @@ func Init(e *echo.Echo, sc Context) {
|
|||||||
e.GET("/signup/:method", handler.HandleAuth(sc, "register"))
|
e.GET("/signup/:method", handler.HandleAuth(sc, "register"))
|
||||||
e.GET("/api/lnauth/callback", handler.HandleLnAuthCallback(sc))
|
e.GET("/api/lnauth/callback", handler.HandleLnAuthCallback(sc))
|
||||||
e.GET("/session", handler.HandleSessionCheck(sc))
|
e.GET("/session", handler.HandleSessionCheck(sc))
|
||||||
|
|
||||||
|
e.GET("/user", handler.HandleUser(sc), middleware.SessionGuard(sc))
|
||||||
|
e.POST("/logout", handler.HandleLogout(sc), middleware.SessionGuard(sc))
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user