Start forum editing experience, including bbcode parser
This commit is contained in:
		
							parent
							
								
									582ad9ee9e
								
							
						
					
					
						commit
						00b0383030
					
				|  | @ -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; | ||||
|  |  | |||
|  | @ -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") | ||||
|  |  | |||
|  | @ -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<cats>[^\d/]+(/[^\d]+)*))?/new?$`) | ||||
| var RegexForumNewThread = regexp.MustCompile(`^/forums(/(?P<cats>[^\d/]+(/[^\d]+)*))?/t/new$`) | ||||
| var RegexForumNewThreadSubmit = regexp.MustCompile(`^/forums(/(?P<cats>[^\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) | ||||
| } | ||||
|  |  | |||
|  | @ -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") | ||||
| } | ||||
|  | @ -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"` | ||||
| } | ||||
|  |  | |||
|  | @ -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<name>[a-zA-Z0-9]+)(?:\s*=\s*(?:'(?P<single_quoted_val>.*?)'|"(?P<double_quoted_val>.*?)"|(?P<bare_val>[^\s\]]+)))?` | ||||
| var reTagOpenStr = `\[\s*(?P<args>(?:` + StripNames(reArgStr) + `)(?:\s+(?:` + StripNames(reArgStr) + `))*)\s*\]` | ||||
| var reTagCloseStr = `\[/\s*(?P<name>[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 | ||||
| } | ||||
|  | @ -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, <strong>amazing</strong> <em>incredible</em> <strong><em><u>world!!!</u></em></strong>" | ||||
| 		assert.Equal(t, expected, ParseBBCode(bbcode)) | ||||
| 	}) | ||||
| } | ||||
|  | @ -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; | ||||
| } | ||||
|  |  | |||
|  | @ -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 { | ||||
|  |  | |||
|  | @ -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, | ||||
|  |  | |||
|  | @ -0,0 +1,94 @@ | |||
| {{ template "base.html" . }} | ||||
| 
 | ||||
| {{ define "extrahead" }} | ||||
|     <link rel="stylesheet" href="{{ static "editor.css" }}" /> | ||||
|     <script type="text/javascript" src="{{ static "util.js" }}"></script> | ||||
|     <script type="text/javascript" src="{{ static "editor.js" }}"></script> | ||||
| {{ end }} | ||||
| 
 | ||||
| {{ define "content" }} | ||||
| <div class="content-block"> | ||||
|     <form action="{{ .SubmitUrl }}" method="post"> | ||||
|         {{ csrftoken .Session }} | ||||
| 
 | ||||
|         <input class="b w-100 mb1" name="title" type="text" placeholder="Post title..." value="{{ .PostTitle }}"/> | ||||
|         {{/* TODO: Reintroduce the toolbar in a way that makes sense for Markdown */}} | ||||
|         {{/* | ||||
|         <div class="toolbar" id="toolbar"> | ||||
|             <input type="button" id="bold" value="B" /> | ||||
|             <input type="button" id="italic" value="I" />  | ||||
|             <input type="button" id="underline" value="U" />  | ||||
|             <input type="button" id="monospace" value="monospace" />  | ||||
|             <input type="button" id="url" value="url" />  | ||||
|             <input type="button" id="img" value="img" />  | ||||
|             <input type="button" id="code" value="code" />  | ||||
|             <input type="button" id="quote_simple" value="quote (anon)" />  | ||||
|             <input type="button" id="quote_member" value="quote (member)" />  | ||||
|             <input type="button" id="spoiler" value="spoiler" />  | ||||
|             <input type="button" id="lalign" value="Left" />  | ||||
|             <input type="button" id="calign" value="Center" />  | ||||
|             <input type="button" id="ralign" value="Right" /> | ||||
|             <input type="button" id="ulist" value="ul" /> | ||||
|             <input type="button" id="olist" value="ol" /> | ||||
|             <input type="button" id="litem" value="li" /> | ||||
|             <input type="button" id="youtube" value="youtube" /> | ||||
|         </div> | ||||
|         */}} | ||||
|         <textarea class="w-100 minw-100 mw-100 h5 minh-5" name="body">{{ .PostBody }}</textarea> | ||||
| 
 | ||||
|         <div class="flex flex-row-reverse justify-start mt2"> | ||||
|             <input type="submit" class="button ml2" name="submit" value="{{ .SubmitLabel }}" /> | ||||
|             <input type="submit" class="button ml2" name="preview" value="{{ .PreviewLabel }}" /> | ||||
|         </div> | ||||
|          | ||||
|         {{/* | ||||
|         {% if are_editing %} | ||||
|             <span class="editreason"> | ||||
|                 <label for="editreason">Edit reason:</label> | ||||
|                 <input name="editreason" maxlength="255" type="text" id="editreason" value="{% if preview_edit_reason|length > 0 %}{{preview_edit_reason}}{% endif %}"/> | ||||
|             </span> | ||||
|         {% endif %} | ||||
|          | ||||
|         {% if user.is_staff and post and post.depth == 0 %} | ||||
|             <div class="checkbox sticky"> | ||||
|                 <input type="checkbox" name="sticky" id="sticky" {% if thread.sticky %}checked{% endif%} /> | ||||
|                 <label for="sticky">Sticky thread</label> | ||||
|             </div> | ||||
|         {% endif %} | ||||
| 
 | ||||
|          | ||||
|          | ||||
|         {% if are_previewing %} | ||||
|             {% include "edit_preview.html" %} | ||||
|         {% endif %} | ||||
| 
 | ||||
|         {% if context_reply_to %} | ||||
|             <h4>The post you're replying to:</h4> | ||||
|             <div class="recent-posts"> | ||||
|                 {% with post=post_reply_to %} | ||||
|                     {% include "forum_thread_single_post.html" %} | ||||
|                 {% endwith %} | ||||
|             </div> | ||||
|         {% endif %} | ||||
| 
 | ||||
|         {% if context_newer %} | ||||
|             <h4>Replies since then:</h4> | ||||
|             <div class="recent-posts"> | ||||
|                 {% for post in posts_newer %} | ||||
|                     {% include "forum_thread_single_post.html" %} | ||||
|                 {% endfor %} | ||||
|             </div> | ||||
|         {% endif %} | ||||
| 
 | ||||
|         {% if context_older %} | ||||
|             <h4>Replies before then:</h4> | ||||
|             <div class="recent-posts"> | ||||
|                 {% for post in posts_older %} | ||||
|                     {% include "forum_thread_single_post.html" %} | ||||
|                 {% endfor %} | ||||
|             </div> | ||||
|         {% endif %} | ||||
|         */}} | ||||
|     </form> | ||||
| </div> | ||||
| {{ end }} | ||||
|  | @ -24,7 +24,7 @@ | |||
|                         {{/* TODO: Forgot password flow? Or just on standalone page? */}} | ||||
|                     </table> | ||||
|                     <input type="hidden" name="redirect" value="{{ $.CurrentUrl }}"> | ||||
|                     <div class="actionbar pt2"> | ||||
|                     <div class="pt2"> | ||||
|                         <input type="submit" value="Log In" /> | ||||
|                     </div> | ||||
|                 </form> | ||||
|  |  | |||
|  | @ -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(`<input type="hidden" name="csrf_token" value="%s">`, s.CSRFToken)) | ||||
| 	}, | ||||
| 	"darken": func(amount float64, color noire.Color) noire.Color { | ||||
| 		return color.Shade(amount) | ||||
| 	}, | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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) | ||||
| 	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 | ||||
|  |  | |||
|  | @ -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()) { | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue