~inferiormartin/shishutsu

be98894c70a6548c123ed23a93ceeb41495c857b — Maarten Vos 29 days ago bb92e25
implement sign-in
A app/application.go => app/application.go +22 -0
@@ 0,0 1,22 @@
package app

import (
	"database/sql"
	"html/template"
	"log"

	"github.com/vaughan0/go-ini"
)

type Application struct {
	Config   *ini.File
	Template *template.Template
}

func (app *Application) SqlOpen() (*sql.DB, error) {
	conn, ok := app.Config.Get("shishutsu", "connection-string")
	if !ok {
		log.Fatal("shi-web: unable to get connection-string. shishutsu connection-string not defined?")
	}
	return sql.Open("postgres", conn)
}

M auth/auth.go => auth/auth.go +40 -1
@@ 1,8 1,47 @@
package auth

import "golang.org/x/crypto/bcrypt"
import (
	"context"
	"net/http"

	"git.sr.ht/~inferiormartin/shishutsu/app"

	"golang.org/x/crypto/bcrypt"
)

func HashPassword(password []byte) (string, error) {
	hash, err := bcrypt.GenerateFromPassword(password, bcrypt.DefaultCost)
	return string(hash), err
}

func Middleware(app *app.Application, next func(ctx context.Context, w http.ResponseWriter, r *http.Request)) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		cookie, err := r.Cookie("session")
		if err != nil {
			http.Redirect(w, r, "/sign-in", http.StatusTemporaryRedirect)
			return
		}
		db, err := app.SqlOpen()
		if err != nil {
			//TODO: cannot authenticate, redirect to sign-in?
			http.Redirect(w, r, "/sign-in", http.StatusTemporaryRedirect)
			return
		}
		defer db.Close()
		var id string
		row := db.QueryRow("SELECT account_id FROM session WHERE session = $1", cookie.Value)
		if err = row.Scan(&id); err != nil {
			// invalid session cookie. delete to allow sign-in again
			http.SetCookie(w, &http.Cookie{
				Name:   "session",
				MaxAge: -1,
			})
			http.Redirect(w, r, "/sign-in", http.StatusTemporaryRedirect)
			return
		}
		ctx := context.Background()
		ctx = context.WithValue(ctx, "db", db)
		ctx = context.WithValue(ctx, "account_id", id)
		next(ctx, w, r)
	}
}

