WIPWIPWIP

This commit is contained in:
Philipp Heckel 2022-12-02 15:37:48 -05:00
parent 84dca41008
commit 2772a38dae
16 changed files with 644 additions and 66 deletions

View file

@ -18,30 +18,62 @@ const (
const ( const (
createAuthTablesQueries = ` createAuthTablesQueries = `
BEGIN; BEGIN;
CREATE TABLE IF NOT EXISTS user ( CREATE TABLE IF NOT EXISTS plan (
user TEXT NOT NULL PRIMARY KEY, id INT NOT NULL,
pass TEXT NOT NULL, name TEXT NOT NULL,
role TEXT NOT NULL limit_messages INT,
PRIMARY KEY (id)
); );
CREATE TABLE IF NOT EXISTS access ( CREATE TABLE IF NOT EXISTS user (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plan_id INT,
user TEXT NOT NULL, user TEXT NOT NULL,
pass TEXT NOT NULL,
role TEXT NOT NULL,
language TEXT,
notification_sound TEXT,
notification_min_priority INT,
notification_delete_after INT,
FOREIGN KEY (plan_id) REFERENCES plan (id)
);
CREATE UNIQUE INDEX idx_user ON user (user);
CREATE TABLE IF NOT EXISTS user_access (
user_id INT NOT NULL,
topic TEXT NOT NULL, topic TEXT NOT NULL,
read INT NOT NULL, read INT NOT NULL,
write INT NOT NULL, write INT NOT NULL,
PRIMARY KEY (topic, user) PRIMARY KEY (user_id, topic),
FOREIGN KEY (user_id) REFERENCES user (id)
); );
CREATE TABLE IF NOT EXISTS user_subscription (
user_id INT NOT NULL,
base_url TEXT NOT NULL,
topic TEXT NOT NULL,
PRIMARY KEY (user_id, base_url, topic)
);
CREATE TABLE IF NOT EXISTS user_token (
token TEXT NOT NULL,
user_id INT NOT NULL,
expires INT NOT NULL,
PRIMARY KEY (token)
);
CREATE INDEX idx_user_id ON user_token (user_id);
CREATE TABLE IF NOT EXISTS schemaVersion ( CREATE TABLE IF NOT EXISTS schemaVersion (
id INT PRIMARY KEY, id INT PRIMARY KEY,
version INT NOT NULL version INT NOT NULL
); );
CREATE INDEX IF NOT EXISTS idx_user ON user_subscription (user);
INSERT INTO plan (id, name) VALUES (1, 'Admin') ON CONFLICT (id) DO NOTHING;
INSERT INTO user (id, user, pass, role) VALUES (1, '*', '', 'anonymous') ON CONFLICT (id) DO NOTHING;
COMMIT; COMMIT;
` `
selectUserQuery = `SELECT pass, role FROM user WHERE user = ?` selectUserQuery = `SELECT pass, role FROM user WHERE user = ?`
selectTopicPermsQuery = ` selectTopicPermsQuery = `
SELECT read, write SELECT read, write
FROM access FROM user_access
WHERE user IN ('*', ?) AND ? LIKE topic JOIN user ON user.user = '*' OR user.user = ?
ORDER BY user DESC WHERE ? LIKE user_access.topic
ORDER BY user.user DESC
` `
) )
@ -53,15 +85,11 @@ const (
updateUserRoleQuery = `UPDATE user SET role = ? WHERE user = ?` updateUserRoleQuery = `UPDATE user SET role = ? WHERE user = ?`
deleteUserQuery = `DELETE FROM user WHERE user = ?` deleteUserQuery = `DELETE FROM user WHERE user = ?`
upsertUserAccessQuery = ` upsertUserAccessQuery = `INSERT INTO user_access (user_id, topic, read, write) VALUES ((SELECT id FROM user WHERE user = ?), ?, ?, ?)`
INSERT INTO access (user, topic, read, write) selectUserAccessQuery = `SELECT topic, read, write FROM user_access WHERE user_id = (SELECT id FROM user WHERE user = ?)`
VALUES (?, ?, ?, ?) deleteAllAccessQuery = `DELETE FROM user_access`
ON CONFLICT (user, topic) DO UPDATE SET read=excluded.read, write=excluded.write deleteUserAccessQuery = `DELETE FROM user_access WHERE user_id = (SELECT id FROM user WHERE user = ?)`
` deleteTopicAccessQuery = `DELETE FROM user_access WHERE user_id = (SELECT id FROM user WHERE user = ?) AND topic = ?`
selectUserAccessQuery = `SELECT topic, read, write FROM access WHERE user = ?`
deleteAllAccessQuery = `DELETE FROM access`
deleteUserAccessQuery = `DELETE FROM access WHERE user = ?`
deleteTopicAccessQuery = `DELETE FROM access WHERE user = ? AND topic = ?`
) )
// Schema management queries // Schema management queries

View file

