cerca

lean forum software (pmc local branch)
Log | Files | Refs | README | LICENSE

commit 1084f24f239ce5d2c6234265e8f713ed20181551
parent 1d81efc1fd3aa3a71b4ef09076251559fcfb5702
Author: Alexander Cobleigh <cblgh@cblgh.org>
Date:   Thu, 13 Jan 2022 12:34:06 +0100

add delete post functionality (#15)

* Added header to group the home link and nav. Added aria label to link for screen readers

* Titles need to start with h1 and flow logically to h2, h3, etc...

* Inputs need labels to be voiced correctly, placeholders are not enough and they disappear when input is active. Added an aria-described on the password input to describe the requirements. Hardcoded styles are temporary.

* Changed some styles for the header and navigations. Same design, just more logical CSS.

* Grouped threads in main to allow screen readers users to jump directly to it. Moved the new thread to aside, as it's semantically aside from the list thread.

* Added main for semantics, added labels for inputs, h2 to h1.

* Inlined the logo with the correct size instead of img tag. Removed margin-bottom so the logo and the nav are aligned and centered.

* Added visually hidden class

* Added some semantic text and hid it to sighted readers with the visually hidden class. It will be audible by screen readers. Did the same for the label as the label may not be 100% needed.

* Added main, label for inputs, made the form vertical for readability. h2 to h1.

* Moved instructions before form. If you use a screen reader, you start filling the form before you have instructions. If you don't go below the form, you don't know why it exists.

* Added password instructions

* Added main and fixed title h2 to h1

* Error messages should not be in footer. Informations should be before form, as you can't know about it before filling the form.

* Changed div>p to article>p to align with the current design but keep semantics

* Added main, h2 to h1.

* Forgot to put everything into main, and article for this page

* add delete post functionality

* add confirmation dialog on delete (and prevent unregrettable mistakes!)

* improve error messages when deleting posts

Co-authored-by: Thomasorus <contact@thomasorus.com>
Diffstat:
Mdatabase/database.go | 24++++++++++++++++++++----
Mhtml/thread.html | 20+++++++++++++++++++-
Mserver/server.go | 94+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Mutil/util.go | 17+++++++++++++++++
4 files changed, 127 insertions(+), 28 deletions(-)

diff --git a/database/database.go b/database/database.go @@ -162,9 +162,11 @@ func (d DB) CreateThread(title, content string, authorid, topicid int) (int, err // https://medium.com/aubergine-solutions/how-i-handled-null-possible-values-from-database-rows-in-golang-521fb0ee267 // type NullTime sql.NullTime type Post struct { + ID int ThreadTitle string Content template.HTML Author string + AuthorID int Publish time.Time LastEdit sql.NullTime // TODO: handle json marshalling with custom type } @@ -182,7 +184,7 @@ func (d DB) GetThread(threadid int) []Post { // users table to get user name // threads table to get thread title query := ` - SELECT t.title, content, u.name, p.publishtime, p.lastedit + SELECT p.id, t.title, content, u.name, p.authorid, p.publishtime, p.lastedit FROM posts p INNER JOIN users u ON u.id = p.authorid INNER JOIN threads t ON t.id = p.threadid @@ -200,7 +202,7 @@ func (d DB) GetThread(threadid int) []Post { var data Post var posts []Post for rows.Next() { - if err := rows.Scan(&data.ThreadTitle, &data.Content, &data.Author, &data.Publish, &data.LastEdit); err != nil { + if err := rows.Scan(&data.ID, &data.ThreadTitle, &data.Content, &data.Author, &data.AuthorID, &data.Publish, &data.LastEdit); err != nil { log.Fatalln(util.Eout(err, "get data for thread %d", threadid)) } posts = append(posts, data) @@ -208,6 +210,20 @@ func (d DB) GetThread(threadid int) []Post { return posts } +func (d DB) GetPost(postid int) (Post, error) { + stmt := ` + SELECT p.id, t.title, content, u.name, p.authorid, p.publishtime, p.lastedit + FROM posts p + INNER JOIN users u ON u.id = p.authorid + INNER JOIN threads t ON t.id = p.threadid + WHERE p.id = ? + ` + var data Post + err := d.db.QueryRow(stmt, postid).Scan(&data.ID, &data.ThreadTitle, &data.Content, &data.Author, &data.AuthorID, &data.Publish, &data.LastEdit) + err = util.Eout(err, "get data for thread %d", postid) + return data, err +} + type Thread struct { Title string Author string @@ -257,10 +273,10 @@ func (d DB) EditPost(content string, postid int) { util.Check(err, "edit post %d", postid) } -func (d DB) DeletePost(postid int) { +func (d DB) DeletePost(postid int) error { stmt := `DELETE FROM posts WHERE id = ?` _, err := d.Exec(stmt, postid) - util.Check(err, "deleting post %d", postid) + return util.Eout(err, "deleting post %d", postid) } func (d DB) CreateTopic(title, description string) { diff --git a/html/thread.html b/html/thread.html @@ -1,10 +1,28 @@ {{ template "head" . }} <main> <h1>{{ .Data.Title }}</h1> + {{ $userID := .LoggedInID }} + {{ $threadURL := .Data.ThreadURL }} {{ range $index, $post := .Data.Posts }} <article> <div> - <p><span><b>{{ $post.Author }}</b><span class="visually-hidden"> responded:</span></span><span style="float: right;"><time datetime="{{ $post.Publish | formatDate }}">{{ $post.Publish | formatDateRelative }}</time></span></p> + {{ if eq $post.AuthorID $userID }} + <span style="float: right;" aria-label="Delete this post"> + <form style="display: inline-block;" method="POST" action="/post/delete/{{ $post.ID }}" + onsubmit="return confirm('Delete post for all posterity?');" + > + <button style="background-color: transparent; border: 0; padding: 0;" type="submit">delete</button> + <input type="hidden" name="thread" value="{{ $threadURL }}"> + </form> + </span> + {{ end }} + <p> + <span><b>{{ $post.Author }}</b> + <span class="visually-hidden"> responded:</span></span> + <span style="margin-left: 0.5rem; font-style: italic;"> + <time datetime="{{ $post.Publish | formatDate }}">{{ $post.Publish | formatDateRelative }}</time> + </span> + </p> {{ $post.Content }} </article> {{ end }} diff --git a/server/server.go b/server/server.go @@ -7,7 +7,6 @@ import ( "html/template" "net/http" "net/url" - "strconv" "strings" "time" @@ -23,9 +22,10 @@ import ( /* TODO (2022-01-03): include csrf token via gorilla, or w/e, when rendering */ type TemplateData struct { - Data interface{} - LoggedIn bool // TODO (2022-01-09): put this in a middleware || template function or sth? - Title string + Data interface{} + LoggedIn bool // TODO (2022-01-09): put this in a middleware || template function or sth? + LoggedInID int + Title string } type IndexData struct { @@ -54,8 +54,9 @@ type LoginData struct { } type ThreadData struct { - Title string - Posts []database.Post + Title string + Posts []database.Post + ThreadURL string } type RequestHandler struct { @@ -159,21 +160,14 @@ func (h RequestHandler) renderView(res http.ResponseWriter, viewName string, dat } func (h RequestHandler) ThreadRoute(res http.ResponseWriter, req *http.Request) { - parts := strings.Split(strings.TrimSpace(req.URL.Path), "/") - // invalid route, redirect to index - if len(parts) < 2 || parts[2] == "" { - IndexRedirect(res, req) - return - } + threadid, ok := util.GetURLPortion(req, 2) loggedIn, userid := h.IsLoggedIn(req) - threadid, err := strconv.Atoi(parts[2]) - if err != nil { - dump(err) - title := "Page not found" + if !ok { + title := "Thread not found" data := GenericMessageData{ Title: title, - Message: "The visited page does not exist (anymore?)", + Message: "The thread does not exist (anymore?)", } h.renderView(res, "generic-message", TemplateData{Data: data, LoggedIn: loggedIn}) return @@ -197,7 +191,7 @@ func (h RequestHandler) ThreadRoute(res http.ResponseWriter, req *http.Request) thread[i].Content = util.Markup(post.Content) } title := thread[0].ThreadTitle - view := TemplateData{ThreadData{title, thread}, loggedIn, title} + view := TemplateData{Data: ThreadData{title, thread, req.URL.Path}, LoggedIn: loggedIn, LoggedInID: userid, Title: title} h.renderView(res, "thread", view) } @@ -219,7 +213,7 @@ func (h RequestHandler) IndexRoute(res http.ResponseWriter, req *http.Request) { loggedIn, _ := h.IsLoggedIn(req) // show index listing threads := h.db.ListThreads() - view := TemplateData{IndexData{threads}, loggedIn, "threads"} + view := TemplateData{Data: IndexData{threads}, LoggedIn: loggedIn, Title: "threads"} h.renderView(res, "index", view) } @@ -240,7 +234,7 @@ func (h RequestHandler) LoginRoute(res http.ResponseWriter, req *http.Request) { loggedIn, _ := h.IsLoggedIn(req) switch req.Method { case "GET": - h.renderView(res, "login", TemplateData{LoginData{}, loggedIn, ""}) + h.renderView(res, "login", TemplateData{Data: LoginData{}, LoggedIn: loggedIn, Title: ""}) case "POST": username := req.PostFormValue("username") password := req.PostFormValue("password") @@ -252,7 +246,7 @@ func (h RequestHandler) LoginRoute(res http.ResponseWriter, req *http.Request) { } if err != nil { fmt.Println(err) - h.renderView(res, "login", TemplateData{LoginData{FailedAttempt: true}, loggedIn, ""}) + h.renderView(res, "login", TemplateData{Data: LoginData{FailedAttempt: true}, LoggedIn: loggedIn, Title: ""}) return } // save user id in cookie @@ -291,7 +285,7 @@ func (h RequestHandler) RegisterRoute(res http.ResponseWriter, req *http.Request LinkMessage: "Visit the", LinkText: "index", } - h.renderView(res, "generic-message", TemplateData{data, loggedIn, "register"}) + h.renderView(res, "generic-message", TemplateData{Data: data, LoggedIn: loggedIn, Title: "register"}) return } @@ -383,7 +377,7 @@ func (h RequestHandler) RegisterRoute(res http.ResponseWriter, req *http.Request ed.Check(err, "generate keypair") kpJson, err := keypair.Marshal() ed.Check(err, "marshal keypair") - h.renderView(res, "register-success", TemplateData{RegisterSuccessData{string(kpJson)}, loggedIn, "registered successfully"}) + h.renderView(res, "register-success", TemplateData{Data: RegisterSuccessData{string(kpJson)}, LoggedIn: loggedIn, Title: "registered successfully"}) default: fmt.Println("non get/post method, redirecting to index") IndexRedirect(res, req) @@ -452,6 +446,59 @@ func (h RequestHandler) NewThreadRoute(res http.ResponseWriter, req *http.Reques } } +func (h RequestHandler) DeletePostRoute(res http.ResponseWriter, req *http.Request) { + if req.Method == "GET" { + IndexRedirect(res, req) + return + } + threadURL := req.PostFormValue("thread") + postid, ok := util.GetURLPortion(req, 3) + loggedIn, userid := h.IsLoggedIn(req) + + // generic error message base, with specifics being swapped out depending on the error + genericErr := GenericMessageData{ + Title: "Unaccepted request", + LinkMessage: "Go back to", + Link: threadURL, + LinkText: "the thread", + } + + renderErr := func(msg string) { + fmt.Println(msg) + genericErr.Message = msg + h.renderView(res, "generic-message", TemplateData{Data: genericErr, LoggedIn: loggedIn}) + } + + if !loggedIn || !ok { + renderErr("Invalid post id, or you were not allowed to delete it") + return + } + + post, err := h.db.GetPost(postid) + if err != nil { + dump(err) + renderErr("The post you tried to delete was not found") + return + } + + authorized := post.AuthorID == userid + switch req.Method { + case "POST": + if authorized { + err = h.db.DeletePost(postid) + if err != nil { + dump(err) + renderErr("Error happened while deleting the post") + return + } + } else { + renderErr("That's not your post to delete? Sorry buddy!") + return + } + } + http.Redirect(res, req, threadURL, http.StatusSeeOther) +} + func Serve(allowlist []string, sessionKey string, isdev bool) { port := ":8272" dbpath := "./data/forum.db" @@ -469,6 +516,7 @@ func Serve(allowlist []string, sessionKey string, isdev bool) { http.HandleFunc("/logout", handler.LogoutRoute) http.HandleFunc("/login", handler.LoginRoute) http.HandleFunc("/register", handler.RegisterRoute) + http.HandleFunc("/post/delete/", handler.DeletePostRoute) http.HandleFunc("/thread/new/", handler.NewThreadRoute) http.HandleFunc("/thread/", handler.ThreadRoute) http.HandleFunc("/robots.txt", handler.RobotsRoute) diff --git a/util/util.go b/util/util.go @@ -4,7 +4,9 @@ import ( "fmt" "html/template" "log" + "net/http" "net/url" + "strconv" "strings" "github.com/gomarkdown/markdown" @@ -98,3 +100,18 @@ func SanitizeURL(input string) string { // TODO(2022-01-08): evaluate use of strict content guardian? return strings.ToLower(input) } + +// returns an id from a url path, and a boolean. the boolean is true if we're returning what we expect; false if the +// operation failed +func GetURLPortion(req *http.Request, index int) (int, bool) { + var desiredID int + parts := strings.Split(strings.TrimSpace(req.URL.Path), "/") + if len(parts) < index || parts[index] == "" { + return -1, false + } + desiredID, err := strconv.Atoi(parts[index]) + if err != nil { + return -1, false + } + return desiredID, true +}