diff --git a/README.md b/README.md
index b1b5b8b..5de4673 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/go.mod b/go.mod
index 1cdf608..e045dae 100644
--- a/go.mod
+++ b/go.mod
@@ -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
)
diff --git a/go.sum b/go.sum
index e2391e7..6b33cd2 100644
--- a/go.sum
+++ b/go.sum
@@ -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=
diff --git a/src/admin.go b/src/admin.go
index cbe1417..fe16fb2 100644
--- a/src/admin.go
+++ b/src/admin.go
@@ -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)
diff --git a/src/auth.go b/src/auth.go
index d486a7a..c564f89 100644
--- a/src/auth.go
+++ b/src/auth.go
@@ -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)
}
diff --git a/src/main.go b/src/main.go
index 88b58a0..539ef51 100644
--- a/src/main.go
+++ b/src/main.go
@@ -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
diff --git a/src/main_test.go b/src/main_test.go
index 191209c..15092ae 100644
--- a/src/main_test.go
+++ b/src/main_test.go
@@ -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)
}
}
diff --git a/src/migrations/003_news_and_last_login.sql b/src/migrations/003_news_and_last_login.sql
new file mode 100644
index 0000000..1e0e42c
--- /dev/null
+++ b/src/migrations/003_news_and_last_login.sql
@@ -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;
diff --git a/src/news.go b/src/news.go
new file mode 100644
index 0000000..bc71163
--- /dev/null
+++ b/src/news.go
@@ -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 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, "