User approval admin page and RSS

This commit is contained in:
Asaf Gartner 2021-09-24 03:12:46 +03:00
parent cad1c397c1
commit f8985e6ee3
10 changed files with 453 additions and 3 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

286
src/website/admin.go Normal file
View File

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

View File

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

View File

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