SQLite replaces Postgres, and two fixes from using the thing. - The database is a file under ./storage instead of a second container. Ten members never needed a database server, and the driver is pure Go, so the build stays CGO_ENABLED=0 and the dependency count is unchanged. One bind mount is now the whole backup: no pgdata, no healthcheck-gated depends_on, no startup retry loop. Timestamps are UTC text, idle_ttl is seconds, the divisive/unified boards carry their own stddev, and foreign keys are on by pragma. Tests get a database file each and run without any setup - Invites are copied, not clicked. An invite is something to send, and the anchor opened the join form in the admin's own browser - Feedback asks for more than faults: the footer reads "Ongelmia? Ideoita? Palautetta?" and the page behind it invites ideas rather than only bugs - Kuuntele YouTubessa opens in a new tab, so a half-typed review survives it
199 lines
5.7 KiB
Go
199 lines
5.7 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()
|
|
}
|
|
|
|
// Moderating a complaint means listening to the song, so the admin surface has its own audio route
|
|
// rather than branching auth inside the member handler.
|
|
func (a *app) adminAudio(w http.ResponseWriter, r *http.Request) {
|
|
a.audio(w, r)
|
|
}
|