User approval admin page and RSS
This commit is contained in:
		
							parent
							
								
									cad1c397c1
								
							
						
					
					
						commit
						f8985e6ee3
					
				| 
						 | 
				
			
			@ -88,6 +88,21 @@ func DeleteSession(ctx context.Context, conn *pgxpool.Pool, id string) error {
 | 
			
		|||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func DeleteSessionForUser(ctx context.Context, conn *pgxpool.Pool, username string) error {
 | 
			
		||||
	_, err := conn.Exec(ctx,
 | 
			
		||||
		`
 | 
			
		||||
		DELETE FROM sessions
 | 
			
		||||
		WHERE LOWER(username) = LOWER($1)
 | 
			
		||||
		`,
 | 
			
		||||
		username,
 | 
			
		||||
	)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return oops.New(err, "failed to delete session")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func NewSessionCookie(session *models.Session) *http.Cookie {
 | 
			
		||||
	return &http.Cookie{
 | 
			
		||||
		Name:  SessionCookieName,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -25,6 +25,10 @@ var Config = HMNConfig{
 | 
			
		|||
		CookieDomain: ".handmade.local",
 | 
			
		||||
		CookieSecure: false,
 | 
			
		||||
	},
 | 
			
		||||
	Admin: AdminConfig {
 | 
			
		||||
		AtomUsername: "admin",
 | 
			
		||||
		AtomPassword: "password",
 | 
			
		||||
	},
 | 
			
		||||
	Email: EmailConfig{
 | 
			
		||||
		ServerAddress:          "smtp.example.com",
 | 
			
		||||
		ServerPort:             587,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -23,6 +23,7 @@ type HMNConfig struct {
 | 
			
		|||
	LogLevel     zerolog.Level
 | 
			
		||||
	Postgres     PostgresConfig
 | 
			
		||||
	Auth         AuthConfig
 | 
			
		||||
	Admin        AdminConfig
 | 
			
		||||
	Email        EmailConfig
 | 
			
		||||
	DigitalOcean DigitalOceanConfig
 | 
			
		||||
	Discord      DiscordConfig
 | 
			
		||||
| 
						 | 
				
			
			@ -82,6 +83,11 @@ type EpisodeGuide struct {
 | 
			
		|||
	Projects         map[string]string // NOTE(asaf): Maps from slugs to default episode guide topic
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type AdminConfig struct {
 | 
			
		||||
	AtomUsername string
 | 
			
		||||
	AtomPassword string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func init() {
 | 
			
		||||
	if Config.EpisodeGuide.Projects == nil {
 | 
			
		||||
		Config.EpisodeGuide.Projects = make(map[string]string)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -102,6 +102,11 @@ func TestUserSettings(t *testing.T) {
 | 
			
		|||
	AssertRegexMatch(t, BuildUserSettings("test"), RegexUserSettings, nil)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestAdmin(t *testing.T) {
 | 
			
		||||
	AssertRegexMatch(t, BuildAdminAtomFeed(), RegexAdminAtomFeed, nil)
 | 
			
		||||
	AssertRegexMatch(t, BuildAdminApprovalQueue(), RegexAdminApprovalQueue, nil)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestSnippet(t *testing.T) {
 | 
			
		||||
	AssertRegexMatch(t, BuildSnippet(15), RegexSnippet, map[string]string{"snippetid": "15"})
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -200,6 +200,24 @@ func BuildUserSettings(section string) string {
 | 
			
		|||
	return ProjectUrlWithFragment("/settings", nil, "", section)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
* Admin
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
var RegexAdminAtomFeed = regexp.MustCompile(`^/admin/atom$`)
 | 
			
		||||
 | 
			
		||||
func BuildAdminAtomFeed() string {
 | 
			
		||||
	defer CatchPanic()
 | 
			
		||||
	return Url("/admin/atom", nil)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var RegexAdminApprovalQueue = regexp.MustCompile(`^/admin/approvals$`)
 | 
			
		||||
 | 
			
		||||
func BuildAdminApprovalQueue() string {
 | 
			
		||||
	defer CatchPanic()
 | 
			
		||||
	return Url("/admin/approvals", nil)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
* Snippets
 | 
			
		||||
 */
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,75 @@
 | 
			
		|||
{{ template "base.html" . }}
 | 
			
		||||
 | 
			
		||||
{{ define "content" }}
 | 
			
		||||
<div class="content-block">
 | 
			
		||||
    {{ range .Posts }}
 | 
			
		||||
        <div class="post background-even pa3">
 | 
			
		||||
            <div class="fl w-100 w-25-l pt3 pa3-l flex tc-l">
 | 
			
		||||
                <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>
 | 
			
		||||
                    </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>
 | 
			
		||||
                    <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">
 | 
			
		||||
                    <div class="inline-flex flex-row-reverse pl3-l pb3 items-center">
 | 
			
		||||
                        <div class="postid">
 | 
			
		||||
                            <a name="{{ .ID }}" href="{{ .Url }}">#{{ .ID }}</a>
 | 
			
		||||
                        </div>
 | 
			
		||||
						<div class="flex pr3">
 | 
			
		||||
							<form method="POST" class="mr4" target="{{ $.SubmitUrl }}">
 | 
			
		||||
								{{ csrftoken $.Session }}
 | 
			
		||||
								<input type="hidden" name="action" value="{{ $.ApprovalAction }}" />
 | 
			
		||||
								<input type="hidden" name="user_id" value="{{ .Author.ID }}" />
 | 
			
		||||
								<input type="submit" value="Approve User" />
 | 
			
		||||
							</form>
 | 
			
		||||
							<form method="POST" target="{{ $.SubmitUrl }}">
 | 
			
		||||
								{{ csrftoken $.Session }}
 | 
			
		||||
								<input type="hidden" name="action" value="{{ $.SpammerAction }}" />
 | 
			
		||||
								<input type="hidden" name="user_id" value="{{ .Author.ID }}" />
 | 
			
		||||
								<input type="submit" value="Mark as spammer" />
 | 
			
		||||
							</form>
 | 
			
		||||
						</div>
 | 
			
		||||
                    </div>
 | 
			
		||||
                    <div class="w-100 pb3">
 | 
			
		||||
                        <div class="b" role="heading" aria-level="2">{{ .Title }}</div>
 | 
			
		||||
                        {{ timehtml (relativedate .PostDate) .PostDate }}
 | 
			
		||||
						{{ if and $.User.IsStaff .IP }}
 | 
			
		||||
							<span>[{{ .IP }}]</span>
 | 
			
		||||
						{{ end }}
 | 
			
		||||
                    </div>
 | 
			
		||||
                </div>
 | 
			
		||||
                <div class="post-content overflow-x-auto">
 | 
			
		||||
                    {{ .Content }}
 | 
			
		||||
                </div>
 | 
			
		||||
                {{/* {% if post.author.signature|length %}
 | 
			
		||||
                    <div class="signature"><hr />
 | 
			
		||||
                        {{ post.author.signature|bbdecode|safe }}
 | 
			
		||||
                    </div>
 | 
			
		||||
                {% endif %} */}}
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class="cb"></div>
 | 
			
		||||
        </div>
 | 
			
		||||
    {{ end }}
 | 
			
		||||
</div>
 | 
			
		||||
{{ end }}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,28 @@
 | 
			
		|||
{{ noescape "<?xml version=\"1.0\" encoding=\"utf-8\"?>" }}
 | 
			
		||||
<feed xmlns="http://www.w3.org/2005/Atom">
 | 
			
		||||
	<title type="text">{{ .Title }}</title>
 | 
			
		||||
	<subtitle type="html">{{ .Subtitle }}</subtitle>
 | 
			
		||||
	<link href="{{ .HomepageUrl }}"/>
 | 
			
		||||
	<link rel="self" type="application/atom+xml" href="{{ .AtomFeedUrl }}"/>
 | 
			
		||||
	<link rel="alternate" type="text/html" hreflang="en" href="{{ .FeedUrl }}"/>
 | 
			
		||||
	<rights type="html">{{ .CopyrightStatement }}</rights>
 | 
			
		||||
	<generator uri="{{ .HomepageUrl }}" version="{{ .SiteVersion }}">Handmade Network site engine v{{ .SiteVersion }}</generator>
 | 
			
		||||
	<updated>{{ rfc3339 .Updated }}</updated>
 | 
			
		||||
	<id>{{ .FeedID }}</id>
 | 
			
		||||
	{{ if .Posts }}
 | 
			
		||||
		{{ range .Posts }}
 | 
			
		||||
			<entry>
 | 
			
		||||
				<title>{{ if .PostTypePrefix }}{{ .PostTypePrefix }}: {{ end }}{{ .Title }}</title>
 | 
			
		||||
				<link rel="alternate" type="text/html" href="{{ .Url }}" />
 | 
			
		||||
				<id>{{ .UUID }}</id>
 | 
			
		||||
				<published>{{ rfc3339 .Date }}</published>
 | 
			
		||||
				<updated>{{ rfc3339 .LastEditDate }}</updated>
 | 
			
		||||
				<author>
 | 
			
		||||
					<name>{{ .User.Name }}</name>
 | 
			
		||||
					<uri>{{ .User.ProfileUrl }}</uri>
 | 
			
		||||
				</author>
 | 
			
		||||
				<summary type="html">{{ .Preview }}</summary>
 | 
			
		||||
			</entry>
 | 
			
		||||
		{{ end }}
 | 
			
		||||
	{{ end }}
 | 
			
		||||
</feed>
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,286 @@
 | 
			
		|||
package website
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"context"
 | 
			
		||||
	"encoding/base64"
 | 
			
		||||
	"errors"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"strconv"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"git.handmade.network/hmn/hmn/src/auth"
 | 
			
		||||
	"git.handmade.network/hmn/hmn/src/config"
 | 
			
		||||
	"git.handmade.network/hmn/hmn/src/db"
 | 
			
		||||
	"git.handmade.network/hmn/hmn/src/hmnurl"
 | 
			
		||||
	"git.handmade.network/hmn/hmn/src/models"
 | 
			
		||||
	"git.handmade.network/hmn/hmn/src/oops"
 | 
			
		||||
	"git.handmade.network/hmn/hmn/src/templates"
 | 
			
		||||
	"github.com/google/uuid"
 | 
			
		||||
	"github.com/jackc/pgx/v4/pgxpool"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type AdminAtomFeedData struct {
 | 
			
		||||
	Title    string
 | 
			
		||||
	Subtitle string
 | 
			
		||||
 | 
			
		||||
	HomepageUrl string
 | 
			
		||||
	AtomFeedUrl string
 | 
			
		||||
	FeedUrl     string
 | 
			
		||||
 | 
			
		||||
	CopyrightStatement string
 | 
			
		||||
	SiteVersion        string
 | 
			
		||||
	Updated            time.Time
 | 
			
		||||
	FeedID             string
 | 
			
		||||
 | 
			
		||||
	Posts []templates.PostListItem
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func AdminAtomFeed(c *RequestContext) ResponseData {
 | 
			
		||||
	creds := fmt.Sprintf("%s:%s", config.Config.Admin.AtomUsername, config.Config.Admin.AtomPassword)
 | 
			
		||||
	expectedAuth := fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(creds)))
 | 
			
		||||
	auth, hasAuth := c.Req.Header["Authorization"]
 | 
			
		||||
	if !hasAuth || auth[0] != expectedAuth {
 | 
			
		||||
		return FourOhFour(c)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	feedData := AdminAtomFeedData{
 | 
			
		||||
		HomepageUrl:        hmnurl.BuildHomepage(),
 | 
			
		||||
		CopyrightStatement: fmt.Sprintf("Copyright (C) 2014-%d Handmade.Network and its contributors", time.Now().Year()),
 | 
			
		||||
		SiteVersion:        "2.0",
 | 
			
		||||
		Title:              "Admin feed",
 | 
			
		||||
		Subtitle:           "Unapproved user posts",
 | 
			
		||||
		FeedID:             uuid.NewSHA1(uuid.NameSpaceURL, []byte(hmnurl.BuildAdminAtomFeed())).URN(),
 | 
			
		||||
		AtomFeedUrl:        hmnurl.BuildAdminAtomFeed(),
 | 
			
		||||
		FeedUrl:            hmnurl.BuildAdminApprovalQueue(),
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	unapprovedPosts, err := fetchUnapprovedPosts(c)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch unapproved posts"))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	c.Perf.StartBlock("SQL", "Fetch subforum tree")
 | 
			
		||||
	subforumTree := models.GetFullSubforumTree(c.Context(), c.Conn)
 | 
			
		||||
	lineageBuilder := models.MakeSubforumLineageBuilder(subforumTree)
 | 
			
		||||
	c.Perf.EndBlock()
 | 
			
		||||
 | 
			
		||||
	for _, post := range unapprovedPosts {
 | 
			
		||||
		postItem := MakePostListItem(
 | 
			
		||||
			lineageBuilder,
 | 
			
		||||
			&post.Project,
 | 
			
		||||
			&post.Thread,
 | 
			
		||||
			&post.Post,
 | 
			
		||||
			&post.Author,
 | 
			
		||||
			false,
 | 
			
		||||
			true,
 | 
			
		||||
			c.Theme,
 | 
			
		||||
		)
 | 
			
		||||
 | 
			
		||||
		postItem.PostTypePrefix = fmt.Sprintf("ADMIN::UNAPPROVED: %s", postItem.PostTypePrefix)
 | 
			
		||||
		postItem.UUID = uuid.NewSHA1(uuid.NameSpaceURL, []byte(postItem.Url)).URN()
 | 
			
		||||
		postItem.LastEditDate = post.CurrentVersion.Date
 | 
			
		||||
		feedData.Posts = append(feedData.Posts, postItem)
 | 
			
		||||
	}
 | 
			
		||||
	if len(feedData.Posts) > 0 {
 | 
			
		||||
		feedData.Updated = feedData.Posts[0].Date
 | 
			
		||||
	} else {
 | 
			
		||||
		feedData.Updated = time.Now()
 | 
			
		||||
	}
 | 
			
		||||
	var res ResponseData
 | 
			
		||||
	res.MustWriteTemplate("admin_atom.xml", feedData, c.Perf)
 | 
			
		||||
	return res
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const (
 | 
			
		||||
	ApprovalQueueActionApprove string = "approve"
 | 
			
		||||
	ApprovalQueueActionSpammer string = "spammer"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type postWithTitle struct {
 | 
			
		||||
	templates.Post
 | 
			
		||||
	Title string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type adminApprovalQueueData struct {
 | 
			
		||||
	templates.BaseData
 | 
			
		||||
 | 
			
		||||
	Posts          []postWithTitle
 | 
			
		||||
	SubmitUrl      string
 | 
			
		||||
	ApprovalAction string
 | 
			
		||||
	SpammerAction  string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func AdminApprovalQueue(c *RequestContext) ResponseData {
 | 
			
		||||
	c.Perf.StartBlock("SQL", "Fetch subforum tree")
 | 
			
		||||
	subforumTree := models.GetFullSubforumTree(c.Context(), c.Conn)
 | 
			
		||||
	lineageBuilder := models.MakeSubforumLineageBuilder(subforumTree)
 | 
			
		||||
	c.Perf.EndBlock()
 | 
			
		||||
 | 
			
		||||
	posts, err := fetchUnapprovedPosts(c)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch unapproved posts"))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	data := adminApprovalQueueData{
 | 
			
		||||
		BaseData:       getBaseDataAutocrumb(c, "Admin approval queue"),
 | 
			
		||||
		SubmitUrl:      hmnurl.BuildAdminApprovalQueue(),
 | 
			
		||||
		ApprovalAction: ApprovalQueueActionApprove,
 | 
			
		||||
		SpammerAction:  ApprovalQueueActionSpammer,
 | 
			
		||||
	}
 | 
			
		||||
	for _, p := range posts {
 | 
			
		||||
		post := templates.PostToTemplate(&p.Post, &p.Author, c.Theme)
 | 
			
		||||
		post.AddContentVersion(p.CurrentVersion, &p.Author) // NOTE(asaf): Don't care about editors here
 | 
			
		||||
		post.Url = UrlForGenericPost(&p.Thread, &p.Post, lineageBuilder, p.Project.Slug)
 | 
			
		||||
		data.Posts = append(data.Posts, postWithTitle{
 | 
			
		||||
			Post:  post,
 | 
			
		||||
			Title: p.Thread.Title,
 | 
			
		||||
		})
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var res ResponseData
 | 
			
		||||
	res.MustWriteTemplate("admin_approval_queue.html", data, c.Perf)
 | 
			
		||||
	return res
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func AdminApprovalQueueSubmit(c *RequestContext) ResponseData {
 | 
			
		||||
	err := c.Req.ParseForm()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return c.ErrorResponse(http.StatusBadRequest, oops.New(err, "failed to parse admin approval form"))
 | 
			
		||||
	}
 | 
			
		||||
	action := c.Req.Form.Get("action")
 | 
			
		||||
	userIdStr := c.Req.Form.Get("user_id")
 | 
			
		||||
	userId, err := strconv.Atoi(userIdStr)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return RejectRequest(c, "User id can't be parsed")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	u, err := db.QueryOne(c.Context(), c.Conn, models.User{},
 | 
			
		||||
		`
 | 
			
		||||
		SELECT $columns FROM auth_user WHERE id = $1
 | 
			
		||||
		`,
 | 
			
		||||
		userId,
 | 
			
		||||
	)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		if errors.Is(err, db.NotFound) {
 | 
			
		||||
			return RejectRequest(c, "User not found")
 | 
			
		||||
		} else {
 | 
			
		||||
			return c.ErrorResponse(http.StatusBadRequest, oops.New(err, "failed to fetch user"))
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	user := u.(*models.User)
 | 
			
		||||
 | 
			
		||||
	whatHappened := ""
 | 
			
		||||
	if action == ApprovalQueueActionApprove {
 | 
			
		||||
		_, err := c.Conn.Exec(c.Context(),
 | 
			
		||||
			`
 | 
			
		||||
			UPDATE auth_user
 | 
			
		||||
			SET status = $1
 | 
			
		||||
			WHERE id = $2
 | 
			
		||||
			`,
 | 
			
		||||
			models.UserStatusApproved,
 | 
			
		||||
			user.ID,
 | 
			
		||||
		)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return c.ErrorResponse(http.StatusBadRequest, oops.New(err, "failed to set user to approved"))
 | 
			
		||||
		}
 | 
			
		||||
		whatHappened = fmt.Sprintf("%s approved successfully", user.Username)
 | 
			
		||||
	} else if action == ApprovalQueueActionSpammer {
 | 
			
		||||
		_, err := c.Conn.Exec(c.Context(),
 | 
			
		||||
			`
 | 
			
		||||
			UPDATE auth_user
 | 
			
		||||
			SET status = $1
 | 
			
		||||
			WHERE id = $2
 | 
			
		||||
			`,
 | 
			
		||||
			models.UserStatusBanned,
 | 
			
		||||
			user.ID,
 | 
			
		||||
		)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return c.ErrorResponse(http.StatusBadRequest, oops.New(err, "failed to set user to banned"))
 | 
			
		||||
		}
 | 
			
		||||
		err = auth.DeleteSessionForUser(c.Context(), c.Conn, user.Username)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return c.ErrorResponse(http.StatusBadRequest, oops.New(err, "failed to log out user"))
 | 
			
		||||
		}
 | 
			
		||||
		err = deleteAllPostsForUser(c.Context(), c.Conn, user.ID)
 | 
			
		||||
		whatHappened = fmt.Sprintf("%s banned successfully", user.Username)
 | 
			
		||||
	} else {
 | 
			
		||||
		whatHappened = fmt.Sprintf("Unrecognized action: %s", action)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	res := c.Redirect(hmnurl.BuildAdminApprovalQueue(), http.StatusSeeOther)
 | 
			
		||||
	res.AddFutureNotice("success", whatHappened)
 | 
			
		||||
	return res
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type UnapprovedPost struct {
 | 
			
		||||
	Project        models.Project     `db:"project"`
 | 
			
		||||
	Thread         models.Thread      `db:"thread"`
 | 
			
		||||
	Post           models.Post        `db:"post"`
 | 
			
		||||
	CurrentVersion models.PostVersion `db:"ver"`
 | 
			
		||||
	Author         models.User        `db:"author"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func fetchUnapprovedPosts(c *RequestContext) ([]*UnapprovedPost, error) {
 | 
			
		||||
	it, err := db.Query(c.Context(), c.Conn, UnapprovedPost{},
 | 
			
		||||
		`
 | 
			
		||||
		SELECT $columns
 | 
			
		||||
		FROM
 | 
			
		||||
			handmade_post AS post
 | 
			
		||||
			JOIN handmade_project AS project ON post.project_id = project.id
 | 
			
		||||
			JOIN handmade_thread AS thread ON post.thread_id = thread.id
 | 
			
		||||
			JOIN handmade_postversion AS ver ON ver.id = post.current_id
 | 
			
		||||
			JOIN auth_user AS author ON author.id = post.author_id
 | 
			
		||||
		WHERE
 | 
			
		||||
			NOT thread.deleted
 | 
			
		||||
			AND author.status = $1
 | 
			
		||||
		ORDER BY post.postdate DESC
 | 
			
		||||
		`,
 | 
			
		||||
		models.UserStatusConfirmed,
 | 
			
		||||
	)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, oops.New(err, "failed to fetch unapproved posts")
 | 
			
		||||
	}
 | 
			
		||||
	var res []*UnapprovedPost
 | 
			
		||||
	for _, iresult := range it.ToSlice() {
 | 
			
		||||
		res = append(res, iresult.(*UnapprovedPost))
 | 
			
		||||
	}
 | 
			
		||||
	return res, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func deleteAllPostsForUser(ctx context.Context, conn *pgxpool.Pool, userId int) error {
 | 
			
		||||
	tx, err := conn.Begin(ctx)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return oops.New(err, "failed to start transaction")
 | 
			
		||||
	}
 | 
			
		||||
	defer tx.Rollback(ctx)
 | 
			
		||||
	type toDelete struct {
 | 
			
		||||
		ThreadID int `db:"thread.id"`
 | 
			
		||||
		PostID   int `db:"post.id"`
 | 
			
		||||
	}
 | 
			
		||||
	it, err := db.Query(ctx, tx, toDelete{},
 | 
			
		||||
		`
 | 
			
		||||
		SELECT $columns
 | 
			
		||||
		FROM
 | 
			
		||||
			handmade_post as post
 | 
			
		||||
			JOIN handmade_thread AS thread ON post.thread_id = thread.id
 | 
			
		||||
			JOIN auth_user AS author ON author.id = post.author_id
 | 
			
		||||
		WHERE author.id = $1
 | 
			
		||||
		`,
 | 
			
		||||
		userId,
 | 
			
		||||
	)
 | 
			
		||||
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return oops.New(err, "failed to fetch posts to delete for user")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for _, iResult := range it.ToSlice() {
 | 
			
		||||
		row := iResult.(*toDelete)
 | 
			
		||||
		DeletePost(ctx, tx, row.ThreadID, row.PostID)
 | 
			
		||||
	}
 | 
			
		||||
	err = tx.Commit(ctx)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return oops.New(err, "failed to commit transaction")
 | 
			
		||||
	}
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -372,8 +372,7 @@ func EmailConfirmationSubmit(c *RequestContext) ResponseData {
 | 
			
		|||
		SET status = $1
 | 
			
		||||
		WHERE id = $2
 | 
			
		||||
		`,
 | 
			
		||||
		// models.UserStatusConfirmed,
 | 
			
		||||
		models.UserStatusApproved, // TODO: Change this back to Confirmed after the jam.
 | 
			
		||||
		models.UserStatusConfirmed,
 | 
			
		||||
		validationResult.User.ID,
 | 
			
		||||
	)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -103,6 +103,16 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool, pe
 | 
			
		|||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	adminMiddleware := func(h Handler) Handler {
 | 
			
		||||
		return func(c *RequestContext) (res ResponseData) {
 | 
			
		||||
			if c.CurrentUser == nil || !c.CurrentUser.IsStaff {
 | 
			
		||||
				return FourOhFour(c)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			return h(c)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	csrfMiddleware := func(h Handler) Handler {
 | 
			
		||||
		// CSRF mitigation actions per the OWASP cheat sheet:
 | 
			
		||||
		// https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html
 | 
			
		||||
| 
						 | 
				
			
			@ -183,6 +193,10 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool, pe
 | 
			
		|||
	mainRoutes.GET(hmnurl.RegexDoPasswordReset, DoPasswordReset)
 | 
			
		||||
	mainRoutes.POST(hmnurl.RegexDoPasswordReset, DoPasswordResetSubmit)
 | 
			
		||||
 | 
			
		||||
	mainRoutes.GET(hmnurl.RegexAdminAtomFeed, AdminAtomFeed)
 | 
			
		||||
	mainRoutes.GET(hmnurl.RegexAdminApprovalQueue, adminMiddleware(AdminApprovalQueue))
 | 
			
		||||
	mainRoutes.POST(hmnurl.RegexAdminApprovalQueue, adminMiddleware(csrfMiddleware(AdminApprovalQueueSubmit)))
 | 
			
		||||
 | 
			
		||||
	mainRoutes.GET(hmnurl.RegexFeed, Feed)
 | 
			
		||||
	mainRoutes.GET(hmnurl.RegexAtomFeed, AtomFeed)
 | 
			
		||||
	mainRoutes.GET(hmnurl.RegexShowcase, Showcase)
 | 
			
		||||
| 
						 | 
				
			
			@ -316,7 +330,7 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc
 | 
			
		|||
 | 
			
		||||
		IsProjectPage: !c.CurrentProject.IsHMN(),
 | 
			
		||||
		Header: templates.Header{
 | 
			
		||||
			AdminUrl:           hmnurl.BuildHomepage(), // TODO(asaf)
 | 
			
		||||
			AdminUrl:           hmnurl.BuildAdminApprovalQueue(), // TODO(asaf): Replace with general-purpose admin page
 | 
			
		||||
			UserSettingsUrl:    hmnurl.BuildUserSettings(""),
 | 
			
		||||
			LoginActionUrl:     hmnurl.BuildLoginAction(c.FullUrl()),
 | 
			
		||||
			LogoutActionUrl:    hmnurl.BuildLogoutAction(c.FullUrl()),
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
		Reference in New Issue