Browse Source

Add server-generated /config.js; add error boundary

Philipp Heckel 3 years ago
parent
commit
840cb5b182

+ 1 - 0
Makefile

@@ -58,6 +58,7 @@ web-build:
 		&& rm -rf ../server/site \
 		&& mv build ../server/site \
 		&& rm \
+			../server/site/config.js \
 			../server/site/precache* \
 			../server/site/service-worker.js \
 			../server/site/asset-manifest.json \

+ 17 - 0
server/server.go

@@ -65,6 +65,7 @@ var (
 	authPathRegex     = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/auth$`)
 	publishPathRegex  = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/(publish|send|trigger)$`)
 
+	webConfigPath    = "/config.js"
 	staticRegex      = regexp.MustCompile(`^/static/.+`)
 	docsRegex        = regexp.MustCompile(`^/docs(|/.*)$`)
 	fileRegex        = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`)
@@ -266,6 +267,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
 		return s.handleExample(w, r)
 	} else if r.Method == http.MethodHead && r.URL.Path == "/" {
 		return s.handleEmpty(w, r, v)
+	} else if r.Method == http.MethodGet && r.URL.Path == webConfigPath {
+		return s.handleWebConfig(w, r)
 	} else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) {
 		return s.handleStatic(w, r)
 	} else if r.Method == http.MethodGet && docsRegex.MatchString(r.URL.Path) {
@@ -331,6 +334,20 @@ func (s *Server) handleExample(w http.ResponseWriter, _ *http.Request) error {
 	return err
 }
 
+func (s *Server) handleWebConfig(w http.ResponseWriter, r *http.Request) error {
+	appRoot := "/"
+	if !s.config.WebRootIsApp {
+		appRoot = "/app"
+	}
+	disallowedTopicsStr := `"` + strings.Join(disallowedTopics, `", "`) + `"`
+	_, err := io.WriteString(w, fmt.Sprintf(`// Generated server configuration
+var config = { 
+  appRoot: "%s",
+  disallowedTopics: [%s]
+};`, appRoot, disallowedTopicsStr))
+	return err
+}
+
 func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) error {
 	r.URL.Path = webSiteDir + r.URL.Path
 	http.FileServer(http.FS(webFsCached)).ServeHTTP(w, r)

+ 9 - 0
web/public/config.js

@@ -0,0 +1,9 @@
+// Configuration injected by the ntfy server.
+//
+// This file is just an example. It is removed during the build process.
+// The actual config is dynamically generated server-side.
+
+var config = {
+    appRoot: "/",
+    disallowedTopics: ["docs", "static", "file", "app", "settings"]
+};

+ 8 - 4
web/public/index.html

@@ -15,13 +15,13 @@
   <meta name="apple-mobile-web-app-status-bar-style" content="#317f6f">
 
   <!-- Favicon, see favicon.io -->
-  <link rel="icon" type="image/png" href="static/img/favicon.png">
+  <link rel="icon" type="image/png" href="%PUBLIC_URL%/static/img/favicon.png">
 
   <!-- Previews in Google, Slack, WhatsApp, etc. -->
   <meta property="og:type" content="website" />
   <meta property="og:locale" content="en_US" />
   <meta property="og:site_name" content="ntfy web" />
-  <meta property="og:title" content="ntfy web | Web app to receive push notifications from scripts via PUT/POST" />
+  <meta property="og:title" content="ntfy web" />
   <meta property="og:description" content="ntfy lets you send push notifications via scripts from any computer or phone, entirely without signup or cost. Made with ❤ by Philipp C. Heckel, Apache License 2.0, source at https://heckel.io/ntfy." />
   <meta property="og:image" content="%PUBLIC_URL%/static/img/ntfy.png" />
   <meta property="og:url" content="https://ntfy.sh" />
@@ -30,10 +30,14 @@
   <meta name="robots" content="noindex, nofollow" />
 
   <!-- Fonts -->
-  <link rel="stylesheet" href="static/css/fonts.css" type="text/css">
+  <link rel="stylesheet" href="%PUBLIC_URL%/static/css/fonts.css" type="text/css">
 </head>
 <body>
-  <noscript>You need to enable JavaScript to run this app.</noscript>
+  <noscript>
+    ntfy web requires JavaScript, but you can use the <a href="https://ntfy.sh/docs/subscribe/cli/">CLI</a>
+    or <a href="https://ntfy.sh/docs/subscribe/phone/">Android/iOS app</a> to subscribe.
+  </noscript>
   <div id="root"></div>
+  <script src="%PUBLIC_URL%/config.js"></script>
 </body>
 </html>

+ 1 - 2
web/src/app/SubscriptionManager.js

@@ -17,12 +17,11 @@ class SubscriptionManager {
         return await db.subscriptions.get(subscriptionId)
     }
 
-    async add(baseUrl, topic, ephemeral) {
+    async add(baseUrl, topic) {
         const subscription = {
             id: topicUrl(baseUrl, topic),
             baseUrl: baseUrl,
             topic: topic,
-            ephemeral: ephemeral,
             mutedUntil: 0,
             last: null
         };

+ 2 - 0
web/src/app/config.js

@@ -0,0 +1,2 @@
+const config = window.config;
+export default config;

+ 8 - 7
web/src/app/utils.js

@@ -6,6 +6,7 @@ import ding from "../sounds/ding.mp3";
 import dadum from "../sounds/dadum.mp3";
 import pop from "../sounds/pop.mp3";
 import popSwoosh from "../sounds/pop-swoosh.mp3";
+import config from "./config";
 
 export const topicUrl = (baseUrl, topic) => `${baseUrl}/${topic}`;
 export const topicUrlWs = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/ws`
