Add server-generated /config.js; add error boundary
parent
04ee6b8be2
commit
840cb5b182
1
Makefile
1
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 \
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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"]
|
||||
};
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
};
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
const config = window.config;
|
||||
export default config;
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
|
@ -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 (
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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]);
|
||||
};
|
|
@ -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;
|
Loading…
Reference in New Issue