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, "lihavointi") { + 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, "jasen@example.com") + 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, "yllapito@example.com") + 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, "esa@example.com") + 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", "lihavoituna", "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": {"esa@example.com"}, "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 = 'esa@example.com'`).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": {"esa@example.com"}, "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 = 'esa@example.com'`).Scan(&last); err != nil { + t.Fatal(err) + } + if last == nil { + t.Fatal("login did not record last_login_at") + } +} diff --git a/src/songs.go b/src/songs.go index d8d6c8b..09f20d0 100644 --- a/src/songs.go +++ b/src/songs.go @@ -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) { diff --git a/src/static/style.css b/src/static/style.css index b3a9b03..3b69125 100644 --- a/src/static/style.css +++ b/src/static/style.css @@ -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; } diff --git a/src/templates/admin.html b/src/templates/admin.html index 6303423..7a3afd9 100644 --- a/src/templates/admin.html +++ b/src/templates/admin.html @@ -33,17 +33,62 @@ +
+

Tiedotteet

+
+ +
+ + + + +
+ + + + + {{range .Data.News}} + + + + + + + {{else}} + + {{end}} + +
OtsikkoTilaLuotuToiminnot
{{.Title}} + {{if .IsDraft}}luonnos + {{else}}julkaistu{{end}} + {{fidate .CreatedAt}} +
+ +
+
+ +
+
Ei tiedotteita.
+
+
+

Jäsenet

- + {{range .Data.Members}} + + {{else}} - + {{end}}
NimiSähköpostiLiittyiToiminnot
NimiSähköpostiLiittyiViimeksi kirjautunutToiminnot
{{.Name}}{{if .Banned}} estetty{{end}} {{.Email}} {{fidate .CreatedAt}}{{if .LastLoginAt}}{{fidate .LastLoginAt}}{{else}}ei koskaan{{end}}
@@ -55,7 +100,7 @@
Ei jäseniä. Luo kutsukoodi ja lähetä se jollekulle.
Ei jäseniä. Luo kutsukoodi ja lähetä se jollekulle.
diff --git a/src/templates/news.html b/src/templates/news.html new file mode 100644 index 0000000..366ebbf --- /dev/null +++ b/src/templates/news.html @@ -0,0 +1,12 @@ +{{define "content"}} +

Tiedotteet

+ +{{if .Data.Items}} +

Uudet ominaisuudet, korjaukset ja muut ilmoitukset.

+
+ {{range $i, $n := .Data.Items}}{{template "newsitem" dict "Item" $n "Open" (eq $i 0)}}{{end}} +
+{{else}} +

Ei vielä tiedotteita.

+{{end}} +{{end}} diff --git a/src/templates/partials/news.html b/src/templates/partials/news.html new file mode 100644 index 0000000..d300a51 --- /dev/null +++ b/src/templates/partials/news.html @@ -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"}} +
+ {{.Item.Title}} {{.Item.Ago}} +
{{.Item.HTML}}
+
+{{end}} diff --git a/src/templates/queue.html b/src/templates/queue.html index 2ee0756..1876f05 100644 --- a/src/templates/queue.html +++ b/src/templates/queue.html @@ -16,4 +16,12 @@

Olet arvostellut kaiken, mitä muut ovat lähettäneet. Lähetä kappale tai lue mitä muut sanoivat.

{{end}} + +{{with .Data.News}} +
+

Tiedotteet

+ {{range $i, $n := .}}{{template "newsitem" dict "Item" $n "Open" (eq $i 0)}}{{end}} + {{if $.Data.MoreNews}}

Kaikki tiedotteet →

{{end}} +
+{{end}} {{end}}