@@ -25,9 +26,16 @@ export const validUrl = (url) => {
 }
 
 export const validTopic = (topic) => {
+    if (disallowedTopic(topic)) {
+        return false;
+    }
     return topic.match(/^([-_a-zA-Z0-9]{1,64})$/); // Regex must match Go & Android app!
 }
 
+export const disallowedTopic = (topic) => {
+    return config.disallowedTopics.includes(topic);
+}
+
 // Format emojis (see emoji.js)
 const emojis = {};
 rawEmojis.forEach(emoji => {
@@ -122,13 +130,6 @@ export const openUrl = (url) => {
     window.open(url, "_blank", "noopener,noreferrer");
 };
 
-export const subscriptionRoute = (subscription) => {
-    if (subscription.baseUrl !== window.location.origin) {
-        return `/${shortUrl(subscription.baseUrl)}/${subscription.topic}`;
-    }
-    return `/${subscription.topic}`;
-}
-
 export const sounds = {
     "beep": beep,
     "juntos": juntos,

+ 4 - 3
web/src/components/ActionBar.js

@@ -7,7 +7,7 @@ import Typography from "@mui/material/Typography";
 import * as React from "react";
 import {useEffect, useRef, useState} from "react";
 import Box from "@mui/material/Box";
-import {subscriptionRoute, topicShortUrl} from "../app/utils";
+import {topicShortUrl} from "../app/utils";
 import {useLocation, useNavigate} from "react-router-dom";
 import ClickAwayListener from '@mui/material/ClickAwayListener';
 import Grow from '@mui/material/Grow';
@@ -19,6 +19,7 @@ import MoreVertIcon from "@mui/icons-material/MoreVert";
 import NotificationsIcon from '@mui/icons-material/Notifications';
 import NotificationsOffIcon from '@mui/icons-material/NotificationsOff';
 import api from "../app/Api";
+import routes from "./routes";
 import subscriptionManager from "../app/SubscriptionManager";
 import logo from "../img/ntfy.svg"
 
@@ -98,9 +99,9 @@ const SettingsIcons = (props) => {
         await subscriptionManager.remove(props.subscription.id);
         const newSelected = await subscriptionManager.first(); // May be undefined
         if (newSelected) {
-            navigate(subscriptionRoute(newSelected));
+            navigate(routes.forSubscription(newSelected));
         } else {
-            navigate("/");
+            navigate(routes.root);
         }
     };
 

+ 25 - 62
web/src/components/App.js

@@ -14,10 +14,16 @@ import Preferences from "./Preferences";
 import {useLiveQuery} from "dexie-react-hooks";
 import subscriptionManager from "../app/SubscriptionManager";
 import userManager from "../app/UserManager";
-import {BrowserRouter, Outlet, Route, Routes, useNavigate, useOutletContext, useParams} from "react-router-dom";
-import {expandSecureUrl, expandUrl, subscriptionRoute, topicUrl} from "../app/utils";
-import poller from "../app/Poller";
+import {BrowserRouter, Outlet, Route, Routes, useOutletContext, useParams} from "react-router-dom";
+import {expandUrl} from "../app/utils";
+import ErrorBoundary from "./ErrorBoundary";
+import routes from "./routes";
+import {useAutoSubscribe, useConnectionListeners} from "./hooks";
 
+// TODO iPhone blank screen
+// TODO better "send test message" (a la android app)
+// TODO docs
+// TODO screenshot on homepage
 // TODO "copy url" toast
 // TODO "copy link url" button
 // TODO races when two tabs are open
@@ -25,19 +31,21 @@ import poller from "../app/Poller";
 
 const App = () => {
     return (
-        <BrowserRouter>
-            <ThemeProvider theme={theme}>
-                <CssBaseline/>
-                <Routes>
-                    <Route element={<Layout/>}>
-                        <Route path="/" element={<AllSubscriptions/>} />
-                        <Route path="settings" element={<Preferences/>} />
-                        <Route path=":topic" element={<SingleSubscription/>} />
-                        <Route path=":baseUrl/:topic" element={<SingleSubscription/>} />
-                    </Route>
-                </Routes>
-            </ThemeProvider>
-        </BrowserRouter>
+        <ErrorBoundary>
+            <BrowserRouter>
+                <ThemeProvider theme={theme}>
+                    <CssBaseline/>
+                    <Routes>
+                        <Route element={<Layout/>}>
+                            <Route path={routes.root} element={<AllSubscriptions/>} />
+                            <Route path={routes.settings} element={<Preferences/>} />
+                            <Route path={routes.subscription} element={<SingleSubscription/>} />
+                            <Route path={routes.subscriptionExternal} element={<SingleSubscription/>} />
+                        </Route>
+                    </Routes>
+                </ThemeProvider>
+            </BrowserRouter>
+        </ErrorBoundary>
     );
 }
 
@@ -65,7 +73,6 @@ const Layout = () => {
     });
 
     useConnectionListeners();
-
     useEffect(() => connectionManager.refresh(subscriptions, users), [subscriptions, users]);
     useEffect(() => updateTitle(newNotificationsCount), [newNotificationsCount]);
 
@@ -113,52 +120,8 @@ const Main = (props) => {
     );
 };
 
-const useConnectionListeners = () => {
-    const navigate = useNavigate();
-    useEffect(() => {
-        const handleNotification = async (subscriptionId, notification) => {
-            const added = await subscriptionManager.addNotification(subscriptionId, notification);
-            if (added) {
-                const defaultClickAction = (subscription) => navigate(subscriptionRoute(subscription));
-                await notifier.notify(subscriptionId, notification, defaultClickAction)
-            }
-        };
-        connectionManager.registerStateListener(subscriptionManager.updateState);
-        connectionManager.registerNotificationListener(handleNotification);
-        return () => {
-            connectionManager.resetStateListener();
-            connectionManager.resetNotificationListener();
-        }
-    },
-    // We have to disable dep checking for "navigate". This is fine, it never changes.
-    // eslint-disable-next-line
-    []);
-};
-
-const useAutoSubscribe = (subscriptions, selected) => {
-    const [hasRun, setHasRun] = useState(false);
-    const params = useParams();
-
-    useEffect(() => {
-        const loaded = subscriptions !== null && subscriptions !== undefined;
-        if (!loaded || hasRun) {
-            return;
-        }
-        setHasRun(true);
-        const eligible = params.topic && !selected;
-        if (eligible) {
-            const baseUrl = (params.baseUrl) ? expandSecureUrl(params.baseUrl) : window.location.origin;
-            console.log(`[App] Auto-subscribing to ${topicUrl(baseUrl, params.topic)}`);
-            (async () => {
-                const subscription = await subscriptionManager.add(baseUrl, params.topic, true);
-                poller.pollInBackground(subscription); // Dangle!
-            })();
-        }
-    }, [params, subscriptions, selected, hasRun]);
-};
-
 const updateTitle = (newNotificationsCount) => {
-    document.title = (newNotificationsCount > 0) ? `(${newNotificationsCount}) ntfy web` : "ntfy web";
+    document.title = (newNotificationsCount > 0) ? `(${newNotificationsCount}) ntfy` : "ntfy";
 }
 
 export default App;

+ 32 - 0
web/src/components/ErrorBoundary.js

@@ -0,0 +1,32 @@
+import * as React from "react";
+
+class ErrorBoundary extends React.Component {
+    constructor(props) {
+        super(props);
+        this.state = { error: null, info: null };
+    }
+
+    componentDidCatch(error, info) {
+        this.setState({ error, info });
+        console.error("[ErrorBoundary] A horrible error occurred", info);
+    }
+
+    static getDerivedStateFromError(error) {
+        return { error: true, errorMessage: error.toString() }
+    }
+
+    render() {
+        if (this.state.info) {
+            return (
+                <div>
+                    <h2>Something went wrong.</h2>
+                    <pre>{this.state.error && this.state.error.toString()}</pre>
+                    <pre>{this.state.info.componentStack}</pre>
+                </div>
+            );
+        }
+        return this.props.children;
+    }
+}
+
+export default ErrorBoundary;

Some files were not shown because too many files changed in this diff