diff --git a/Makefile b/Makefile index 32d2a129798465dc016b874abb226fb8b5b00156..b9c7e31822e4f2bd4ad134f73e9782565ca0a3c7 100644 --- a/Makefile +++ b/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 \ diff --git a/server/server.go b/server/server.go index 74caa26128c4566169c5982bc111db3bd99660a6..3aff4e15fe538f03190ea151f4af5e7572cc7b6b 100644 --- a/server/server.go +++ b/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) diff --git a/web/public/config.js b/web/public/config.js new file mode 100644 index 0000000000000000000000000000000000000000..cd5fbf053dc52c4e528a6ec23c2ef845b5ceb646 --- /dev/null +++ b/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"] +}; diff --git a/web/public/index.html b/web/public/index.html index 232b0cf932e5721f719908d99f79aec701cfa4a7..93aa4af5111d005dc116d836be73b96ca332ae1d 100644 --- a/web/public/index.html +++ b/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> diff --git a/web/src/app/SubscriptionManager.js b/web/src/app/SubscriptionManager.js index 253acf17b7e5c3cc0ae77f50b4c3f54512805e5a..b1e44498a8fd03e2732d11a73c3dd22324443a12 100644 --- a/web/src/app/SubscriptionManager.js +++ b/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 }; diff --git a/web/src/app/config.js b/web/src/app/config.js new file mode 100644 index 0000000000000000000000000000000000000000..71a9ece3bb3f805b2cd837dcd71685854f7f90c6 --- /dev/null +++ b/web/src/app/config.js @@ -0,0 +1,2 @@ +const config = window.config; +export default config; diff --git a/web/src/app/utils.js b/web/src/app/utils.js index 08979fb2f6ac7ec6fa4c9ed2e77ac62e4f817e85..13ff76f8039792a1b7a55f3d00ca4e33793f56d6 100644 --- a/web/src/app/utils.js +++ b/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, diff --git a/web/src/components/ActionBar.js b/web/src/components/ActionBar.js index 0dcf6963e27174f9b73932792068574306fef271..1f7ec0e4619a1a3ac145a741bd5569e28f5c9f64 100644 --- a/web/src/components/ActionBar.js +++ b/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); } }; diff --git a/web/src/components/App.js b/web/src/components/App.js index 5104d2058f04b15fb8d779f9e5a88a9b61b71cd0..bd3d29e1b37cfac3ca9b4afb2cd01c5c2fb026b7 100644 --- a/web/src/components/App.js +++ b/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; diff --git a/web/src/components/ErrorBoundary.js b/web/src/components/ErrorBoundary.js new file mode 100644 index 0000000000000000000000000000000000000000..87202a99edee3420549da443cf528862aa77b531 --- /dev/null +++ b/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; diff --git a/web/src/components/Navigation.js b/web/src/components/Navigation.js index 0cb1de0bffb7d74f06e384ae0704dc5295a6f42a..05805435e889e40d774ef6b77888adef95961af3 100644 --- a/web/src/components/Navigation.js +++ b/web/src/components/Navigation.js @@ -14,13 +14,15 @@ import SubscribeDialog from "./SubscribeDialog"; import {Alert, AlertTitle, Badge, CircularProgress, ListSubheader} from "@mui/material"; import Button from "@mui/material/Button"; import Typography from "@mui/material/Typography"; -import {subscriptionRoute, topicShortUrl, topicUrl} from "../app/utils"; +import {topicShortUrl, topicUrl} from "../app/utils"; +import routes from "./routes"; import {ConnectionState} from "../app/Connection"; import {useLocation, useNavigate} from "react-router-dom"; import subscriptionManager from "../app/SubscriptionManager"; import {ChatBubble, NotificationsOffOutlined} from "@mui/icons-material"; import Box from "@mui/material/Box"; import notifier from "../app/Notifier"; +import config from "../app/config"; const navWidth = 280; @@ -71,7 +73,7 @@ const NavList = (props) => { const handleSubscribeSubmit = (subscription) => { console.log(`[Navigation] New subscription: ${subscription.id}`, subscription); handleSubscribeReset(); - navigate(subscriptionRoute(subscription)); + navigate(routes.forSubscription(subscription)); handleRequestNotificationPermission(); } @@ -88,14 +90,14 @@ const NavList = (props) => { <List component="nav" sx={{ paddingTop: (showGrantPermissionsBox) ? '0' : '' }}> {showGrantPermissionsBox && <PermissionAlert onRequestPermissionClick={handleRequestNotificationPermission}/>} {!showSubscriptionsList && - <ListItemButton onClick={() => navigate("/")} selected={location.pathname === "/"}> + <ListItemButton onClick={() => navigate(routes.root)} selected={location.pathname === config.appRoot}> <ListItemIcon><ChatBubble/></ListItemIcon> <ListItemText primary="All notifications"/> </ListItemButton>} {showSubscriptionsList && <> <ListSubheader>Subscribed topics</ListSubheader> - <ListItemButton onClick={() => navigate("/")} selected={location.pathname === "/"}> + <ListItemButton onClick={() => navigate(routes.root)} selected={location.pathname === config.appRoot}> <ListItemIcon><ChatBubble/></ListItemIcon> <ListItemText primary="All notifications"/> </ListItemButton> @@ -105,7 +107,7 @@ const NavList = (props) => { /> <Divider sx={{my: 1}}/> </>} - <ListItemButton onClick={() => navigate("/settings")} selected={location.pathname === "/settings"}> + <ListItemButton onClick={() => navigate(routes.settings)} selected={location.pathname === routes.settings}> <ListItemIcon><SettingsIcon/></ListItemIcon> <ListItemText primary="Settings"/> </ListItemButton> @@ -152,7 +154,7 @@ const SubscriptionItem = (props) => { ? subscription.topic : topicShortUrl(subscription.baseUrl, subscription.topic); const handleClick = async () => { - navigate(subscriptionRoute(subscription)); + navigate(routes.forSubscription(subscription)); await subscriptionManager.markNotificationsRead(subscription.id); }; return ( diff --git a/web/src/components/SubscribeDialog.js b/web/src/components/SubscribeDialog.js index a3278708a1bba11f59ee1d2aa62fe1f1ebfd8f09..d7713ae7536e77fde0b62f98d587f11e790563d3 100644 --- a/web/src/components/SubscribeDialog.js +++ b/web/src/components/SubscribeDialog.js @@ -25,7 +25,7 @@ const SubscribeDialog = (props) => { const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); const handleSuccess = async () => { const actualBaseUrl = (baseUrl) ? baseUrl : window.location.origin; - const subscription = await subscriptionManager.add(actualBaseUrl, topic, false); + const subscription = await subscriptionManager.add(actualBaseUrl, topic); poller.pollInBackground(subscription); // Dangle! props.onSuccess(subscription); } diff --git a/web/src/components/hooks.js b/web/src/components/hooks.js new file mode 100644 index 0000000000000000000000000000000000000000..b0f8787ad6b190e8503ab6fdbfcc3a61a535e235 --- /dev/null +++ b/web/src/components/hooks.js @@ -0,0 +1,52 @@ +import {useNavigate, useParams} from "react-router-dom"; +import {useEffect, useState} from "react"; +import subscriptionManager from "../app/SubscriptionManager"; +import {disallowedTopic, expandSecureUrl, topicUrl} from "../app/utils"; +import notifier from "../app/Notifier"; +import routes from "./routes"; +import connectionManager from "../app/ConnectionManager"; +import poller from "../app/Poller"; + +export const useConnectionListeners = () => { + const navigate = useNavigate(); + useEffect(() => { + const handleNotification = async (subscriptionId, notification) => { + const added = await subscriptionManager.addNotification(subscriptionId, notification); + if (added) { + const defaultClickAction = (subscription) => navigate(routes.forSubscription(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 + []); +}; + +export 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 && !disallowedTopic(params.topic); + 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); + poller.pollInBackground(subscription); // Dangle! + })(); + } + }, [params, subscriptions, selected, hasRun]); +}; diff --git a/web/src/components/routes.js b/web/src/components/routes.js new file mode 100644 index 0000000000000000000000000000000000000000..81042391c5bd66c24dbfd94af0644a1e23e090bd --- /dev/null +++ b/web/src/components/routes.js @@ -0,0 +1,16 @@ +import config from "../app/config"; +import {shortUrl} from "../app/utils"; + +const routes = { + root: config.appRoot, + settings: "/settings", + subscription: "/:topic", + subscriptionExternal: "/:baseUrl/:topic", + forSubscription: (subscription) => { + if (subscription.baseUrl !== window.location.origin) { + return `/${shortUrl(subscription.baseUrl)}/${subscription.topic}`; + } + return `/${subscription.topic}`; + } +}; +export default routes;