Ten members and a handful of songs a week never needed a database server,
and the server was the last thing making this a two-container deployment.
modernc.org/sqlite is pure Go, so CGO_ENABLED=0 survives and the dependency
count is unchanged: pgx out, sqlite in.
The port stayed small because the driver matches $1-style placeholders
against argument ordinals exactly as pgx does, so no query needed rewriting
for parameters. What did change:
- timestamptz becomes timestamp holding UTC 'YYYY-MM-DD HH:MM:SS'. The
declared type is what makes the driver return time.Time, and the
fixed-width UTC string is what makes ordering and comparison against
datetime('now') mean what they say.
- interval has no equivalent: sessions.idle_ttl is seconds, and the review
edit window travels as a SQLite date modifier string.
- No stddev_pop, so the divisive and unified boards spell the population
formula out, guarded with max(0.0, ...) because cancellation returns a
tiny negative when every score is identical.
- foreign_keys is off by default, so the cascades only exist because the
pragma is set on every connection.
Drops the postgres service, its healthcheck, the depends_on gate, the
startup retry loop and POSTGRES_PASSWORD. ./storage is now the whole
backup. Tests get a fresh database file per test and run everywhere
instead of skipping without TEST_DATABASE_URL.
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)
|
|
}
|