M cmd/shi-import/main.go => cmd/shi-import/main.go +1 -1
@@ 70,7 70,7 @@ func read() []*database.Transaction {
	for _, result := range results {
		item := &database.Transaction{
			-1,
			"dc5dadda-063c-45a4-b6a8-61357b962a37", //TODO: allow import for any user
			"d8230e7e-ddcd-4455-b215-b72ca4a9638b", //TODO: allow import for any user
			"TODO",                                 //TODO: my bank's specific csv does not contain an IBAN because the statement exports are specific for an account. allow manual override using flag?
			int64(parseFloat(result[6]) * 100),
			parseDate(result[4]),

M cmd/shi-web/main.go => cmd/shi-web/main.go +6 -44
@@ 1,69 1,31 @@
package main

import (
	"database/sql"
	"html/template"
	"log"
	"net/http"

	"git.sr.ht/~inferiormartin/shishutsu/app"
	"git.sr.ht/~inferiormartin/shishutsu/config"
	"git.sr.ht/~inferiormartin/shishutsu/database"
	"git.sr.ht/~inferiormartin/shishutsu/web"
	"git.sr.ht/~inferiormartin/shishutsu/services/dashboard"
	"git.sr.ht/~inferiormartin/shishutsu/services/signin"
)

func main() {
	cfg, err := config.Load()

	if err != nil {
		log.Fatal(err)
	}

	tmpl, err := template.ParseGlob("templates/*.html")
	if err != nil {
		log.Fatal(err)
	}

	conn, ok := cfg.Get("shishutsu", "connection-string")
	if !ok {
		log.Fatal("shi-web: unable to get connection-string. shishutsu connection-string not defined?")
	}
	//TODO: better connection management
	db, err := sql.Open("postgres", conn)
	defer db.Close()

	app := &app.Application{cfg, tmpl}
	fs := http.FileServer(http.Dir("./static"))
	http.Handle("/static/", http.StripPrefix("/static/", fs))
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		rows, err := db.Query("SELECT id, account_id, iban, amount, transaction_date, interest_date, description FROM transaction")
		if err != nil {
			log.Fatal(err)
		}
		defer rows.Close()
		var transactions []database.Transaction
		for rows.Next() {
			var t database.Transaction
			if err = rows.Scan(&t.ID, &t.AccountID, &t.IBAN, &t.Amount, &t.TransactionDate, &t.InterestDate, &t.Description); err != nil {
				log.Fatal(err)
			}
			transactions = append(transactions, t)
		}
		if err = rows.Err(); err != nil {
			log.Fatal(err)
		}
		err = tmpl.ExecuteTemplate(w, "index.html", &web.DashboardPage{transactions})
	})

	http.HandleFunc("/sign-in", func(w http.ResponseWriter, r *http.Request) {
		switch r.Method {
		case http.MethodGet:
			err = tmpl.ExecuteTemplate(w, "sign-in.html", nil)
		case http.MethodPost:
			//_ := r.FormValue("email")
			//_ := r.FormValue("password")

			//err = tmpl.ExecuteTemplate(w, "index.html", web.getDashboard())
		}
	})
	http.HandleFunc("/", dashboard.Handler(app))
	http.HandleFunc("/sign-in", signin.Handler(app))

	bind, ok := cfg.Get("shishutsu::web", "bind")


M go.mod => go.mod +1 -0
@@ 3,6 3,7 @@ module git.sr.ht/~inferiormartin/shishutsu
go 1.18

require (
	github.com/google/uuid v1.3.0 // indirect
	github.com/lib/pq v1.10.6 // indirect
	github.com/vaughan0/go-ini v0.0.0-20130923145212-a98ad7ee00ec // indirect
	golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d // indirect

M go.sum => go.sum +2 -0
@@ 1,3 1,5 @@
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/lib/pq v1.10.6 h1:jbk+ZieJ0D7EVGJYpL9QTz7/YW6UHbmdnZWYyK5cdBs=
github.com/lib/pq v1.10.6/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/vaughan0/go-ini v0.0.0-20130923145212-a98ad7ee00ec h1:DGmKwyZwEB8dI7tbLt/I/gQuP559o/0FrAkHKlQM/Ks=

M schema.sql => schema.sql +6 -0
@@ 16,3 16,9 @@ CREATE TABLE IF NOT EXISTS transaction (
    interest_date date NOT NULL,
    description text NOT NULL
);

CREATE TABLE IF NOT EXISTS session (
    id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    account_id uuid NOT NULL REFERENCES account(id),
    session uuid NOT NULL UNIQUE
);

A services/dashboard/handler.go => services/dashboard/handler.go +41 -0
@@ 0,0 1,41 @@
package dashboard

import (
	"context"
	"database/sql"
	"log"
	"net/http"

	"git.sr.ht/~inferiormartin/shishutsu/app"
	"git.sr.ht/~inferiormartin/shishutsu/auth"
	"git.sr.ht/~inferiormartin/shishutsu/database"
)

type Dashboard struct {
	Transactions []database.Transaction
}

func Handler(app *app.Application) http.HandlerFunc {
	return auth.Middleware(app, func(ctx context.Context, w http.ResponseWriter, r *http.Request) {
		db := ctx.Value("db").(*sql.DB)
		id := ctx.Value("account_id").(string)
		defer db.Close()
		rows, err := db.Query("SELECT id, account_id, iban, amount, transaction_date, interest_date, description FROM transaction WHERE account_id = $1", id)
		if err != nil {
			log.Fatal(err)
		}
		defer rows.Close()
		var transactions []database.Transaction
		for rows.Next() {
			var t database.Transaction
			if err = rows.Scan(&t.ID, &t.AccountID, &t.IBAN, &t.Amount, &t.TransactionDate, &t.InterestDate, &t.Description); err != nil {
				log.Fatal(err)
			}
			transactions = append(transactions, t)
		}
		if err = rows.Err(); err != nil {
			log.Fatal(err)
		}
		err = app.Template.ExecuteTemplate(w, "index.html", &Dashboard{transactions})
	})
}

A services/notification/notification.go => services/notification/notification.go +15 -0
@@ 0,0 1,15 @@
package notification

type NotificationType string

const (
	NotificationNotice  NotificationType = "notice"
	NotificationWarning                  = "warning"
	NotificationError                    = "error"
	NotificationOK                       = "ok"
)

type Notification struct {
	Text string
	Type NotificationType
}

A services/signin/handler.go => services/signin/handler.go +77 -0
@@ 0,0 1,77 @@
package signin

import (
	"log"
	"net/http"

	"git.sr.ht/~inferiormartin/shishutsu/app"
	"git.sr.ht/~inferiormartin/shishutsu/services/notification"
	"github.com/google/uuid"
	"golang.org/x/crypto/bcrypt"
)

type SignIn struct {
	Email        string
	Success      bool
	Notification *notification.Notification
}

func Handler(app *app.Application) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		switch r.Method {
		case http.MethodGet:
			//TODO: if session exists, redirect to /
			err := app.Template.ExecuteTemplate(w, "sign-in.html", nil)
			//TODO: no Fatal
			if err != nil {
				log.Fatal(err)
			}
		case http.MethodPost:
			db, err := app.SqlOpen()
			//TODO: no Fatal
			if err != nil {
				log.Fatal(err)
			}
			defer db.Close()
			email := r.FormValue("email")
			password := r.FormValue("password")
			var id string
			var hash string
			row := db.QueryRow(`SELECT id, password FROM account WHERE email = $1`, email)
			if err := row.Scan(&id, &hash); err != nil {
				err = app.Template.ExecuteTemplate(w, "sign-in.html", SignIn{email, false, &notification.Notification{
					"Incorrect email or password.",
					notification.NotificationError,
				}})
				return
			}
			if err = bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
				err = app.Template.ExecuteTemplate(w, "sign-in.html", SignIn{email, false, &notification.Notification{
					"Incorrect email or password.",
					notification.NotificationError,
				}})
				return
			}

			session := uuid.NewString()

			_, err = db.Exec("INSERT INTO session (account_id, session) VALUES ($1, $2)", id, session)

			if err != nil {
				log.Println(err)
				err = app.Template.ExecuteTemplate(w, "sign-in.html", SignIn{email, false, &notification.Notification{
					"An error occured while attempting to sign you in.",
					notification.NotificationError,
				}})
				return
			}

			http.SetCookie(w, &http.Cookie{
				Name:  "session",
				Value: session,
			})

			http.Redirect(w, r, "/", http.StatusFound)
		}
	}
}

