hmn/src/website/admin.go

500 lines
14 KiB
Go

package website
import (
"context"
"encoding/base64"
"errors"
"fmt"
"net/http"
"sort"
"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/hmndata"
"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 {
res := ResponseData{
StatusCode: http.StatusUnauthorized,
}
res.Header().Set("WWW-Authenticate", "Basic realm=\"Admin\"")
return res
} else if 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: "Handmade Network 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
UnapprovedUsers []*unapprovedUserData
SubmitUrl string
ApprovalAction string
SpammerAction string
}
type projectWithLinks struct {
Project templates.Project
Links []templates.Link
}
type unapprovedUserData struct {
User templates.User
Date time.Time
UserLinks []templates.Link
Posts []postWithTitle
ProjectsWithLinks []projectWithLinks
}
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"))
}
projects, err := fetchUnapprovedProjects(c)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch unapproved projects"))
}
unapprovedUsers := make([]*unapprovedUserData, 0)
userIDToDataIdx := make(map[int]int)
for _, p := range posts {
var userData *unapprovedUserData
if idx, ok := userIDToDataIdx[p.Author.ID]; ok {
userData = unapprovedUsers[idx]
} else {
userData = &unapprovedUserData{
User: templates.UserToTemplate(&p.Author, c.Theme),
UserLinks: make([]templates.Link, 0, 10),
}
unapprovedUsers = append(unapprovedUsers, userData)
userIDToDataIdx[p.Author.ID] = len(unapprovedUsers) - 1
}
if p.Post.PostDate.After(userData.Date) {
userData.Date = p.Post.PostDate
}
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(hmndata.UrlContextForProject(&p.Project), &p.Thread, &p.Post, lineageBuilder)
userData.Posts = append(userData.Posts, postWithTitle{
Post: post,
Title: p.Thread.Title,
})
}
for _, p := range projects {
var userData *unapprovedUserData
if idx, ok := userIDToDataIdx[p.User.ID]; ok {
userData = unapprovedUsers[idx]
} else {
userData = &unapprovedUserData{
User: templates.UserToTemplate(p.User, c.Theme),
UserLinks: make([]templates.Link, 0, 10),
}
unapprovedUsers = append(unapprovedUsers, userData)
userIDToDataIdx[p.User.ID] = len(unapprovedUsers) - 1
}
projectLinks := make([]templates.Link, 0, len(p.ProjectLinks))
for _, l := range p.ProjectLinks {
projectLinks = append(projectLinks, templates.LinkToTemplate(l))
}
if p.ProjectAndStuff.Project.DateCreated.After(userData.Date) {
userData.Date = p.ProjectAndStuff.Project.DateCreated
}
userData.ProjectsWithLinks = append(userData.ProjectsWithLinks, projectWithLinks{
Project: templates.ProjectAndStuffToTemplate(p.ProjectAndStuff, hmndata.UrlContextForProject(&p.ProjectAndStuff.Project).BuildHomepage(), c.Theme),
Links: projectLinks,
})
}
userIds := make([]int, 0, len(unapprovedUsers))
for _, u := range unapprovedUsers {
userIds = append(userIds, u.User.ID)
}
userLinks, err := db.Query[models.Link](c.Context(), c.Conn,
`
SELECT $columns
FROM
link
WHERE
user_id = ANY($1)
ORDER BY ordering ASC
`,
userIds,
)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user links"))
}
for _, link := range userLinks {
userData := unapprovedUsers[userIDToDataIdx[*link.UserID]]
userData.UserLinks = append(userData.UserLinks, templates.LinkToTemplate(link))
}
sort.Slice(unapprovedUsers, func(a, b int) bool {
return unapprovedUsers[a].Date.After(unapprovedUsers[b].Date)
})
data := adminApprovalQueueData{
BaseData: getBaseDataAutocrumb(c, "Admin approval queue"),
UnapprovedUsers: unapprovedUsers,
SubmitUrl: hmnurl.BuildAdminApprovalQueue(),
ApprovalAction: ApprovalQueueActionApprove,
SpammerAction: ApprovalQueueActionSpammer,
}
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")
}
user, err := hmndata.FetchUser(c.Context(), c.Conn, c.CurrentUser, userId, hmndata.UsersQuery{})
if err != nil {
if errors.Is(err, db.NotFound) {
return RejectRequest(c, "User not found")
} else {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user"))
}
}
whatHappened := ""
if action == ApprovalQueueActionApprove {
_, err := c.Conn.Exec(c.Context(),
`
UPDATE hmn_user
SET status = $1
WHERE id = $2
`,
models.UserStatusApproved,
user.ID,
)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, 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 hmn_user
SET status = $1
WHERE id = $2
`,
models.UserStatusBanned,
user.ID,
)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, 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.StatusInternalServerError, oops.New(err, "failed to log out user"))
}
err = deleteAllPostsForUser(c.Context(), c.Conn, user.ID)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to delete spammer's posts"))
}
err = deleteAllProjectsForUser(c.Context(), c.Conn, user.ID)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to delete spammer's projects"))
}
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) {
posts, err := db.Query[UnapprovedPost](c.Context(), c.Conn,
`
SELECT $columns
FROM
post
JOIN project ON post.project_id = project.id
JOIN thread ON post.thread_id = thread.id
JOIN post_version AS ver ON ver.id = post.current_id
JOIN hmn_user AS author ON author.id = post.author_id
LEFT JOIN asset AS author_avatar ON author_avatar.id = author.avatar_asset_id
WHERE
NOT thread.deleted
AND NOT post.deleted
AND author.status = ANY($1)
ORDER BY post.postdate DESC
`,
[]models.UserStatus{models.UserStatusConfirmed},
)
if err != nil {
return nil, oops.New(err, "failed to fetch unapproved posts")
}
return posts, nil
}
type UnapprovedProject struct {
User *models.User
ProjectAndStuff *hmndata.ProjectAndStuff
ProjectLinks []*models.Link
}
func fetchUnapprovedProjects(c *RequestContext) ([]UnapprovedProject, error) {
ownerIDs, err := db.QueryScalar[int](c.Context(), c.Conn,
`
SELECT id
FROM
hmn_user AS u
WHERE
u.status = ANY($1)
`,
[]models.UserStatus{models.UserStatusConfirmed},
)
if err != nil {
return nil, oops.New(err, "failed to fetch unapproved users")
}
projects, err := hmndata.FetchProjects(c.Context(), c.Conn, c.CurrentUser, hmndata.ProjectsQuery{
OwnerIDs: ownerIDs,
IncludeHidden: true,
})
if err != nil {
return nil, err
}
projectIDs := make([]int, 0, len(projects))
for _, p := range projects {
projectIDs = append(projectIDs, p.Project.ID)
}
projectLinks, err := db.Query[models.Link](c.Context(), c.Conn,
`
SELECT $columns
FROM
link
WHERE
link.project_id = ANY($1)
ORDER BY link.ordering ASC
`,
projectIDs,
)
if err != nil {
return nil, oops.New(err, "failed to fetch links for projects")
}
var result []UnapprovedProject
for idx, proj := range projects {
links := make([]*models.Link, 0, 10) // NOTE(asaf): 10 should be enough for most projects.
for _, link := range projectLinks {
if *link.ProjectID == proj.Project.ID {
links = append(links, link)
}
}
for _, u := range proj.Owners {
if u.Status == models.UserStatusConfirmed {
result = append(result, UnapprovedProject{
User: u,
ProjectAndStuff: &projects[idx],
ProjectLinks: links,
})
}
}
}
return result, 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"`
}
rows, err := db.Query[toDelete](ctx, tx,
`
SELECT $columns
FROM
post as post
JOIN thread ON post.thread_id = thread.id
JOIN hmn_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 _, row := range rows {
hmndata.DeletePost(ctx, tx, row.ThreadID, row.PostID)
}
err = tx.Commit(ctx)
if err != nil {
return oops.New(err, "failed to commit transaction")
}
return nil
}
func deleteAllProjectsForUser(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)
projectIDsToDelete, err := db.QueryScalar[int](ctx, tx,
`
SELECT project.id
FROM
project
JOIN user_project AS up ON up.project_id = project.id
WHERE
up.user_id = $1
`,
userId,
)
if err != nil {
return oops.New(err, "failed to fetch user's projects")
}
if len(projectIDsToDelete) > 0 {
_, err = tx.Exec(ctx,
`
DELETE FROM project WHERE id = ANY($1)
`,
projectIDsToDelete,
)
if err != nil {
return oops.New(err, "failed to delete user's projects")
}
}
err = tx.Commit(ctx)
if err != nil {
return oops.New(err, "failed to commit transaction")
}
return nil
}