diff --git a/Makefile b/Makefile index 32d2a129..b9c7e318 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 74caa261..3aff4e15 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 00000000..cd5fbf05 --- /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 232b0cf9..93aa4af5 100644 --- a/web/public/index.html +++ b/web/public/index.html @@ -15,13 +15,13 @@ - + - + @@ -30,10 +30,14 @@ - + - +
+ diff --git a/web/src/app/SubscriptionManager.js b/web/src/app/SubscriptionManager.js index 253acf17..b1e44498 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 00000000..71a9ece3 --- /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 08979fb2..13ff76f8 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 0dcf6963..1f7ec0e4 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 5104d205..bd3d29e1 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 ( - - - - - }> - } /> - } /> - } /> - } /> - - - - + + + + + + }> + } /> + } /> + } /> + } /> + + + + + ); } @@ -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 00000000..87202a99 --- /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 ( +
+

Something went wrong.

+
{this.state.error && this.state.error.toString()}
+
{this.state.info.componentStack}
+
+ ); + } + return this.props.children; + } +} + +export default ErrorBoundary; diff --git a/web/src/components/Navigation.js b/web/src/components/Navigation.js index 0cb1de0b..05805435 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) => { {showGrantPermissionsBox && } {!showSubscriptionsList && - navigate("/")} selected={location.pathname === "/"}> + navigate(routes.root)} selected={location.pathname === config.appRoot}> } {showSubscriptionsList && <> Subscribed topics - navigate("/")} selected={location.pathname === "/"}> + navigate(routes.root)} selected={location.pathname === config.appRoot}> @@ -105,7 +107,7 @@ const NavList = (props) => { /> } - navigate("/settings")} selected={location.pathname === "/settings"}> + navigate(routes.settings)} selected={location.pathname === routes.settings}> @@ -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 a3278708..d7713ae7 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 00000000..b0f8787a --- /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 00000000..81042391 --- /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;