Merge branch 'master' of gitssh.handmade.network:hmn/hmn

This commit is contained in:
Asaf Gartner 2021-07-23 06:22:57 +03:00
commit b41a556fcd
31 changed files with 2863 additions and 431 deletions

View File

@ -0,0 +1,33 @@
importScripts('../go_wasm_exec.js');
/*
NOTE(ben): The structure here is a little funny but allows for some debouncing. Any postMessages
that got queued up can run all at once, then it can process the latest one.
*/
let ready = false;
let inputData = null;
onmessage = ({ data }) => {
inputData = data;
setTimeout(doPreview, 0);
}
const go = new Go();
WebAssembly.instantiateStreaming(fetch('../parsing.wasm'), go.importObject)
.then(result => {
go.run(result.instance); // don't await this; we want it to be continuously running
ready = true;
setTimeout(doPreview, 0);
});
const doPreview = () => {
if (!ready || inputData === null) {
return;
}
const result = parseMarkdown(inputData);
inputData = null;
postMessage(result);
}

BIN
public/parsing.wasm Normal file → Executable file

Binary file not shown.

View File

@ -7312,6 +7312,17 @@ article code {
.flex-grow-1 {
flex-grow: 1; }
.flex-fair {
flex-basis: 1px;
flex-grow: 1;
flex-shrink: 1; }
@media screen and (min-width: 30em) {
.flex-fair-ns {
flex-basis: 1px;
flex-grow: 1;
flex-shrink: 1; } }
.b--theme {
border-color: #666;
border-color: var(--theme-color); }

View File

@ -54,6 +54,11 @@ func typeIsQueryable(t reflect.Type) bool {
return false
}
// This interface should match both a direct pgx connection or a pgx transaction.
type ConnOrTx interface {
Query(ctx context.Context, sql string, args ...interface{}) (pgx.Rows, error)
}
var connInfo = pgtype.NewConnInfo()
func NewConn() *pgx.Conn {
@ -211,7 +216,7 @@ func followPathThroughStructs(structPtrVal reflect.Value, path []int) (reflect.V
return val, field
}
func Query(ctx context.Context, conn *pgxpool.Pool, destExample interface{}, query string, args ...interface{}) (*StructQueryIterator, error) {
func Query(ctx context.Context, conn ConnOrTx, destExample interface{}, query string, args ...interface{}) (*StructQueryIterator, error) {
destType := reflect.TypeOf(destExample)
columnNames, fieldPaths, err := getColumnNamesAndPaths(destType, nil, "")
if err != nil {
@ -279,7 +284,7 @@ func getColumnNamesAndPaths(destType reflect.Type, pathSoFar []int, prefix strin
var ErrNoMatchingRows = errors.New("no matching rows")
func QueryOne(ctx context.Context, conn *pgxpool.Pool, destExample interface{}, query string, args ...interface{}) (interface{}, error) {
func QueryOne(ctx context.Context, conn ConnOrTx, destExample interface{}, query string, args ...interface{}) (interface{}, error) {
rows, err := Query(ctx, conn, destExample, query, args...)
if err != nil {
return nil, err
@ -294,7 +299,7 @@ func QueryOne(ctx context.Context, conn *pgxpool.Pool, destExample interface{},
return result, nil
}
func QueryScalar(ctx context.Context, conn *pgxpool.Pool, query string, args ...interface{}) (interface{}, error) {
func QueryScalar(ctx context.Context, conn ConnOrTx, query string, args ...interface{}) (interface{}, error) {
rows, err := conn.Query(ctx, query, args...)
if err != nil {
return nil, err
@ -317,7 +322,7 @@ func QueryScalar(ctx context.Context, conn *pgxpool.Pool, query string, args ...
return nil, ErrNoMatchingRows
}
func QueryInt(ctx context.Context, conn *pgxpool.Pool, query string, args ...interface{}) (int, error) {
func QueryInt(ctx context.Context, conn ConnOrTx, query string, args ...interface{}) (int, error) {
result, err := QueryScalar(ctx, conn, query, args...)
if err != nil {
return 0, err
@ -334,3 +339,17 @@ func QueryInt(ctx context.Context, conn *pgxpool.Pool, query string, args ...int
return 0, oops.New(nil, "QueryInt got a non-int result: %v", result)
}
}
func QueryBool(ctx context.Context, conn ConnOrTx, query string, args ...interface{}) (bool, error) {
result, err := QueryScalar(ctx, conn, query, args...)
if err != nil {
return false, err
}
switch r := result.(type) {
case bool:
return r, nil
default:
return false, oops.New(nil, "QueryBool got a non-bool result: %v", result)
}
}

View File

@ -392,15 +392,6 @@ func BuildForumPostReply(projectSlug string, subforums []string, threadId int, p
return ProjectUrl(builder.String(), nil, projectSlug)
}
var RegexForumPostQuote = regexp.MustCompile(`^/forums(/(?P<cats>[^\d/]+(/[^\d]+)*))?/t/(?P<threadid>\d+)/p/(?P<postid>\d+)/quote$`)
func BuildForumPostQuote(projectSlug string, subforums []string, threadId int, postId int) string {
defer CatchPanic()
builder := buildForumPostPath(subforums, threadId, postId)
builder.WriteString("/quote")
return ProjectUrl(builder.String(), nil, projectSlug)
}
/*
* Blog
*/

View File

@ -0,0 +1,46 @@
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(AddPostReplyId{})
}
type AddPostReplyId struct{}
func (m AddPostReplyId) Version() types.MigrationVersion {
return types.MigrationVersion(time.Date(2021, 7, 20, 2, 40, 51, 0, time.UTC))
}
func (m AddPostReplyId) Name() string {
return "AddPostReplyId"
}
func (m AddPostReplyId) Description() string {
return "Add a reply id to posts"
}
func (m AddPostReplyId) Up(ctx context.Context, tx pgx.Tx) error {
_, err := tx.Exec(ctx,
`
ALTER TABLE handmade_post
ADD reply_id INT REFERENCES handmade_post (id) ON DELETE SET NULL;
`,
)
if err != nil {
return oops.New(err, "failed to add columns")
}
return nil
}
func (m AddPostReplyId) Down(ctx context.Context, tx pgx.Tx) error {
panic("Implement me")
}

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(DropSuperuserColumn{})
}
type DropSuperuserColumn struct{}
func (m DropSuperuserColumn) Version() types.MigrationVersion {
return types.MigrationVersion(time.Date(2021, 7, 22, 1, 59, 29, 0, time.UTC))
}
func (m DropSuperuserColumn) Name() string {
return "DropSuperuserColumn"
}
func (m DropSuperuserColumn) Description() string {
return "Drop the is_superuser column on users, in favor of is_staff"
}
func (m DropSuperuserColumn) Up(ctx context.Context, tx pgx.Tx) error {
_, err := tx.Exec(ctx, `
ALTER TABLE auth_user
DROP is_superuser;
`)
if err != nil {
return oops.New(err, "failed to drop superuser column")
}
return nil
}
func (m DropSuperuserColumn) Down(ctx context.Context, tx pgx.Tx) error {
panic("Implement me")
}

View File

@ -17,9 +17,8 @@ type User struct {
DateJoined time.Time `db:"date_joined"`
LastLogin *time.Time `db:"last_login"`
IsSuperuser bool `db:"is_superuser"`
IsStaff bool `db:"is_staff"`
IsActive bool `db:"is_active"`
IsStaff bool `db:"is_staff"`
IsActive bool `db:"is_active"`
Name string `db:"name"`
Bio string `db:"bio"`

View File

@ -23,7 +23,8 @@ import (
var BBCodePriority = 1 // TODO: This is maybe too high a priority?
var reTag = regexp.MustCompile(`(?P<open>\[\s*(?P<opentagname>[a-zA-Z0-9]+))|(?P<close>\[\s*\/\s*(?P<closetagname>[a-zA-Z0-9]+)\s*\])`)
var reOpenTag = regexp.MustCompile(`^\[\s*(?P<name>[a-zA-Z0-9]+)`)
var reTag = regexp.MustCompile(`\[\s*(?P<opentagname>[a-zA-Z0-9]+)|\[\s*\/\s*(?P<closetagname>[a-zA-Z0-9]+)\s*\]`)
var previewBBCodeCompiler = bbcode.NewCompiler(false, false)
var realBBCodeCompiler = bbcode.NewCompiler(false, false)
@ -245,38 +246,42 @@ func (s bbcodeParser) Parse(parent gast.Node, block text.Reader, pc parser.Conte
_, pos := block.Position()
restOfSource := block.Source()[pos.Start:]
matches := reTag.FindAllSubmatchIndex(restOfSource, -1)
if matches == nil {
// No tags anywhere
openMatch := reOpenTag.FindSubmatch(restOfSource)
if openMatch == nil {
// not a bbcode tag
return nil
}
otIndex := reTag.SubexpIndex("opentagname")
ctIndex := reTag.SubexpIndex("closetagname")
tagName := extractStringBySubmatchIndices(restOfSource, matches[0], otIndex)
if tagName == "" {
// Not an opening tag
return nil
}
tagName := string(openMatch[reOpenTag.SubexpIndex("name")])
depth := 0
endIndex := -1
for _, m := range matches {
if openName := extractStringBySubmatchIndices(restOfSource, m, otIndex); openName != "" {
if openName == tagName {
depth++
}
} else if closeName := extractStringBySubmatchIndices(restOfSource, m, ctIndex); closeName != "" {
if closeName == tagName {
depth--
if depth == 0 {
// We have balanced out!
endIndex = m[1] // the end index of this closing tag (exclusive)
break
}
searchStartIndex := 0
for {
searchText := restOfSource[searchStartIndex:]
match := reTag.FindSubmatchIndex(searchText)
if match == nil {
// no more tags
break
}
if openName := extractStringBySubmatchIndices(searchText, match, otIndex); openName == tagName {
depth++
} else if closeName := extractStringBySubmatchIndices(searchText, match, ctIndex); closeName == tagName {
depth--
if depth == 0 {
// We have balanced out!
endIndex = searchStartIndex + match[1] // the end index of this closing tag (exclusive)
break
}
}
searchStartIndex = searchStartIndex + match[1]
}
if endIndex < 0 {
// Unbalanced, too many opening tags

1650
src/parsing/corpus_test.go Normal file

File diff suppressed because it is too large Load Diff

View File

@ -63,58 +63,28 @@ func main() {
})
}
const allBBCode = `
[b]bold[/b]
func TestSharlock(t *testing.T) {
t.Skipf("This doesn't pass right now because parts of Sharlock's original source read as indented code blocks, or depend on different line break behavior.")
t.Run("sanity check", func(t *testing.T) {
result := ParsePostInput(sharlock, RealMarkdown)
[i]italic[/i]
[u]underline[/u]
[h1]heading 1[/h1]
[h2]heading 2[/h2]
[h3]heading 3[/h3]
[m]monospace[/m]
[ol]
[li]ordered lists[/li]
[/ol]
[ul]
[li]unordered list[/li]
[/ul]
[url]https://handmade.network/[/url]
[url=https://handmade.network/]Handmade Network[/url]
[img=https://handmade.network/static/media/members/avatars/delix.jpeg]Ryan[/img]
[quote]quotes[/quote]
[quote=delix]Some quote[/quote]
[code]
Code
[/code]
[code language=go]
func main() {
fmt.Println("Hello, world!")
for _, line := range strings.Split(result, "\n") {
assert.NotContains(t, line, "[b]")
assert.NotContains(t, line, "[/b]")
assert.NotContains(t, line, "[ul]")
assert.NotContains(t, line, "[/ul]")
assert.NotContains(t, line, "[li]")
assert.NotContains(t, line, "[/li]")
assert.NotContains(t, line, "[img]")
assert.NotContains(t, line, "[/img]")
assert.NotContains(t, line, "[code")
assert.NotContains(t, line, "[/code]")
}
})
}
[/code]
[spoiler]spoilers[/spoiler]
[table]
[tr]
[th]Heading 1[/th] [th]Heading 2[/th]
[/tr]
[tr]
[td]Body 1[/td] [td]Body 2[/td]
[/tr]
[/table]
[youtube]https://www.youtube.com/watch?v=0J8G9qNT7gQ[/youtube]
[youtube]https://youtu.be/0J8G9qNT7gQ[/youtube]
`
func BenchmarkSharlock(b *testing.B) {
for i := 0; i < b.N; i++ {
ParsePostInput(sharlock, RealMarkdown)
}
}

0
src/parsing/wasm/build.sh Normal file → Executable file
View File

View File

@ -180,6 +180,20 @@ article code {
flex-grow: 1;
}
.flex-fair {
flex-basis: 1px;
flex-grow: 1;
flex-shrink: 1;
}
@media #{$breakpoint-not-small} {
.flex-fair-ns {
flex-basis: 1px;
flex-grow: 1;
flex-shrink: 1;
}
}
.b--theme {
@include usevar(border-color, theme-color);
}

View File

@ -12,12 +12,6 @@ import (
)
func PostToTemplate(p *models.Post, author *models.User, currentTheme string) Post {
var authorUser *User
if author != nil {
authorTmpl := UserToTemplate(author, currentTheme)
authorUser = &authorTmpl
}
return Post{
ID: p.ID,
@ -26,7 +20,7 @@ func PostToTemplate(p *models.Post, author *models.User, currentTheme string) Po
Preview: p.Preview,
ReadOnly: p.ReadOnly,
Author: authorUser,
Author: UserToTemplate(author, currentTheme),
// No content. A lot of the time we don't have this handy and don't need it. See AddContentVersion.
PostDate: p.PostDate,
}
@ -49,7 +43,6 @@ func (p *Post) AddUrls(projectSlug string, subforums []string, threadId int, pos
p.DeleteUrl = hmnurl.BuildForumPostDelete(projectSlug, subforums, threadId, postId)
p.EditUrl = hmnurl.BuildForumPostEdit(projectSlug, subforums, threadId, postId)
p.ReplyUrl = hmnurl.BuildForumPostReply(projectSlug, subforums, threadId, postId)
p.QuoteUrl = hmnurl.BuildForumPostQuote(projectSlug, subforums, threadId, postId)
}
var LifecycleBadgeClasses = map[models.ProjectLifecycle]string{
@ -132,7 +125,7 @@ func UserAvatarUrl(u *models.User, currentTheme string) string {
currentTheme = "light"
}
avatar := ""
if u.Avatar != nil && len(*u.Avatar) > 0 {
if u != nil && u.Avatar != nil && len(*u.Avatar) > 0 {
avatar = hmnurl.BuildUserFile(*u.Avatar)
} else {
avatar = hmnurl.BuildTheme("empty-avatar.svg", currentTheme, true)
@ -149,7 +142,12 @@ func UserDisplayName(u *models.User) string {
}
func UserToTemplate(u *models.User, currentTheme string) User {
// TODO: Handle deleted users. Maybe not here, but if not, at call sites of this function.
if u == nil {
return User{
Name: "Deleted user",
AvatarUrl: UserAvatarUrl(u, currentTheme),
}
}
email := ""
if u.ShowEmail {
@ -158,11 +156,10 @@ func UserToTemplate(u *models.User, currentTheme string) User {
}
return User{
ID: u.ID,
Username: u.Username,
Email: email,
IsSuperuser: u.IsSuperuser,
IsStaff: u.IsStaff,
ID: u.ID,
Username: u.Username,
Email: email,
IsStaff: u.IsStaff,
Name: UserDisplayName(u),
Blurb: u.Blurb,
@ -267,7 +264,7 @@ func TimelineItemsToJSON(items []TimelineItem) string {
builder.WriteString(`",`)
builder.WriteString(`"owner_name":"`)
builder.WriteString(item.OwnerName)
builder.WriteString(item.OwnerName) // TODO: Do we need to do escaping on these other string fields too? Feels like someone could use this for XSS.
builder.WriteString(`",`)
builder.WriteString(`"owner_avatar":"`)

View File

@ -7,115 +7,177 @@
<script src="{{ static "go_wasm_exec.js" }}"></script>
<script>
const go = new Go();
const goLoaded = WebAssembly.instantiateStreaming(fetch("{{ static "parsing.wasm" }}"), go.importObject)
.then(result => {
go.run(result.instance);
});
const previewWorker = new Worker('{{ static "js/editorpreviews.js" }}');
</script>
<style>
#editor {
resize: vertical;
}
</style>
{{ end }}
{{ define "content" }}
<div class="content-block">
<form action="{{ .SubmitUrl }}" method="post">
{{ csrftoken .Session }}
<div class="content-block ph3 ph0-ns">
{{ if .Title }}
<h2>{{ .Title }}</h2>
{{ end }}
<div class="flex flex-column flex-row-ns">
<form id="form" action="{{ .SubmitUrl }}" method="post" class="flex-fair-ns">
{{ 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 id="editor" class="w-100 minw-100 mw-100 h5 minh-5" name="body">{{ .PostBody }}</textarea>
{{ if not (or .PostReplyingTo .IsEditing) }}
<input id="title" class="b w-100 mb1" name="title" type="text" placeholder="Post title..."/>
{{ end }}
{{/* 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 id="editor" class="w-100 h5 minh-5" name="body">{{ if .IsEditing }}{{ .EditInitialContents }}{{ end }}</textarea>
<div class="flex flex-row-reverse justify-start mt2">
<input type="submit" class="button ml2" name="submit" value="{{ .SubmitLabel }}" />
</div>
<div class="flex flex-row-reverse justify-start mt2">
<input type="submit" class="button ml2" name="submit" value="{{ .SubmitLabel }}" />
</div>
<div class="post post-preview mv3 mathjax">
{{ if .IsEditing }}
<span class="editreason">
<label for="editreason">Edit reason:</label>
<input name="editreason" maxlength="255" type="text" id="editreason" />
</span>
{{ end }}
{{/* TODO: Sticky threads
{% 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 %}
*/}}
{{ with .PostReplyingTo }}
<h4 class="mt3">The post you're replying to:</h4>
{{ template "forum_post_standalone.html" . }}
{{ end }}
{{/*
{% 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 class="post post-preview mv3 mathjax flex-fair-ns mv0-ns ml3-ns">
<div id="preview" class="body contents"></div>
</div>
{{ if .IsEditing }}
<span class="editreason">
<label for="editreason">Edit reason:</label>
<input name="editreason" maxlength="255" type="text" id="editreason" />
</span>
{{ end }}
{{/* TODO: Sticky threads
{% 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 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>
</div>
<script>
const tf = document.querySelector('#editor');
const form = document.querySelector('#form');
const titleField = document.querySelector('#title'); // may be undefined, be careful!
const textField = document.querySelector('#editor');
const preview = document.querySelector('#preview');
function updatePreview() {
const previewHtml = parseMarkdown(tf.value);
const storagePrefix = 'post-contents';
// Delete old irrelevant local post contents
const aWeekAgo = new Date().getTime() - (7 * 24 * 60 * 60 * 1000);
for (const key in window.localStorage) {
if (!window.localStorage.hasOwnProperty(key)) {
continue;
}
if (key.startsWith(storagePrefix)) {
try {
const { when } = JSON.parse(window.localStorage.getItem(key));
if (when <= aWeekAgo) {
window.localStorage.removeItem(key);
}
} catch (e) {
console.error(e);
}
}
}
// Load any stored content from localStorage
const storageKey = `${storagePrefix}/${window.location.host}${window.location.pathname}`;
const storedContents = window.localStorage.getItem(storageKey);
if (storedContents && !textField.value) {
try {
const { title, contents } = JSON.parse(storedContents);
if (titleField) {
titleField.value = title;
}
textField.value = contents;
} catch (e) {
console.error(e);
}
}
function updatePreview(previewHtml) {
preview.innerHTML = previewHtml;
MathJax.typeset();
}
goLoaded.then(() => {
updatePreview();
});
tf.addEventListener('input', () => {
updatePreview();
previewWorker.onmessage = ({ data }) => {
updatePreview(data);
};
function doMarkdown() {
const md = textField.value;
previewWorker.postMessage(md);
updateContentCache();
}
function updateContentCache() {
window.localStorage.setItem(storageKey, JSON.stringify({
when: new Date().getTime(),
title: titleField ? titleField.value : '',
contents: textField.value,
}));
}
doMarkdown();
textField.addEventListener('input', () => doMarkdown());
if (titleField) {
titleField.addEventListener('input', () => updateContentCache());
}
form.addEventListener('submit', e => {
window.localStorage.removeItem(storageKey);
});
</script>
{{ end }}

View File

@ -0,0 +1,11 @@
{{ template "base.html" . }}
{{ define "content" }}
<div class="mw7 margin-center">
<h3 class="mb3">Are you sure you want to delete this post?</h3>
{{ template "forum_post_standalone.html" .Post }}
<form action="{{ .SubmitUrl }}" method="POST" class="pv3 flex justify-end">
<input type="submit" value="Delete Post">
</form>
</div>
{{ end }}

View File

@ -7,55 +7,50 @@
{{ template "pagination.html" .Pagination }}
</div>
{{ range .Posts }}
<div class="post background-even pa3 bbcode"> {{/* TODO: Dynamically switch between bbcode and markdown */}}
<div class="post background-even pa3">
<div class="fl w-100 w-25-l pt3 pa3-l flex tc-l">
{{ if .Author }}
<div class="fl w-20 mw3 dn-l w3">
<!-- Mobile avatar -->
<div class="aspect-ratio--1x1 contain bg-center" style="background-image:url('{{ .Author.AvatarUrl }}');"></div>
</div>
<div class="w-100-l pl3 pl0-l flex flex-column items-center-l">
<div>
<a class="username" href="{{ .Author.ProfileUrl }}" target="_blank">{{ .Author.Username }}</a> {{/* TODO: Text scale stuff? Seems unnecessary. */}}
<!-- Mobile badges -->
<div class="di dn-l ph1">
{{ if .Author.IsStaff }}
<div class="badge staff"></div>
{{ end }}
</div>
</div>
{{ if and .Author.Name (ne .Author.Name .Author.Username) }}
<div class="c--dim f7"> {{ .Author.Name }} </div>
{{ end }}
<!-- Large avatar -->
<div class="dn db-l w-60 pv2">
<div class="aspect-ratio--1x1 contain bg-center" style="background-image:url('{{ .Author.AvatarUrl }}');"></div>
</div>
{{/* TODO: Aggregate user data
<div class="c--dim f7">
{{ post.author.posts }} posts
{% if post.author.public_projects.values|length > 0 %}
/ {{ post.author.public_projects.values|length }} project{%if post.author.public_projects.values|length > 1 %}s{% endif %}
{% endif %}
</div> */}}
<!-- Large badges -->
<div class="dn db-l pv2">
<div class="fl w-20 mw3 dn-l w3">
<!-- Mobile avatar -->
<div class="aspect-ratio--1x1 contain bg-center" style="background-image:url('{{ .Author.AvatarUrl }}');"></div>
</div>
<div class="w-100-l pl3 pl0-l flex flex-column items-center-l">
<div>
<a class="username" href="{{ .Author.ProfileUrl }}" target="_blank">{{ .Author.Username }}</a> {{/* TODO: Text scale stuff? Seems unnecessary. */}}
<!-- Mobile badges -->
<div class="di dn-l ph1">
{{ if .Author.IsStaff }}
<div class="badge staff"></div>
{{ end }}
</div>
<div class="i c--dim f7">
{{ if .Author.Blurb }}
{{ .Author.Blurb }} {{/* TODO: Linebreaks? */}}
{{ else if .Author.Bio }}
{{ .Author.Bio }}
{{ end }}
</div>
</div>
{{ else }}
<div class="username">Deleted member</div>
<div class="avatar" style="background-image:url('{{ .Author.AvatarUrl }}');"></div>
{{ end }}
{{ if and .Author.Name (ne .Author.Name .Author.Username) }}
<div class="c--dim f7"> {{ .Author.Name }} </div>
{{ end }}
<!-- Large avatar -->
<div class="dn db-l w-60 pv2">
<div class="aspect-ratio--1x1 contain bg-center" style="background-image:url('{{ .Author.AvatarUrl }}');"></div>
</div>
{{/* TODO: Aggregate user data
<div class="c--dim f7">
{{ post.author.posts }} posts
{% if post.author.public_projects.values|length > 0 %}
/ {{ post.author.public_projects.values|length }} project{%if post.author.public_projects.values|length > 1 %}s{% endif %}
{% endif %}
</div> */}}
<!-- Large badges -->
<div class="dn db-l pv2">
{{ if .Author.IsStaff }}
<div class="badge staff"></div>
{{ end }}
</div>
<div class="i c--dim f7">
{{ if .Author.Blurb }}
{{ .Author.Blurb }} {{/* TODO: Linebreaks? */}}
{{ else if .Author.Bio }}
{{ .Author.Bio }}
{{ end }}
</div>
</div>
</div>
<div class="fl w-100 w-75-l pv3 pa3-l">
<div class="w-100 flex-l flex-row-reverse-l">
@ -74,7 +69,6 @@
WARNING: locked thread - use power responsibly!
{{ end }}
<a class="reply action button" href="{{ .ReplyUrl }}" title="Reply">&hookrightarrow;</a>&nbsp;
<a class="quote action button" href="{{ .QuoteUrl }}" title="Quote">&#10077;</a>
{{ end }}
</div>
{{ end }}
@ -101,6 +95,11 @@
{{ end }}
</div>
</div>
{{ if .ReplyPost }}
<div class="i c--dim f7 pb2">
Replying to {{ if .Author }}{{ .Author.Username }}{{ else }}deleted user{{ end }} (<a href="{{ .ReplyPost.Url }}">#{{ .ReplyPost.ID }}</a>)
</div>
{{ end }}
<div class="contents overflow-x-auto">
{{ .Content }}
</div>

View File

@ -0,0 +1,40 @@
<div class="bg--dim pa3 br3 tl">
<div class="w-100 flex items-center">
<div class="w-20 mw3 w3">
<!-- Mobile avatar -->
<div class="aspect-ratio--1x1 contain bg-center" style="background-image:url('{{ .Author.AvatarUrl }}');"></div>
</div>
<div class="pl3 flex flex-column">
<div>
<a class="username" href="{{ .Author.ProfileUrl }}" target="_blank">{{ .Author.Username }}</a> {{/* TODO: Text scale stuff? Seems unnecessary. */}}
<!-- Mobile badges -->
<div class="di ph1">
{{ if .Author.IsStaff }}
<div class="badge staff"></div>
{{ end }}
</div>
</div>
{{ if and .Author.Name (ne .Author.Name .Author.Username) }}
<div class="c--dim f7"> {{ .Author.Name }} </div>
{{ end }}
<div class="c--dim f7">
{{ timehtml (relativedate .PostDate) .PostDate }}
{{ if .Editor }}
<span class="pl3">
Edited by
<a class="name" href="{{ .Editor.ProfileUrl }}" target="_blank">{{ coalesce .Editor.Name .Editor.Username }}</a>
on {{ timehtml (absolutedate .EditDate) .EditDate }}
{{ with .EditReason }}
Reason: {{ . }}
{{ end }}
</span>
{{ end }}
</div>
</div>
</div>
<div class="w-100 pt3">
<div class="contents overflow-x-auto">
{{ .Content }}
</div>
</div>
</div>

View File

@ -1,7 +1,7 @@
<header class="mb3">
<div class="user-options flex justify-center justify-end-ns">
{{ if .User }}
{{ if .User.IsSuperuser }}
{{ if .User.IsStaff }}
<a class="admin-panel" href="{{ .Header.AdminUrl }}"><span class="icon-settings"> Admin</span></a>
{{ end }}
<a class="username settings" href="{{ .Header.UserSettingsUrl }}"><span class="icon-settings"></span> {{ .User.Username }}</a>

View File

@ -21,8 +21,8 @@
{{ end }}
{{ end }}
</style>
<script type="text/javascript" src="{{ static "js/templates.js" }}"></script>
<script type="text/javascript" src="{{ static "js/showcase.js" }}"></script>
<script src="{{ static "js/templates.js" }}"></script>
<script src="{{ static "js/showcase.js" }}"></script>
{{ end }}
{{ define "content" }}

View File

@ -73,12 +73,11 @@ type Post struct {
DeleteUrl string
EditUrl string
ReplyUrl string
QuoteUrl string
Preview string
ReadOnly bool
Author *User
Author User
Content template.HTML
PostDate time.Time
@ -87,6 +86,8 @@ type Post struct {
EditReason string
IP string
ReplyPost *Post
}
type Project struct {
@ -116,11 +117,10 @@ type Project struct {
}
type User struct {
ID int
Username string
Email string
IsSuperuser bool
IsStaff bool
ID int
Username string
Email string
IsStaff bool
Name string
Blurb string

View File

@ -94,7 +94,7 @@ func Feed(c *RequestContext) ResponseData {
baseData.BodyClasses = append(baseData.BodyClasses, "feed")
var res ResponseData
err = res.WriteTemplate("feed.html", FeedData{
res.MustWriteTemplate("feed.html", FeedData{
BaseData: baseData,
AtomFeedUrl: hmnurl.BuildAtomFeed(),
@ -102,10 +102,6 @@ func Feed(c *RequestContext) ResponseData {
Posts: posts,
Pagination: pagination,
}, c.Perf)
if err != nil {
panic(err)
}
return res
}
@ -303,11 +299,7 @@ func AtomFeed(c *RequestContext) ResponseData {
}
var res ResponseData
err := res.WriteTemplate("atom.xml", feedData, c.Perf)
if err != nil {
panic(err)
}
res.MustWriteTemplate("atom.xml", feedData, c.Perf)
return res
}

View File

@ -1,7 +1,9 @@
package website
import (
"context"
"errors"
"fmt"
"math"
"net"
"net/http"
@ -16,6 +18,8 @@ import (
"git.handmade.network/hmn/hmn/src/parsing"
"git.handmade.network/hmn/hmn/src/templates"
"git.handmade.network/hmn/hmn/src/utils"
"github.com/jackc/pgx/v4"
"github.com/jackc/pgx/v4/pgxpool"
)
type forumCategoryData struct {
@ -35,6 +39,18 @@ type forumSubcategoryData struct {
TotalThreads int
}
type editorData struct {
templates.BaseData
SubmitUrl string
Title string
SubmitLabel string
IsEditing bool // false if new post, true if updating existing one
EditInitialContents string
PostReplyingTo *templates.Post
}
func ForumCategory(c *RequestContext) ResponseData {
const threadsPerPage = 25
@ -260,7 +276,7 @@ func ForumCategory(c *RequestContext) ResponseData {
}
var res ResponseData
err = res.WriteTemplate("forum_category.html", forumCategoryData{
res.MustWriteTemplate("forum_category.html", forumCategoryData{
BaseData: baseData,
NewThreadUrl: hmnurl.BuildForumNewThread(c.CurrentProject.Slug, currentSubforumSlugs, false),
MarkReadUrl: hmnurl.BuildMarkRead(currentCatId),
@ -276,10 +292,6 @@ func ForumCategory(c *RequestContext) ResponseData {
},
Subcategories: subcats,
}, c.Perf)
if err != nil {
panic(err)
}
return res
}
@ -376,6 +388,9 @@ func ForumThread(c *RequestContext) ResponseData {
Ver models.PostVersion `db:"ver"`
Author *models.User `db:"author"`
Editor *models.User `db:"editor"`
ReplyPost *models.Post `db:"reply"`
ReplyAuthor *models.User `db:"reply_author"`
}
itPosts, err := db.Query(c.Context(), c.Conn, postsQueryResult{},
`
@ -385,10 +400,12 @@ func ForumThread(c *RequestContext) ResponseData {
JOIN handmade_postversion AS ver ON post.current_id = ver.id
LEFT JOIN auth_user AS author ON post.author_id = author.id
LEFT JOIN auth_user AS editor ON ver.editor_id = editor.id
LEFT JOIN handmade_post AS reply ON post.reply_id = reply.id
LEFT JOIN auth_user AS reply_author ON reply.author_id = reply_author.id
WHERE
post.thread_id = $1
AND NOT post.deleted
ORDER BY postdate
ORDER BY post.postdate
LIMIT $2 OFFSET $3
`,
thread.ID,
@ -409,6 +426,12 @@ func ForumThread(c *RequestContext) ResponseData {
post.AddContentVersion(row.Ver, row.Editor)
post.AddUrls(c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, post.ID)
if row.ReplyPost != nil {
reply := templates.PostToTemplate(row.ReplyPost, row.ReplyAuthor, c.Theme)
reply.AddUrls(c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, post.ID)
post.ReplyPost = &reply
}
posts = append(posts, post)
}
@ -417,7 +440,7 @@ func ForumThread(c *RequestContext) ResponseData {
// TODO(asaf): Set breadcrumbs
var res ResponseData
err = res.WriteTemplate("forum_thread.html", forumThreadData{
res.MustWriteTemplate("forum_thread.html", forumThreadData{
BaseData: baseData,
Thread: templates.ThreadToTemplate(&thread),
Posts: posts,
@ -425,14 +448,13 @@ func ForumThread(c *RequestContext) ResponseData {
ReplyUrl: hmnurl.BuildForumPostReply(c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, *thread.FirstID),
Pagination: pagination,
}, c.Perf)
if err != nil {
panic(err)
}
return res
}
func ForumPostRedirect(c *RequestContext) ResponseData {
// TODO(compression): This logic for fetching posts by thread id / post id is gonna
// show up in a lot of places. It's used multiple times for forums, and also for blogs.
// Consider compressing this later.
c.Perf.StartBlock("SQL", "Fetch category tree")
categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn)
lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree)
@ -517,15 +539,6 @@ func ForumPostRedirect(c *RequestContext) ResponseData {
), http.StatusSeeOther)
}
type editorData struct {
templates.BaseData
SubmitUrl string
PostTitle string
PostBody string
SubmitLabel string
IsEditing bool // false if new post, true if updating existing one
}
func ForumNewThread(c *RequestContext) ResponseData {
baseData := getBaseData(c)
baseData.Title = "Create New Thread"
@ -543,15 +556,11 @@ func ForumNewThread(c *RequestContext) ResponseData {
}
var res ResponseData
err := res.WriteTemplate("editor.html", editorData{
res.MustWriteTemplate("editor.html", editorData{
BaseData: baseData,
SubmitUrl: hmnurl.BuildForumNewThread(c.CurrentProject.Slug, lineageBuilder.GetSubforumLineageSlugs(currentCatId), true),
SubmitLabel: "Post New Thread",
}, c.Perf)
if err != nil {
panic(err)
}
return res
}
@ -581,17 +590,6 @@ func ForumNewThreadSubmit(c *RequestContext) ResponseData {
sticky = true
}
parsed := parsing.ParsePostInput(unparsed, parsing.RealMarkdown)
now := time.Now()
ip := net.ParseIP(c.Req.RemoteAddr)
const previewMaxLength = 100
parsedPlaintext := parsing.ParsePostInput(unparsed, parsing.PlaintextMarkdown)
preview := parsedPlaintext
if len(preview) > previewMaxLength-1 {
preview = preview[:previewMaxLength-1] + "…"
}
// Create thread
var threadId int
err = tx.QueryRow(c.Context(),
@ -610,58 +608,7 @@ func ForumNewThreadSubmit(c *RequestContext) ResponseData {
panic(oops.New(err, "failed to create thread"))
}
// Create post
var postId int
err = tx.QueryRow(c.Context(),
`
INSERT INTO handmade_post (postdate, category_id, thread_id, preview, current_id, author_id, category_kind, project_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id
`,
now,
currentCatId,
threadId,
preview,
-1,
c.CurrentUser.ID,
models.CatKindForum,
c.CurrentProject.ID,
).Scan(&postId)
if err != nil {
panic(oops.New(err, "failed to create post"))
}
// Create post version
var versionId int
err = tx.QueryRow(c.Context(),
`
INSERT INTO handmade_postversion (post_id, text_raw, text_parsed, ip, date)
VALUES ($1, $2, $3, $4, $5)
RETURNING id
`,
postId,
unparsed,
parsed,
ip,
now,
).Scan(&versionId)
if err != nil {
panic(oops.New(err, "failed to create post version"))
}
// Update post with version id
_, err = tx.Exec(c.Context(),
`
UPDATE handmade_post
SET current_id = $1
WHERE id = $2
`,
versionId,
postId,
)
if err != nil {
panic(oops.New(err, "failed to set current post version"))
}
postId, _ := createNewForumPostAndVersion(c.Context(), tx, currentCatId, threadId, c.CurrentUser.ID, c.CurrentProject.ID, unparsed, c.Req.Host, nil)
// Update thread with post id
_, err = tx.Exec(c.Context(),
@ -688,6 +635,550 @@ func ForumNewThreadSubmit(c *RequestContext) ResponseData {
return c.Redirect(newThreadUrl, http.StatusSeeOther)
}
func ForumPostReply(c *RequestContext) ResponseData {
// TODO(compression): This logic for fetching posts by thread id / post id is gonna
// show up in a lot of places. It's used multiple times for forums, and also for blogs.
// Consider compressing this later.
c.Perf.StartBlock("SQL", "Fetch category tree")
categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn)
lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree)
c.Perf.EndBlock()
currentCatId, valid := validateSubforums(lineageBuilder, c.CurrentProject, c.PathParams["cats"])
if !valid {
return FourOhFour(c)
}
requestedThreadId, err := strconv.Atoi(c.PathParams["threadid"])
if err != nil {
return FourOhFour(c)
}
requestedPostId, err := strconv.Atoi(c.PathParams["postid"])
if err != nil {
return FourOhFour(c)
}
c.Perf.StartBlock("SQL", "Fetch post to reply to")
// TODO: Scope this down to just what you need
type postQuery struct {
Thread models.Thread `db:"thread"`
Post models.Post `db:"post"`
CurrentVersion models.PostVersion `db:"ver"`
Author *models.User `db:"author"`
Editor *models.User `db:"editor"`
}
postQueryResult, err := db.QueryOne(c.Context(), c.Conn, postQuery{},
`
SELECT $columns
FROM
handmade_thread AS thread
JOIN handmade_post AS post ON post.thread_id = thread.id
JOIN handmade_postversion AS ver ON post.current_id = ver.id
LEFT JOIN auth_user AS author ON post.author_id = author.id
LEFT JOIN auth_user AS editor ON ver.editor_id = editor.id
WHERE
post.category_id = $1
AND post.thread_id = $2
AND post.id = $3
AND NOT post.deleted
ORDER BY postdate
`,
currentCatId,
requestedThreadId,
requestedPostId,
)
if err != nil {
if errors.Is(err, db.ErrNoMatchingRows) {
return FourOhFour(c)
} else {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch reply post"))
}
}
result := postQueryResult.(*postQuery)
baseData := getBaseData(c)
baseData.Title = fmt.Sprintf("Replying to \"%s\" | %s", result.Thread.Title, *categoryTree[currentCatId].Name)
baseData.MathjaxEnabled = true
// TODO(ben): Set breadcrumbs
templatePost := templates.PostToTemplate(&result.Post, result.Author, c.Theme)
templatePost.AddContentVersion(result.CurrentVersion, result.Editor)
var res ResponseData
res.MustWriteTemplate("editor.html", editorData{
BaseData: baseData,
SubmitUrl: hmnurl.BuildForumPostReply(c.CurrentProject.Slug, lineageBuilder.GetSubforumLineageSlugs(currentCatId), requestedThreadId, requestedPostId),
SubmitLabel: "Submit Reply",
Title: "Replying to post",
PostReplyingTo: &templatePost,
}, c.Perf)
return res
}
func ForumPostReplySubmit(c *RequestContext) ResponseData {
tx, err := c.Conn.Begin(c.Context())
if err != nil {
panic(err)
}
defer tx.Rollback(c.Context())
c.Perf.StartBlock("SQL", "Fetch category tree")
categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn)
lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree)
c.Perf.EndBlock()
currentCatId, valid := validateSubforums(lineageBuilder, c.CurrentProject, c.PathParams["cats"])
if !valid {
return FourOhFour(c)
}
threadId, err := strconv.Atoi(c.PathParams["threadid"])
if err != nil {
return FourOhFour(c)
}
postId, err := strconv.Atoi(c.PathParams["postid"])
if err != nil {
return FourOhFour(c)
}
c.Req.ParseForm()
unparsed := c.Req.Form.Get("body")
newPostId, _ := createNewForumPostAndVersion(c.Context(), tx, currentCatId, threadId, c.CurrentUser.ID, c.CurrentProject.ID, unparsed, c.Req.Host, &postId)
err = tx.Commit(c.Context())
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to reply to forum post"))
}
newPostUrl := hmnurl.BuildForumPost(c.CurrentProject.Slug, lineageBuilder.GetSubforumLineageSlugs(currentCatId), threadId, newPostId)
return c.Redirect(newPostUrl, http.StatusSeeOther)
}
func ForumPostEdit(c *RequestContext) ResponseData {
// TODO(compression): This logic for fetching posts by thread id / post id is gonna
// show up in a lot of places. It's used multiple times for forums, and also for blogs.
// Consider compressing this later.
c.Perf.StartBlock("SQL", "Fetch category tree")
categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn)
lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree)
c.Perf.EndBlock()
currentCatId, valid := validateSubforums(lineageBuilder, c.CurrentProject, c.PathParams["cats"])
if !valid {
return FourOhFour(c)
}
requestedThreadId, err := strconv.Atoi(c.PathParams["threadid"])
if err != nil {
return FourOhFour(c)
}
requestedPostId, err := strconv.Atoi(c.PathParams["postid"])
if err != nil {
return FourOhFour(c)
}
c.Perf.StartBlock("SQL", "Fetch post to edit")
// TODO: Scope this down to just what you need
type postQuery struct {
Thread models.Thread `db:"thread"`
Post models.Post `db:"post"`
CurrentVersion models.PostVersion `db:"ver"`
Author *models.User `db:"author"`
Editor *models.User `db:"editor"`
}
postQueryResult, err := db.QueryOne(c.Context(), c.Conn, postQuery{},
`
SELECT $columns
FROM
handmade_thread AS thread
JOIN handmade_post AS post ON post.thread_id = thread.id
JOIN handmade_postversion AS ver ON post.current_id = ver.id
LEFT JOIN auth_user AS author ON post.author_id = author.id
LEFT JOIN auth_user AS editor ON ver.editor_id = editor.id
WHERE
post.category_id = $1
AND post.thread_id = $2
AND post.id = $3
AND NOT post.deleted
ORDER BY postdate
`,
currentCatId,
requestedThreadId,
requestedPostId,
)
if err != nil {
if errors.Is(err, db.ErrNoMatchingRows) {
return FourOhFour(c)
} else {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch reply post"))
}
}
result := postQueryResult.(*postQuery)
// Ensure that the user is permitted to edit the post
canEdit, err := canEditPost(c.Context(), c.Conn, requestedPostId, *c.CurrentUser)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, err)
} else if !canEdit {
return FourOhFour(c)
}
baseData := getBaseData(c)
baseData.Title = fmt.Sprintf("Editing \"%s\" | %s", result.Thread.Title, *categoryTree[currentCatId].Name)
baseData.MathjaxEnabled = true
// TODO(ben): Set breadcrumbs
templatePost := templates.PostToTemplate(&result.Post, result.Author, c.Theme)
templatePost.AddContentVersion(result.CurrentVersion, result.Editor)
var res ResponseData
res.MustWriteTemplate("editor.html", editorData{
BaseData: baseData,
SubmitUrl: hmnurl.BuildForumPostEdit(c.CurrentProject.Slug, lineageBuilder.GetSubforumLineageSlugs(currentCatId), requestedThreadId, requestedPostId),
Title: result.Thread.Title,
SubmitLabel: "Submit Edited Post",
IsEditing: true,
EditInitialContents: result.CurrentVersion.TextRaw,
}, c.Perf)
return res
}
func ForumPostEditSubmit(c *RequestContext) ResponseData {
tx, err := c.Conn.Begin(c.Context())
if err != nil {
panic(err)
}
defer tx.Rollback(c.Context())
c.Perf.StartBlock("SQL", "Fetch category tree")
categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn)
lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree)
c.Perf.EndBlock()
currentCatId, valid := validateSubforums(lineageBuilder, c.CurrentProject, c.PathParams["cats"])
if !valid {
return FourOhFour(c)
}
threadId, err := strconv.Atoi(c.PathParams["threadid"])
if err != nil {
return FourOhFour(c)
}
postId, err := strconv.Atoi(c.PathParams["postid"])
if err != nil {
return FourOhFour(c)
}
// Ensure that the user is permitted to edit the post
canEdit, err := canEditPost(c.Context(), c.Conn, postId, *c.CurrentUser)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, err)
} else if !canEdit {
return FourOhFour(c)
}
c.Req.ParseForm()
unparsed := c.Req.Form.Get("body")
editReason := c.Req.Form.Get("editreason")
createForumPostVersion(c.Context(), tx, postId, unparsed, c.Req.Host, editReason, &c.CurrentUser.ID)
err = tx.Commit(c.Context())
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to edit forum post"))
}
postUrl := hmnurl.BuildForumPost(c.CurrentProject.Slug, lineageBuilder.GetSubforumLineageSlugs(currentCatId), threadId, postId)
return c.Redirect(postUrl, http.StatusSeeOther)
}
func ForumPostDelete(c *RequestContext) ResponseData {
// TODO(compression): This logic for fetching posts by thread id / post id is gonna
// show up in a lot of places. It's used multiple times for forums, and also for blogs.
// Consider compressing this later.
c.Perf.StartBlock("SQL", "Fetch category tree")
categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn)
lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree)
c.Perf.EndBlock()
currentCatId, valid := validateSubforums(lineageBuilder, c.CurrentProject, c.PathParams["cats"])
if !valid {
return FourOhFour(c)
}
requestedThreadId, err := strconv.Atoi(c.PathParams["threadid"])
if err != nil {
return FourOhFour(c)
}
requestedPostId, err := strconv.Atoi(c.PathParams["postid"])
if err != nil {
return FourOhFour(c)
}
// Ensure that the user is allowed to delete this post
canEdit, err := canEditPost(c.Context(), c.Conn, requestedPostId, *c.CurrentUser)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, err)
} else if !canEdit {
return FourOhFour(c)
}
c.Perf.StartBlock("SQL", "Fetch post to delete")
type postQuery struct {
Thread models.Thread `db:"thread"`
Post models.Post `db:"post"`
CurrentVersion models.PostVersion `db:"ver"`
Author *models.User `db:"author"`
Editor *models.User `db:"editor"`
}
postQueryResult, err := db.QueryOne(c.Context(), c.Conn, postQuery{},
`
SELECT $columns
FROM
handmade_thread AS thread
JOIN handmade_post AS post ON post.thread_id = thread.id
JOIN handmade_postversion AS ver ON post.current_id = ver.id
LEFT JOIN auth_user AS author ON post.author_id = author.id
LEFT JOIN auth_user AS editor ON ver.editor_id = editor.id
WHERE
post.category_id = $1
AND post.thread_id = $2
AND post.id = $3
AND NOT post.deleted
ORDER BY postdate
`,
currentCatId,
requestedThreadId,
requestedPostId,
)
if err != nil {
if errors.Is(err, db.ErrNoMatchingRows) {
return FourOhFour(c)
} else {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch post to delete"))
}
}
result := postQueryResult.(*postQuery)
baseData := getBaseData(c)
baseData.Title = fmt.Sprintf("Deleting post in \"%s\" | %s", result.Thread.Title, *categoryTree[currentCatId].Name)
baseData.MathjaxEnabled = true
// TODO(ben): Set breadcrumbs
templatePost := templates.PostToTemplate(&result.Post, result.Author, c.Theme)
templatePost.AddContentVersion(result.CurrentVersion, result.Editor)
type forumPostDeleteData struct {
templates.BaseData
Post templates.Post
SubmitUrl string
}
var res ResponseData
res.MustWriteTemplate("forum_post_delete.html", forumPostDeleteData{
BaseData: baseData,
SubmitUrl: hmnurl.BuildForumPostDelete(c.CurrentProject.Slug, lineageBuilder.GetSubforumLineageSlugs(currentCatId), requestedThreadId, requestedPostId),
Post: templatePost,
}, c.Perf)
return res
}
func ForumPostDeleteSubmit(c *RequestContext) ResponseData {
// TODO(compression): This logic for fetching posts by thread id / post id is gonna
// show up in a lot of places. It's used multiple times for forums, and also for blogs.
// Consider compressing this later.
c.Perf.StartBlock("SQL", "Fetch category tree")
categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn)
lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree)
c.Perf.EndBlock()
currentCatId, valid := validateSubforums(lineageBuilder, c.CurrentProject, c.PathParams["cats"])
if !valid {
return FourOhFour(c)
}
threadId, err := strconv.Atoi(c.PathParams["threadid"])
if err != nil {
return FourOhFour(c)
}
postId, err := strconv.Atoi(c.PathParams["postid"])
if err != nil {
return FourOhFour(c)
}
// Ensure that the user is allowed to delete this post
canEdit, err := canEditPost(c.Context(), c.Conn, postId, *c.CurrentUser)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, err)
} else if !canEdit {
return FourOhFour(c)
}
tx, err := c.Conn.Begin(c.Context())
if err != nil {
panic(err)
}
defer tx.Rollback(c.Context())
isFirstPost, err := db.QueryBool(c.Context(), tx,
`
SELECT thread.first_id = $1
FROM
handmade_thread AS thread
WHERE
thread.id = $2
`,
postId,
threadId,
)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to check if post was the first post in the thread"))
}
if isFirstPost {
// Just delete the whole thread and all its posts.
_, err = tx.Exec(c.Context(),
`
UPDATE handmade_thread
SET deleted = TRUE
WHERE id = $1
`,
threadId,
)
_, err = tx.Exec(c.Context(),
`
UPDATE handmade_post
SET deleted = TRUE
WHERE thread_id = $1
`,
threadId,
)
err = tx.Commit(c.Context())
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to delete thread and posts when deleting the first post"))
}
forumUrl := hmnurl.BuildForumCategory(c.CurrentProject.Slug, lineageBuilder.GetSubforumLineageSlugs(currentCatId), 1)
return c.Redirect(forumUrl, http.StatusSeeOther)
}
_, err = tx.Exec(c.Context(),
`
UPDATE handmade_post
SET deleted = TRUE
WHERE
id = $1
`,
postId,
)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to mark forum post as deleted"))
}
err = fixThreadPostIds(c.Context(), tx, threadId)
if err != nil {
if errors.Is(err, errThreadEmpty) {
panic("it shouldn't be possible to delete the last remaining post in a thread, without it also being the first post in the thread and thus resulting in the whole thread getting deleted earlier")
} else {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fix up thread post ids"))
}
}
err = tx.Commit(c.Context())
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to delete post"))
}
threadUrl := hmnurl.BuildForumThread(c.CurrentProject.Slug, lineageBuilder.GetSubforumLineageSlugs(currentCatId), threadId, "", 1) // TODO: Go to the last page of the thread? Or the post before the post we just deleted?
return c.Redirect(threadUrl, http.StatusSeeOther)
}
func createNewForumPostAndVersion(ctx context.Context, tx pgx.Tx, catId, threadId, userId, projectId int, unparsedContent string, ipString string, replyId *int) (postId, versionId int) {
// Create post
err := tx.QueryRow(ctx,
`
INSERT INTO handmade_post (postdate, category_id, thread_id, current_id, author_id, category_kind, project_id, reply_id, preview)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id
`,
time.Now(),
catId,
threadId,
-1,
userId,
models.CatKindForum,
projectId,
replyId,
"", // empty preview, will be updated later
).Scan(&postId)
if err != nil {
panic(oops.New(err, "failed to create post"))
}
versionId = createForumPostVersion(ctx, tx, postId, unparsedContent, ipString, "", nil)
return
}
func createForumPostVersion(ctx context.Context, tx pgx.Tx, postId int, unparsedContent string, ipString string, editReason string, editorId *int) (versionId int) {
parsed := parsing.ParsePostInput(unparsedContent, parsing.RealMarkdown)
ip := net.ParseIP(ipString)
const previewMaxLength = 100
parsedPlaintext := parsing.ParsePostInput(unparsedContent, parsing.PlaintextMarkdown)
preview := parsedPlaintext
if len(preview) > previewMaxLength-1 {
preview = preview[:previewMaxLength-1] + "…"
}
// Create post version
err := tx.QueryRow(ctx,
`
INSERT INTO handmade_postversion (post_id, text_raw, text_parsed, ip, date, edit_reason, editor_id)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id
`,
postId,
unparsedContent,
parsed,
ip,
time.Now(),
editReason,
editorId,
).Scan(&versionId)
if err != nil {
panic(oops.New(err, "failed to create post version"))
}
// Update post with version id and preview
_, err = tx.Exec(ctx,
`
UPDATE handmade_post
SET current_id = $1, preview = $2
WHERE id = $3
`,
versionId,
preview,
postId,
)
if err != nil {
panic(oops.New(err, "failed to set current post version and preview"))
}
return
}
func validateSubforums(lineageBuilder *models.CategoryLineageBuilder, project *models.Project, catPath string) (int, bool) {
if project.ForumID == nil {
return -1, false
@ -721,3 +1212,92 @@ func validateSubforums(lineageBuilder *models.CategoryLineageBuilder, project *m
}
return subforumCatId, valid
}
func canEditPost(ctx context.Context, conn *pgxpool.Pool, postId int, currentUser models.User) (bool, error) {
if currentUser.IsStaff {
return true, nil
}
type postResult struct {
AuthorID *int `db:"author.id"`
}
iresult, err := db.QueryOne(ctx, conn, postResult{},
`
SELECT $columns
FROM
handmade_post AS post
LEFT JOIN auth_user AS author ON post.author_id = author.id
WHERE
post.id = $1
AND NOT post.deleted
ORDER BY postdate
`,
postId,
)
if err != nil {
if errors.Is(err, db.ErrNoMatchingRows) {
return false, nil
} else {
return false, oops.New(err, "failed to get author of post when checking permissions")
}
}
result := iresult.(*postResult)
return result.AuthorID != nil && *result.AuthorID == currentUser.ID, nil
}
var errThreadEmpty = errors.New("thread contained no non-deleted posts")
/*
Ensures that the first_id and last_id on the thread are still good.
Returns errThreadEmpty if the thread contains no visible posts any more.
You should probably mark the thread as deleted in this case.
*/
func fixThreadPostIds(ctx context.Context, tx pgx.Tx, threadId int) error {
postsIter, err := db.Query(ctx, tx, models.Post{},
`
SELECT $columns
FROM handmade_post
WHERE
thread_id = $1
AND NOT deleted
`,
threadId,
)
if err != nil {
return oops.New(err, "failed to fetch posts when fixing up thread")
}
var firstPost, lastPost *models.Post
for _, ipost := range postsIter.ToSlice() {
post := ipost.(*models.Post)
if firstPost == nil || post.PostDate.Before(firstPost.PostDate) {
firstPost = post
}
if lastPost == nil || post.PostDate.After(lastPost.PostDate) {
lastPost = post
}
}
if firstPost == nil || lastPost == nil {
return errThreadEmpty
}
_, err = tx.Exec(ctx,
`
UPDATE handmade_thread
SET first_id = $1, last_id = $2
WHERE id = $3
`,
firstPost.ID,
lastPost.ID,
threadId,
)
if err != nil {
return oops.New(err, "failed to update thread first/last ids")
}
return nil
}

View File

@ -186,7 +186,7 @@ func ProjectIndex(c *RequestContext) ResponseData {
baseData := getBaseData(c)
baseData.Title = "Project List"
var res ResponseData
err = res.WriteTemplate("project_index.html", ProjectTemplateData{
res.MustWriteTemplate("project_index.html", ProjectTemplateData{
BaseData: baseData,
Pagination: pagination,
@ -203,9 +203,6 @@ func ProjectIndex(c *RequestContext) ResponseData {
RegisterUrl: hmnurl.BuildRegister(),
LoginUrl: hmnurl.BuildLoginPage(c.FullUrl()),
}, c.Perf)
if err != nil {
panic(err)
}
return res
}

View File

@ -254,6 +254,13 @@ func (rd *ResponseData) WriteTemplate(name string, data interface{}, rp *perf.Re
return template.Execute(rd, data)
}
func (rd *ResponseData) MustWriteTemplate(name string, data interface{}, rp *perf.RequestPerf) {
err := rd.WriteTemplate(name, data, rp)
if err != nil {
panic(err)
}
}
func ErrorResponse(status int, errs ...error) ResponseData {
return ResponseData{
StatusCode: status,

View File

@ -153,11 +153,17 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt
mainRoutes.GET(hmnurl.RegexProjectNotApproved, ProjectHomepage)
// NOTE(asaf): Any-project routes:
mainRoutes.Handle([]string{http.MethodGet, http.MethodPost}, hmnurl.RegexForumNewThread, authMiddleware(ForumNewThread))
mainRoutes.GET(hmnurl.RegexForumNewThread, authMiddleware(ForumNewThread))
mainRoutes.POST(hmnurl.RegexForumNewThreadSubmit, authMiddleware(csrfMiddleware(ForumNewThreadSubmit)))
mainRoutes.GET(hmnurl.RegexForumThread, ForumThread)
mainRoutes.GET(hmnurl.RegexForumCategory, ForumCategory)
mainRoutes.GET(hmnurl.RegexForumPost, ForumPostRedirect)
mainRoutes.GET(hmnurl.RegexForumPostReply, authMiddleware(ForumPostReply))
mainRoutes.POST(hmnurl.RegexForumPostReply, authMiddleware(ForumPostReplySubmit))
mainRoutes.GET(hmnurl.RegexForumPostEdit, authMiddleware(ForumPostEdit))
mainRoutes.POST(hmnurl.RegexForumPostEdit, authMiddleware(ForumPostEditSubmit))
mainRoutes.GET(hmnurl.RegexForumPostDelete, authMiddleware(ForumPostDelete))
mainRoutes.POST(hmnurl.RegexForumPostDelete, authMiddleware(ForumPostDeleteSubmit))
mainRoutes.GET(hmnurl.RegexPodcast, PodcastIndex)
mainRoutes.GET(hmnurl.RegexPodcastEdit, PodcastEdit)
@ -308,10 +314,7 @@ func FourOhFour(c *RequestContext) ResponseData {
BaseData: getBaseData(c),
Wanted: c.FullUrl(),
}
err := res.WriteTemplate("404.html", templateData, c.Perf)
if err != nil {
panic(err)
}
res.MustWriteTemplate("404.html", templateData, c.Perf)
} else {
res.Write([]byte("Not Found"))
}

View File

@ -56,13 +56,10 @@ func Showcase(c *RequestContext) ResponseData {
baseData := getBaseData(c)
baseData.Title = "Community Showcase"
var res ResponseData
err = res.WriteTemplate("showcase.html", ShowcaseData{
res.MustWriteTemplate("showcase.html", ShowcaseData{
BaseData: baseData,
ShowcaseItems: jsonItems,
ShowcaseAtomFeedUrl: hmnurl.BuildAtomFeedForShowcase(),
}, c.Perf)
if err != nil {
panic(err)
}
return res
}

View File

@ -63,42 +63,42 @@ func Snippet(c *RequestContext) ResponseData {
snippet := SnippetToTimelineItem(&snippetData.Snippet, snippetData.Asset, snippetData.DiscordMessage, &snippetData.Owner, c.Theme)
opengraph := []templates.OpenGraphItem{
templates.OpenGraphItem{Property: "og:site_name", Value: "Handmade.Network"},
templates.OpenGraphItem{Property: "og:type", Value: "article"},
templates.OpenGraphItem{Property: "og:url", Value: snippet.Url},
templates.OpenGraphItem{Property: "og:title", Value: fmt.Sprintf("Snippet by %s", snippet.OwnerName)},
templates.OpenGraphItem{Property: "og:description", Value: string(snippet.Description)},
{Property: "og:site_name", Value: "Handmade.Network"},
{Property: "og:type", Value: "article"},
{Property: "og:url", Value: snippet.Url},
{Property: "og:title", Value: fmt.Sprintf("Snippet by %s", snippet.OwnerName)},
{Property: "og:description", Value: string(snippet.Description)},
}
if snippet.Type == templates.TimelineTypeSnippetImage {
opengraphImage := []templates.OpenGraphItem{
templates.OpenGraphItem{Property: "og:image", Value: snippet.AssetUrl},
templates.OpenGraphItem{Property: "og:image:width", Value: strconv.Itoa(snippet.Width)},
templates.OpenGraphItem{Property: "og:image:height", Value: strconv.Itoa(snippet.Height)},
templates.OpenGraphItem{Property: "og:image:type", Value: snippet.MimeType},
templates.OpenGraphItem{Name: "twitter:card", Value: "summary_large_image"},
{Property: "og:image", Value: snippet.AssetUrl},
{Property: "og:image:width", Value: strconv.Itoa(snippet.Width)},
{Property: "og:image:height", Value: strconv.Itoa(snippet.Height)},
{Property: "og:image:type", Value: snippet.MimeType},
{Name: "twitter:card", Value: "summary_large_image"},
}
opengraph = append(opengraph, opengraphImage...)
} else if snippet.Type == templates.TimelineTypeSnippetVideo {
opengraphVideo := []templates.OpenGraphItem{
templates.OpenGraphItem{Property: "og:video", Value: snippet.AssetUrl},
templates.OpenGraphItem{Property: "og:video:width", Value: strconv.Itoa(snippet.Width)},
templates.OpenGraphItem{Property: "og:video:height", Value: strconv.Itoa(snippet.Height)},
templates.OpenGraphItem{Property: "og:video:type", Value: snippet.MimeType},
templates.OpenGraphItem{Name: "twitter:card", Value: "player"},
{Property: "og:video", Value: snippet.AssetUrl},
{Property: "og:video:width", Value: strconv.Itoa(snippet.Width)},
{Property: "og:video:height", Value: strconv.Itoa(snippet.Height)},
{Property: "og:video:type", Value: snippet.MimeType},
{Name: "twitter:card", Value: "player"},
}
opengraph = append(opengraph, opengraphVideo...)
} else if snippet.Type == templates.TimelineTypeSnippetAudio {
opengraphAudio := []templates.OpenGraphItem{
templates.OpenGraphItem{Property: "og:audio", Value: snippet.AssetUrl},
templates.OpenGraphItem{Property: "og:audio:type", Value: snippet.MimeType},
templates.OpenGraphItem{Name: "twitter:card", Value: "player"},
{Property: "og:audio", Value: snippet.AssetUrl},
{Property: "og:audio:type", Value: snippet.MimeType},
{Name: "twitter:card", Value: "player"},
}
opengraph = append(opengraph, opengraphAudio...)
} else if snippet.Type == templates.TimelineTypeSnippetYoutube {
opengraphYoutube := []templates.OpenGraphItem{
templates.OpenGraphItem{Property: "og:video", Value: fmt.Sprintf("https://youtube.com/watch?v=%s", snippet.YoutubeID)},
templates.OpenGraphItem{Name: "twitter:card", Value: "player"},
{Property: "og:video", Value: fmt.Sprintf("https://youtube.com/watch?v=%s", snippet.YoutubeID)},
{Name: "twitter:card", Value: "player"},
}
opengraph = append(opengraph, opengraphYoutube...)
}

View File

@ -1,66 +1,43 @@
package website
import ()
func Manifesto(c *RequestContext) ResponseData {
var res ResponseData
err := res.WriteTemplate("manifesto.html", getBaseData(c), c.Perf)
if err != nil {
panic(err)
}
res.MustWriteTemplate("manifesto.html", getBaseData(c), c.Perf)
return res
}
func About(c *RequestContext) ResponseData {
var res ResponseData
err := res.WriteTemplate("about.html", getBaseData(c), c.Perf)
if err != nil {
panic(err)
}
res.MustWriteTemplate("about.html", getBaseData(c), c.Perf)
return res
}
func CodeOfConduct(c *RequestContext) ResponseData {
var res ResponseData
err := res.WriteTemplate("code_of_conduct.html", getBaseData(c), c.Perf)
if err != nil {
panic(err)
}
res.MustWriteTemplate("code_of_conduct.html", getBaseData(c), c.Perf)
return res
}
func CommunicationGuidelines(c *RequestContext) ResponseData {
var res ResponseData
err := res.WriteTemplate("communication_guidelines.html", getBaseData(c), c.Perf)
if err != nil {
panic(err)
}
res.MustWriteTemplate("communication_guidelines.html", getBaseData(c), c.Perf)
return res
}
func ContactPage(c *RequestContext) ResponseData {
var res ResponseData
err := res.WriteTemplate("contact.html", getBaseData(c), c.Perf)
if err != nil {
panic(err)
}
res.MustWriteTemplate("contact.html", getBaseData(c), c.Perf)
return res
}
func MonthlyUpdatePolicy(c *RequestContext) ResponseData {
var res ResponseData
err := res.WriteTemplate("monthly_update_policy.html", getBaseData(c), c.Perf)
if err != nil {
panic(err)
}
res.MustWriteTemplate("monthly_update_policy.html", getBaseData(c), c.Perf)
return res
}
func ProjectSubmissionGuidelines(c *RequestContext) ResponseData {
var res ResponseData
err := res.WriteTemplate("project_submission_guidelines.html", getBaseData(c), c.Perf)
if err != nil {
panic(err)
}
res.MustWriteTemplate("project_submission_guidelines.html", getBaseData(c), c.Perf)
return res
}

View File

@ -42,7 +42,7 @@ var TimelineItemClassMap = map[templates.TimelineType]string{
var TimelineTypeTitleMap = map[templates.TimelineType]string{
templates.TimelineTypeUnknown: "",
templates.TimelineTypeForumThread: "New forums thread",
templates.TimelineTypeForumThread: "New forum thread",
templates.TimelineTypeForumReply: "Forum reply",
templates.TimelineTypeBlogPost: "New blog post",

View File

@ -100,7 +100,7 @@ func UserProfile(c *RequestContext) ResponseData {
AND ($2 OR (project.flags = 0 AND project.lifecycle = ANY ($3)))
`,
profileUser.ID,
(c.CurrentUser != nil && (profileUser == c.CurrentUser || c.CurrentUser.IsSuperuser)),
(c.CurrentUser != nil && (profileUser == c.CurrentUser || c.CurrentUser.IsStaff)),
models.VisibleProjectLifecycles,
)
if err != nil {
@ -222,21 +222,12 @@ func UserProfile(c *RequestContext) ResponseData {
c.Theme,
)
switch timelineItem.Type {
case templates.TimelineTypeForumThread:
case templates.TimelineTypeForumThread, templates.TimelineTypeForumReply:
numForums += 1
case templates.TimelineTypeForumReply:
numForums += 1
case templates.TimelineTypeBlogPost:
case templates.TimelineTypeBlogPost, templates.TimelineTypeBlogComment:
numBlogs += 1
case templates.TimelineTypeBlogComment:
numBlogs += 1
case templates.TimelineTypeWikiCreate:
case templates.TimelineTypeWikiCreate, templates.TimelineTypeWikiTalk:
numWiki += 1
case templates.TimelineTypeWikiTalk:
numWiki += 1
case templates.TimelineTypeLibraryComment:
numLibrary += 1
}
@ -283,7 +274,7 @@ func UserProfile(c *RequestContext) ResponseData {
baseData := getBaseData(c)
var res ResponseData
err = res.WriteTemplate("user_profile.html", UserProfileTemplateData{
res.MustWriteTemplate("user_profile.html", UserProfileTemplateData{
BaseData: baseData,
ProfileUser: templates.UserToTemplate(profileUser, c.Theme),
ProfileUserLinks: profileUserLinks,
@ -295,8 +286,5 @@ func UserProfile(c *RequestContext) ResponseData {
NumLibrary: numLibrary,
NumSnippets: numSnippets,
}, c.Perf)
if err != nil {
panic(err)
}
return res
}