Merge branch 'master' of gitssh.handmade.network:hmn/hmn
This commit is contained in:
commit
b41a556fcd
|
@ -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);
|
||||
}
|
Binary file not shown.
|
@ -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); }
|
||||
|
|
27
src/db/db.go
27
src/db/db.go
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -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")
|
||||
}
|
|
@ -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")
|
||||
}
|
|
@ -17,7 +17,6 @@ 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"`
|
||||
|
||||
|
|
|
@ -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++
|
||||
|
||||
searchStartIndex := 0
|
||||
|
||||
for {
|
||||
searchText := restOfSource[searchStartIndex:]
|
||||
|
||||
match := reTag.FindSubmatchIndex(searchText)
|
||||
if match == nil {
|
||||
// no more tags
|
||||
break
|
||||
}
|
||||
} else if closeName := extractStringBySubmatchIndices(restOfSource, m, ctIndex); closeName != "" {
|
||||
if closeName == tagName {
|
||||
|
||||
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 = m[1] // the end index of this closing tag (exclusive)
|
||||
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
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
@ -161,7 +159,6 @@ func UserToTemplate(u *models.User, currentTheme string) User {
|
|||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
Email: email,
|
||||
IsSuperuser: u.IsSuperuser,
|
||||
IsStaff: u.IsStaff,
|
||||
|
||||
Name: UserDisplayName(u),
|
||||
|
@ -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":"`)
|
||||
|
|
|
@ -7,20 +7,28 @@
|
|||
|
||||
<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">
|
||||
<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 }}"/>
|
||||
{{ 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">
|
||||
|
@ -43,16 +51,12 @@
|
|||
<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>
|
||||
<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="post post-preview mv3 mathjax">
|
||||
<div id="preview" class="body contents"></div>
|
||||
</div>
|
||||
|
||||
{{ if .IsEditing }}
|
||||
<span class="editreason">
|
||||
<label for="editreason">Edit reason:</label>
|
||||
|
@ -69,16 +73,12 @@
|
|||
{% endif %}
|
||||
*/}}
|
||||
|
||||
{{/*
|
||||
{{ with .PostReplyingTo }}
|
||||
<h4 class="mt3">The post you're replying to:</h4>
|
||||
{{ template "forum_post_standalone.html" . }}
|
||||
{{ end }}
|
||||
|
||||
{% 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>
|
||||
|
@ -99,23 +99,85 @@
|
|||
{% endif %}
|
||||
*/}}
|
||||
</form>
|
||||
<div class="post post-preview mv3 mathjax flex-fair-ns mv0-ns ml3-ns">
|
||||
<div id="preview" class="body contents"></div>
|
||||
</div>
|
||||
</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 }}
|
||||
|
|
|
@ -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 }}
|
|
@ -7,9 +7,8 @@
|
|||
{{ 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>
|
||||
|
@ -52,10 +51,6 @@
|
|||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
{{ else }}
|
||||
<div class="username">Deleted member</div>
|
||||
<div class="avatar" style="background-image:url('{{ .Author.AvatarUrl }}');"></div>
|
||||
{{ end }}
|
||||
</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">↪</a>
|
||||
<a class="quote action button" href="{{ .QuoteUrl }}" title="Quote">❝</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>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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" }}
|
||||
|
|
|
@ -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 {
|
||||
|
@ -119,7 +120,6 @@ type User struct {
|
|||
ID int
|
||||
Username string
|
||||
Email string
|
||||
IsSuperuser bool
|
||||
IsStaff bool
|
||||
|
||||
Name string
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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"))
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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...)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue