binwiederhier 3 年 前
コミット
b5e2c83fba

+ 2 - 0
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"}

+ 23 - 32
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)
 }

+ 4 - 1
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
 	}

+ 30 - 0
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
+}

+ 0 - 12
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({

+ 0 - 1
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`;

+ 1 - 2
web/src/components/ActionBar.js

@@ -264,11 +264,10 @@ const ProfileIcon = (props) => {
         session.reset();
         window.location.href = routes.app;
     };
-
     return (
         <>
             {session.exists() &&
-                <IconButton color="inherit" size="large" edge="end" onClick={handleClick} sx={{marginRight: 0}} aria-label={t("xxxxxxx")}>
+                <IconButton color="inherit" size="large" edge="end" onClick={handleClick} aria-label={t("xxxxxxx")}>
                     <AccountCircleIcon/>
                 </IconButton>
             }

+ 8 - 5
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
                 />
                 <TextField
@@ -71,12 +71,15 @@ const Login = () => {
                     label={t("Password")}
                     type="password"
                     id="password"
+                    value={password}
+                    onChange={ev => setPassword(ev.target.value.trim())}
                     autoComplete="current-password"
                 />
                 <Button
                     type="submit"
                     fullWidth
                     variant="contained"
+                    disabled={username === "" || password === ""}
                     sx={{mt: 2, mb: 2}}
                 >
                     {t("Sign in")}

+ 45 - 10
web/src/components/Signup.js

@@ -9,21 +9,37 @@ import Typography from "@mui/material/Typography";
 import {NavLink} from "react-router-dom";
 import AvatarBox from "./AvatarBox";
 import {useTranslation} from "react-i18next";
+import {useState} from "react";
+import WarningAmberIcon from "@mui/icons-material/WarningAmber";
 
 const Signup = () => {
     const { t } = useTranslation();
+    const [error, setError] = useState("");
+    const [username, setUsername] = useState("");
+    const [password, setPassword] = useState("");
+    const [confirm, setConfirm] = useState("");
     const handleSubmit = async (event) => {
         event.preventDefault();
-        const data = new FormData(event.currentTarget);
-        const user = {
-            username: data.get('username'),
-            password: data.get('password')
-        };
-        await api.createAccount(config.baseUrl, user.username, user.password);
-        const token = await api.login(config.baseUrl, user);
-        console.log(`[Api] User auth for user ${user.username} successful, token is ${token}`);
-        session.store(user.username, token);
-        window.location.href = routes.app;
+        const user = { username, password };
+        try {
+            await api.createAccount(config.baseUrl, user.username, user.password);
+            const token = await api.login(config.baseUrl, user);
+            if (token) {
+                console.log(`[Signup] User signup for user ${user.username} successful, token is ${token}`);
+                session.store(user.username, token);
+                window.location.href = routes.app;
+            } else {
+                console.log(`[Signup] Signup for user ${user.username} failed, access denied`);
+                setError(t("Login failed: Invalid username or password"));
+            }
+        } catch (e) {
+            console.log(`[Signup] Signup for user ${user.username} failed`, e);
+            if (e && e.message) {
+                setError(e.message);
+            } else {
+                setError(t("Unknown error. Check logs for details."))
+            }
+        }
     };
     if (!config.enableSignup) {
         return (
@@ -45,6 +61,8 @@ const Signup = () => {
                     id="username"
                     label="Username"
                     name="username"
+                    value={username}
+                    onChange={ev => setUsername(ev.target.value.trim())}
                     autoFocus
                 />
                 <TextField
@@ -56,6 +74,8 @@ const Signup = () => {
                     type="password"
                     id="password"
                     autoComplete="current-password"
+                    value={password}
+                    onChange={ev => setPassword(ev.target.value.trim())}
                 />
                 <TextField
                     margin="dense"
@@ -65,15 +85,30 @@ const Signup = () => {
                     label="Confirm password"
                     type="password"
                     id="confirm-password"
+                    value={confirm}
+                    onChange={ev => setConfirm(ev.target.value.trim())}
+
                 />
                 <Button
                     type="submit"
                     fullWidth
                     variant="contained"
+                    disabled={username === "" || password === "" || password !== confirm}
                     sx={{mt: 2, mb: 2}}
                 >
                     {t("Sign up")}
                 </Button>
+                {error &&
+                    <Box sx={{
+                        mb: 1,
+                        display: 'flex',
+                        flexGrow: 1,
+                        justifyContent: 'center',
+                    }}>
+                        <WarningAmberIcon color="error" sx={{mr: 1}}/>
+                        <Typography sx={{color: 'error.main'}}>{error}</Typography>
+                    </Box>
+                }
             </Box>
             {config.enableLogin &&
                 <Typography sx={{mb: 4}}>