diff options
| author | Samuel Johnson <[email protected]> | 2025-11-24 13:53:18 -0500 |
|---|---|---|
| committer | Samuel Johnson <[email protected]> | 2025-11-24 13:53:18 -0500 |
| commit | 368a462bc744d8e9084eacfaddeb9afcaf7f7133 (patch) | |
| tree | c6e8f665d6cb9713b9226b10c4a341e60b8e91c2 | |
| parent | 4d4419f51557bef6b64dca8635ed61616d262a9b (diff) | |
Add session management
| -rw-r--r-- | cmd/parser/main.go | 37 | ||||
| -rw-r--r-- | cmd/web/handlers/blog.go | 9 | ||||
| -rw-r--r-- | cmd/web/handlers/login.go | 170 | ||||
| -rw-r--r-- | cmd/web/handlers/routes.go | 13 | ||||
| -rw-r--r-- | cmd/web/main.go | 3 | ||||
| -rw-r--r-- | cmd/web/middleware/auth.go | 81 | ||||
| -rw-r--r-- | go.mod | 1 | ||||
| -rw-r--r-- | go.sum | 2 | ||||
| -rw-r--r-- | internal/dbmigrations.go | 51 | ||||
| -rw-r--r-- | internal/models/user.go | 9 | ||||
| -rw-r--r-- | static/app.css | 45 | ||||
| -rw-r--r-- | static/music_player.js | 12 | ||||
| -rw-r--r-- | ui/html/base.tmpl.html | 10 | ||||
| -rw-r--r-- | ui/html/login.tmpl.html | 19 | ||||
| -rw-r--r-- | ui/html/pages/index.tmpl.html | 21 |
15 files changed, 432 insertions, 51 deletions
diff --git a/cmd/parser/main.go b/cmd/parser/main.go index 6d3c3c4..3d0b132 100644 --- a/cmd/parser/main.go +++ b/cmd/parser/main.go @@ -16,7 +16,7 @@ import ( "github.com/yuin/goldmark/extension" "github.com/yuin/goldmark/parser" "github.com/yuin/goldmark/renderer/html" - "golang.org/x/crypto/bcrypt" + "paterissa.net/mblog/internal" _ "github.com/jackc/pgx/v5/stdlib" ) @@ -121,40 +121,7 @@ func main() { } defer db.Close() - _, table_check := db.Query("SELECT * FROM posts;") - if table_check != nil { - _, err = db.Exec("CREATE TABLE posts (id SERIAL PRIMARY KEY, name VARCHAR(50) NOT NULL, time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, content TEXT NOT NULL);") - if err != nil { - fmt.Fprintf(os.Stderr, "Unable to create posts table: %v\n", err) - os.Exit(1) - } - } - - webmaster := os.Getenv("webmaster") - passOne := os.Getenv("blog_pass1") - passTwo := os.Getenv("blog_pass2") - - _, table_check = db.Query("SELECT * FROM logins;") - if table_check != nil { - _, err = db.Exec("CREATE TABLE logins (id SERIAL PRIMARY KEY, name VARCHAR(50) NOT NULL, time DATE DEFAULT CURRENT_DATE, pass_one TEXT NOT NULL, pass_two TEXT NOT NULL);") - if err != nil { - fmt.Fprintf(os.Stderr, "Unable to create logins table: %v\n", err) - os.Exit(1) - } - - hashOne, errHashOne := bcrypt.GenerateFromPassword([]byte(passOne), 12) - hashTwo, errHashTwo := bcrypt.GenerateFromPassword([]byte(passTwo), 12) - if errHashOne != nil || errHashTwo != nil { - fmt.Fprintf(os.Stderr, "Failed to hash password") - os.Exit(1) - } - - _, err = db.Exec(fmt.Sprintf("INSERT INTO logins (name, pass_one, pass_two) VALUES ('%s', '%s', '%s');", webmaster, hashOne, hashTwo)) - if err != nil { - fmt.Fprintf(os.Stderr, "Unable to add webmaster to logins table: %v\n", err) - os.Exit(1) - } - } + internal.Migrate(db, os.Getenv("webmaster"), os.Getenv("blog_pass1"), os.Getenv("blog_pass2")) router := http.NewServeMux() router.HandleFunc("/", mdServe) diff --git a/cmd/web/handlers/blog.go b/cmd/web/handlers/blog.go index bd2d97f..573e52f 100644 --- a/cmd/web/handlers/blog.go +++ b/cmd/web/handlers/blog.go @@ -15,22 +15,29 @@ type blogContext struct { Rows []models.Post Name string + IsAuth bool } func (ctx *blogContext) index(w http.ResponseWriter, r *http.Request) { ctx.Rows = []models.Post{} + ctx.IsAuth = false if r.URL.Path != "/" { http.NotFound(w, r) return } + + _, err := r.Cookie("paterissa_session_token") + if err == nil { + ctx.IsAuth = true; + } offset := r.URL.Query().Get("offset") if offset == "" { offset = "20" } - rows, err := ctx.db.Query("SELECT * FROM posts WHERE id < " + offset + " ORDER BY id DESC LIMIT 20;") + rows, err := ctx.db.Query("SELECT p.id, u.name, p.time, p.content FROM posts p INNER JOIN logins u ON p.user_id = u.id WHERE p.id < " + offset + " ORDER BY p.id DESC LIMIT 20;") if err != nil { ctx.err.Print(err.Error()) http.Error(w, "Internal Server Error", 500) diff --git a/cmd/web/handlers/login.go b/cmd/web/handlers/login.go new file mode 100644 index 0000000..e867e07 --- /dev/null +++ b/cmd/web/handlers/login.go @@ -0,0 +1,170 @@ +package handlers + +import ( + "database/sql" + "html/template" + "log" + "net/http" + "os" + "time" + + "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" + "paterissa.net/mblog/internal/models" +) + +type loginContext struct { + err *log.Logger + db *sql.DB +} + +func (ctx *loginContext) index(w http.ResponseWriter, r *http.Request) { + files := []string{ + "ui/html/base.tmpl.html", + "ui/html/login.tmpl.html", + "ui/html/music_player.tmpl.html", + } + + compiled, err := template.ParseFiles(files...) + if err != nil { + w.WriteHeader(500) + w.Write([]byte("Internal Server Error")) + ctx.err.Printf("Failed to parse templates: %v\n", err) + return + } + + err = compiled.ExecuteTemplate(w, "base", ctx) + if err != nil { + w.WriteHeader(500) + w.Write([]byte("Internal Server Error")) + ctx.err.Printf("Failed to parse templates: %v\n", err) + return + } + + return +} + +func (ctx *loginContext) login(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + w.WriteHeader(405) + w.Write([]byte("Method Not Allowed")) + return + } + + err := r.ParseForm() + if err != nil { + w.WriteHeader(500) + w.Write([]byte("Internal Error")) + ctx.err.Printf("Failed to retrieve form data: %v\n", err) + return + } + + user := r.PostForm.Get("user") + passOne := r.PostForm.Get("pass_one") + passTwo := r.PostForm.Get("pass_two") + + stmt, err := ctx.db.Prepare("SELECT * FROM logins WHERE name = $1;") + if err != nil { + w.WriteHeader(500) + w.Write([]byte("Internal Error")) + ctx.err.Printf("Failed to retrieve form data: %v\n", err) + return + } + defer stmt.Close() + + var u models.User + + row := stmt.QueryRow(user) + err = row.Scan(&u.Id, &u.Name, &u.Time, &u.PassOne, &u.PassTwo) + if err != nil { + w.WriteHeader(401) + w.Write([]byte("Unauthorized")) + ctx.err.Printf("Failed to retrieve user info from DB: %v\n", err) + return + } + + passOneErr := bcrypt.CompareHashAndPassword([]byte(u.PassOne), []byte(passOne)) + passTwoErr := bcrypt.CompareHashAndPassword([]byte(u.PassTwo), []byte(passTwo)) + if passOneErr != nil || passTwoErr != nil { + w.WriteHeader(401) + w.Write([]byte("Failed to login - not authorized")) + return + } + + cookie := http.Cookie{ + Name: "paterissa_session_token", + Value: uuid.New().String(), + Expires: time.Now().AddDate(0, 0, 1), + Path: "/", + Domain: os.Getenv("serv"), + HttpOnly: true, + Secure: true, + } + + commit, err := ctx.db.Prepare("INSERT INTO cookies (content, user_id, expiration) VALUES ($1, $2, $3);") + if err != nil { + w.WriteHeader(500) + w.Write([]byte("Internal Error")) + ctx.err.Printf("Failed to prepare DB statement: %v\n", err) + return + } + + _, err = commit.Exec(cookie.Value, u.Id, cookie.Expires) + if err != nil { + w.WriteHeader(500) + w.Write([]byte("Internal Error")) + ctx.err.Printf("Failed to prepare DB statement: %v\n", err) + } + + http.SetCookie(w, &cookie) + http.Redirect(w, r, "/", http.StatusFound) + return +} + +func (ctx *loginContext) logout(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie("paterissa_session_token") + if err != nil { + w.WriteHeader(405) + w.Write([]byte("Unauthorized")) + return + } + + stmt, err := ctx.db.Prepare("UPDATE cookies SET expiration = $1 WHERE content = $2;") + if err != nil { + w.WriteHeader(500) + w.Write([]byte("Internal Error")) + ctx.err.Printf("Could not prepare DB statement: %v\n", err) + return + } + defer stmt.Close() + + _, err = stmt.Exec(time.Now(), cookie.Value) + if err != nil { + w.WriteHeader(500) + w.Write([]byte("Internal Error")) + ctx.err.Printf("Could not execute DB statement: %v\n", err) + return + } + + cookie = &http.Cookie{ + Name: "paterissa_session_token", + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + } + + http.SetCookie(w, cookie) + http.Redirect(w, r, "/", http.StatusFound) + return +} + +func (ctx *loginContext) handle(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + ctx.index(w, r) + return + } else { + ctx.login(w, r) + return + } +} diff --git a/cmd/web/handlers/routes.go b/cmd/web/handlers/routes.go index e9fd0f5..0196331 100644 --- a/cmd/web/handlers/routes.go +++ b/cmd/web/handlers/routes.go @@ -4,15 +4,25 @@ import ( "database/sql" "net/http" + "paterissa.net/mblog/cmd/web/middleware" "paterissa.net/mblog/cmd/web/types" ) func RegisterEndpoints(app types.Application, db *sql.DB) *http.ServeMux { + auth := middleware.AuthMiddleware{ + Err: app.Err, + Db: db, + } + blog := blogContext{ err: app.Err, db: db, Name: app.Env.Webmaster, } + login := loginContext{ + err: app.Err, + db: db, + } audio := fsContext{ err: app.Err, path: app.AudioDir, @@ -26,6 +36,9 @@ func RegisterEndpoints(app types.Application, db *sql.DB) *http.ServeMux { blogRouter := http.NewServeMux() blogRouter.HandleFunc("/", blog.index) + blogRouter.HandleFunc("/login", login.handle) + blogRouter.HandleFunc("/logout", auth.Resolve(login.logout)) + blogRouter.HandleFunc("/audio", audio.readdir) blogRouter.HandleFunc("/audio/get", audio.get) diff --git a/cmd/web/main.go b/cmd/web/main.go index 42b672a..0b35ff2 100644 --- a/cmd/web/main.go +++ b/cmd/web/main.go @@ -14,6 +14,7 @@ import ( "paterissa.net/mblog/cmd/web/handlers" "paterissa.net/mblog/cmd/web/types" + "paterissa.net/mblog/internal" ) func main() { @@ -57,6 +58,8 @@ func main() { } defer db.Close() + internal.Migrate(db, os.Getenv("webmaster"), os.Getenv("blog_pass1"), os.Getenv("blog_pass2")) + router := handlers.RegisterEndpoints(app, db) srv := &http.Server{ Addr: fmt.Sprintf(":%d", app.Env.AppPort), diff --git a/cmd/web/middleware/auth.go b/cmd/web/middleware/auth.go new file mode 100644 index 0000000..b53980a --- /dev/null +++ b/cmd/web/middleware/auth.go @@ -0,0 +1,81 @@ +package middleware + +import ( + "database/sql" + "log" + "net/http" + "time" +) + +type AuthMiddleware struct { + Err *log.Logger + Db *sql.DB +} + +func (auth *AuthMiddleware) Resolve(next http.HandlerFunc) http.HandlerFunc { + return http.HandlerFunc( + func (w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie("paterissa_session_token") + if err != nil { + w.WriteHeader(401) + w.Write([]byte("Unauthorized")) + return + } + + stmt, err := auth.Db.Prepare("SELECT * FROM cookies WHERE content = $1;") + if err != nil { + cookie = &http.Cookie{ + Name: "paterissa_session_token", + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + } + http.SetCookie(w, cookie) + + w.Write([]byte("Unauthorized")) + auth.Err.Printf("Could not retrieve cookie from DB: %v\n", err) + return + } + defer stmt.Close() + + var id int + var content string + var userId int + var expiration time.Time + + row := stmt.QueryRow(cookie.Value) + err = row.Scan(&id, &content, &userId, &expiration) + if err != nil { + cookie = &http.Cookie{ + Name: "paterissa_session_token", + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + } + http.SetCookie(w, cookie) + + w.Write([]byte("Unauthorized")) + auth.Err.Printf("Could not retrieve cookie from DB: %v\n", err) + return + } + + if time.Now().After(expiration) { + cookie = &http.Cookie{ + Name: "paterissa_session_token", + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + } + http.SetCookie(w, cookie) + + w.Write([]byte("Expired")) + return + } + + next.ServeHTTP(w, r) + return + }) +} @@ -8,6 +8,7 @@ require ( ) require ( + github.com/google/uuid v1.6.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.7.6 // indirect @@ -1,4 +1,6 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= diff --git a/internal/dbmigrations.go b/internal/dbmigrations.go new file mode 100644 index 0000000..e6c11ff --- /dev/null +++ b/internal/dbmigrations.go @@ -0,0 +1,51 @@ +package internal + +import ( + "database/sql" + "fmt" + "os" + + "golang.org/x/crypto/bcrypt" +) + +func Migrate (db *sql.DB, webmaster string, passOne string, passTwo string) { + _, table_check := db.Query("SELECT * FROM logins;") + if table_check != nil { + _, err := db.Exec("CREATE TABLE logins (id SERIAL PRIMARY KEY, name VARCHAR(50) NOT NULL, time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, pass_one TEXT NOT NULL, pass_two TEXT NOT NULL);") + if err != nil { + fmt.Fprintf(os.Stderr, "Unable to create logins table: %v\n", err) + os.Exit(1) + } + + hashOne, errHashOne := bcrypt.GenerateFromPassword([]byte(passOne), 12) + hashTwo, errHashTwo := bcrypt.GenerateFromPassword([]byte(passTwo), 12) + if errHashOne != nil || errHashTwo != nil { + fmt.Fprintf(os.Stderr, "Failed to hash password") + os.Exit(1) + } + + _, err = db.Exec(fmt.Sprintf("INSERT INTO logins (name, pass_one, pass_two) VALUES ('%s', '%s', '%s');", webmaster, hashOne, hashTwo)) + if err != nil { + fmt.Fprintf(os.Stderr, "Unable to add webmaster to logins table: %v\n", err) + os.Exit(1) + } + } + + _, table_check = db.Query("SELECT * FROM posts;") + if table_check != nil { + _, err := db.Exec("CREATE TABLE posts (id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES logins(id), time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, content TEXT NOT NULL);") + if err != nil { + fmt.Fprintf(os.Stderr, "Unable to create posts table: %v\n", err) + os.Exit(1) + } + } + + + _, table_check = db.Query("SELECT * FROM cookies;") + if table_check != nil { + _, err := db.Exec("CREATE TABLE cookies (id SERIAL PRIMARY KEY, content VARCHAR(255) NOT NULL, user_id INTEGER REFERENCES logins(id), expiration TIMESTAMP);") + if err != nil { + fmt.Fprintf(os.Stderr, "Unable to create cookies table: %v\n", err) + } + } +} diff --git a/internal/models/user.go b/internal/models/user.go new file mode 100644 index 0000000..8753810 --- /dev/null +++ b/internal/models/user.go @@ -0,0 +1,9 @@ +package models + +type User struct { + Id int + Name string + Time string + PassOne string + PassTwo string +} diff --git a/static/app.css b/static/app.css index 0148d24..7b2d083 100644 --- a/static/app.css +++ b/static/app.css @@ -8,6 +8,7 @@ header { header > h1 { padding: 0.5rem; + font-size: 2rem; } header > nav { @@ -16,8 +17,27 @@ header > nav { margin-left: 2em; } -a { +.flex-between { + display: flex; + flex-direction: row; +} + +.spacer { + visibility: hidden; + flex-grow: 1; +} + +.nav_tag { margin: 0.5em; +} + +.right { + float: right; + margin: auto; + margin-right: 2em; +} + +a { color: inherit; position: relative; display: inline-block; @@ -50,7 +70,7 @@ a:hover::after { .pane { margin: auto; - padding: 4px 10px; + padding: 0.2em 0.5em; width: 75%; background-color: black; @@ -66,6 +86,7 @@ a:hover::after { flex-direction: row; justify-content: center; align-content: center; + margin: 2em; } .topline > p { @@ -74,6 +95,16 @@ a:hover::after { margin-left: 2em; } +@media only screen and (max-width: 768px) { + header > h1 { + font-size: 1em; + } + + .topline > p { + display: none; + } +} + .card { border-top: 1px solid #fff; @@ -126,6 +157,16 @@ a:hover::after { right: 0; } +.margin_2rem { + margin: 2rem; +} + +.login_form { + margin-top: 2rem; + margin-bottom: 2rem; + padding: 0.5rem; +} + .vert_align { display: inline-block; vertical-align: middle; diff --git a/static/music_player.js b/static/music_player.js index 8fe89c2..33063d1 100644 --- a/static/music_player.js +++ b/static/music_player.js @@ -45,7 +45,7 @@ function mpSeek () { audio.currentTime = goal; } -async function mpUpdate () { +async function mpInit () { clearInterval(tick); const trackSelector = document.getElementById("mp_tracks"); @@ -61,6 +61,10 @@ async function mpUpdate () { ppButton.innerHTML = "<img src='/static/get?file=images/play.svg' alt='Play'>"; tick = setInterval(mpTick, 1000); +} + +async function mpUpdate () { + mpInit(); mpPpTrack(); } @@ -90,7 +94,6 @@ function mpNext () { } mpUpdate(); - mpPpTrack(); } function mpPrevious () { @@ -103,7 +106,6 @@ function mpPrevious () { } mpUpdate(); - mpPpTrack(); } const trackList = await mpFetchAsync("/audio"); @@ -113,7 +115,7 @@ trackList.forEach((track) => { let option = document.createElement("option"); option.text = track; - trackSelector.add(option, 0); + trackSelector.append(option) }); document.getElementById("mp_tracks").addEventListener("change", mpUpdate, false); @@ -122,4 +124,4 @@ document.getElementById("mp_pp_track").addEventListener("click", mpPpTrack, fals document.getElementById("mp_prev_track").addEventListener("click", mpPrevious, false); document.getElementById("mp_next_track").addEventListener("click", mpNext, false); -mpUpdate(); +mpInit(); diff --git a/ui/html/base.tmpl.html b/ui/html/base.tmpl.html index dda949b..014868e 100644 --- a/ui/html/base.tmpl.html +++ b/ui/html/base.tmpl.html @@ -3,7 +3,7 @@ <html lang="en"> <head> <meta charset="UTF-8" /> - <meta name="viewport" content="width=device-width, initial-scale=1" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta property="og:site_name" content="paterissa" /> <meta property="og:type" content="object" /> <meta property="og:title" content="Paterissa - thehinterlander's blog" /> @@ -16,11 +16,11 @@ <img src="/static/get?file=images/bg.png" id="bg-image"> <div class="pane"> <header> - <h1>Paterissa</h1> + <h1><a href="https://paterissa.net">Paterissa</a></h1> <nav> - <a href="https://git.paterissa.net">Git</a> - <a href="https://starless.paterissa.net">Starless</a> - <a href="https://charts.paterissa.net">Parliament Builder</a> + <a href="https://git.paterissa.net" class="nav_tag">Git</a> + <a href="https://starless.paterissa.net" class="nav_tag">Starless</a> + <a href="https://charts.paterissa.net" class="nav_tag">Parliament Builder</a> </nav> </header> <hr> diff --git a/ui/html/login.tmpl.html b/ui/html/login.tmpl.html new file mode 100644 index 0000000..6377dc0 --- /dev/null +++ b/ui/html/login.tmpl.html @@ -0,0 +1,19 @@ +{{define "title"}}Login{{end}} + +{{define "main"}} + <div class="topline"> + <h2>Log In</h2> + </div> + <div class="card"> + <form class="login_form center" action="/login" method="POST"> + <label for="user">Username:</label> + <input type="text" id="user" name="user"> + <label for="pass_one">Password:</label> + <input type="text" id="pass_one" name="pass_one"> + <label for="pass_two">Password:</label> + <input type="text" id="pass_two" name="pass_two"> + <br> + <input type="submit" value="Submit"> + </form> + </div> +{{end}} diff --git a/ui/html/pages/index.tmpl.html b/ui/html/pages/index.tmpl.html index 78c2bb2..011f964 100644 --- a/ui/html/pages/index.tmpl.html +++ b/ui/html/pages/index.tmpl.html @@ -1,10 +1,25 @@ {{define "title"}}Blog{{end}} {{define "main"}} - <div class="topline"> - <h2>Blog</h2> - <p>Home of {{.Name}}</p> + <div class="flex-between"> + <div class="topline"> + <h2>Blog</h2> + <p>Home of {{.Name}}</p> + </div> + <div class="spacer"></div> + {{if .IsAuth}} + <a href="/logout" class="nav_tag right">Logout</a> + {{else}} + <a href="/login" class="nav_tag right">Login</a> + {{end}} </div> + {{if .IsAuth}} + <div class="card"> + <div class="header"> + <h4><b>New</b></h4> + </div> + </div> + {{end}} {{range .Rows}} <div class="card"> <div class="header"> |