@ -43,7 +43,7 @@ type Server struct {
smtpServerBackend *smtpBackend smtpServerBackend *smtpBackend
smtpSender mailer smtpSender mailer
topics map[string]*topic topics map[string]*topic
visitors map[netip.Addr]*visitor visitors map[string]*visitor // ip:<ip> or user:<user>
firebaseClient *firebaseClient firebaseClient *firebaseClient
messages int64 messages int64
auth auth.Auther auth auth.Auther
@ -69,7 +69,9 @@ var (
publishPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/(publish|send|trigger)$`) publishPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/(publish|send|trigger)$`)
webConfigPath = "/config.js" webConfigPath = "/config.js"
userStatsPath = "/user/stats" userStatsPath = "/user/stats" // FIXME get rid of this in favor of /user/account
userAuthPath = "/user/auth"
userAccountPath = "/user/account"
matrixPushPath = "/_matrix/push/v1/notify" matrixPushPath = "/_matrix/push/v1/notify"
staticRegex = regexp.MustCompile(`^/static/.+`) staticRegex = regexp.MustCompile(`^/static/.+`)
docsRegex = regexp.MustCompile(`^/docs(|/.*)$`) docsRegex = regexp.MustCompile(`^/docs(|/.*)$`)
@ -151,7 +153,7 @@ func New(conf *Config) (*Server, error) {
smtpSender: mailer, smtpSender: mailer,
topics: topics, topics: topics,
auth: auther, auth: auther,
visitors: make(map[netip.Addr]*visitor), visitors: make(map[string]*visitor),
}, nil }, nil
} }
@ -255,12 +257,15 @@ func (s *Server) Stop() {
} }
func (s *Server) handle(w http.ResponseWriter, r *http.Request) { func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
v := s.visitor(r) v, err := s.visitor(r) // Note: Always returns v, even when error is returned
log.Debug("%s Dispatching request", logHTTPPrefix(v, r)) if err == nil {
if log.IsTrace() { log.Debug("%s Dispatching request", logHTTPPrefix(v, r))
log.Trace("%s Entire request (headers and body):\n%s", logHTTPPrefix(v, r), renderHTTPRequest(r)) if log.IsTrace() {
log.Trace("%s Entire request (headers and body):\n%s", logHTTPPrefix(v, r), renderHTTPRequest(r))
}
err = s.handleInternal(w, r, v)
} }
if err := s.handleInternal(w, r, v); err != nil { if err != nil {
if websocket.IsWebSocketUpgrade(r) { if websocket.IsWebSocketUpgrade(r) {
isNormalError := strings.Contains(err.Error(), "i/o timeout") isNormalError := strings.Contains(err.Error(), "i/o timeout")
if isNormalError { if isNormalError {
@ -300,6 +305,10 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
return s.ensureWebEnabled(s.handleWebConfig)(w, r, v) return s.ensureWebEnabled(s.handleWebConfig)(w, r, v)
} else if r.Method == http.MethodGet && r.URL.Path == userStatsPath { } else if r.Method == http.MethodGet && r.URL.Path == userStatsPath {
return s.handleUserStats(w, r, v) return s.handleUserStats(w, r, v)
} else if r.Method == http.MethodGet && r.URL.Path == userAuthPath {
return s.handleUserAuth(w, r, v)
} else if r.Method == http.MethodGet && r.URL.Path == userAccountPath {
return s.handleUserAccount(w, r, v)
} else if r.Method == http.MethodGet && r.URL.Path == matrixPushPath { } else if r.Method == http.MethodGet && r.URL.Path == matrixPushPath {
return s.handleMatrixDiscovery(w) return s.handleMatrixDiscovery(w)
} else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) { } else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) {
@ -394,6 +403,72 @@ func (s *Server) handleUserStats(w http.ResponseWriter, r *http.Request, v *visi
return nil return nil
} }
var sessions = make(map[string]*auth.User) // token-> user
type tokenAuthResponse struct {
Token string `json:"token"`
}
func (s *Server) handleUserAuth(w http.ResponseWriter, r *http.Request, v *visitor) error {
// TODO rate limit
if v.user == nil {
return errHTTPUnauthorized
}
token := util.RandomString(32)
sessions[token] = v.user
w.Header().Set("Content-Type", "text/json")
w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
response := &tokenAuthResponse{
Token: token,
}
if err := json.NewEncoder(w).Encode(response); err != nil {
return err
}
return nil
}
type userSubscriptionResponse struct {
BaseURL string `json:"base_url"`
Topic string `json:"topic"`
}
type userAccountResponse struct {
Username string `json:"username"`
Role string `json:"role,omitempty"`
Language string `json:"language,omitempty"`
Plan struct {
Id int `json:"id"`
Name string `json:"name"`
} `json:"plan,omitempty"`
Notification struct {
Sound string `json:"sound"`
MinPriority string `json:"min_priority"`
DeleteAfter int `json:"delete_after"`
} `json:"notification,omitempty"`
Subscriptions []*userSubscriptionResponse `json:"subscriptions,omitempty"`
}
func (s *Server) handleUserAccount(w http.ResponseWriter, r *http.Request, v *visitor) error {
w.Header().Set("Content-Type", "text/json")
w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
var response *userAccountResponse
if v.user != nil {
response = &userAccountResponse{
Username: v.user.Name,
Role: string(v.user.Role),
Language: "en_US",
}
} else {
response = &userAccountResponse{
Username: "anonymous",
}
}
if err := json.NewEncoder(w).Encode(response); err != nil {
return err
}
return nil
}
func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request, _ *visitor) error { func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request, _ *visitor) error {
r.URL.Path = webSiteDir + r.URL.Path r.URL.Path = webSiteDir + r.URL.Path
util.Gzip(http.FileServer(http.FS(webFsCached))).ServeHTTP(w, r) util.Gzip(http.FileServer(http.FS(webFsCached))).ServeHTTP(w, r)
@ -1221,7 +1296,7 @@ func (s *Server) runFirebaseKeepaliver() {
if s.firebaseClient == nil { if s.firebaseClient == nil {
return return
} }
v := newVisitor(s.config, s.messageCache, netip.IPv4Unspecified()) // Background process, not a real visitor, uses IP 0.0.0.0 v := newVisitor(s.config, s.messageCache, netip.IPv4Unspecified(), nil) // Background process, not a real visitor, uses IP 0.0.0.0
for { for {
select { select {
case <-time.After(s.config.FirebaseKeepaliveInterval): case <-time.After(s.config.FirebaseKeepaliveInterval):
@ -1253,7 +1328,7 @@ func (s *Server) sendDelayedMessages() error {
return err return err
} }
for _, m := range messages { for _, m := range messages {
v := s.visitorFromIP(m.Sender) v := s.visitorFromID(fmt.Sprintf("ip:%s", m.Sender.String()), m.Sender, nil) // FIXME: This is wrong wrong wrong
if err := s.sendDelayedMessage(v, m); err != nil { if err := s.sendDelayedMessage(v, m); err != nil {
log.Warn("%s Error sending delayed message: %s", logMessagePrefix(v, m), err.Error()) log.Warn("%s Error sending delayed message: %s", logMessagePrefix(v, m), err.Error())
} }
@ -1395,16 +1470,8 @@ func (s *Server) withAuth(next handleFunc, perm auth.Permission) handleFunc {
if err != nil { if err != nil {
return err return err
} }
var user *auth.User // may stay nil if no auth header!
username, password, ok := extractUserPass(r)
if ok {
if user, err = s.auth.Authenticate(username, password); err != nil {
log.Info("authentication failed: %s", err.Error())
return errHTTPUnauthorized
}
}
for _, t := range topics { for _, t := range topics {
if err := s.auth.Authorize(user, t.ID, perm); err != nil { if err := s.auth.Authorize(v.user, t.ID, perm); err != nil {
log.Info("unauthorized: %s", err.Error()) log.Info("unauthorized: %s", err.Error())
return errHTTPForbidden return errHTTPForbidden
} }
@ -1435,8 +1502,39 @@ func extractUserPass(r *http.Request) (username string, password string, ok bool
} }
// visitor creates or retrieves a rate.Limiter for the given visitor. // visitor creates or retrieves a rate.Limiter for the given visitor.
// This function was taken from https://www.alexedwards.net/blog/how-to-rate-limit-http-requests (MIT). // Note that this function will always return a visitor, even if an error occurs.
func (s *Server) visitor(r *http.Request) *visitor { func (s *Server) visitor(r *http.Request) (v *visitor, err error) {
ip := s.extractIPAddress(r)
visitorID := fmt.Sprintf("ip:%s", ip.String())
var user *auth.User // may stay nil if no auth header!
username, password, ok := extractUserPass(r)
if ok {
if user, err = s.auth.Authenticate(username, password); err != nil {
log.Debug("authentication failed: %s", err.Error())
err = errHTTPUnauthorized // Always return visitor, even when error occurs!
} else {
visitorID = fmt.Sprintf("user:%s", user.Name)
}
}
v = s.visitorFromID(visitorID, ip, user)
v.user = user // Update user -- FIXME this is ugly, do "newVisitorFromUser" instead
return v, err // Always return visitor, even when error occurs!
}
func (s *Server) visitorFromID(visitorID string, ip netip.Addr, user *auth.User) *visitor {
s.mu.Lock()
defer s.mu.Unlock()
v, exists := s.visitors[visitorID]
if !exists {
s.visitors[visitorID] = newVisitor(s.config, s.messageCache, ip, user)
return s.visitors[visitorID]
}
v.Keepalive()
return v
}
func (s *Server) extractIPAddress(r *http.Request) netip.Addr {
remoteAddr := r.RemoteAddr remoteAddr := r.RemoteAddr
addrPort, err := netip.ParseAddrPort(remoteAddr) addrPort, err := netip.ParseAddrPort(remoteAddr)
ip := addrPort.Addr() ip := addrPort.Addr()
@ -1461,17 +1559,5 @@ func (s *Server) visitor(r *http.Request) *visitor {
ip = realIP ip = realIP
} }
} }
return s.visitorFromIP(ip) return ip
}
func (s *Server) visitorFromIP(ip netip.Addr) *visitor {
s.mu.Lock()
defer s.mu.Unlock()
v, exists := s.visitors[ip]
if !exists {
s.visitors[ip] = newVisitor(s.config, s.messageCache, ip)
return s.visitors[ip]
}
v.Keepalive()
return v
} }

View file

@ -2,6 +2,7 @@ package server
import ( import (
"errors" "errors"
"heckel.io/ntfy/auth"
"net/netip" "net/netip"
"sync" "sync"
"time" "time"
@ -26,6 +27,7 @@ type visitor struct {
config *Config config *Config
messageCache *messageCache messageCache *messageCache
ip netip.Addr ip netip.Addr
user *auth.User
requests *rate.Limiter requests *rate.Limiter
emails *rate.Limiter emails *rate.Limiter
subscriptions util.Limiter subscriptions util.Limiter
@ -42,11 +44,12 @@ type visitorStats struct {
VisitorAttachmentBytesRemaining int64 `json:"visitorAttachmentBytesRemaining"` VisitorAttachmentBytesRemaining int64 `json:"visitorAttachmentBytesRemaining"`
} }
func newVisitor(conf *Config, messageCache *messageCache, ip netip.Addr) *visitor { func newVisitor(conf *Config, messageCache *messageCache, ip netip.Addr, user *auth.User) *visitor {
return &visitor{ return &visitor{
config: conf, config: conf,
messageCache: messageCache, messageCache: messageCache,
ip: ip, ip: ip,
user: user,
requests: rate.NewLimiter(rate.Every(conf.VisitorRequestLimitReplenish), conf.VisitorRequestLimitBurst), requests: rate.NewLimiter(rate.Every(conf.VisitorRequestLimitReplenish), conf.VisitorRequestLimitBurst),
emails: rate.NewLimiter(rate.Every(conf.VisitorEmailLimitReplenish), conf.VisitorEmailLimitBurst), emails: rate.NewLimiter(rate.Every(conf.VisitorEmailLimitReplenish), conf.VisitorEmailLimitBurst),
subscriptions: util.NewFixedLimiter(int64(conf.VisitorSubscriptionLimit)), subscriptions: util.NewFixedLimiter(int64(conf.VisitorSubscriptionLimit)),

View file

@ -4,6 +4,6 @@
// The actual config is dynamically generated server-side. // The actual config is dynamically generated server-side.
var config = { var config = {
appRoot: "/", appRoot: "/app",
disallowedTopics: ["docs", "static", "file", "app", "settings"] disallowedTopics: ["docs", "static", "file", "app", "settings"]
}; };

View file

@ -5,7 +5,7 @@ import {
topicUrl, topicUrl,
topicUrlAuth, topicUrlAuth,
topicUrlJsonPoll, topicUrlJsonPoll,
topicUrlJsonPollWithSince, topicUrlJsonPollWithSince, userAuthUrl,
userStatsUrl userStatsUrl
} from "./utils"; } from "./utils";
import userManager from "./UserManager"; import userManager from "./UserManager";
@ -101,7 +101,7 @@ class Api {
return send; return send;
} }
async auth(baseUrl, topic, user) { async topicAuth(baseUrl, topic, user) {
const url = topicUrlAuth(baseUrl, topic); const url = topicUrlAuth(baseUrl, topic);
console.log(`[Api] Checking auth for ${url}`); console.log(`[Api] Checking auth for ${url}`);
const response = await fetch(url, { const response = await fetch(url, {
@ -117,6 +117,22 @@ class Api {
throw new Error(`Unexpected server response ${response.status}`); throw new Error(`Unexpected server response ${response.status}`);
} }
async userAuth(baseUrl, user) {
const url = userAuthUrl(baseUrl);
console.log(`[Api] Checking auth for ${url}`);
const response = await fetch(url, {
headers: maybeWithBasicAuth({}, user)
});
if (response.status !== 200) {
throw new Error(`Unexpected server response ${response.status}`);
}
const json = await response.json();
if (!json.token) {
throw new Error(`Unexpected server response: Cannot find token`);
}
return json.token;
}
async userStats(baseUrl) { async userStats(baseUrl) {
const url = userStatsUrl(baseUrl); const url = userStatsUrl(baseUrl);
console.log(`[Api] Fetching user stats ${url}`); console.log(`[Api] Fetching user stats ${url}`);

22
web/src/app/Session.js Normal file
View file

@ -0,0 +1,22 @@
class Session {
store(username, token) {
localStorage.setItem("user", username);
localStorage.setItem("token", token);
}
reset() {
localStorage.removeItem("user");
localStorage.removeItem("token");
}
username() {
return localStorage.getItem("user");
}
token() {
return localStorage.getItem("token");
}
}
const session = new Session();
export default session;

View file

@ -1,4 +1,5 @@
import Dexie from 'dexie'; import Dexie from 'dexie';
import session from "./Session";
// Uses Dexie.js // Uses Dexie.js
// https://dexie.org/docs/API-Reference#quick-reference // https://dexie.org/docs/API-Reference#quick-reference
@ -6,7 +7,8 @@ import Dexie from 'dexie';
// Notes: // Notes:
// - As per docs, we only declare the indexable columns, not all columns // - As per docs, we only declare the indexable columns, not all columns
const db = new Dexie('ntfy'); const dbName = (session.username()) ? `ntfy-${session.username()}` : "ntfy";
const db = new Dexie(dbName);
db.version(1).stores({ db.version(1).stores({
subscriptions: '&id,baseUrl', subscriptions: '&id,baseUrl',

View file

@ -19,6 +19,7 @@ export const topicUrlJsonPollWithSince = (baseUrl, topic, since) => `${topicUrlJ
export const topicUrlAuth = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/auth`; export const topicUrlAuth = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/auth`;
export const topicShortUrl = (baseUrl, topic) => shortUrl(topicUrl(baseUrl, topic)); export const topicShortUrl = (baseUrl, topic) => shortUrl(topicUrl(baseUrl, topic));
export const userStatsUrl = (baseUrl) => `${baseUrl}/user/stats`; export const userStatsUrl = (baseUrl) => `${baseUrl}/user/stats`;
export const userAuthUrl = (baseUrl) => `${baseUrl}/user/auth`;
export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, ""); export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, "");
export const expandUrl = (url) => [`https://${url}`, `http://${url}`]; export const expandUrl = (url) => [`https://${url}`, `http://${url}`];
export const expandSecureUrl = (url) => `https://${url}`; export const expandSecureUrl = (url) => `https://${url}`;

View file

@ -25,10 +25,14 @@ import logo from "../img/ntfy.svg";
import {useTranslation} from "react-i18next"; import {useTranslation} from "react-i18next";
import {Portal, Snackbar} from "@mui/material"; import {Portal, Snackbar} from "@mui/material";
import SubscriptionSettingsDialog from "./SubscriptionSettingsDialog"; import SubscriptionSettingsDialog from "./SubscriptionSettingsDialog";
import session from "../app/Session";
import AccountCircleIcon from '@mui/icons-material/AccountCircle';
import Button from "@mui/material/Button";
const ActionBar = (props) => { const ActionBar = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const location = useLocation(); const location = useLocation();
const username = session.username();
let title = "ntfy"; let title = "ntfy";
if (props.selected) { if (props.selected) {
title = topicDisplayName(props.selected); title = topicDisplayName(props.selected);
@ -69,6 +73,7 @@ const ActionBar = (props) => {
subscription={props.selected} subscription={props.selected}
onUnsubscribe={props.onUnsubscribe} onUnsubscribe={props.onUnsubscribe}
/>} />}
<ProfileIcon/>
</Toolbar> </Toolbar>
</AppBar> </AppBar>
); );
@ -114,7 +119,7 @@ const SettingsIcons = (props) => {
if (newSelected) { if (newSelected) {
navigate(routes.forSubscription(newSelected)); navigate(routes.forSubscription(newSelected));
} else { } else {
navigate(routes.root); navigate(routes.app);
} }
}; };
@ -237,4 +242,90 @@ const SettingsIcons = (props) => {
); );
}; };
const ProfileIcon = (props) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const anchorRef = useRef(null);
const username = session.username();
const handleToggleOpen = () => {
setOpen((prevOpen) => !prevOpen);
};
const handleClose = (event) => {
if (anchorRef.current && anchorRef.current.contains(event.target)) {
return;
}
setOpen(false);
};
const handleListKeyDown = (event) => {
if (event.key === 'Tab') {
event.preventDefault();
setOpen(false);
} else if (event.key === 'Escape') {
setOpen(false);
}
}
const handleUpgrade = () => {
// TODO
};
const handleLogout = () => {
session.reset();
window.location.href = routes.app;
};
// return focus to the button when we transitioned from !open -> open
const prevOpen = useRef(open);
useEffect(() => {
if (prevOpen.current === true && open === false) {
anchorRef.current.focus();
}
prevOpen.current = open;
}, [open]);
return (
<>
{username &&
<IconButton color="inherit" size="large" edge="end" ref={anchorRef} onClick={handleToggleOpen} sx={{marginRight: 0}} aria-label={t("xxxxxxx")}>
<AccountCircleIcon/>
</IconButton>
}
{!username &&
<>
<Button>Sign in</Button>
<Button>Sign up</Button>
</>
}
<Popper
open={open}
anchorEl={anchorRef.current}
role={undefined}
placement="bottom-start"
transition
disablePortal
>
{({TransitionProps, placement}) => (
<Grow
{...TransitionProps}
style={{transformOrigin: placement === 'bottom-start' ? 'left top' : 'left bottom'}}
>
<Paper>
<ClickAwayListener onClickAway={handleClose}>
<MenuList autoFocusItem={open} onKeyDown={handleListKeyDown}>
<MenuItem onClick={handleUpgrade}>Upgrade</MenuItem>
<MenuItem onClick={handleLogout}>Logout</MenuItem>
</MenuList>
</ClickAwayListener>
</Paper>
</Grow>
)}
</Popper>
</>
);
};
export default ActionBar; export default ActionBar;

View file

@ -23,6 +23,8 @@ import PublishDialog from "./PublishDialog";
import Messaging from "./Messaging"; import Messaging from "./Messaging";
import "./i18n"; // Translations! import "./i18n"; // Translations!
import {Backdrop, CircularProgress} from "@mui/material"; import {Backdrop, CircularProgress} from "@mui/material";
import Home from "./Home";
import Login from "./Login";
// TODO races when two tabs are open // TODO races when two tabs are open
// TODO investigate service workers // TODO investigate service workers
@ -35,8 +37,10 @@ const App = () => {
<CssBaseline/> <CssBaseline/>
<ErrorBoundary> <ErrorBoundary>
<Routes> <Routes>
<Route path={routes.home} element={<Home/>}/>
<Route path={routes.login} element={<Login/>}/>
<Route element={<Layout/>}> <Route element={<Layout/>}>
<Route path={routes.root} element={<AllSubscriptions/>}/> <Route path={routes.app} element={<AllSubscriptions/>}/>
<Route path={routes.settings} element={<Preferences/>}/> <Route path={routes.settings} element={<Preferences/>}/>
<Route path={routes.subscription} element={<SingleSubscription/>}/> <Route path={routes.subscription} element={<SingleSubscription/>}/>
<Route path={routes.subscriptionExternal} element={<SingleSubscription/>}/> <Route path={routes.subscriptionExternal} element={<SingleSubscription/>}/>

View file

@ -0,0 +1,49 @@
import * as React from 'react';
import {useEffect, useState} from 'react';
import {
CardActions,
CardContent,
FormControl, Link,
Select,
Stack,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
useMediaQuery
} from "@mui/material";
import Typography from "@mui/material/Typography";
import prefs from "../app/Prefs";
import {Paragraph} from "./styles";
import EditIcon from '@mui/icons-material/Edit';
import CloseIcon from "@mui/icons-material/Close";
import IconButton from "@mui/material/IconButton";
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import Container from "@mui/material/Container";
import TextField from "@mui/material/TextField";
import MenuItem from "@mui/material/MenuItem";
import Card from "@mui/material/Card";
import Button from "@mui/material/Button";
import {useLiveQuery} from "dexie-react-hooks";
import theme from "./theme";
import Dialog from "@mui/material/Dialog";
import DialogTitle from "@mui/material/DialogTitle";
import DialogContent from "@mui/material/DialogContent";
import DialogActions from "@mui/material/DialogActions";
import userManager from "../app/UserManager";
import {playSound, shuffle, sounds, validUrl} from "../app/utils";
import {useTranslation} from "react-i18next";
const Home = () => {
return (
<Container maxWidth="md" sx={{marginTop: 3, marginBottom: 3}}>
<Stack spacing={3}>
This is the landing page
<Link href="/login">Login</Link>
</Stack>
</Container>
);
};
export default Home;

113
web/src/components/Login.js Normal file
View file

@ -0,0 +1,113 @@
import * as React from 'react';
import {Avatar, Checkbox, FormControlLabel, Grid, Link, Stack} from "@mui/material";
import Typography from "@mui/material/Typography";
import Container from "@mui/material/Container";
import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
import TextField from "@mui/material/TextField";
import Button from "@mui/material/Button";
import Box from "@mui/material/Box";
import api from "../app/Api";
import {useNavigate} from "react-router-dom";
import routes from "./routes";
import session from "../app/Session";
const Copyright = (props) => {
return (
<Typography variant="body2" color="text.secondary" align="center" {...props}>
{'Copyright © '}
<Link color="inherit" href="https://mui.com/">
Your Website
</Link>{' '}
{new Date().getFullYear()}
{'.'}
</Typography>
);
};
const Login = () => {
const handleSubmit = async (event) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
console.log({
email: data.get('email'),
password: data.get('password'),
});
const user ={
username: data.get('email'),
password: data.get('password'),
}
const token = await api.userAuth("http://localhost:2586"/*window.location.origin*/, user);
console.log(`[Api] User auth for user ${user.username} successful, token is ${token}`);
session.store(user.username, token);
window.location.href = routes.app;
};
return (
<>
<Box
sx={{
marginTop: 8,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
<Avatar sx={{m: 1, bgcolor: 'secondary.main'}}>
<LockOutlinedIcon/>
</Avatar>
<Typography component="h1" variant="h5">
Sign in
</Typography>
<Box component="form" onSubmit={handleSubmit} noValidate sx={{mt: 1}}>
<TextField
margin="normal"
required
fullWidth
id="email"
label="Email Address"
name="email"
autoComplete="email"
autoFocus
/>
<TextField
margin="normal"
required
fullWidth
name="password"
label="Password"
type="password"
id="password"
autoComplete="current-password"
/>
<FormControlLabel
control={<Checkbox value="remember" color="primary"/>}
label="Remember me"
/>
<Button
type="submit"
fullWidth
variant="contained"
sx={{mt: 3, mb: 2}}
>
Sign In
</Button>
<Grid container>
<Grid item xs>
<Link href="#" variant="body2">
Forgot password?
</Link>
</Grid>
<Grid item>
<Link href="#" variant="body2">
{"Don't have an account? Sign Up"}
</Link>
</Grid>
</Grid>
</Box>
</Box>
<Copyright sx={{mt: 8, mb: 4}}/>
</>
);
}
export default Login;

View file

@ -104,14 +104,14 @@ const NavList = (props) => {
{showNotificationContextNotSupportedBox && <NotificationContextNotSupportedAlert/>} {showNotificationContextNotSupportedBox && <NotificationContextNotSupportedAlert/>}
{showNotificationGrantBox && <NotificationGrantAlert onRequestPermissionClick={handleRequestNotificationPermission}/>} {showNotificationGrantBox && <NotificationGrantAlert onRequestPermissionClick={handleRequestNotificationPermission}/>}
{!showSubscriptionsList && {!showSubscriptionsList &&
<ListItemButton onClick={() => navigate(routes.root)} selected={location.pathname === config.appRoot}> <ListItemButton onClick={() => navigate(routes.app)} selected={location.pathname === config.appRoot}>
<ListItemIcon><ChatBubble/></ListItemIcon> <ListItemIcon><ChatBubble/></ListItemIcon>
<ListItemText primary={t("nav_button_all_notifications")}/> <ListItemText primary={t("nav_button_all_notifications")}/>
</ListItemButton>} </ListItemButton>}
{showSubscriptionsList && {showSubscriptionsList &&
<> <>
<ListSubheader>{t("nav_topics_title")}</ListSubheader> <ListSubheader>{t("nav_topics_title")}</ListSubheader>
<ListItemButton onClick={() => navigate(routes.root)} selected={location.pathname === config.appRoot}> <ListItemButton onClick={() => navigate(routes.app)} selected={location.pathname === config.appRoot}>
<ListItemIcon><ChatBubble/></ListItemIcon> <ListItemIcon><ChatBubble/></ListItemIcon>
<ListItemText primary={t("nav_button_all_notifications")}/> <ListItemText primary={t("nav_button_all_notifications")}/>
</ListItemButton> </ListItemButton>

View file

@ -32,7 +32,7 @@ import DialogTitle from "@mui/material/DialogTitle";
import DialogContent from "@mui/material/DialogContent"; import DialogContent from "@mui/material/DialogContent";
import DialogActions from "@mui/material/DialogActions"; import DialogActions from "@mui/material/DialogActions";
import userManager from "../app/UserManager"; import userManager from "../app/UserManager";
import {playSound, shuffle, sounds, validUrl} from "../app/utils"; import {playSound, shuffle, sounds, validTopic, validUrl} from "../app/utils";
import {useTranslation} from "react-i18next"; import {useTranslation} from "react-i18next";
const Preferences = () => { const Preferences = () => {
@ -42,6 +42,7 @@ const Preferences = () => {
<Notifications/> <Notifications/>
<Appearance/> <Appearance/>
<Users/> <Users/>
<AccessControl/>
</Stack> </Stack>
</Container> </Container>
); );
@ -473,4 +474,164 @@ const Language = () => {
) )
}; };
const AccessControl = () => {
const { t } = useTranslation();
const [dialogKey, setDialogKey] = useState(0);
const [dialogOpen, setDialogOpen] = useState(false);
const entries = useLiveQuery(() => userManager.all());
const handleAddClick = () => {
setDialogKey(prev => prev+1);
setDialogOpen(true);
};
const handleDialogCancel = () => {
setDialogOpen(false);
};
const handleDialogSubmit = async (user) => {
setDialogOpen(false);
try {
await userManager.save(user);
console.debug(`[Preferences] User ${user.username} for ${user.baseUrl} added`);
} catch (e) {
console.log(`[Preferences] Error adding user.`, e);
}
};
return (
<Card sx={{ padding: 1 }} aria-label={t("prefs_users_title")}>
<CardContent sx={{ paddingBottom: 1 }}>
<Typography variant="h5" sx={{marginBottom: 2}}>
Access control
</Typography>
<Paragraph>
Define read/write access to topics for this server.
</Paragraph>
{entries?.length > 0 && <AccessControlTable entries={entries}/>}
</CardContent>
<CardActions>
<Button onClick={handleAddClick}>{t("prefs_users_add_button")}</Button>
<AccessControlDialog
key={`aclDialog${dialogKey}`}
open={dialogOpen}
user={null}
users={entries}
onCancel={handleDialogCancel}
onSubmit={handleDialogSubmit}
/>
</CardActions>
</Card>
);
};
const AccessControlTable = (props) => {
const { t } = useTranslation();
const [dialogKey, setDialogKey] = useState(0);
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogUser, setDialogUser] = useState(null);
const handleEditClick = (user) => {
setDialogKey(prev => prev+1);
setDialogUser(user);
setDialogOpen(true);
};
const handleDialogCancel = () => {
setDialogOpen(false);
};
const handleDialogSubmit = async (user) => {
setDialogOpen(false);
try {
await userManager.save(user);
console.debug(`[Preferences] User ${user.username} for ${user.baseUrl} updated`);
} catch (e) {
console.log(`[Preferences] Error updating user.`, e);
}
};
const handleDeleteClick = async (user) => {
try {
await userManager.delete(user.baseUrl);
console.debug(`[Preferences] User ${user.username} for ${user.baseUrl} deleted`);
} catch (e) {
console.error(`[Preferences] Error deleting user for ${user.baseUrl}`, e);
}
};
return (
<Table size="small" aria-label={t("prefs_users_table")}>
<TableHead>
<TableRow>
<TableCell sx={{paddingLeft: 0}}>Topic</TableCell>
<TableCell>User</TableCell>
<TableCell>Access</TableCell>
<TableCell/>
</TableRow>
</TableHead>
<TableBody>
{props.entries?.map(user => (
<TableRow
key={user.baseUrl}
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
>
<TableCell component="th" scope="row" sx={{paddingLeft: 0}} aria-label={t("prefs_users_table_user_header")}>{user.username}</TableCell>
<TableCell aria-label={t("xxxxxxxxxx")}>{user.baseUrl}</TableCell>
<TableCell align="right">
<IconButton onClick={() => handleEditClick(user)} aria-label={t("prefs_users_edit_button")}>
<EditIcon/>
</IconButton>
<IconButton onClick={() => handleDeleteClick(user)} aria-label={t("prefs_users_delete_button")}>
<CloseIcon />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
<AccessControlDialog
key={`userEditDialog${dialogKey}`}
open={dialogOpen}
user={dialogUser}
users={props.entries}
onCancel={handleDialogCancel}
onSubmit={handleDialogSubmit}
/>
</Table>
);
};
const AccessControlDialog = (props) => {
const { t } = useTranslation();
const [topic, setTopic] = useState("");
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
const addButtonEnabled = (() => {
return validTopic(topic);
})();
const handleSubmit = async () => {
// TODO
};
return (
<Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
<DialogTitle>Add entry</DialogTitle>
<DialogContent>
<TextField
autoFocus={editMode}
margin="dense"
id="topic"
label={"Topic"}
aria-label={"Topic xx"}
value={topic}
onChange={ev => setTopic(ev.target.value)}
type="text"
fullWidth
variant="standard"
/>
<FormControl fullWidth variant="standard" sx={{ margin: 1 }}>
<Select value={"read-write"} onChange={() => {}}>
<MenuItem value={"private"}>Read/write access only by me</MenuItem>
<MenuItem value={"read-only"}>Read/write access by user, anonymous read</MenuItem>
</Select>
</FormControl>
</DialogContent>
<DialogActions>
<Button onClick={props.onCancel}>Cancel</Button>
<Button onClick={handleSubmit} disabled={!addButtonEnabled}>Add entry</Button>
</DialogActions>
</Dialog>
);
};
export default Preferences; export default Preferences;

View file

@ -63,7 +63,7 @@ const SubscribePage = (props) => {
const handleSubscribe = async () => { const handleSubscribe = async () => {
const user = await userManager.get(baseUrl); // May be undefined const user = await userManager.get(baseUrl); // May be undefined
const username = (user) ? user.username : t("subscribe_dialog_error_user_anonymous"); const username = (user) ? user.username : t("subscribe_dialog_error_user_anonymous");
const success = await api.auth(baseUrl, topic, user); const success = await api.topicAuth(baseUrl, topic, user);
if (!success) { if (!success) {
console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`); console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`);
if (user) { if (user) {
@ -163,7 +163,7 @@ const LoginPage = (props) => {
const topic = props.topic; const topic = props.topic;
const handleLogin = async () => { const handleLogin = async () => {
const user = {baseUrl, username, password}; const user = {baseUrl, username, password};
const success = await api.auth(baseUrl, topic, user); const success = await api.topicAuth(baseUrl, topic, user);
if (!success) { if (!success) {
console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`); console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`);
setErrorText(t("subscribe_dialog_error_user_not_authorized", { username: username })); setErrorText(t("subscribe_dialog_error_user_not_authorized", { username: username }));

View file

@ -2,7 +2,9 @@ import config from "../app/config";
import {shortUrl} from "../app/utils"; import {shortUrl} from "../app/utils";
const routes = { const routes = {
root: config.appRoot, home: "/",
login: "/login",
app: config.appRoot,
settings: "/settings", settings: "/settings",
subscription: "/:topic", subscription: "/:topic",
subscriptionExternal: "/:baseUrl/:topic", subscriptionExternal: "/:baseUrl/:topic",