Start forum editing experience, including bbcode parser

This commit is contained in:
Ben Visness 2021-06-11 22:51:07 -05:00
parent 582ad9ee9e
commit 00b0383030
17 changed files with 413 additions and 102 deletions

View File

@ -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;

View File

@ -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")

View File

@ -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)
}

View File

@ -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")
}

View File

@ -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"`
}

87
src/parsing/parsing.go Normal file
View File

@ -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
}

View File

@ -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))
})
}

View File

@ -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;
}

View File

@ -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 {

View File

@ -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,

View File

@ -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 }}

View File

@ -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>

View File

@ -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)
},

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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()) {