1 Commits
Author SHA1 Message Date
Esa Kataja deaadd2f5c Add announcements, and record when members last logged in
A single place to say "downloads work again" without messaging everyone.
The body is markdown, stored as typed and rendered on the way out, so a post
survives editing without a lossy round trip through HTML. goldmark drops raw
HTML rather than rendering it, which matters because the body reaches the
page through template.HTML with Go's own escaping switched off.

The front page carries the three newest under the queue, newest expanded,
and links to /news only when there is a fourth. News is decoration there: if
the query fails the queue still renders. Drafts exist so a post can be
written before it is sent, and hiding is the same toggle as publishing.

Ages read as "5 minuuttia sitten" for a week and then become a date, since
past that the exact age stops being the interesting part.

last_login_at is unrelated to the feed — it answers "does anyone actually
use this", and stays null until a real login, which is how an unused invite
shows up in the members table.

Ago and HTML take value receivers on purpose: templates reach them through
dict, which boxes the item in an interface, and a pointer method on a
non-addressable value is invisible there. That failure renders as a 500 and
is invisible to go vet, so TestFrontPageRendersNews renders the real page.
2026-09-05 16:00:01 +03:00
16 changed files with 635 additions and 39 deletions
+16 -1
View File
@@ -110,7 +110,22 @@ An admin is **an ordinary member with `is_admin` set** — the same account, the
session cookie. Admins submit and review like anyone else; the flag adds a Ylläpito link to the nav
and unlocks `/admin` on the normal listener. A signed-in member without the flag gets a 404 there.
From `/admin`: mint invites, reset member passwords, ban members, delete songs, read issue reports.
From `/admin`: mint invites, reset member passwords, ban members, delete songs, read issue reports,
post announcements, and see when each member last logged in.
## Announcements
`/admin` has a plain title-and-textarea form. The body is **markdown**, stored exactly as typed and
rendered on the way out, so a post can be edited without a lossy round trip through HTML. Raw HTML
in a post is dropped rather than rendered — the parser is [goldmark](https://github.com/yuin/goldmark)
with the unsafe option deliberately off.
A post is published unless *Tallenna luonnoksena* is ticked. Draft and published is one toggle
afterwards, so something that went out too early can be pulled back without losing the text.
Members see the three newest on the front page under the queue, newest expanded, with the rest on
`/news`. Reading requires login, like everything else. Timestamps are relative for the first week
(*5 minuuttia sitten*, *eilen*, *3 päivää sitten*) and a plain date after that.
An admin cannot ban themselves, since banning drops every session for the target and nothing would
be left to undo it.
+1
View File
@@ -3,6 +3,7 @@ module git.kessinen.com/kessinen/levyraati26-go
go 1.27.0
require (
github.com/yuin/goldmark v1.8.6
golang.org/x/crypto v0.32.0
modernc.org/sqlite v1.54.0
)
+31 -26
View File
@@ -1,50 +1,55 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI=
github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog=
modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+14 -7
View File
@@ -21,11 +21,12 @@ type adminInvite struct {
}
type adminMember struct {
ID int64
Name string
Email string
Banned bool
CreatedAt time.Time
ID int64
Name string
Email string
Banned bool
CreatedAt time.Time
LastLoginAt *time.Time // nil until the account has logged in once
}
type dashboard struct {
@@ -33,6 +34,7 @@ type dashboard struct {
SpentCount int
Members []adminMember
Songs []adminSong
News []newsItem
OpenCount int
}
@@ -68,7 +70,7 @@ func (a *app) adminDashboard(w http.ResponseWriter, r *http.Request) {
}
rows, err = a.db.QueryContext(r.Context(),
`select id, name, email, banned, created_at from users order by created_at`)
`select id, name, email, banned, created_at, last_login_at from users order by created_at`)
if err != nil {
adminError(w, "users", err)
return
@@ -76,7 +78,7 @@ func (a *app) adminDashboard(w http.ResponseWriter, r *http.Request) {
defer rows.Close()
for rows.Next() {
var m adminMember
if err := rows.Scan(&m.ID, &m.Name, &m.Email, &m.Banned, &m.CreatedAt); err != nil {
if err := rows.Scan(&m.ID, &m.Name, &m.Email, &m.Banned, &m.CreatedAt, &m.LastLoginAt); err != nil {
adminError(w, "users", err)
return
}
@@ -91,6 +93,11 @@ func (a *app) adminDashboard(w http.ResponseWriter, r *http.Request) {
adminError(w, "songs", err)
return
}
// Drafts included: this is the only place they are visible.
if d.News, err = a.adminNews(r.Context()); err != nil {
adminError(w, "news", err)
return
}
if err := a.db.QueryRowContext(r.Context(),
`select count(*) from reports where resolved_at is null`).Scan(&d.OpenCount); err != nil {
adminError(w, "reports", err)
+6
View File
@@ -205,6 +205,12 @@ func (a *app) login(w http.ResponseWriter, r *http.Request) {
}
a.logins.succeed(email)
a.setSessionCookie(w, tok, expires)
// Best effort: a member who is already through the door should not be turned back because
// bookkeeping failed.
if _, err := a.db.ExecContext(r.Context(),
`update users set last_login_at = datetime('now') where id = $1`, id); err != nil {
slog.Error("last login", "ctx", "auth", "error", err, "user", id)
}
slog.Info("login", "ctx", "auth", "user", id)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
+4
View File
@@ -204,6 +204,7 @@ func (a *app) memberMux() *http.ServeMux {
mux.HandleFunc("GET /audio/{id}", a.requireMember(a.audio))
mux.HandleFunc("GET /avatars/{id}", a.avatar) // public: avatars are not secret
mux.HandleFunc("GET /news", a.requireMember(a.newsPage))
mux.HandleFunc("GET /stats", a.requireMember(a.statsPage))
mux.HandleFunc("GET /profile", a.requireMember(a.profilePage))
mux.HandleFunc("GET /profile/{id}", a.requireMember(a.profilePage))
@@ -240,6 +241,9 @@ func (a *app) adminRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /admin/songs/{id}/delete", a.requireAdmin(a.adminDeleteSong))
mux.HandleFunc("GET /admin/reports", a.requireAdmin(a.adminReports))
mux.HandleFunc("POST /admin/reports/{id}/resolve", a.requireAdmin(a.resolveReport))
mux.HandleFunc("POST /admin/news", a.requireAdmin(a.createNews))
mux.HandleFunc("POST /admin/news/{id}/draft", a.requireAdmin(a.toggleNewsDraft))
mux.HandleFunc("POST /admin/news/{id}/delete", a.requireAdmin(a.deleteNews))
}
// ponytail: one flag, no roles. A moderator tier is a second column on the day someone needs to
+2 -2
View File
@@ -89,7 +89,7 @@ func TestMigrateIsIdempotent(t *testing.T) {
if err := a.db.QueryRowContext(ctx, `select count(*) from schema_migrations`).Scan(&n); err != nil {
t.Fatal(err)
}
if n != 2 {
t.Fatalf("applied migrations = %d, want 2", n)
if n != 3 {
t.Fatalf("applied migrations = %d, want 3", n)
}
}
@@ -0,0 +1,16 @@
-- Announcements. The body is markdown, stored exactly as typed and rendered on the way out, so a
-- post can be edited without a lossy round trip through HTML.
create table news (
id integer primary key autoincrement,
title text not null,
body text not null,
is_draft integer not null default 0,
created_at timestamp not null default (datetime('now'))
);
-- Newest first is the only order anyone reads news in.
create index news_visible on news (is_draft, created_at desc);
-- Not for the news feed — for answering "does anyone actually use this". Null until the account
-- logs in for the first time, which is also how a never-used invite shows up.
alter table users add column last_login_at timestamp;
+211
View File
@@ -0,0 +1,211 @@
package main
import (
"bytes"
"context"
"database/sql"
"errors"
"html/template"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
)
const (
maxNewsTitle = 120
maxNewsBody = 20000
// The front page carries a taste, not an archive. /news has the rest.
newsOnFront = 3
)
// No WithUnsafe: raw HTML in a post renders as literal text. The body reaches the page through
// template.HTML, which turns off Go's own escaping, so this is the only thing standing between a
// post and a <script> tag.
var markdown = goldmark.New(goldmark.WithExtensions(extension.Linkify))
type newsItem struct {
ID int64
Title string
Body string
IsDraft bool
CreatedAt time.Time
}
// HTML renders the stored markdown. A parse failure falls back to the escaped source rather than
// an empty panel — a mangled announcement still beats a missing one.
// Value receivers, both of them: templates reach these through dict, which boxes the item in an
// interface. A pointer method on a non-addressable value is invisible there.
func (n newsItem) HTML() template.HTML {
var buf bytes.Buffer
if err := markdown.Convert([]byte(n.Body), &buf); err != nil {
slog.Error("markdown", "ctx", "news", "error", err, "news", n.ID)
return template.HTML(template.HTMLEscapeString(n.Body))
}
return template.HTML(buf.String())
}
// Ago is "5 minuuttia sitten" for anything inside a week and a plain date beyond it: past a week
// the exact age stops being the interesting part.
func (n newsItem) Ago() string { return ago(n.CreatedAt, time.Now()) }
func ago(t, now time.Time) string {
d := now.Sub(t)
switch {
case d < time.Minute:
return "juuri nyt"
case d < time.Hour:
return plural(int(d.Minutes()), "minuutti sitten", "minuuttia sitten")
case d < 24*time.Hour:
return plural(int(d.Hours()), "tunti sitten", "tuntia sitten")
case d < 7*24*time.Hour:
if days := int(d.Hours() / 24); days == 1 {
return "eilen"
} else {
return strconv.Itoa(days) + " päivää sitten"
}
}
return t.Local().Format("2.1.2006")
}
// Finnish counts the singular with the nominative and everything else with the partitive.
func plural(n int, one, many string) string {
if n <= 1 {
return one
}
return strconv.Itoa(n) + " " + many
}
// Drafts are the author's alone: they never reach a member, on the front page or on /news.
func (a *app) publishedNews(ctx context.Context, limit int) ([]newsItem, error) {
rows, err := a.db.QueryContext(ctx, `
select id, title, body, is_draft, created_at
from news where not is_draft
order by created_at desc, id desc limit $1`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []newsItem
for rows.Next() {
var n newsItem
if err := rows.Scan(&n.ID, &n.Title, &n.Body, &n.IsDraft, &n.CreatedAt); err != nil {
return nil, err
}
out = append(out, n)
}
return out, rows.Err()
}
type newsPage struct {
Items []newsItem
// True when the front page had to cut the list short, so the "kaikki tiedotteet" link only
// appears when there is actually more to see.
More bool
}
func (a *app) newsPage(w http.ResponseWriter, r *http.Request) {
items, err := a.publishedNews(r.Context(), 100)
if err != nil {
slog.Error("list news", "ctx", "news", "error", err)
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
a.render(w, r, http.StatusOK, "news.html", page{Title: "Tiedotteet", Data: newsPage{Items: items}})
}
// --- admin ---
func (a *app) adminNews(ctx context.Context) ([]newsItem, error) {
rows, err := a.db.QueryContext(ctx, `
select id, title, body, is_draft, created_at from news order by created_at desc, id desc`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []newsItem
for rows.Next() {
var n newsItem
if err := rows.Scan(&n.ID, &n.Title, &n.Body, &n.IsDraft, &n.CreatedAt); err != nil {
return nil, err
}
out = append(out, n)
}
return out, rows.Err()
}
func (a *app) createNews(w http.ResponseWriter, r *http.Request) {
title := clean(r.FormValue("title"), maxNewsTitle)
// Not clean(): the body is markdown, where newlines and leading spaces are the syntax.
body := strings.TrimSpace(r.FormValue("body"))
if len(body) > maxNewsBody {
body = body[:maxNewsBody]
}
if title == "" || body == "" {
a.flash(w, "Otsikko ja teksti ovat pakollisia.")
http.Redirect(w, r, "/admin", http.StatusSeeOther)
return
}
// Checkbox: present means draft. A post is published unless it says otherwise.
draft := r.FormValue("is_draft") != ""
var id int64
if err := a.db.QueryRowContext(r.Context(),
`insert into news (title, body, is_draft) values ($1, $2, $3) returning id`,
title, body, draft).Scan(&id); err != nil {
adminError(w, "news", err)
return
}
slog.Info("news posted", "ctx", "news", "news", id, "draft", draft)
if draft {
a.flash(w, "Luonnos tallennettu.")
} else {
a.flash(w, "Tiedote julkaistu.")
}
http.Redirect(w, r, "/admin", http.StatusSeeOther)
}
// Publishing a draft and unpublishing a post are the same button: the flag is a toggle, so a post
// that went out too early can be pulled back without deleting what was written.
func (a *app) toggleNewsDraft(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
var draft bool
err = a.db.QueryRowContext(r.Context(),
`update news set is_draft = not is_draft where id = $1 returning is_draft`, id).Scan(&draft)
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, "not found", http.StatusNotFound)
return
} else if err != nil {
adminError(w, "news", err)
return
}
slog.Info("news draft toggled", "ctx", "news", "news", id, "draft", draft)
if draft {
a.flash(w, "Tiedote piilotettu.")
} else {
a.flash(w, "Tiedote julkaistu.")
}
http.Redirect(w, r, "/admin", http.StatusSeeOther)
}
func (a *app) deleteNews(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
if _, err := a.db.ExecContext(r.Context(), `delete from news where id = $1`, id); err != nil {
adminError(w, "news", err)
return
}
slog.Info("news deleted", "ctx", "news", "news", id)
a.flash(w, "Tiedote poistettu.")
http.Redirect(w, r, "/admin", http.StatusSeeOther)
}
+201
View File
@@ -0,0 +1,201 @@
package main
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
)
func TestAgo(t *testing.T) {
now := time.Date(2026, 9, 5, 12, 0, 0, 0, time.Local)
for _, tc := range []struct {
name string
at time.Time
want string
}{
{"seconds", now.Add(-30 * time.Second), "juuri nyt"},
{"one minute", now.Add(-time.Minute), "minuutti sitten"},
{"minutes", now.Add(-5 * time.Minute), "5 minuuttia sitten"},
{"one hour", now.Add(-time.Hour), "tunti sitten"},
{"hours", now.Add(-5 * time.Hour), "5 tuntia sitten"},
{"yesterday", now.Add(-25 * time.Hour), "eilen"},
{"days", now.Add(-5 * 24 * time.Hour), "5 päivää sitten"},
// Past a week the exact age stops mattering and the date takes over.
{"a week", now.Add(-7 * 24 * time.Hour), "29.8.2026"},
{"months", now.Add(-60 * 24 * time.Hour), "7.7.2026"},
} {
t.Run(tc.name, func(t *testing.T) {
if got := ago(tc.at, now); got != tc.want {
t.Fatalf("ago = %q, want %q", got, tc.want)
}
})
}
}
// A draft is the author's alone. It must not reach a member through either surface.
func TestDraftsAreInvisibleToMembers(t *testing.T) {
a := testApp(t)
ctx := context.Background()
for _, n := range []struct {
title string
draft bool
}{
{"Julkaistu tiedote", false},
{"Salainen luonnos", true},
} {
if _, err := a.db.ExecContext(ctx,
`insert into news (title, body, is_draft) values ($1, 'teksti', $2)`,
n.title, n.draft); err != nil {
t.Fatal(err)
}
}
items, err := a.publishedNews(ctx, 10)
if err != nil {
t.Fatal(err)
}
if len(items) != 1 || items[0].Title != "Julkaistu tiedote" {
t.Fatalf("published news = %+v, want only the published one", items)
}
// The admin listing is the one place a draft shows up.
all, err := a.adminNews(ctx)
if err != nil {
t.Fatal(err)
}
if len(all) != 2 {
t.Fatalf("admin news = %d items, want 2", len(all))
}
}
// The body reaches the page through template.HTML, which turns off Go's escaping. goldmark has to
// be the thing that neutralises a script tag, so assert it actually does.
func TestMarkdownEscapesRawHTML(t *testing.T) {
n := newsItem{Body: "Hei <script>alert(1)</script> ja **lihavointi** ja [linkki](https://example.com)."}
got := string(n.HTML())
// goldmark drops raw HTML rather than escaping it, so the tag disappears entirely — stricter
// than escaping, and either outcome is safe. What matters is that no tag survives.
if strings.Contains(got, "<script") || strings.Contains(got, "</script") {
t.Fatalf("raw script tag survived rendering: %s", got)
}
if !strings.Contains(got, "<strong>lihavointi</strong>") {
t.Fatalf("markdown emphasis did not render: %s", got)
}
if !strings.Contains(got, `href="https://example.com"`) {
t.Fatalf("markdown link did not render: %s", got)
}
}
// Posting news is admin-only, and the checkbox decides whether members ever see it.
func TestCreateNewsRequiresAdminAndHonoursDraft(t *testing.T) {
a := testApp(t)
ctx := context.Background()
mux := a.withMember(a.memberMux())
plain := a.seedMember(t, "[email protected]")
memberTok, _, err := a.startSession(ctx, plain, false)
if err != nil {
t.Fatal(err)
}
form := url.Values{"title": {"Otsikko"}, "body": {"Teksti"}}
if w := postAs(t, mux, "/admin/news", form, memberTok); w.Code != http.StatusNotFound {
t.Fatalf("member posting news: status = %d, want 404", w.Code)
}
_, adminTok := a.seedAdminMember(t, "[email protected]")
draftForm := url.Values{"title": {"Luonnos"}, "body": {"Teksti"}, "is_draft": {"1"}}
if w := postAs(t, mux, "/admin/news", draftForm, adminTok); w.Code != http.StatusSeeOther {
t.Fatalf("admin posting draft: status = %d, want 303", w.Code)
}
if w := postAs(t, mux, "/admin/news", form, adminTok); w.Code != http.StatusSeeOther {
t.Fatalf("admin posting news: status = %d, want 303", w.Code)
}
items, err := a.publishedNews(ctx, 10)
if err != nil {
t.Fatal(err)
}
if len(items) != 1 || items[0].Title != "Otsikko" {
t.Fatalf("published = %+v, want only the non-draft", items)
}
}
// The templates reach Ago and HTML through dict, which boxes the item in an interface — a pointer
// receiver there is invisible and only shows up as a 500 in a browser. go vet cannot see it, so
// render the real page and insist the markdown came out the far side.
func TestFrontPageRendersNews(t *testing.T) {
a := testApp(t)
ctx := context.Background()
mux := a.withMember(a.memberMux())
id := a.seedMember(t, "[email protected]")
tok, _, err := a.startSession(ctx, id, false)
if err != nil {
t.Fatal(err)
}
if _, err := a.db.ExecContext(ctx,
`insert into news (title, body) values ('Tiedote', 'Teksti **lihavoituna**.')`); err != nil {
t.Fatal(err)
}
r := httptest.NewRequest("GET", "/", nil)
r.AddCookie(&http.Cookie{Name: sessionCookie, Value: tok})
w := httptest.NewRecorder()
mux.ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("front page: status = %d, want 200", w.Code)
}
body := w.Body.String()
for _, want := range []string{"Tiedote", "<strong>lihavoituna</strong>", "juuri nyt"} {
if !strings.Contains(body, want) {
t.Fatalf("front page is missing %q", want)
}
}
}
// Logging in is what records last_login_at; nothing else writes it.
func TestLoginRecordsLastLogin(t *testing.T) {
a := testApp(t)
ctx := context.Background()
mux := a.withMember(a.memberMux())
if _, err := a.db.ExecContext(ctx, `insert into invites (code) values ('kutsu9')`); err != nil {
t.Fatal(err)
}
reg := url.Values{
"code": {"kutsu9"}, "name": {"Esa"},
"email": {"[email protected]"}, "password": {"salasana1"},
}
if w := post(t, mux, "/register", reg); w.Code != http.StatusSeeOther {
t.Fatalf("register: status = %d, want 303", w.Code)
}
var last *time.Time
if err := a.db.QueryRowContext(ctx,
`select last_login_at from users where email = '[email protected]'`).Scan(&last); err != nil {
t.Fatal(err)
}
if last != nil {
t.Fatalf("registration set last_login_at to %v, want null until a real login", last)
}
if w := post(t, mux, "/login", url.Values{
"email": {"[email protected]"}, "password": {"salasana1"},
}); w.Code != http.StatusSeeOther {
t.Fatalf("login: status = %d, want 303", w.Code)
}
if err := a.db.QueryRowContext(ctx,
`select last_login_at from users where email = '[email protected]'`).Scan(&last); err != nil {
t.Fatal(err)
}
if last == nil {
t.Fatal("login did not record last_login_at")
}
}
+19 -1
View File
@@ -122,6 +122,14 @@ func cursorOf(r *http.Request) int64 {
return n
}
// The front page carries the queue and the latest announcements. songList is embedded so the
// template keeps reaching Items and the cursors exactly as before.
type queueView struct {
*songList
News []newsItem
MoreNews bool
}
func (a *app) queuePage(w http.ResponseWriter, r *http.Request) {
list, err := a.queue(r.Context(), memberFrom(r.Context()).ID, cursorOf(r))
if err != nil {
@@ -129,7 +137,17 @@ func (a *app) queuePage(w http.ResponseWriter, r *http.Request) {
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
a.render(w, r, http.StatusOK, "queue.html", page{Title: "Jono", Data: list})
v := queueView{songList: list}
// One more than shown, so "kaikki tiedotteet" appears only when there is a fourth. News is
// decoration on this page: if it fails to load, the queue still renders.
if news, err := a.publishedNews(r.Context(), newsOnFront+1); err != nil {
slog.Error("front page news", "ctx", "news", "error", err)
} else if len(news) > newsOnFront {
v.News, v.MoreNews = news[:newsOnFront], true
} else {
v.News = news
}
a.render(w, r, http.StatusOK, "queue.html", page{Title: "Jono", Data: v})
}
func (a *app) browsePage(w http.ResponseWriter, r *http.Request) {
+39
View File
@@ -1088,3 +1088,42 @@ img.avatar { object-fit: cover; }
.lyricsbar { display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap;
margin-top: var(--space-2); }
/* --- Tiedotteet -------------------------------------------------------------------------- */
/* The panel is .board's recipe; the items borrow details.lyrics' summary. Nothing new invented. */
.news { background: var(--surface); border: 1px solid var(--hairline); border-radius: var(--radius);
box-shadow: var(--shadow-card); padding: var(--space-4) var(--space-5); }
.news > h2 { font-size: 1.1rem; margin-bottom: var(--space-3); }
.newsitem { border-bottom: 1px solid var(--hairline); }
.newsitem:last-of-type { border-bottom: 0; }
.newsitem > summary { cursor: pointer; display: flex; align-items: baseline; gap: var(--space-3);
padding: var(--space-3) 0; font-family: var(--font-display);
font-size: 1.05rem; color: var(--primary); }
.newsitem > summary:hover { color: var(--primary-hover); }
/* Pushed right and never wrapped: the age is a label on the row, not part of the title. */
.newsitem .newsdate { margin-left: auto; font-size: 0.7rem; letter-spacing: 0.08em;
text-transform: uppercase; color: var(--muted); white-space: nowrap; }
.newsitem .md { padding: 0 0 var(--space-4); }
.news .pager { margin-top: var(--space-4); justify-content: flex-end; }
/* Rendered markdown. Deliberately narrow — an announcement is prose, not a document. */
.md > *:first-child { margin-top: 0; }
.md > *:last-child { margin-bottom: 0; }
.md p { margin: 0 0 var(--space-3); }
.md ul, .md ol { margin: 0 0 var(--space-3); padding-left: var(--space-5); }
.md li { margin-bottom: var(--space-1); }
.md h2, .md h3 { font-size: 1.05rem; margin: var(--space-4) 0 var(--space-2); }
.md strong { color: var(--text-strong); }
.md code { background: var(--bar); border: 1px solid var(--hairline); border-radius: var(--radius);
padding: 0.05rem 0.3rem; font-size: 0.9em; }
.md pre { background: var(--bar); border: 1px solid var(--hairline); border-radius: var(--radius);
padding: var(--space-3); overflow-x: auto; }
.md pre code { border: 0; padding: 0; background: none; }
.md blockquote { margin: 0 0 var(--space-3); padding-left: var(--space-4);
border-left: 3px solid var(--hairline); color: var(--muted); }
.newsform { margin-bottom: var(--space-5); }
.newsform textarea { font-family: ui-monospace, monospace; font-size: 0.9rem; }
+47 -2
View File
@@ -33,17 +33,62 @@
</div>
</section>
<section class="adminsection">
<header><h2>Tiedotteet</h2></header>
<div class="body">
<!-- Plain textarea on purpose: the body is markdown and stays markdown. No editor to fight. -->
<form method="post" action="/admin/news" class="stack newsform">
<label>Otsikko <input type="text" name="title" maxlength="120" required></label>
<label>Teksti (markdown)
<textarea name="body" rows="10" required
placeholder="**Lihavointi**, *kursiivi*, [linkki](https://…), - lista"></textarea>
</label>
<label class="inline"><input type="checkbox" name="is_draft" value="1"> Tallenna luonnoksena</label>
<button type="submit">Julkaise tiedote</button>
</form>
<table>
<thead><tr><th>Otsikko</th><th>Tila</th><th>Luotu</th><th>Toiminnot</th></tr></thead>
<tbody>
{{range .Data.News}}
<tr>
<td>{{.Title}}</td>
<td>
{{if .IsDraft}}<span class="badge pending">luonnos</span>
{{else}}<span class="badge reviewed">julkaistu</span>{{end}}
</td>
<td>{{fidate .CreatedAt}}</td>
<td class="actions">
<form method="post" action="/admin/news/{{.ID}}/draft">
<button type="submit" class="ghost">{{if .IsDraft}}Julkaise{{else}}Piilota{{end}}</button>
</form>
<form method="post" action="/admin/news/{{.ID}}/delete"
onsubmit="return confirm('Poistetaanko tiedote pysyvästi?')">
<button type="submit" class="ghost">Poista</button>
</form>
</td>
</tr>
{{else}}
<tr><td colspan="4" class="muted">Ei tiedotteita.</td></tr>
{{end}}
</tbody>
</table>
</div>
</section>
<section class="adminsection">
<header><h2>Jäsenet</h2></header>
<div class="body">
<table>
<thead><tr><th>Nimi</th><th>Sähköposti</th><th>Liittyi</th><th>Toiminnot</th></tr></thead>
<thead><tr><th>Nimi</th><th>Sähköposti</th><th>Liittyi</th><th>Viimeksi kirjautunut</th><th>Toiminnot</th></tr></thead>
<tbody>
{{range .Data.Members}}
<tr{{if .Banned}} class="banned"{{end}}>
<td>{{.Name}}{{if .Banned}} <span class="badge pending">estetty</span>{{end}}</td>
<td>{{.Email}}</td>
<td>{{fidate .CreatedAt}}</td>
<!-- Null until they log in once, which is exactly how an unused account shows up. -->
<td>{{if .LastLoginAt}}{{fidate .LastLoginAt}}{{else}}<span class="muted">ei koskaan</span>{{end}}</td>
<td class="actions">
<form method="post" action="/admin/users/{{.ID}}/ban">
<button type="submit" class="ghost">{{if .Banned}}Poista esto{{else}}Estä{{end}}</button>
@@ -55,7 +100,7 @@
</td>
</tr>
{{else}}
<tr><td colspan="4" class="muted">Ei jäseniä. Luo kutsukoodi ja lähetä se jollekulle.</td></tr>
<tr><td colspan="5" class="muted">Ei jäseniä. Luo kutsukoodi ja lähetä se jollekulle.</td></tr>
{{end}}
</tbody>
</table>
+12
View File
@@ -0,0 +1,12 @@
{{define "content"}}
<h1>Tiedotteet</h1>
{{if .Data.Items}}
<p class="muted">Uudet ominaisuudet, korjaukset ja muut ilmoitukset.</p>
<section class="news">
{{range $i, $n := .Data.Items}}{{template "newsitem" dict "Item" $n "Open" (eq $i 0)}}{{end}}
</section>
{{else}}
<p class="empty">Ei vielä tiedotteita.</p>
{{end}}
{{end}}
+8
View File
@@ -0,0 +1,8 @@
{{/* One announcement, collapsible. Newest is opened by the caller; the rest stay shut so three
posts read as a list rather than a wall. */}}
{{define "newsitem"}}
<details class="newsitem"{{if .Open}} open{{end}}>
<summary>{{.Item.Title}} <span class="newsdate">{{.Item.Ago}}</span></summary>
<div class="md">{{.Item.HTML}}</div>
</details>
{{end}}
+8
View File
@@ -16,4 +16,12 @@
<p>Olet arvostellut kaiken, mitä muut ovat lähettäneet.
<a href="/submit">Lähetä kappale</a> tai lue <a href="/songs">mitä muut sanoivat</a>.</p>
{{end}}
{{with .Data.News}}
<section class="news">
<h2>Tiedotteet</h2>
{{range $i, $n := .}}{{template "newsitem" dict "Item" $n "Open" (eq $i 0)}}{{end}}
{{if $.Data.MoreNews}}<p class="pager"><a href="/news">Kaikki tiedotteet →</a></p>{{end}}
</section>
{{end}}
{{end}}