diff --git a/server/errors.go b/server/errors.go index 63dffee..fc97f0b 100644 --- a/server/errors.go +++ b/server/errors.go @@ -53,9 +53,11 @@ var ( errHTTPBadRequestMatrixMessageInvalid = &errHTTP{40019, http.StatusBadRequest, "invalid request: Matrix JSON invalid", "https://ntfy.sh/docs/publish/#matrix-gateway"} errHTTPBadRequestMatrixPushkeyBaseURLMismatch = &errHTTP{40020, http.StatusBadRequest, "invalid request: push key must be prefixed with base URL", "https://ntfy.sh/docs/publish/#matrix-gateway"} errHTTPBadRequestIconURLInvalid = &errHTTP{40021, http.StatusBadRequest, "invalid request: icon URL is invalid", "https://ntfy.sh/docs/publish/#icons"} + errHTTPBadRequestSignupNotEnabled = &errHTTP{40022, http.StatusBadRequest, "invalid request: signup not enabled", "https://ntfy.sh/docs/config"} errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", ""} errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication"} errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication"} + errHTTPConflictUserExists = &errHTTP{40901, http.StatusConflict, "conflict: user already exists", ""} errHTTPEntityTooLargeAttachmentTooLarge = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations"} errHTTPEntityTooLargeMatrixRequestTooLarge = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", ""} errHTTPTooManyRequestsLimitRequests = &errHTTP{42901, http.StatusTooManyRequests, "limit reached: too many requests, please be nice", "https://ntfy.sh/docs/publish/#limitations"} diff --git a/server/server.go b/server/server.go index cbe1ca6..2484ba5 100644 --- a/server/server.go +++ b/server/server.go @@ -47,6 +47,7 @@ import ( purge accounts that were not logged into in X sync subscription display name store users + signup: check unique user Pages: - Home - Password reset @@ -1307,7 +1308,17 @@ func (s *Server) sendDelayedMessages() error { return err } for _, m := range messages { - v := s.visitorFromID(fmt.Sprintf("ip:%s", m.Sender.String()), m.Sender, nil) // FIXME: This is wrong wrong wrong + var v *visitor + if m.User != "" { + user, err := s.auth.User(m.User) + if err != nil { + log.Warn("%s Error sending delayed message: %s", logMessagePrefix(v, m), err.Error()) + continue + } + v = s.visitorFromUser(user, m.Sender) + } else { + v = s.visitorFromIP(m.Sender) + } if err := s.sendDelayedMessage(v, m); err != nil { log.Warn("%s Error sending delayed message: %s", logMessagePrefix(v, m), err.Error()) } @@ -1462,18 +1473,18 @@ func (s *Server) autorizeTopic(next handleFunc, perm auth.Permission) handleFunc // visitor creates or retrieves a rate.Limiter for the given visitor. // Note that this function will always return a visitor, even if an error occurs. func (s *Server) visitor(r *http.Request) (v *visitor, err error) { - ip := s.extractIPAddress(r) - visitorID := fmt.Sprintf("ip:%s", ip.String()) + ip := extractIPAddress(r, s.config.BehindProxy) var user *auth.User // may stay nil if no auth header! if user, err = s.authenticate(r); err != nil { log.Debug("authentication failed: %s", err.Error()) err = errHTTPUnauthorized // Always return visitor, even when error occurs! } if user != nil { - visitorID = fmt.Sprintf("user:%s", user.Name) + v = s.visitorFromUser(user, ip) + } else { + v = s.visitorFromIP(ip) } - v = s.visitorFromID(visitorID, ip, user) - v.user = user // Update user -- FIXME this is ugly, do "newVisitorFromUser" instead + v.user = user // Update user -- FIXME race? return v, err // Always return visitor, even when error occurs! } @@ -1526,30 +1537,10 @@ func (s *Server) visitorFromID(visitorID string, ip netip.Addr, user *auth.User) return v } -func (s *Server) extractIPAddress(r *http.Request) netip.Addr { - remoteAddr := r.RemoteAddr - addrPort, err := netip.ParseAddrPort(remoteAddr) - ip := addrPort.Addr() - if err != nil { - // This should not happen in real life; only in tests. So, using falling back to 0.0.0.0 if address unspecified - ip, err = netip.ParseAddr(remoteAddr) - if err != nil { - ip = netip.IPv4Unspecified() - log.Warn("unable to parse IP (%s), new visitor with unspecified IP (0.0.0.0) created %s", remoteAddr, err) - } - } - if s.config.BehindProxy && strings.TrimSpace(r.Header.Get("X-Forwarded-For")) != "" { - // X-Forwarded-For can contain multiple addresses (see #328). If we are behind a proxy, - // only the right-most address can be trusted (as this is the one added by our proxy server). - // See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For for details. - ips := util.SplitNoEmpty(r.Header.Get("X-Forwarded-For"), ",") - realIP, err := netip.ParseAddr(strings.TrimSpace(util.LastString(ips, remoteAddr))) - if err != nil { - log.Error("invalid IP address %s received in X-Forwarded-For header: %s", ip, err.Error()) - // Fall back to regular remote address if X-Forwarded-For is damaged - } else { - ip = realIP - } - } - return ip +func (s *Server) visitorFromIP(ip netip.Addr) *visitor { + return s.visitorFromID(fmt.Sprintf("ip:%s", ip.String()), ip, nil) +} + +func (s *Server) visitorFromUser(user *auth.User, ip netip.Addr) *visitor { + return s.visitorFromID(fmt.Sprintf("user:%s", user.Name), ip, user) } diff --git a/server/server_account.go b/server/server_account.go index 6529560..990efdb 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -12,7 +12,7 @@ func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v * signupAllowed := s.config.EnableSignup admin := v.user != nil && v.user.Role == auth.RoleAdmin if !signupAllowed && !admin { - return errHTTPUnauthorized + return errHTTPBadRequestSignupNotEnabled } body, err := util.Peek(r.Body, 4096) // FIXME if err != nil { @@ -23,6 +23,9 @@ func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v * if err := json.NewDecoder(body).Decode(&newAccount); err != nil { return err } + if existingUser, _ := s.auth.User(newAccount.Username); existingUser != nil { + return errHTTPConflictUserExists + } if err := s.auth.AddUser(newAccount.Username, newAccount.Password, auth.RoleUser); err != nil { // TODO this should return a User return err } diff --git a/server/util.go b/server/util.go index 269a9d5..c1f9d2a 100644 --- a/server/util.go +++ b/server/util.go @@ -3,8 +3,10 @@ package server import ( "fmt" "github.com/emersion/go-smtp" + "heckel.io/ntfy/log" "heckel.io/ntfy/util" "net/http" + "net/netip" "strings" "unicode/utf8" ) @@ -89,3 +91,31 @@ func renderHTTPRequest(r *http.Request) string { r.Body = body // Important: Reset body, so it can be re-read return strings.TrimSpace(lines) } + +func extractIPAddress(r *http.Request, behindProxy bool) netip.Addr { + remoteAddr := r.RemoteAddr + addrPort, err := netip.ParseAddrPort(remoteAddr) + ip := addrPort.Addr() + if err != nil { + // This should not happen in real life; only in tests. So, using falling back to 0.0.0.0 if address unspecified + ip, err = netip.ParseAddr(remoteAddr) + if err != nil { + ip = netip.IPv4Unspecified() + log.Warn("unable to parse IP (%s), new visitor with unspecified IP (0.0.0.0) created %s", remoteAddr, err) + } + } + if behindProxy && strings.TrimSpace(r.Header.Get("X-Forwarded-For")) != "" { + // X-Forwarded-For can contain multiple addresses (see #328). If we are behind a proxy, + // only the right-most address can be trusted (as this is the one added by our proxy server). + // See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For for details. + ips := util.SplitNoEmpty(r.Header.Get("X-Forwarded-For"), ",") + realIP, err := netip.ParseAddr(strings.TrimSpace(util.LastString(ips, remoteAddr))) + if err != nil { + log.Error("invalid IP address %s received in X-Forwarded-For header: %s", ip, err.Error()) + // Fall back to regular remote address if X-Forwarded-For is damaged + } else { + ip = realIP + } + } + return ip +} diff --git a/web/src/app/Api.js b/web/src/app/Api.js index 1f93dc1..381b198 100644 --- a/web/src/app/Api.js +++ b/web/src/app/Api.js @@ -149,18 +149,6 @@ class Api { } } - async userStats(baseUrl) { - const url = userStatsUrl(baseUrl); - console.log(`[Api] Fetching user stats ${url}`); - const response = await fetch(url); - if (response.status !== 200) { - throw new Error(`Unexpected server response ${response.status}`); - } - const stats = await response.json(); - console.log(`[Api] Stats`, stats); - return stats; - } - async createAccount(baseUrl, username, password) { const url = accountUrl(baseUrl); const body = JSON.stringify({ diff --git a/web/src/app/utils.js b/web/src/app/utils.js index fc2ad85..7224746 100644 --- a/web/src/app/utils.js +++ b/web/src/app/utils.js @@ -18,7 +18,6 @@ export const topicUrlJsonPoll = (baseUrl, topic) => `${topicUrlJson(baseUrl, top export const topicUrlJsonPollWithSince = (baseUrl, topic, since) => `${topicUrlJson(baseUrl, topic)}?poll=1&since=${since}`; export const topicUrlAuth = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/auth`; export const topicShortUrl = (baseUrl, topic) => shortUrl(topicUrl(baseUrl, topic)); -export const userStatsUrl = (baseUrl) => `${baseUrl}/user/stats`; export const accountUrl = (baseUrl) => `${baseUrl}/v1/account`; export const accountPasswordUrl = (baseUrl) => `${baseUrl}/v1/account/password`; export const accountTokenUrl = (baseUrl) => `${baseUrl}/v1/account/token`; diff --git a/web/src/components/ActionBar.js b/web/src/components/ActionBar.js index c5af9d9..6783bbb 100644 --- a/web/src/components/ActionBar.js +++ b/web/src/components/ActionBar.js @@ -264,11 +264,10 @@ const ProfileIcon = (props) => { session.reset(); window.location.href = routes.app; }; - return ( <> {session.exists() && - + } diff --git a/web/src/components/Login.js b/web/src/components/Login.js index 0a6b6f6..b84e0d8 100644 --- a/web/src/components/Login.js +++ b/web/src/components/Login.js @@ -15,13 +15,11 @@ import {useState} from "react"; const Login = () => { const { t } = useTranslation(); const [error, setError] = useState(""); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); const handleSubmit = async (event) => { event.preventDefault(); - const data = new FormData(event.currentTarget); - const user = { - username: data.get('username'), - password: data.get('password'), - } + const user = { username, password }; try { const token = await api.login(config.baseUrl, user); if (token) { @@ -61,6 +59,8 @@ const Login = () => { id="username" label={t("Username")} name="username" + value={username} + onChange={ev => setUsername(ev.target.value.trim())} autoFocus /> { label={t("Password")} type="password" id="password" + value={password} + onChange={ev => setPassword(ev.target.value.trim())} autoComplete="current-password" /> + {error && + + + {error} + + } {config.enableLogin &&