diff --git a/src/auth/session.go b/src/auth/session.go
index 0ac2d05a..84c103b3 100644
--- a/src/auth/session.go
+++ b/src/auth/session.go
@@ -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,
diff --git a/src/config/config.go.example b/src/config/config.go.example
index d0970128..203faa3e 100644
--- a/src/config/config.go.example
+++ b/src/config/config.go.example
@@ -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,
diff --git a/src/config/types.go b/src/config/types.go
index 397c65a4..20b1292a 100644
--- a/src/config/types.go
+++ b/src/config/types.go
@@ -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)
diff --git a/src/hmnurl/hmnurl_test.go b/src/hmnurl/hmnurl_test.go
index 3194a2f7..27a3099a 100644
--- a/src/hmnurl/hmnurl_test.go
+++ b/src/hmnurl/hmnurl_test.go
@@ -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"})
}
diff --git a/src/hmnurl/urls.go b/src/hmnurl/urls.go
index 4122218e..6f0bf085 100644
--- a/src/hmnurl/urls.go
+++ b/src/hmnurl/urls.go
@@ -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
*/
diff --git a/src/templates/src/admin_approval_queue.html b/src/templates/src/admin_approval_queue.html
new file mode 100644
index 00000000..789823f8
--- /dev/null
+++ b/src/templates/src/admin_approval_queue.html
@@ -0,0 +1,75 @@
+{{ template "base.html" . }}
+
+{{ define "content" }}
+
+ {{ range .Posts }}
+
+
+
+
+
+ {{ if and .Author.Name (ne .Author.Name .Author.Username) }}
+
{{ .Author.Name }}
+ {{ end }}
+
+
+
+ {{ if .Author.Blurb }}
+ {{ .Author.Blurb }} {{/* TODO: Linebreaks? */}}
+ {{ else if .Author.Bio }}
+ {{ .Author.Bio }}
+ {{ end }}
+
+
+
+
+
+
+
+
{{ .Title }}
+ {{ timehtml (relativedate .PostDate) .PostDate }}
+ {{ if and $.User.IsStaff .IP }}
+
[{{ .IP }}]
+ {{ end }}
+
+
+
+ {{ .Content }}
+
+ {{/* {% if post.author.signature|length %}
+
+ {{ post.author.signature|bbdecode|safe }}
+
+ {% endif %} */}}
+
+
+
+ {{ end }}
+
+{{ end }}
+
diff --git a/src/templates/src/admin_atom.xml b/src/templates/src/admin_atom.xml
new file mode 100644
index 00000000..2cd211e8
--- /dev/null
+++ b/src/templates/src/admin_atom.xml
@@ -0,0 +1,28 @@
+{{ noescape "" }}
+
+ {{ .Title }}
+ {{ .Subtitle }}
+
+
+
+ {{ .CopyrightStatement }}
+ Handmade Network site engine v{{ .SiteVersion }}
+ {{ rfc3339 .Updated }}
+ {{ .FeedID }}
+ {{ if .Posts }}
+ {{ range .Posts }}
+
+ {{ if .PostTypePrefix }}{{ .PostTypePrefix }}: {{ end }}{{ .Title }}
+
+ {{ .UUID }}
+ {{ rfc3339 .Date }}
+ {{ rfc3339 .LastEditDate }}
+
+ {{ .User.Name }}
+ {{ .User.ProfileUrl }}
+
+ {{ .Preview }}
+
+ {{ end }}
+ {{ end }}
+
diff --git a/src/website/admin.go b/src/website/admin.go
new file mode 100644
index 00000000..780df1f0
--- /dev/null
+++ b/src/website/admin.go
@@ -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
+}
diff --git a/src/website/auth.go b/src/website/auth.go
index 2ba48108..5b208fd1 100644
--- a/src/website/auth.go
+++ b/src/website/auth.go
@@ -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 {
diff --git a/src/website/routes.go b/src/website/routes.go
index fa5bfe96..d5cc14f0 100644
--- a/src/website/routes.go
+++ b/src/website/routes.go
@@ -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()),