The admin was a set of env credentials on its own loopback listener. That bought network isolation, and charged a second port to tunnel and proxy and a second credential in the password manager. It also sat outside the SameSite protection the member cookie already had, and left every ban and password reset with no actor to log. is_admin on users reuses what was already there: the session, the login rate limiter, ban-drops-sessions, CSRF. /admin is now a route on the member mux. A member without the flag gets 404 rather than 403 — the pages are none of their business, and "forbidden" confirms there is something to be forbidden from. Registration needs an invite and invites come from /admin, so an empty database cannot grow its first user. seedAdmin breaks that circle exactly once, from ADMIN_EMAIL and ADMIN_PASSWORD, and does nothing against a database that already has users. An admin cannot ban themselves: banning drops the target's sessions, and nothing would be left that could undo it. This reverses decision 8, which is rewritten rather than deleted, along with the admin entry in the CONTEXT.md vocabulary.
193 lines
5.5 KiB
Go
193 lines
5.5 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const maxReportBody = 2000
|
|
|
|
type report struct {
|
|
ID int64
|
|
Body string
|
|
Page string
|
|
UserAgent string
|
|
Reporter string
|
|
ResolvedAt *time.Time
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
func (r *report) Open() bool { return r.ResolvedAt == nil }
|
|
|
|
type reportPage struct {
|
|
From string
|
|
Mine []*report
|
|
}
|
|
|
|
// Free text and nothing else. No category, no priority, no severity — with ten users a sentence and
|
|
// a page URL beat a taxonomy nobody fills in honestly.
|
|
func (a *app) reportPage(w http.ResponseWriter, r *http.Request) {
|
|
mine, err := a.myReports(r.Context(), memberFrom(r.Context()).ID)
|
|
if err != nil {
|
|
slog.Error("list reports", "ctx", "reports", "error", err)
|
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
from := r.URL.Query().Get("from")
|
|
if !strings.HasPrefix(from, "/") {
|
|
from = "/" // never redirect off-site on the strength of a query parameter
|
|
}
|
|
a.render(w, r, http.StatusOK, "report.html",
|
|
page{Title: "Palaute", Data: reportPage{From: from, Mine: mine}})
|
|
}
|
|
|
|
// Seeing your own past reports is what stops the same bug arriving four times.
|
|
func (a *app) myReports(ctx context.Context, userID int64) ([]*report, error) {
|
|
rows, err := a.db.QueryContext(ctx, `
|
|
select id, body, coalesce(page, ''), resolved_at, created_at
|
|
from reports where user_id = $1 order by created_at desc`, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []*report
|
|
for rows.Next() {
|
|
var rep report
|
|
if err := rows.Scan(&rep.ID, &rep.Body, &rep.Page, &rep.ResolvedAt, &rep.CreatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, &rep)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (a *app) createReport(w http.ResponseWriter, r *http.Request) {
|
|
me := memberFrom(r.Context())
|
|
body := clean(r.FormValue("body"), maxReportBody)
|
|
from := r.FormValue("from")
|
|
if !strings.HasPrefix(from, "/") {
|
|
from = "/"
|
|
}
|
|
if body == "" {
|
|
a.flash(w, "Kirjoita muutama sana siitä, mikä meni pieleen.")
|
|
http.Redirect(w, r, "/report?from="+from, http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
// "Only on my phone" is the most common bug report and this answers it without asking.
|
|
_, err := a.db.ExecContext(r.Context(), `
|
|
insert into reports (user_id, body, page, user_agent) values ($1, $2, nullif($3, ''), $4)`,
|
|
me.ID, body, from, clean(r.Header.Get("User-Agent"), 300))
|
|
if err != nil {
|
|
slog.Error("create report", "ctx", "reports", "error", err)
|
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
slog.Info("report filed", "ctx", "reports", "user", me.ID)
|
|
a.flash(w, "Kiitos! Palaute on perillä.")
|
|
http.Redirect(w, r, from, http.StatusSeeOther)
|
|
}
|
|
|
|
// --- admin ---
|
|
|
|
func (a *app) adminReports(w http.ResponseWriter, r *http.Request) {
|
|
rows, err := a.db.QueryContext(r.Context(), `
|
|
select rep.id, rep.body, coalesce(rep.page, ''), coalesce(rep.user_agent, ''),
|
|
u.name, rep.resolved_at, rep.created_at
|
|
from reports rep join users u on u.id = rep.user_id
|
|
order by rep.resolved_at nulls first, rep.created_at desc`)
|
|
if err != nil {
|
|
adminError(w, "reports", err)
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
var out []*report
|
|
for rows.Next() {
|
|
var rep report
|
|
if err := rows.Scan(&rep.ID, &rep.Body, &rep.Page, &rep.UserAgent, &rep.Reporter,
|
|
&rep.ResolvedAt, &rep.CreatedAt); err != nil {
|
|
adminError(w, "reports", err)
|
|
return
|
|
}
|
|
out = append(out, &rep)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
adminError(w, "reports", err)
|
|
return
|
|
}
|
|
a.render(w, r, http.StatusOK, "admin_reports.html",
|
|
page{Title: "Palautteet", Admin: true, Data: out})
|
|
}
|
|
|
|
// A nullable timestamp rather than a status enum: smaller, and it tells you *when*.
|
|
func (a *app) resolveReport(w http.ResponseWriter, r *http.Request) {
|
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
if _, err := a.db.ExecContext(r.Context(),
|
|
`update reports set resolved_at = case when resolved_at is null then datetime('now') end
|
|
where id = $1`,
|
|
id); err != nil {
|
|
adminError(w, "reports", err)
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/admin/reports", http.StatusSeeOther)
|
|
}
|
|
|
|
// The admin deletes a song unconditionally — a separate route from the submitter's, rather than one
|
|
// route with a branch. The row and the file go together here too.
|
|
func (a *app) adminDeleteSong(w http.ResponseWriter, r *http.Request) {
|
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
res, err := a.db.ExecContext(r.Context(), `delete from songs where id = $1`, id)
|
|
if err != nil {
|
|
adminError(w, "songs", err)
|
|
return
|
|
}
|
|
if affected(res) > 0 {
|
|
removeFile(a.audioPath(id))
|
|
slog.Info("song deleted by admin", "ctx", "songs", "song", id)
|
|
a.flash(w, "Kappale poistettu.")
|
|
}
|
|
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
|
}
|
|
|
|
type adminSong struct {
|
|
ID int64
|
|
Title string
|
|
Artist string
|
|
Submitter string
|
|
Reviews int
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
func (a *app) adminSongs(ctx context.Context) ([]adminSong, error) {
|
|
rows, err := a.db.QueryContext(ctx, `
|
|
select s.id, s.title, s.artist, u.name,
|
|
(select count(*) from reviews r where r.song_id = s.id), s.created_at
|
|
from songs s join users u on u.id = s.submitted_by
|
|
order by s.created_at desc`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []adminSong
|
|
for rows.Next() {
|
|
var s adminSong
|
|
if err := rows.Scan(&s.ID, &s.Title, &s.Artist, &s.Submitter, &s.Reviews, &s.CreatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, s)
|
|
}
|
|
return out, rows.Err()
|
|
}
|