Start forum editing experience, including bbcode parser
This commit is contained in:
parent
582ad9ee9e
commit
00b0383030
|
@ -7462,6 +7462,24 @@ article code {
|
||||||
.mh-80vh {
|
.mh-80vh {
|
||||||
max-height: 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 {
|
.fira {
|
||||||
font-family: "Fira Sans", sans-serif; }
|
font-family: "Fira Sans", sans-serif; }
|
||||||
|
|
||||||
|
@ -7981,10 +7999,6 @@ header {
|
||||||
pre {
|
pre {
|
||||||
font-family: monospace; }
|
font-family: monospace; }
|
||||||
|
|
||||||
.post-edit {
|
|
||||||
width: 90%;
|
|
||||||
margin: auto; }
|
|
||||||
|
|
||||||
.toolbar {
|
.toolbar {
|
||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
background-color: var(--editor-toolbar-background);
|
background-color: var(--editor-toolbar-background);
|
||||||
|
@ -8019,27 +8033,6 @@ pre {
|
||||||
border: 0px solid transparent;
|
border: 0px solid transparent;
|
||||||
/* Not themed */ }
|
/* 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 {
|
.editor .toolbar {
|
||||||
width: 95%;
|
width: 95%;
|
||||||
margin: 10px auto; }
|
margin: 10px auto; }
|
||||||
|
@ -8062,10 +8055,6 @@ pre {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
font-style: italic; }
|
font-style: italic; }
|
||||||
|
|
||||||
.editor .actionbar input[type=button] {
|
|
||||||
margin-left: 10px;
|
|
||||||
margin-right: 10px; }
|
|
||||||
|
|
||||||
.edit-form .error {
|
.edit-form .error {
|
||||||
margin-left: 5em;
|
margin-left: 5em;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
|
|
|
@ -31,6 +31,16 @@ func makeSessionId() string {
|
||||||
return base64.StdEncoding.EncodeToString(idBytes)[:40]
|
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")
|
var ErrNoSession = errors.New("no session found")
|
||||||
|
|
||||||
func GetSession(ctx context.Context, conn *pgxpool.Pool, id string) (*models.Session, error) {
|
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(),
|
ID: makeSessionId(),
|
||||||
Username: username,
|
Username: username,
|
||||||
ExpiresAt: time.Now().Add(sessionDuration),
|
ExpiresAt: time.Now().Add(sessionDuration),
|
||||||
|
CSRFToken: makeCSRFToken(),
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := conn.Exec(ctx,
|
_, err := conn.Exec(ctx,
|
||||||
"INSERT INTO sessions (id, username, expires_at) VALUES ($1, $2, $3)",
|
"INSERT INTO sessions (id, username, expires_at, csrf_token) VALUES ($1, $2, $3, $4)",
|
||||||
session.ID, session.Username, session.ExpiresAt,
|
session.ID, session.Username, session.ExpiresAt, session.CSRFToken,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, oops.New(err, "failed to persist session")
|
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)
|
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()
|
defer CatchPanic()
|
||||||
builder := buildForumCategoryPath(subforums)
|
builder := buildForumCategoryPath(subforums)
|
||||||
builder.WriteString("/new")
|
builder.WriteString("/t/new")
|
||||||
|
if submit {
|
||||||
|
builder.WriteString("/submit")
|
||||||
|
}
|
||||||
|
|
||||||
return ProjectUrl(builder.String(), nil, projectSlug)
|
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"`
|
ID string `db:"id"`
|
||||||
Username string `db:"username"`
|
Username string `db:"username"`
|
||||||
ExpiresAt time.Time `db:"expires_at"`
|
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;
|
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 {
|
.fira {
|
||||||
font-family: "Fira Sans", sans-serif;
|
font-family: "Fira Sans", sans-serif;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,3 @@
|
||||||
.post-edit {
|
|
||||||
width:90%;
|
|
||||||
margin:auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar {
|
.toolbar {
|
||||||
@include usevar('background-color', 'editor-toolbar-background');
|
@include usevar('background-color', 'editor-toolbar-background');
|
||||||
@include usevar('border-color', 'editor-toolbar-border-color');
|
@include usevar('border-color', 'editor-toolbar-border-color');
|
||||||
|
@ -40,39 +35,7 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.actionbar {
|
|
||||||
text-align:center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor {
|
.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 {
|
.toolbar {
|
||||||
width:95%;
|
width:95%;
|
||||||
margin:10px auto;
|
margin:10px auto;
|
||||||
|
@ -111,11 +74,6 @@
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.actionbar input[type=button] {
|
|
||||||
margin-left:10px;
|
|
||||||
margin-right:10px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-form {
|
.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 {
|
func ThreadToTemplate(t *models.Thread) Thread {
|
||||||
return Thread{
|
return Thread{
|
||||||
Title: t.Title,
|
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? */}}
|
{{/* TODO: Forgot password flow? Or just on standalone page? */}}
|
||||||
</table>
|
</table>
|
||||||
<input type="hidden" name="redirect" value="{{ $.CurrentUrl }}">
|
<input type="hidden" name="redirect" value="{{ $.CurrentUrl }}">
|
||||||
<div class="actionbar pt2">
|
<div class="pt2">
|
||||||
<input type="submit" value="Log In" />
|
<input type="submit" value="Log In" />
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
@ -112,6 +112,9 @@ var HMNTemplateFuncs = template.FuncMap{
|
||||||
"color2css": func(color noire.Color) template.CSS {
|
"color2css": func(color noire.Color) template.CSS {
|
||||||
return template.CSS(color.HTML())
|
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 {
|
"darken": func(amount float64, color noire.Color) noire.Color {
|
||||||
return color.Shade(amount)
|
return color.Shade(amount)
|
||||||
},
|
},
|
||||||
|
|
|
@ -20,6 +20,7 @@ type BaseData struct {
|
||||||
|
|
||||||
Project Project
|
Project Project
|
||||||
User *User
|
User *User
|
||||||
|
Session *Session
|
||||||
|
|
||||||
IsProjectPage bool
|
IsProjectPage bool
|
||||||
Header Header
|
Header Header
|
||||||
|
@ -147,6 +148,10 @@ type Link struct {
|
||||||
Value string
|
Value string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Session struct {
|
||||||
|
CSRFToken string
|
||||||
|
}
|
||||||
|
|
||||||
type OpenGraphItem struct {
|
type OpenGraphItem struct {
|
||||||
Property string
|
Property string
|
||||||
Name string
|
Name string
|
||||||
|
|
|
@ -260,7 +260,7 @@ func ForumCategory(c *RequestContext) ResponseData {
|
||||||
var res ResponseData
|
var res ResponseData
|
||||||
err = res.WriteTemplate("forum_category.html", forumCategoryData{
|
err = res.WriteTemplate("forum_category.html", forumCategoryData{
|
||||||
BaseData: baseData,
|
BaseData: baseData,
|
||||||
NewThreadUrl: hmnurl.BuildForumNewThread(c.CurrentProject.Slug, currentSubforumSlugs),
|
NewThreadUrl: hmnurl.BuildForumNewThread(c.CurrentProject.Slug, currentSubforumSlugs, false),
|
||||||
MarkReadUrl: hmnurl.BuildMarkRead(currentCatId),
|
MarkReadUrl: hmnurl.BuildMarkRead(currentCatId),
|
||||||
Threads: threads,
|
Threads: threads,
|
||||||
Pagination: templates.Pagination{
|
Pagination: templates.Pagination{
|
||||||
|
@ -515,6 +515,50 @@ func ForumPostRedirect(c *RequestContext) ResponseData {
|
||||||
), http.StatusSeeOther)
|
), 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) {
|
func validateSubforums(lineageBuilder *models.CategoryLineageBuilder, project *models.Project, catPath string) (int, bool) {
|
||||||
if project.ForumID == nil {
|
if project.ForumID == nil {
|
||||||
return -1, false
|
return -1, false
|
||||||
|
|
|
@ -47,29 +47,31 @@ func WrapStdHandler(h http.Handler) Handler {
|
||||||
|
|
||||||
type Middleware func(h 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)
|
h = rb.Middleware(h)
|
||||||
rb.Router.Routes = append(rb.Router.Routes, Route{
|
for _, method := range methods {
|
||||||
Method: method,
|
rb.Router.Routes = append(rb.Router.Routes, Route{
|
||||||
Regex: regex,
|
Method: method,
|
||||||
Handler: h,
|
Regex: regex,
|
||||||
})
|
Handler: h,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rb *RouteBuilder) AnyMethod(regex *regexp.Regexp, h Handler) {
|
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) {
|
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) {
|
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) {
|
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) {
|
func (r *Router) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
@ -122,6 +124,7 @@ type RequestContext struct {
|
||||||
Conn *pgxpool.Pool
|
Conn *pgxpool.Pool
|
||||||
CurrentProject *models.Project
|
CurrentProject *models.Project
|
||||||
CurrentUser *models.User
|
CurrentUser *models.User
|
||||||
|
CurrentSession *models.Session
|
||||||
Theme string
|
Theme string
|
||||||
|
|
||||||
Perf *perf.RequestPerf
|
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.
|
// TODO(asaf): login/logout shouldn't happen on subdomains. We should verify that in the middleware.
|
||||||
routes.POST(hmnurl.RegexLoginAction, Login)
|
routes.POST(hmnurl.RegexLoginAction, Login)
|
||||||
routes.GET(hmnurl.RegexLogoutAction, Logout)
|
routes.GET(hmnurl.RegexLogoutAction, Logout)
|
||||||
|
@ -113,6 +123,8 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt
|
||||||
mainRoutes.GET(hmnurl.RegexUserProfile, UserProfile)
|
mainRoutes.GET(hmnurl.RegexUserProfile, UserProfile)
|
||||||
|
|
||||||
// NOTE(asaf): Any-project routes:
|
// 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.RegexForumThread, ForumThread)
|
||||||
mainRoutes.GET(hmnurl.RegexForumCategory, ForumCategory)
|
mainRoutes.GET(hmnurl.RegexForumCategory, ForumCategory)
|
||||||
mainRoutes.GET(hmnurl.RegexForumPost, ForumPostRedirect)
|
mainRoutes.GET(hmnurl.RegexForumPost, ForumPostRedirect)
|
||||||
|
@ -127,14 +139,12 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt
|
||||||
|
|
||||||
func getBaseData(c *RequestContext) templates.BaseData {
|
func getBaseData(c *RequestContext) templates.BaseData {
|
||||||
var templateUser *templates.User
|
var templateUser *templates.User
|
||||||
|
var templateSession *templates.Session
|
||||||
if c.CurrentUser != nil {
|
if c.CurrentUser != nil {
|
||||||
templateUser = &templates.User{
|
u := templates.UserToTemplate(c.CurrentUser, c.Theme)
|
||||||
Username: c.CurrentUser.Username,
|
s := templates.SessionToTemplate(c.CurrentSession)
|
||||||
Name: c.CurrentUser.Name,
|
templateUser = &u
|
||||||
Email: c.CurrentUser.Email,
|
templateSession = &s
|
||||||
IsSuperuser: c.CurrentUser.IsSuperuser,
|
|
||||||
IsStaff: c.CurrentUser.IsStaff,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return templates.BaseData{
|
return templates.BaseData{
|
||||||
|
@ -146,6 +156,7 @@ func getBaseData(c *RequestContext) templates.BaseData {
|
||||||
|
|
||||||
Project: templates.ProjectToTemplate(c.CurrentProject, c.Theme),
|
Project: templates.ProjectToTemplate(c.CurrentProject, c.Theme),
|
||||||
User: templateUser,
|
User: templateUser,
|
||||||
|
Session: templateSession,
|
||||||
|
|
||||||
IsProjectPage: !c.CurrentProject.IsHMN(),
|
IsProjectPage: !c.CurrentProject.IsHMN(),
|
||||||
Header: templates.Header{
|
Header: templates.Header{
|
||||||
|
@ -293,12 +304,13 @@ func LoadCommonWebsiteData(c *RequestContext) (bool, ResponseData) {
|
||||||
{
|
{
|
||||||
sessionCookie, err := c.Req.Cookie(auth.SessionCookieName)
|
sessionCookie, err := c.Req.Cookie(auth.SessionCookieName)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
user, err := getCurrentUser(c, sessionCookie.Value)
|
user, session, err := getCurrentUserAndSession(c, sessionCookie.Value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to get current user"))
|
return false, ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to get current user"))
|
||||||
}
|
}
|
||||||
|
|
||||||
c.CurrentUser = user
|
c.CurrentUser = user
|
||||||
|
c.CurrentSession = session
|
||||||
}
|
}
|
||||||
// http.ErrNoCookie is the only error Cookie ever returns, so no further handling to do here.
|
// 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
|
// 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.
|
// 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)
|
session, err := auth.GetSession(c.Context(), c.Conn, sessionId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, auth.ErrNoSession) {
|
if errors.Is(err, auth.ErrNoSession) {
|
||||||
return nil, nil
|
return nil, nil, nil
|
||||||
} else {
|
} 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 err != nil {
|
||||||
if errors.Is(err, db.ErrNoMatchingRows) {
|
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")
|
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 {
|
} 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)
|
user := userRow.(*models.User)
|
||||||
|
|
||||||
return user, nil
|
return user, session, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func TrackRequestPerf(c *RequestContext, perfCollector *perf.PerfCollector) (after func()) {
|
func TrackRequestPerf(c *RequestContext, perfCollector *perf.PerfCollector) (after func()) {
|
||||||
|
|
Loading…
Reference in New Issue