M static/main.css => static/main.css +4 -1
@@ 1,7 1,10 @@
* {
    font-family: sans-serif;
}

.styled-table {
    border-collapse: collapse;
    font-size: 0.9em;
    font-family: sans-serif;
    min-width: 400px;
    box-shadow: 0 0 20px rgba(0, 0, 0, 0.15);
    width: 100%;

M static/sign-in.css => static/sign-in.css +9 -11
@@ 1,35 1,33 @@
input[type=submit] {
    border: none;
    padding: 10px;
    background-color: #009879;
}

* {
    font-family: sans-serif;
}

h1 {
    text-align: center;
    margin: 15px;
}

input[type=email],
input[type=password] {
    margin: 5px 0;
    padding: 4px;
    padding: 5px;
}

input[type=submit] {
    margin-top: 15px;
    width: 100%;
    margin: 5px 0;
    color: #fff;
    font-size: 15px;
    border: none;
    padding: 10px;
    background-color: #009879;
}

.sign-in {
    margin: 0;
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%); 
    padding: 50px;
    padding: 15px;
    background-color: #F5F5F5;
    box-shadow: 0 0 20px rgba(0, 0, 0, 0.15);
}

A templates/notification.html => templates/notification.html +6 -0
@@ 0,0 1,6 @@
<!-- TODO: style this correctly -->
<!-- blue for notice, red for error, yellow for warning, green for OK -->

<div style="background-color: #D2042D; color: white; padding: 7px;">
    {{ .Text }}
</div>

M templates/sign-in.html => templates/sign-in.html +9 -2
@@ 6,11 6,18 @@
    <body>
        <div class="sign-in">
            <h1>shishutsu</h1>
            {{ if . }}
                {{ if not .Success }}
                    {{ template "notification.html" .Notification }}
                {{ end }}
                <div style="margin-bottom: 15px;"></div>
            {{ end }}
            <form action="/sign-in" method="POST">
                <label for="email">Email</label></br>
                <input type="email" id="email"></br>
                <input value="{{ .Email }}" type="email" id="email" name="email" required></br>
                <div style="margin-top: 15px;"></div>
                <label for="password">Password</label></br>
                <input type="password" id="password"></br>
                <input type="password" id="password" name="password" required></br>
                <input type="submit" value="Sign In">
            </form>
        </div>

D web/dashboard.go => web/dashboard.go +0 -9
@@ 1,9 0,0 @@
package web

import (
	"git.sr.ht/~inferiormartin/shishutsu/database"
)

type DashboardPage struct {
	Transactions []database.Transaction
}