From 00b0383030881088fac31f789e1221367700699e Mon Sep 17 00:00:00 2001 From: Ben Visness Date: Fri, 11 Jun 2021 22:51:07 -0500 Subject: [PATCH] Start forum editing experience, including bbcode parser --- public/style.css | 47 ++++------ src/auth/session.go | 15 ++- src/hmnurl/urls.go | 10 +- .../2021-06-12T014231Z_AddCSRFToken.go | 44 +++++++++ src/models/session.go | 1 + src/parsing/parsing.go | 87 +++++++++++++++++ src/parsing/parsing_test.go | 26 +++++ src/rawdata/scss/_core.scss | 24 +++++ src/rawdata/scss/_editor.scss | 42 --------- src/templates/mapping.go | 6 ++ src/templates/src/editor.html | 94 +++++++++++++++++++ src/templates/src/include/header.html | 2 +- src/templates/templates.go | 3 + src/templates/types.go | 5 + src/website/forums.go | 46 ++++++++- src/website/requesthandling.go | 23 +++-- src/website/routes.go | 40 +++++--- 17 files changed, 413 insertions(+), 102 deletions(-) create mode 100644 src/migration/migrations/2021-06-12T014231Z_AddCSRFToken.go create mode 100644 src/parsing/parsing.go create mode 100644 src/parsing/parsing_test.go create mode 100644 src/templates/src/editor.html diff --git a/public/style.css b/public/style.css index 51cfa76e..cd46ccc4 100644 --- a/public/style.css +++ b/public/style.css @@ -7462,6 +7462,24 @@ article code { .mh-80vh { max-height: 80vh; } +.minw-100 { + min-width: 100%; } + +.minh-1 { + min-height: 1rem; } + +.minh-2 { + min-height: 2rem; } + +.minh-3 { + min-height: 4rem; } + +.minh-4 { + min-height: 8rem; } + +.minh-5 { + min-height: 16rem; } + .fira { font-family: "Fira Sans", sans-serif; } @@ -7981,10 +7999,6 @@ header { pre { font-family: monospace; } -.post-edit { - width: 90%; - margin: auto; } - .toolbar { background-color: #fff; background-color: var(--editor-toolbar-background); @@ -8019,27 +8033,6 @@ pre { border: 0px solid transparent; /* Not themed */ } -.actionbar { - text-align: center; } - -.editor .body { - width: 100%; - font-size: 13pt; - height: 25em; } - -.editor .title-edit { - width: 100%; } - .editor .title-edit label { - font-weight: bold; } - .editor .title-edit input { - width: 100%; } - -.editor .editreason label { - font-weight: bold; } - -.editor .editreason input { - width: 100%; } - .editor .toolbar { width: 95%; margin: 10px auto; } @@ -8062,10 +8055,6 @@ pre { text-decoration: underline; font-style: italic; } -.editor .actionbar input[type=button] { - margin-left: 10px; - margin-right: 10px; } - .edit-form .error { margin-left: 5em; padding: 10px; diff --git a/src/auth/session.go b/src/auth/session.go index ad81e118..e02c9740 100644 --- a/src/auth/session.go +++ b/src/auth/session.go @@ -31,6 +31,16 @@ func makeSessionId() string { return base64.StdEncoding.EncodeToString(idBytes)[:40] } +func makeCSRFToken() string { + idBytes := make([]byte, 30) + _, err := io.ReadFull(rand.Reader, idBytes) + if err != nil { + panic(err) + } + + return base64.StdEncoding.EncodeToString(idBytes)[:30] +} + var ErrNoSession = errors.New("no session found") func GetSession(ctx context.Context, conn *pgxpool.Pool, id string) (*models.Session, error) { @@ -52,11 +62,12 @@ func CreateSession(ctx context.Context, conn *pgxpool.Pool, username string) (*m ID: makeSessionId(), Username: username, ExpiresAt: time.Now().Add(sessionDuration), + CSRFToken: makeCSRFToken(), } _, err := conn.Exec(ctx, - "INSERT INTO sessions (id, username, expires_at) VALUES ($1, $2, $3)", - session.ID, session.Username, session.ExpiresAt, + "INSERT INTO sessions (id, username, expires_at, csrf_token) VALUES ($1, $2, $3, $4)", + session.ID, session.Username, session.ExpiresAt, session.CSRFToken, ) if err != nil { return nil, oops.New(err, "failed to persist session") diff --git a/src/hmnurl/urls.go b/src/hmnurl/urls.go index fee567d8..6a0cf440 100644 --- a/src/hmnurl/urls.go +++ b/src/hmnurl/urls.go @@ -267,12 +267,16 @@ func BuildForumCategory(projectSlug string, subforums []string, page int) string return ProjectUrl(builder.String(), nil, projectSlug) } -var RegexForumNewThread = regexp.MustCompile(`^/forums(/(?P[^\d/]+(/[^\d]+)*))?/new?$`) +var RegexForumNewThread = regexp.MustCompile(`^/forums(/(?P[^\d/]+(/[^\d]+)*))?/t/new$`) +var RegexForumNewThreadSubmit = regexp.MustCompile(`^/forums(/(?P[^\d/]+(/[^\d]+)*))?/t/new/submit$`) -func BuildForumNewThread(projectSlug string, subforums []string) string { +func BuildForumNewThread(projectSlug string, subforums []string, submit bool) string { defer CatchPanic() builder := buildForumCategoryPath(subforums) - builder.WriteString("/new") + builder.WriteString("/t/new") + if submit { + builder.WriteString("/submit") + } return ProjectUrl(builder.String(), nil, projectSlug) } diff --git a/src/migration/migrations/2021-06-12T014231Z_AddCSRFToken.go b/src/migration/migrations/2021-06-12T014231Z_AddCSRFToken.go new file mode 100644 index 00000000..d329a5a8 --- /dev/null +++ b/src/migration/migrations/2021-06-12T014231Z_AddCSRFToken.go @@ -0,0 +1,44 @@ +package migrations + +import ( + "context" + "time" + + "git.handmade.network/hmn/hmn/src/migration/types" + "git.handmade.network/hmn/hmn/src/oops" + "github.com/jackc/pgx/v4" +) + +func init() { + registerMigration(AddCSRFToken{}) +} + +type AddCSRFToken struct{} + +func (m AddCSRFToken) Version() types.MigrationVersion { + return types.MigrationVersion(time.Date(2021, 6, 12, 1, 42, 31, 0, time.UTC)) +} + +func (m AddCSRFToken) Name() string { + return "AddCSRFToken" +} + +func (m AddCSRFToken) Description() string { + return "Adds a CSRF token to user sessions" +} + +func (m AddCSRFToken) Up(ctx context.Context, tx pgx.Tx) error { + _, err := tx.Exec(ctx, ` + ALTER TABLE sessions + ADD csrf_token VARCHAR(30) NOT NULL; + `) + if err != nil { + return oops.New(err, "failed to add CSRF token column") + } + + return nil +} + +func (m AddCSRFToken) Down(ctx context.Context, tx pgx.Tx) error { + panic("Implement me") +} diff --git a/src/models/session.go b/src/models/session.go index 578d421b..3f57b191 100644 --- a/src/models/session.go +++ b/src/models/session.go @@ -6,4 +6,5 @@ type Session struct { ID string `db:"id"` Username string `db:"username"` ExpiresAt time.Time `db:"expires_at"` + CSRFToken string `db:"csrf_token"` } diff --git a/src/parsing/parsing.go b/src/parsing/parsing.go new file mode 100644 index 00000000..5666396d --- /dev/null +++ b/src/parsing/parsing.go @@ -0,0 +1,87 @@ +package parsing + +import ( + "regexp" + "sort" +) + +func StripNames(regexStr string) string { + return regexp.MustCompile(`\(\?P<[a-zA-Z0-9_]+>`).ReplaceAllString(regexStr, `(?:`) +} + +var reArgStr = `(?P[a-zA-Z0-9]+)(?:\s*=\s*(?:'(?P.*?)'|"(?P.*?)"|(?P[^\s\]]+)))?` +var reTagOpenStr = `\[\s*(?P(?:` + StripNames(reArgStr) + `)(?:\s+(?:` + StripNames(reArgStr) + `))*)\s*\]` +var reTagCloseStr = `\[/\s*(?P[a-zA-Z0-9]+)?\s*\]` + +var reArg = regexp.MustCompile(reArgStr) +var reTagOpen = regexp.MustCompile(reTagOpenStr) +var reTagClose = regexp.MustCompile(reTagCloseStr) + +const tokenTypeString = "string" +const tokenTypeOpenTag = "openTag" +const tokenTypeCloseTag = "closeTag" + +type token struct { + Type string + StartIndex int + EndIndex int + Contents string +} + +func ParseBBCode(input string) string { + return input +} + +func tokenizeBBCode(input string) []token { + openMatches := reTagOpen.FindAllStringIndex(input, -1) + closeMatches := reTagClose.FindAllStringIndex(input, -1) + + // Build tokens from regex matches + var tagTokens []token + for _, match := range openMatches { + tagTokens = append(tagTokens, token{ + Type: tokenTypeOpenTag, + StartIndex: match[0], + EndIndex: match[1], + Contents: input[match[0]:match[1]], + }) + } + for _, match := range closeMatches { + tagTokens = append(tagTokens, token{ + Type: tokenTypeCloseTag, + StartIndex: match[0], + EndIndex: match[1], + Contents: input[match[0]:match[1]], + }) + } + + // Sort those tokens together + sort.Slice(tagTokens, func(i, j int) bool { + return tagTokens[i].StartIndex < tagTokens[j].StartIndex + }) + + // Make a new list of tokens that fills in the gaps with plain old text + var tokens []token + for i, tagToken := range tagTokens { + prevEnd := 0 + if i > 0 { + prevEnd = tagTokens[i-1].EndIndex + } + + tokens = append(tokens, token{ + Type: tokenTypeString, + StartIndex: prevEnd, + EndIndex: tagToken.StartIndex, + Contents: input[prevEnd:tagToken.StartIndex], + }) + tokens = append(tokens, tagToken) + } + tokens = append(tokens, token{ + Type: tokenTypeString, + StartIndex: tokens[len(tokens)-1].EndIndex, + EndIndex: len(input), + Contents: input[tokens[len(tokens)-1].EndIndex:], + }) + + return tokens +} diff --git a/src/parsing/parsing_test.go b/src/parsing/parsing_test.go new file mode 100644 index 00000000..ed210888 --- /dev/null +++ b/src/parsing/parsing_test.go @@ -0,0 +1,26 @@ +package parsing + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseBBCode(t *testing.T) { + const testDoc = `Hello, [b]amazing[/b] [i]incredible[/i] [b][i][u]world!!![/u][/i][/b] + +Too many opening tags: [b]wow.[b] + +Too many closing tags: [/i]wow.[/i] + +Mix 'em: [u][/i]wow.[/i][/u] + +[url=https://google.com/]Google![/url] +` + + t.Run("hello world", func(t *testing.T) { + bbcode := "Hello, [b]amazing[/b] [i]incredible[/i] [b][i][u]world!!![/u][/i][/b]" + expected := "Hello, amazing incredible world!!!" + assert.Equal(t, expected, ParseBBCode(bbcode)) + }) +} diff --git a/src/rawdata/scss/_core.scss b/src/rawdata/scss/_core.scss index d0daa3ba..09fc8f01 100644 --- a/src/rawdata/scss/_core.scss +++ b/src/rawdata/scss/_core.scss @@ -280,6 +280,30 @@ article code { max-height: 80vh; } +.minw-100 { + min-width: 100%; +} + +.minh-1 { + min-height: $height-1; +} + +.minh-2 { + min-height: $height-2; +} + +.minh-3 { + min-height: $height-3; +} + +.minh-4 { + min-height: $height-4; +} + +.minh-5 { + min-height: $height-5; +} + .fira { font-family: "Fira Sans", sans-serif; } diff --git a/src/rawdata/scss/_editor.scss b/src/rawdata/scss/_editor.scss index 808e7b11..d1c1e5b8 100644 --- a/src/rawdata/scss/_editor.scss +++ b/src/rawdata/scss/_editor.scss @@ -1,8 +1,3 @@ -.post-edit { - width:90%; - margin:auto; -} - .toolbar { @include usevar('background-color', 'editor-toolbar-background'); @include usevar('border-color', 'editor-toolbar-border-color'); @@ -40,39 +35,7 @@ } } -.actionbar { - text-align:center; -} - .editor { - .body { - width:100%; - font-size:13pt; - height:25em; - } - - .title-edit { - width:100%; - - label { - font-weight:bold; - } - - input { - width:100%; - } - } - - .editreason { - label { - font-weight:bold; - } - - input { - width:100%; - } - } - .toolbar { width:95%; margin:10px auto; @@ -111,11 +74,6 @@ font-style: italic; } } - - .actionbar input[type=button] { - margin-left:10px; - margin-right:10px; - } } .edit-form { diff --git a/src/templates/mapping.go b/src/templates/mapping.go index 7a329617..1f359495 100644 --- a/src/templates/mapping.go +++ b/src/templates/mapping.go @@ -115,6 +115,12 @@ func ProjectToTemplate(p *models.Project, theme string) Project { } } +func SessionToTemplate(s *models.Session) Session { + return Session{ + CSRFToken: s.CSRFToken, + } +} + func ThreadToTemplate(t *models.Thread) Thread { return Thread{ Title: t.Title, diff --git a/src/templates/src/editor.html b/src/templates/src/editor.html new file mode 100644 index 00000000..e8bf3cdb --- /dev/null +++ b/src/templates/src/editor.html @@ -0,0 +1,94 @@ +{{ template "base.html" . }} + +{{ define "extrahead" }} + + + +{{ end }} + +{{ define "content" }} +
+
+ {{ csrftoken .Session }} + + + {{/* TODO: Reintroduce the toolbar in a way that makes sense for Markdown */}} + {{/* +
+ + + + + + + + + + + + + + + + + +
+ */}} + + +
+ + +
+ + {{/* + {% if are_editing %} + + + + + {% endif %} + + {% if user.is_staff and post and post.depth == 0 %} +
+ + +
+ {% endif %} + + + + {% if are_previewing %} + {% include "edit_preview.html" %} + {% endif %} + + {% if context_reply_to %} +

The post you're replying to:

+
+ {% with post=post_reply_to %} + {% include "forum_thread_single_post.html" %} + {% endwith %} +
+ {% endif %} + + {% if context_newer %} +

Replies since then:

+
+ {% for post in posts_newer %} + {% include "forum_thread_single_post.html" %} + {% endfor %} +
+ {% endif %} + + {% if context_older %} +

Replies before then:

+
+ {% for post in posts_older %} + {% include "forum_thread_single_post.html" %} + {% endfor %} +
+ {% endif %} + */}} +
+
+{{ end }} diff --git a/src/templates/src/include/header.html b/src/templates/src/include/header.html index ca43b20d..62ccc245 100644 --- a/src/templates/src/include/header.html +++ b/src/templates/src/include/header.html @@ -24,7 +24,7 @@ {{/* TODO: Forgot password flow? Or just on standalone page? */}} -
+
diff --git a/src/templates/templates.go b/src/templates/templates.go index e713c3c7..09640cdc 100644 --- a/src/templates/templates.go +++ b/src/templates/templates.go @@ -112,6 +112,9 @@ var HMNTemplateFuncs = template.FuncMap{ "color2css": func(color noire.Color) template.CSS { return template.CSS(color.HTML()) }, + "csrftoken": func(s Session) template.HTML { + return template.HTML(fmt.Sprintf(``, s.CSRFToken)) + }, "darken": func(amount float64, color noire.Color) noire.Color { return color.Shade(amount) }, diff --git a/src/templates/types.go b/src/templates/types.go index 4b796440..ec867ad4 100644 --- a/src/templates/types.go +++ b/src/templates/types.go @@ -20,6 +20,7 @@ type BaseData struct { Project Project User *User + Session *Session IsProjectPage bool Header Header @@ -147,6 +148,10 @@ type Link struct { Value string } +type Session struct { + CSRFToken string +} + type OpenGraphItem struct { Property string Name string diff --git a/src/website/forums.go b/src/website/forums.go index 1b0f584e..998f3a83 100644 --- a/src/website/forums.go +++ b/src/website/forums.go @@ -260,7 +260,7 @@ func ForumCategory(c *RequestContext) ResponseData { var res ResponseData err = res.WriteTemplate("forum_category.html", forumCategoryData{ BaseData: baseData, - NewThreadUrl: hmnurl.BuildForumNewThread(c.CurrentProject.Slug, currentSubforumSlugs), + NewThreadUrl: hmnurl.BuildForumNewThread(c.CurrentProject.Slug, currentSubforumSlugs, false), MarkReadUrl: hmnurl.BuildMarkRead(currentCatId), Threads: threads, Pagination: templates.Pagination{ @@ -515,6 +515,50 @@ func ForumPostRedirect(c *RequestContext) ResponseData { ), http.StatusSeeOther) } +type editorData struct { + templates.BaseData + SubmitUrl string + PostTitle string + PostBody string + SubmitLabel string + PreviewLabel string +} + +func ForumNewThread(c *RequestContext) ResponseData { + if c.Req.Method == http.MethodPost { + // TODO: Get preview data + } + + baseData := getBaseData(c) + baseData.Title = "Create New Thread" + // TODO(ben): Set breadcrumbs + + var res ResponseData + err := res.WriteTemplate("editor.html", editorData{ + BaseData: baseData, + SubmitLabel: "Post New Thread", + PreviewLabel: "Preview", + }, c.Perf) + // err := res.WriteTemplate("forum_thread.html", forumThreadData{ + // BaseData: baseData, + // Thread: templates.ThreadToTemplate(&thread), + // Posts: posts, + // CategoryUrl: hmnurl.BuildForumCategory(c.CurrentProject.Slug, currentSubforumSlugs, 1), + // ReplyUrl: hmnurl.BuildForumPostReply(c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, *thread.FirstID), + // Pagination: pagination, + // }, c.Perf) + if err != nil { + panic(err) + } + + return res +} + +func ForumNewThreadSubmit(c *RequestContext) ResponseData { + var res ResponseData + return res +} + func validateSubforums(lineageBuilder *models.CategoryLineageBuilder, project *models.Project, catPath string) (int, bool) { if project.ForumID == nil { return -1, false diff --git a/src/website/requesthandling.go b/src/website/requesthandling.go index f8dcd680..b643df4e 100644 --- a/src/website/requesthandling.go +++ b/src/website/requesthandling.go @@ -47,29 +47,31 @@ func WrapStdHandler(h http.Handler) Handler { type Middleware func(h Handler) Handler -func (rb *RouteBuilder) Handle(method string, regex *regexp.Regexp, h Handler) { +func (rb *RouteBuilder) Handle(methods []string, regex *regexp.Regexp, h Handler) { h = rb.Middleware(h) - rb.Router.Routes = append(rb.Router.Routes, Route{ - Method: method, - Regex: regex, - Handler: h, - }) + for _, method := range methods { + rb.Router.Routes = append(rb.Router.Routes, Route{ + Method: method, + Regex: regex, + Handler: h, + }) + } } func (rb *RouteBuilder) AnyMethod(regex *regexp.Regexp, h Handler) { - rb.Handle("", regex, h) + rb.Handle([]string{""}, regex, h) } func (rb *RouteBuilder) GET(regex *regexp.Regexp, h Handler) { - rb.Handle(http.MethodGet, regex, h) + rb.Handle([]string{http.MethodGet}, regex, h) } func (rb *RouteBuilder) POST(regex *regexp.Regexp, h Handler) { - rb.Handle(http.MethodPost, regex, h) + rb.Handle([]string{http.MethodPost}, regex, h) } func (rb *RouteBuilder) StdHandler(regex *regexp.Regexp, h http.Handler) { - rb.Handle("", regex, WrapStdHandler(h)) + rb.Handle([]string{""}, regex, WrapStdHandler(h)) } func (r *Router) ServeHTTP(rw http.ResponseWriter, req *http.Request) { @@ -122,6 +124,7 @@ type RequestContext struct { Conn *pgxpool.Pool CurrentProject *models.Project CurrentUser *models.User + CurrentSession *models.Session Theme string Perf *perf.RequestPerf diff --git a/src/website/routes.go b/src/website/routes.go index 709cb53e..6b5051af 100644 --- a/src/website/routes.go +++ b/src/website/routes.go @@ -80,6 +80,16 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt } } + authMiddleware := func(h Handler) Handler { + return func(c *RequestContext) (res ResponseData) { + if c.CurrentUser == nil { + return c.Redirect(hmnurl.BuildLoginPage(c.FullUrl()), http.StatusSeeOther) + } + + return h(c) + } + } + // TODO(asaf): login/logout shouldn't happen on subdomains. We should verify that in the middleware. routes.POST(hmnurl.RegexLoginAction, Login) routes.GET(hmnurl.RegexLogoutAction, Logout) @@ -113,6 +123,8 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt mainRoutes.GET(hmnurl.RegexUserProfile, UserProfile) // NOTE(asaf): Any-project routes: + mainRoutes.Handle([]string{http.MethodGet, http.MethodPost}, hmnurl.RegexForumNewThread, authMiddleware(ForumNewThread)) + mainRoutes.POST(hmnurl.RegexForumNewThreadSubmit, authMiddleware(ForumNewThreadSubmit)) mainRoutes.GET(hmnurl.RegexForumThread, ForumThread) mainRoutes.GET(hmnurl.RegexForumCategory, ForumCategory) mainRoutes.GET(hmnurl.RegexForumPost, ForumPostRedirect) @@ -127,14 +139,12 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt func getBaseData(c *RequestContext) templates.BaseData { var templateUser *templates.User + var templateSession *templates.Session if c.CurrentUser != nil { - templateUser = &templates.User{ - Username: c.CurrentUser.Username, - Name: c.CurrentUser.Name, - Email: c.CurrentUser.Email, - IsSuperuser: c.CurrentUser.IsSuperuser, - IsStaff: c.CurrentUser.IsStaff, - } + u := templates.UserToTemplate(c.CurrentUser, c.Theme) + s := templates.SessionToTemplate(c.CurrentSession) + templateUser = &u + templateSession = &s } return templates.BaseData{ @@ -146,6 +156,7 @@ func getBaseData(c *RequestContext) templates.BaseData { Project: templates.ProjectToTemplate(c.CurrentProject, c.Theme), User: templateUser, + Session: templateSession, IsProjectPage: !c.CurrentProject.IsHMN(), Header: templates.Header{ @@ -293,12 +304,13 @@ func LoadCommonWebsiteData(c *RequestContext) (bool, ResponseData) { { sessionCookie, err := c.Req.Cookie(auth.SessionCookieName) if err == nil { - user, err := getCurrentUser(c, sessionCookie.Value) + user, session, err := getCurrentUserAndSession(c, sessionCookie.Value) if err != nil { return false, ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to get current user")) } c.CurrentUser = user + c.CurrentSession = session } // http.ErrNoCookie is the only error Cookie ever returns, so no further handling to do here. } @@ -315,13 +327,13 @@ func LoadCommonWebsiteData(c *RequestContext) (bool, ResponseData) { // Given a session id, fetches user data from the database. Will return nil if // the user cannot be found, and will only return an error if it's serious. -func getCurrentUser(c *RequestContext, sessionId string) (*models.User, error) { +func getCurrentUserAndSession(c *RequestContext, sessionId string) (*models.User, *models.Session, error) { session, err := auth.GetSession(c.Context(), c.Conn, sessionId) if err != nil { if errors.Is(err, auth.ErrNoSession) { - return nil, nil + return nil, nil, nil } else { - return nil, oops.New(err, "failed to get current session") + return nil, nil, oops.New(err, "failed to get current session") } } @@ -329,14 +341,14 @@ func getCurrentUser(c *RequestContext, sessionId string) (*models.User, error) { if err != nil { if errors.Is(err, db.ErrNoMatchingRows) { logging.Debug().Str("username", session.Username).Msg("returning no current user for this request because the user for the session couldn't be found") - return nil, nil // user was deleted or something + return nil, nil, nil // user was deleted or something } else { - return nil, oops.New(err, "failed to get user for session") + return nil, nil, oops.New(err, "failed to get user for session") } } user := userRow.(*models.User) - return user, nil + return user, session, nil } func TrackRequestPerf(c *RequestContext, perfCollector *perf.PerfCollector) (after func()) {