Compare commits

..

14 Commits

Author SHA1 Message Date
ec81068b1c wip: create market 2024-07-15 12:57:51 +02:00
9b8734f5a9 Add QR component 2024-07-15 12:55:48 +02:00
db23fbb64a Use hex-encoded tls cert and macaroon 2024-07-15 08:00:57 +02:00
3957d4d774 Return 501 Not Implemented for nostr auth 2024-07-15 06:22:50 +02:00
26485a2307 Fix error rendering 2024-07-15 06:16:46 +02:00
642e7785a1 Fix login redirects 2024-07-15 06:16:27 +02:00
897d973b8d Fix theme-color 2024-07-14 13:48:02 +02:00
dfd5bb728a Remove -color suffix 2024-07-14 13:48:02 +02:00
782bfec93a Implement logout 2024-07-14 13:39:42 +02:00
8d84e29d34 Improve htmx comment about 302 Found in session check 2024-07-14 12:49:38 +02:00
738d511f01 Call tx.Rollback() in bail 2024-07-14 12:38:16 +02:00
23ab67e8fc Always use c.JSON in HandleSessionCheck 2024-07-14 12:01:41 +02:00
2ec59e96eb Return c.JSON 2024-07-12 22:15:48 +02:00
2b8ab8b51c Fix duplicate logs 2024-07-12 20:41:41 +02:00
19 changed files with 391 additions and 90 deletions

View File

@ -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),

View File

@ -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

View File

@ -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
View File

@ -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

View File

@ -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);
} }
} }

View File

@ -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)
} }

View File

@ -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)
}
}

View 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)
}
}

View 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)
}
}

View File

@ -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)
} }

View File

@ -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"

View File

@ -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='{

View 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>
}
}

View File

@ -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>

View 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))
}

View File

@ -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
}

View File

@ -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>

View 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>
}

View File

@ -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))
} }