Compare commits
2
Commits
2026.09.05-3
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
deaadd2f5c | ||
|
|
522827879b |
@@ -42,10 +42,14 @@ run: build
|
|||||||
# out of this file: pass it in, or put it in the .env this reads nothing from.
|
# out of this file: pass it in, or put it in the .env this reads nothing from.
|
||||||
#
|
#
|
||||||
# make image IMAGE=registry.example.com/owner/levyraati26-go
|
# make image IMAGE=registry.example.com/owner/levyraati26-go
|
||||||
|
#
|
||||||
|
# --pull --no-cache is the point of the target, not caution. yt-dlp rots against YouTube within
|
||||||
|
# weeks, and "a rebuild is the update" is only true if the apk layer is actually re-run — a cached
|
||||||
|
# one silently ships whatever yt-dlp was current the day that layer was first built.
|
||||||
image:
|
image:
|
||||||
@test -n "$(IMAGE)" || { echo "set IMAGE, e.g. make image IMAGE=registry.example.com/owner/levyraati26-go"; exit 1; }
|
@test -n "$(IMAGE)" || { echo "set IMAGE, e.g. make image IMAGE=registry.example.com/owner/levyraati26-go"; exit 1; }
|
||||||
@v=$$(git describe --tags --exact-match 2>/dev/null) || { echo "HEAD is not tagged; tag the release first"; exit 1; }; \
|
@v=$$(git describe --tags --exact-match 2>/dev/null) || { echo "HEAD is not tagged; tag the release first"; exit 1; }; \
|
||||||
podman build --build-arg VERSION=$$v -t $(IMAGE):$$v -t $(IMAGE):latest . && \
|
podman build --pull --no-cache --build-arg VERSION=$$v -t $(IMAGE):$$v -t $(IMAGE):latest . && \
|
||||||
echo "built $(IMAGE):$$v — push with: podman push $(IMAGE):$$v"
|
echo "built $(IMAGE):$$v — push with: podman push $(IMAGE):$$v"
|
||||||
|
|
||||||
# Operations against the running container, straight out of docs/deployment.md.
|
# Operations against the running container, straight out of docs/deployment.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
|
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.
|
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
|
An admin cannot ban themselves, since banning drops every session for the target and nothing would
|
||||||
be left to undo it.
|
be left to undo it.
|
||||||
|
|||||||
+1
-1
@@ -269,7 +269,7 @@ bounded by the two conversion slots.
|
|||||||
| Uploads fail near 50 MB | The reverse proxy's body limit, not the app's |
|
| Uploads fail near 50 MB | The reverse proxy's body limit, not the app's |
|
||||||
| Invite links are relative | `PUBLIC_URL` unset |
|
| Invite links are relative | `PUBLIC_URL` unset |
|
||||||
| Everything 500s after a restore | `-wal`/`-shm` sidecars from the replaced database were left in place |
|
| Everything 500s after a restore | `-wal`/`-shm` sidecars from the replaced database were left in place |
|
||||||
| Submissions all fail at download | yt-dlp is stale, or YouTube is refusing this server's IP; rebuild and publish the image first. `docker compose logs app \| grep '"stage":"download"'` shows yt-dlp's own stderr under `detail` |
|
| Submissions all fail at download | yt-dlp is stale, or YouTube is refusing this server's IP. `docker compose logs app \| grep '"stage":"download"'` shows yt-dlp's own stderr under `detail`. A plain `HTTP Error 403` is the stale case — rebuild with `make image`, which forces `--no-cache` so the `apk add` layer is genuinely re-run. A `docker build` without it can ship a months-old yt-dlp from a cached layer |
|
||||||
| A submitter reports a *virhekoodi* | `docker compose logs app \| grep <code>` — one line, with the stage, the submission id, the URL and the tool's stderr |
|
| A submitter reports a *virhekoodi* | `docker compose logs app \| grep <code>` — one line, with the stage, the submission id, the URL and the tool's stderr |
|
||||||
| `/admin` returns 404 while logged in | That account has no `is_admin`. Set it in the database; nothing in the UI grants it |
|
| `/admin` returns 404 while logged in | That account has no `is_admin`. Set it in the database; nothing in the UI grants it |
|
||||||
| Setting `ADMIN_PASSWORD` again changes nothing | Seeding only fires on an empty `users` table. Reset the hash in the database instead |
|
| Setting `ADMIN_PASSWORD` again changes nothing | Seeding only fires on an empty `users` table. Reset the hash in the database instead |
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ module git.kessinen.com/kessinen/levyraati26-go
|
|||||||
go 1.27.0
|
go 1.27.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/yuin/goldmark v1.8.6
|
||||||
golang.org/x/crypto v0.32.0
|
golang.org/x/crypto v0.32.0
|
||||||
modernc.org/sqlite v1.54.0
|
modernc.org/sqlite v1.54.0
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
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/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
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/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 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
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/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||||
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=
|
|
||||||
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
|
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
|
||||||
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
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/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
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/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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
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/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
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 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
|
||||||
modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
|
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 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
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 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
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 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog=
|
||||||
modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
|
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=
|
||||||
|
|||||||
+9
-2
@@ -26,6 +26,7 @@ type adminMember struct {
|
|||||||
Email string
|
Email string
|
||||||
Banned bool
|
Banned bool
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
|
LastLoginAt *time.Time // nil until the account has logged in once
|
||||||
}
|
}
|
||||||
|
|
||||||
type dashboard struct {
|
type dashboard struct {
|
||||||
@@ -33,6 +34,7 @@ type dashboard struct {
|
|||||||
SpentCount int
|
SpentCount int
|
||||||
Members []adminMember
|
Members []adminMember
|
||||||
Songs []adminSong
|
Songs []adminSong
|
||||||
|
News []newsItem
|
||||||
OpenCount int
|
OpenCount int
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +70,7 @@ func (a *app) adminDashboard(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
rows, err = a.db.QueryContext(r.Context(),
|
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 {
|
if err != nil {
|
||||||
adminError(w, "users", err)
|
adminError(w, "users", err)
|
||||||
return
|
return
|
||||||
@@ -76,7 +78,7 @@ func (a *app) adminDashboard(w http.ResponseWriter, r *http.Request) {
|
|||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var m adminMember
|
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)
|
adminError(w, "users", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -91,6 +93,11 @@ func (a *app) adminDashboard(w http.ResponseWriter, r *http.Request) {
|
|||||||
adminError(w, "songs", err)
|
adminError(w, "songs", err)
|
||||||
return
|
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(),
|
if err := a.db.QueryRowContext(r.Context(),
|
||||||
`select count(*) from reports where resolved_at is null`).Scan(&d.OpenCount); err != nil {
|
`select count(*) from reports where resolved_at is null`).Scan(&d.OpenCount); err != nil {
|
||||||
adminError(w, "reports", err)
|
adminError(w, "reports", err)
|
||||||
|
|||||||
@@ -205,6 +205,12 @@ func (a *app) login(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
a.logins.succeed(email)
|
a.logins.succeed(email)
|
||||||
a.setSessionCookie(w, tok, expires)
|
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)
|
slog.Info("login", "ctx", "auth", "user", id)
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -204,6 +204,7 @@ func (a *app) memberMux() *http.ServeMux {
|
|||||||
mux.HandleFunc("GET /audio/{id}", a.requireMember(a.audio))
|
mux.HandleFunc("GET /audio/{id}", a.requireMember(a.audio))
|
||||||
mux.HandleFunc("GET /avatars/{id}", a.avatar) // public: avatars are not secret
|
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 /stats", a.requireMember(a.statsPage))
|
||||||
mux.HandleFunc("GET /profile", a.requireMember(a.profilePage))
|
mux.HandleFunc("GET /profile", a.requireMember(a.profilePage))
|
||||||
mux.HandleFunc("GET /profile/{id}", 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("POST /admin/songs/{id}/delete", a.requireAdmin(a.adminDeleteSong))
|
||||||
mux.HandleFunc("GET /admin/reports", a.requireAdmin(a.adminReports))
|
mux.HandleFunc("GET /admin/reports", a.requireAdmin(a.adminReports))
|
||||||
mux.HandleFunc("POST /admin/reports/{id}/resolve", a.requireAdmin(a.resolveReport))
|
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
|
// ponytail: one flag, no roles. A moderator tier is a second column on the day someone needs to
|
||||||
|
|||||||
+2
-2
@@ -89,7 +89,7 @@ func TestMigrateIsIdempotent(t *testing.T) {
|
|||||||
if err := a.db.QueryRowContext(ctx, `select count(*) from schema_migrations`).Scan(&n); err != nil {
|
if err := a.db.QueryRowContext(ctx, `select count(*) from schema_migrations`).Scan(&n); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if n != 2 {
|
if n != 3 {
|
||||||
t.Fatalf("applied migrations = %d, want 2", n)
|
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
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
@@ -122,6 +122,14 @@ func cursorOf(r *http.Request) int64 {
|
|||||||
return n
|
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) {
|
func (a *app) queuePage(w http.ResponseWriter, r *http.Request) {
|
||||||
list, err := a.queue(r.Context(), memberFrom(r.Context()).ID, cursorOf(r))
|
list, err := a.queue(r.Context(), memberFrom(r.Context()).ID, cursorOf(r))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -129,7 +137,17 @@ func (a *app) queuePage(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "virhe", http.StatusInternalServerError)
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||||
return
|
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) {
|
func (a *app) browsePage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -1088,3 +1088,42 @@ img.avatar { object-fit: cover; }
|
|||||||
|
|
||||||
.lyricsbar { display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap;
|
.lyricsbar { display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap;
|
||||||
margin-top: var(--space-2); }
|
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; }
|
||||||
|
|||||||
@@ -33,17 +33,62 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<section class="adminsection">
|
||||||
<header><h2>Jäsenet</h2></header>
|
<header><h2>Jäsenet</h2></header>
|
||||||
<div class="body">
|
<div class="body">
|
||||||
<table>
|
<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>
|
<tbody>
|
||||||
{{range .Data.Members}}
|
{{range .Data.Members}}
|
||||||
<tr{{if .Banned}} class="banned"{{end}}>
|
<tr{{if .Banned}} class="banned"{{end}}>
|
||||||
<td>{{.Name}}{{if .Banned}} <span class="badge pending">estetty</span>{{end}}</td>
|
<td>{{.Name}}{{if .Banned}} <span class="badge pending">estetty</span>{{end}}</td>
|
||||||
<td>{{.Email}}</td>
|
<td>{{.Email}}</td>
|
||||||
<td>{{fidate .CreatedAt}}</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">
|
<td class="actions">
|
||||||
<form method="post" action="/admin/users/{{.ID}}/ban">
|
<form method="post" action="/admin/users/{{.ID}}/ban">
|
||||||
<button type="submit" class="ghost">{{if .Banned}}Poista esto{{else}}Estä{{end}}</button>
|
<button type="submit" class="ghost">{{if .Banned}}Poista esto{{else}}Estä{{end}}</button>
|
||||||
@@ -55,7 +100,7 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{else}}
|
{{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}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -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}}
|
||||||
@@ -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}}
|
||||||
@@ -16,4 +16,12 @@
|
|||||||
<p>Olet arvostellut kaiken, mitä muut ovat lähettäneet.
|
<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>
|
<a href="/submit">Lähetä kappale</a> tai lue <a href="/songs">mitä muut sanoivat</a>.</p>
|
||||||
{{end}}
|
{{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}}
|
{{end}}
|
||||||
|
|||||||
Reference in New Issue
Block a user