From cfdc364e3fc561781a085fac4efece2b9239d15b Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 8 Feb 2026 14:28:27 -0500 Subject: [PATCH] Version API endpoint --- docs/releases.md | 1 + server/server.go | 3 +++ server/server_admin.go | 8 ++++++++ server/server_admin_test.go | 36 ++++++++++++++++++++++++++++++++++++ server/types.go | 6 ++++++ 5 files changed, 54 insertions(+) diff --git a/docs/releases.md b/docs/releases.md index cd16054a..08439c27 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1686,6 +1686,7 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release **Features:** * Server: Support templating in the priority field ([#1426](https://github.com/binwiederhier/ntfy/issues/1426), thanks to [@seantomburke](https://github.com/seantomburke) for reporting) +* Server: Add admin-only `GET /v1/version` endpoint returning server version, build commit, and date ([#1599](https://github.com/binwiederhier/ntfy/issues/1599), thanks to [@crivchri](https://github.com/crivchri) for reporting) * Web: Show red notification dot on favicon when there are unread messages ([#1017](https://github.com/binwiederhier/ntfy/issues/1017), thanks to [@ad-si](https://github.com/ad-si) for reporting) **Bug fixes + maintenance:** diff --git a/server/server.go b/server/server.go index 820913e9..bf982503 100644 --- a/server/server.go +++ b/server/server.go @@ -90,6 +90,7 @@ var ( matrixPushPath = "/_matrix/push/v1/notify" metricsPath = "/metrics" apiHealthPath = "/v1/health" + apiVersionPath = "/v1/version" apiConfigPath = "/v1/config" apiStatsPath = "/v1/stats" apiWebPushPath = "/v1/webpush" @@ -467,6 +468,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit return s.ensureWebEnabled(s.handleEmpty)(w, r, v) } else if r.Method == http.MethodGet && r.URL.Path == apiHealthPath { return s.handleHealth(w, r, v) + } else if r.Method == http.MethodGet && r.URL.Path == apiVersionPath { + return s.ensureAdmin(s.handleVersion)(w, r, v) } else if r.Method == http.MethodGet && r.URL.Path == apiConfigPath { return s.handleConfig(w, r, v) } else if r.Method == http.MethodGet && r.URL.Path == webConfigPath { diff --git a/server/server_admin.go b/server/server_admin.go index b724d4b7..2560796f 100644 --- a/server/server_admin.go +++ b/server/server_admin.go @@ -6,6 +6,14 @@ import ( "net/http" ) +func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request, v *visitor) error { + return s.writeJSON(w, &apiVersionResponse{ + Version: s.config.BuildVersion, + Commit: s.config.BuildCommit, + Date: s.config.BuildDate, + }) +} + func (s *Server) handleUsersGet(w http.ResponseWriter, r *http.Request, v *visitor) error { users, err := s.userManager.Users() if err != nil { diff --git a/server/server_admin_test.go b/server/server_admin_test.go index 8925702e..cedaed87 100644 --- a/server/server_admin_test.go +++ b/server/server_admin_test.go @@ -1,6 +1,7 @@ package server import ( + "encoding/json" "github.com/stretchr/testify/require" "heckel.io/ntfy/v2/user" "heckel.io/ntfy/v2/util" @@ -9,6 +10,41 @@ import ( "time" ) +func TestVersion_Admin(t *testing.T) { + c := newTestConfigWithAuthFile(t) + c.BuildVersion = "1.2.3" + c.BuildCommit = "abcdef0" + c.BuildDate = "2026-02-08T00:00:00Z" + s := newTestServer(t, c) + defer s.closeDatabases() + + // Create admin and regular user + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) + require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false)) + + // Admin can access /v1/version + rr := request(t, s, "GET", "/v1/version", "", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, rr.Code) + + var versionResponse apiVersionResponse + require.Nil(t, json.NewDecoder(rr.Body).Decode(&versionResponse)) + require.Equal(t, "1.2.3", versionResponse.Version) + require.Equal(t, "abcdef0", versionResponse.Commit) + require.Equal(t, "2026-02-08T00:00:00Z", versionResponse.Date) + + // Non-admin user cannot access /v1/version + rr = request(t, s, "GET", "/v1/version", "", map[string]string{ + "Authorization": util.BasicAuth("ben", "ben"), + }) + require.Equal(t, 401, rr.Code) + + // Unauthenticated user cannot access /v1/version + rr = request(t, s, "GET", "/v1/version", "", nil) + require.Equal(t, 401, rr.Code) +} + func TestUser_AddRemove(t *testing.T) { s := newTestServer(t, newTestConfigWithAuthFile(t)) defer s.closeDatabases() diff --git a/server/types.go b/server/types.go index 3494f570..208b4050 100644 --- a/server/types.go +++ b/server/types.go @@ -319,6 +319,12 @@ type apiHealthResponse struct { Healthy bool `json:"healthy"` } +type apiVersionResponse struct { + Version string `json:"version"` + Commit string `json:"commit"` + Date string `json:"date"` +} + type apiStatsResponse struct { Messages int64 `json:"messages"` MessagesRate float64 `json:"messages_rate"` // Average number of messages per second