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

This commit is contained in:
Philipp Heckel 2022-03-09 23:28:55 -05:00
parent 04ee6b8be2
commit 840cb5b182
14 changed files with 184 additions and 85 deletions

View file

@ -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 \

View file

@ -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
web/public/config.js Normal file
View file

@ -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"]
};

View file

@ -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>

View file

@ -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
web/src/app/config.js Normal file
View file

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

View file

@ -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,

View file

@ -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);
}
};

View file

@ -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;

View file

@ -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;

View file

@ -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 (

View file

@ -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);
}

View file

@ -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]);
};

View file

@ -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;