diff --git a/server/server.go b/server/server.go
index b54a9d87..88be069f 100644
--- a/server/server.go
+++ b/server/server.go
@@ -862,7 +862,7 @@ func parseSince(r *http.Request, poll bool) (sinceTime, error) {
func (s *Server) handleOptions(w http.ResponseWriter, _ *http.Request) error {
w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, POST")
w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests
- w.Header().Set("Access-Control-Allow-Headers", "Authorization") // CORS, allow auth
+ w.Header().Set("Access-Control-Allow-Headers", "Authorization") // CORS, allow auth via JS
return nil
}
@@ -1091,7 +1091,7 @@ func (s *Server) withAuth(next handleFunc, perm auth.Permission) handleFunc {
return err
}
var user *auth.User // may stay nil if no auth header!
- username, password, ok := r.BasicAuth()
+ username, password, ok := extractUserPass(r)
if ok {
if user, err = s.auth.Authenticate(username, password); err != nil {
log.Printf("authentication failed: %s", err.Error())
@@ -1108,6 +1108,27 @@ func (s *Server) withAuth(next handleFunc, perm auth.Permission) handleFunc {
}
}
+// extractUserPass reads the username/password from the basic auth header (Authorization: Basic ...),
+// or from the ?auth=... query param. The latter is required only to support the WebSocket JavaScript
+// class, which does not support passing headers during the initial request. The auth query param
+// is effectively double base64 encoded. Its format is base64(Basic base64(user:pass)).
+func extractUserPass(r *http.Request) (username string, password string, ok bool) {
+ username, password, ok = r.BasicAuth()
+ if ok {
+ return
+ }
+ authParam := readQueryParam(r, "authorization", "auth")
+ if authParam != "" {
+ a, err := base64.RawURLEncoding.DecodeString(authParam)
+ if err != nil {
+ return
+ }
+ r.Header.Set("Authorization", string(a))
+ return r.BasicAuth()
+ }
+ return
+}
+
// 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).
func (s *Server) visitor(r *http.Request) *visitor {
diff --git a/server/server_test.go b/server/server_test.go
index 614cd5c9..dcc23650 100644
--- a/server/server_test.go
+++ b/server/server_test.go
@@ -657,6 +657,25 @@ func TestServer_Auth_Fail_CannotPublish(t *testing.T) {
require.Equal(t, 403, response.Code) // Anonymous read not allowed
}
+func TestServer_Auth_ViaQuery(t *testing.T) {
+ c := newTestConfig(t)
+ c.AuthFile = filepath.Join(t.TempDir(), "user.db")
+ c.AuthDefaultRead = false
+ c.AuthDefaultWrite = false
+ s := newTestServer(t, c)
+
+ manager := s.auth.(auth.Manager)
+ require.Nil(t, manager.AddUser("ben", "some pass", auth.RoleAdmin))
+
+ u := fmt.Sprintf("/mytopic/json?poll=1&auth=%s", base64.RawURLEncoding.EncodeToString([]byte(basicAuth("ben:some pass"))))
+ response := request(t, s, "GET", u, "", nil)
+ require.Equal(t, 200, response.Code)
+
+ u = fmt.Sprintf("/mytopic/json?poll=1&auth=%s", base64.RawURLEncoding.EncodeToString([]byte(basicAuth("ben:WRONNNGGGG"))))
+ response = request(t, s, "GET", u, "", nil)
+ require.Equal(t, 401, response.Code)
+}
+
/*
func TestServer_Curl_Publish_Poll(t *testing.T) {
s, port := test.StartServer(t)
diff --git a/server/util.go b/server/util.go
index 08832dcf..7c596344 100644
--- a/server/util.go
+++ b/server/util.go
@@ -14,12 +14,24 @@ func readBoolParam(r *http.Request, defaultValue bool, names ...string) bool {
}
func readParam(r *http.Request, names ...string) string {
+ value := readHeaderParam(r, names...)
+ if value != "" {
+ return value
+ }
+ return readQueryParam(r, names...)
+}
+
+func readHeaderParam(r *http.Request, names ...string) string {
for _, name := range names {
value := r.Header.Get(name)
if value != "" {
return strings.TrimSpace(value)
}
}
+ return ""
+}
+
+func readQueryParam(r *http.Request, names ...string) string {
for _, name := range names {
value := r.URL.Query().Get(strings.ToLower(name))
if value != "" {
diff --git a/web/src/app/Api.js b/web/src/app/Api.js
index dc43a296..ae21ad3c 100644
--- a/web/src/app/Api.js
+++ b/web/src/app/Api.js
@@ -1,31 +1,32 @@
-import {topicUrlJsonPoll, fetchLinesIterator, topicUrl, topicUrlAuth} from "./utils";
+import {topicUrlJsonPoll, fetchLinesIterator, topicUrl, topicUrlAuth, maybeWithBasicAuth} from "./utils";
class Api {
- async poll(baseUrl, topic) {
+ async poll(baseUrl, topic, user) {
const url = topicUrlJsonPoll(baseUrl, topic);
const messages = [];
+ const headers = maybeWithBasicAuth({}, user);
console.log(`[Api] Polling ${url}`);
- for await (let line of fetchLinesIterator(url)) {
+ for await (let line of fetchLinesIterator(url, headers)) {
messages.push(JSON.parse(line));
}
return messages;
}
- async publish(baseUrl, topic, message) {
+ async publish(baseUrl, topic, user, message) {
const url = topicUrl(baseUrl, topic);
console.log(`[Api] Publishing message to ${url}`);
await fetch(url, {
method: 'PUT',
- body: message
+ body: message,
+ headers: maybeWithBasicAuth({}, user)
});
}
async auth(baseUrl, topic, user) {
const url = topicUrlAuth(baseUrl, topic);
console.log(`[Api] Checking auth for ${url}`);
- const headers = this.maybeAddAuthorization({}, user);
const response = await fetch(url, {
- headers: headers
+ headers: maybeWithBasicAuth({}, user)
});
if (response.status >= 200 && response.status <= 299) {
return true;
@@ -36,14 +37,6 @@ class Api {
}
throw new Error(`Unexpected server response ${response.status}`);
}
-
- maybeAddAuthorization(headers, user) {
- if (user) {
- const encoded = new Buffer(`${user.username}:${user.password}`).toString('base64');
- headers['Authorization'] = `Basic ${encoded}`;
- }
- return headers;
- }
}
const api = new Api();
diff --git a/web/src/app/Connection.js b/web/src/app/Connection.js
index 914fcf45..fdf5c99f 100644
--- a/web/src/app/Connection.js
+++ b/web/src/app/Connection.js
@@ -1,14 +1,15 @@
-import {shortTopicUrl, topicUrlWs, topicUrlWsWithSince} from "./utils";
+import {basicAuth, encodeBase64Url, topicShortUrl, topicUrlWs} from "./utils";
const retryBackoffSeconds = [5, 10, 15, 20, 30, 45];
class Connection {
- constructor(subscriptionId, baseUrl, topic, since, onNotification) {
+ constructor(subscriptionId, baseUrl, topic, user, since, onNotification) {
this.subscriptionId = subscriptionId;
this.baseUrl = baseUrl;
this.topic = topic;
+ this.user = user;
this.since = since;
- this.shortUrl = shortTopicUrl(baseUrl, topic);
+ this.shortUrl = topicShortUrl(baseUrl, topic);
this.onNotification = onNotification;
this.ws = null;
this.retryCount = 0;
@@ -18,10 +19,10 @@ class Connection {
start() {
// Don't fetch old messages; we do that as a poll() when adding a subscription;
// we don't want to re-trigger the main view re-render potentially hundreds of times.
- const wsUrl = (this.since === 0)
- ? topicUrlWs(this.baseUrl, this.topic)
- : topicUrlWsWithSince(this.baseUrl, this.topic, this.since.toString());
+
+ const wsUrl = this.wsUrl();
console.log(`[Connection, ${this.shortUrl}] Opening connection to ${wsUrl}`);
+
this.ws = new WebSocket(wsUrl);
this.ws.onopen = (event) => {
console.log(`[Connection, ${this.shortUrl}] Connection established`, event);
@@ -75,6 +76,19 @@ class Connection {
this.retryTimeout = null;
this.ws = null;
}
+
+ wsUrl() {
+ const params = [];
+ if (this.since > 0) {
+ params.push(`since=${this.since.toString()}`);
+ }
+ if (this.user !== null) {
+ const auth = encodeBase64Url(basicAuth(this.user.username, this.user.password));
+ params.push(`auth=${auth}`);
+ }
+ const wsUrl = topicUrlWs(this.baseUrl, this.topic);
+ return (params.length === 0) ? wsUrl : `${wsUrl}?${params.join('&')}`;
+ }
}
export default Connection;
diff --git a/web/src/app/ConnectionManager.js b/web/src/app/ConnectionManager.js
index 374bba2b..67b41362 100644
--- a/web/src/app/ConnectionManager.js
+++ b/web/src/app/ConnectionManager.js
@@ -1,11 +1,11 @@
import Connection from "./Connection";
-export class ConnectionManager {
+class ConnectionManager {
constructor() {
this.connections = new Map();
}
- refresh(subscriptions, onNotification) {
+ refresh(subscriptions, users, onNotification) {
console.log(`[ConnectionManager] Refreshing connections`);
const subscriptionIds = subscriptions.ids();
const deletedIds = Array.from(this.connections.keys()).filter(id => !subscriptionIds.includes(id));
@@ -16,8 +16,9 @@ export class ConnectionManager {
if (added) {
const baseUrl = subscription.baseUrl;
const topic = subscription.topic;
+ const user = users.get(baseUrl);
const since = 0;
- const connection = new Connection(id, baseUrl, topic, since, onNotification);
+ const connection = new Connection(id, baseUrl, topic, user, since, onNotification);
this.connections.set(id, connection);
console.log(`[ConnectionManager] Starting new connection ${id}`);
connection.start();
diff --git a/web/src/app/Repository.js b/web/src/app/Repository.js
index 541e651d..72ebb11d 100644
--- a/web/src/app/Repository.js
+++ b/web/src/app/Repository.js
@@ -1,7 +1,9 @@
import Subscription from "./Subscription";
import Subscriptions from "./Subscriptions";
+import Users from "./Users";
+import User from "./User";
-export class Repository {
+class Repository {
loadSubscriptions() {
console.log(`[Repository] Loading subscriptions from localStorage`);
const subscriptions = new Subscriptions();
@@ -10,8 +12,7 @@ export class Repository {
return subscriptions;
}
try {
- const serializedSubscriptions = JSON.parse(serialized);
- serializedSubscriptions.forEach(s => {
+ JSON.parse(serialized).forEach(s => {
const subscription = new Subscription(s.baseUrl, s.topic);
subscription.addNotifications(s.notifications);
subscriptions.add(subscription);
@@ -39,26 +40,32 @@ export class Repository {
loadUsers() {
console.log(`[Repository] Loading users from localStorage`);
+ const users = new Users();
const serialized = localStorage.getItem('users');
if (serialized === null) {
- return {};
+ return users;
}
try {
- return JSON.parse(serialized);
+ JSON.parse(serialized).forEach(u => {
+ users.add(new User(u.baseUrl, u.username, u.password));
+ });
+ return users;
} catch (e) {
console.log(`[Repository] Unable to deserialize users: ${e.message}`);
- return {};
+ return users;
}
}
- saveUser(baseUrl, username, password) {
+ saveUsers(users) {
console.log(`[Repository] Saving users to localStorage`);
- const users = this.loadUsers();
- users[baseUrl] = {
- username: username,
- password: password
- };
- localStorage.setItem('users', users);
+ const serialized = JSON.stringify(users.map(user => {
+ return {
+ baseUrl: user.baseUrl,
+ username: user.username,
+ password: user.password
+ }
+ }));
+ localStorage.setItem('users', serialized);
}
}
diff --git a/web/src/app/Subscription.js b/web/src/app/Subscription.js
index 8b19a18b..56b360d0 100644
--- a/web/src/app/Subscription.js
+++ b/web/src/app/Subscription.js
@@ -1,6 +1,6 @@
-import {shortTopicUrl, topicUrl} from './utils';
+import {topicShortUrl, topicUrl} from './utils';
-export default class Subscription {
+class Subscription {
constructor(baseUrl, topic) {
this.id = topicUrl(baseUrl, topic);
this.baseUrl = baseUrl;
@@ -40,6 +40,8 @@ export default class Subscription {
}
shortUrl() {
- return shortTopicUrl(this.baseUrl, this.topic);
+ return topicShortUrl(this.baseUrl, this.topic);
}
}
+
+export default Subscription;
diff --git a/web/src/app/User.js b/web/src/app/User.js
new file mode 100644
index 00000000..f92a83dc
--- /dev/null
+++ b/web/src/app/User.js
@@ -0,0 +1,9 @@
+class User {
+ constructor(baseUrl, username, password) {
+ this.baseUrl = baseUrl;
+ this.username = username;
+ this.password = password;
+ }
+}
+
+export default User;
diff --git a/web/src/app/Users.js b/web/src/app/Users.js
new file mode 100644
index 00000000..a795a23a
--- /dev/null
+++ b/web/src/app/Users.js
@@ -0,0 +1,36 @@
+class Users {
+ constructor() {
+ this.users = new Map();
+ }
+
+ add(user) {
+ this.users.set(user.baseUrl, user);
+ return this;
+ }
+
+ get(baseUrl) {
+ const user = this.users.get(baseUrl);
+ return (user) ? user : null;
+ }
+
+ update(user) {
+ return this.add(user);
+ }
+
+ remove(baseUrl) {
+ this.users.delete(baseUrl);
+ return this;
+ }
+
+ map(cb) {
+ return Array.from(this.users.values()).map(cb);
+ }
+
+ clone() {
+ const c = new Users();
+ c.users = new Map(this.users);
+ return c;
+ }
+}
+
+export default Users;
diff --git a/web/src/app/utils.js b/web/src/app/utils.js
index 40b9f568..04847f2a 100644
--- a/web/src/app/utils.js
+++ b/web/src/app/utils.js
@@ -1,15 +1,14 @@
-import { rawEmojis} from "./emojis";
+import {rawEmojis} from "./emojis";
export const topicUrl = (baseUrl, topic) => `${baseUrl}/${topic}`;
export const topicUrlWs = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/ws`
.replaceAll("https://", "wss://")
.replaceAll("http://", "ws://");
-export const topicUrlWsWithSince = (baseUrl, topic, since) => `${topicUrlWs(baseUrl, topic)}?since=${since}`;
export const topicUrlJson = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/json`;
export const topicUrlJsonPoll = (baseUrl, topic) => `${topicUrlJson(baseUrl, topic)}?poll=1`;
export const topicUrlAuth = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/auth`;
+export const topicShortUrl = (baseUrl, topic) => shortUrl(topicUrl(baseUrl, topic));
export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, "");
-export const shortTopicUrl = (baseUrl, topic) => shortUrl(topicUrl(baseUrl, topic));
// Format emojis (see emoji.js)
const emojis = {};
@@ -51,10 +50,35 @@ export const unmatchedTags = (tags) => {
else return tags.filter(tag => !(tag in emojis));
}
+
+export const maybeWithBasicAuth = (headers, user) => {
+ if (user) {
+ headers['Authorization'] = `Basic ${encodeBase64(`${user.username}:${user.password}`)}`;
+ }
+ return headers;
+}
+
+export const basicAuth = (username, password) => {
+ return `Basic ${encodeBase64(`${username}:${password}`)}`;
+}
+
+export const encodeBase64 = (s) => {
+ return new Buffer(s).toString('base64');
+}
+
+export const encodeBase64Url = (s) => {
+ return encodeBase64(s)
+ .replaceAll('+', '-')
+ .replaceAll('/', '_')
+ .replaceAll('=', '');
+}
+
// From: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch
-export async function* fetchLinesIterator(fileURL) {
+export async function* fetchLinesIterator(fileURL, headers) {
const utf8Decoder = new TextDecoder('utf-8');
- const response = await fetch(fileURL);
+ const response = await fetch(fileURL, {
+ headers: headers
+ });
const reader = response.body.getReader();
let { value: chunk, done: readerDone } = await reader.read();
chunk = chunk ? utf8Decoder.decode(chunk) : '';
diff --git a/web/src/components/ActionBar.js b/web/src/components/ActionBar.js
index 7ecce8e4..05806478 100644
--- a/web/src/components/ActionBar.js
+++ b/web/src/components/ActionBar.js
@@ -25,6 +25,7 @@ const ActionBar = (props) => {
{title}
{props.selectedSubscription !== null && }
diff --git a/web/src/components/App.js b/web/src/components/App.js
index 51097833..d7aea030 100644
--- a/web/src/components/App.js
+++ b/web/src/components/App.js
@@ -12,12 +12,14 @@ import connectionManager from "../app/ConnectionManager";
import Subscriptions from "../app/Subscriptions";
import Navigation from "./Navigation";
import ActionBar from "./ActionBar";
+import Users from "../app/Users";
const App = () => {
console.log(`[App] Rendering main view`);
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
const [subscriptions, setSubscriptions] = useState(new Subscriptions());
+ const [users, setUsers] = useState(new Users());
const [selectedSubscription, setSelectedSubscription] = useState(null);
const handleNotification = (subscriptionId, notification) => {
setSubscriptions(prev => {
@@ -25,11 +27,14 @@ const App = () => {
return prev.update(newSubscription).clone();
});
};
- const handleSubscribeSubmit = (subscription) => {
+ const handleSubscribeSubmit = (subscription, user) => {
console.log(`[App] New subscription: ${subscription.id}`);
+ if (user !== null) {
+ setUsers(prev => prev.add(user).clone());
+ }
setSubscriptions(prev => prev.add(subscription).clone());
setSelectedSubscription(subscription);
- api.poll(subscription.baseUrl, subscription.topic)
+ api.poll(subscription.baseUrl, subscription.topic, user)
.then(messages => {
setSubscriptions(prev => {
const newSubscription = prev.get(subscription.id).addNotifications(messages);
@@ -61,12 +66,13 @@ const App = () => {
};
useEffect(() => {
setSubscriptions(repository.loadSubscriptions());
+ setUsers(repository.loadUsers());
}, [/* initial render only */]);
useEffect(() => {
- connectionManager.refresh(subscriptions, handleNotification);
+ connectionManager.refresh(subscriptions, users, handleNotification);
repository.saveSubscriptions(subscriptions);
- }, [subscriptions]);
-
+ repository.saveUsers(users);
+ }, [subscriptions, users]);
return (
@@ -74,6 +80,7 @@ const App = () => {
setMobileDrawerOpen(!mobileDrawerOpen)}
diff --git a/web/src/components/IconSubscribeSettings.js b/web/src/components/IconSubscribeSettings.js
index a3f18779..c8a3603a 100644
--- a/web/src/components/IconSubscribeSettings.js
+++ b/web/src/components/IconSubscribeSettings.js
@@ -14,6 +14,7 @@ import api from "../app/Api";
const IconSubscribeSettings = (props) => {
const [open, setOpen] = useState(false);
const anchorRef = useRef(null);
+ const users = props.users;
const handleToggle = () => {
setOpen((prevOpen) => !prevOpen);
@@ -39,7 +40,9 @@ const IconSubscribeSettings = (props) => {
const handleSendTestMessage = () => {
const baseUrl = props.subscription.baseUrl;
const topic = props.subscription.topic;
- api.publish(baseUrl, topic, `This is a test notification sent by the ntfy Web UI at ${new Date().toString()}.`); // FIXME result ignored
+ const user = users.get(baseUrl); // May be null
+ api.publish(baseUrl, topic, user,
+ `This is a test notification sent by the ntfy Web UI at ${new Date().toString()}.`); // FIXME result ignored
setOpen(false);
}
diff --git a/web/src/components/Navigation.js b/web/src/components/Navigation.js
index 5d8b6e7e..2fc29d23 100644
--- a/web/src/components/Navigation.js
+++ b/web/src/components/Navigation.js
@@ -54,10 +54,15 @@ const Navigation = (props) => {
Navigation.width = navWidth;
const NavList = (props) => {
+ const [subscribeDialogKey, setSubscribeDialogKey] = useState(0);
const [subscribeDialogOpen, setSubscribeDialogOpen] = useState(false);
- const handleSubscribeSubmit = (subscription) => {
+ const handleSubscribeReset = () => {
setSubscribeDialogOpen(false);
- props.onSubscribeSubmit(subscription);
+ setSubscribeDialogKey(prev => prev+1);
+ }
+ const handleSubscribeSubmit = (subscription, user) => {
+ handleSubscribeReset();
+ props.onSubscribeSubmit(subscription, user);
}
return (
<>
@@ -85,13 +90,15 @@ const NavList = (props) => {
setSubscribeDialogOpen(false)}
- onSubmit={handleSubscribeSubmit}
+ onCancel={handleSubscribeReset}
+ onSuccess={handleSubscribeSubmit}
/>
>
);
};
+
const NavSubscriptionList = (props) => {
const subscriptions = props.subscriptions;
return (
diff --git a/web/src/components/SubscribeDialog.js b/web/src/components/SubscribeDialog.js
index 20b3b0fd..6e34151c 100644
--- a/web/src/components/SubscribeDialog.js
+++ b/web/src/components/SubscribeDialog.js
@@ -13,6 +13,7 @@ import theme from "./theme";
import api from "../app/Api";
import {topicUrl} from "../app/utils";
import useStyles from "./styles";
+import User from "../app/User";
const defaultBaseUrl = "http://127.0.0.1"
//const defaultBaseUrl = "https://ntfy.sh"
@@ -20,43 +21,50 @@ const defaultBaseUrl = "http://127.0.0.1"
const SubscribeDialog = (props) => {
const [baseUrl, setBaseUrl] = useState(defaultBaseUrl); // FIXME
const [topic, setTopic] = useState("");
- const [user, setUser] = useState(null);
const [showLoginPage, setShowLoginPage] = useState(false);
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
const handleCancel = () => {
setTopic('');
props.onCancel();
}
- const handleSubmit = async () => {
- const success = await api.auth(baseUrl, topic, null);
- if (!success) {
- console.log(`[SubscribeDialog] Login required for ${topicUrl(baseUrl, topic)}`)
- setShowLoginPage(true);
- return;
- }
- const subscription = new Subscription(defaultBaseUrl, topic);
- props.onSubmit(subscription);
+ const handleSuccess = (baseUrl, topic, user) => {
+ const subscription = new Subscription(baseUrl, topic);
+ props.onSuccess(subscription, user);
setTopic('');
}
return (
);
};
const SubscribePage = (props) => {
+ const baseUrl = props.baseUrl;
+ const topic = props.topic;
+ const handleSubscribe = async () => {
+ const success = await api.auth(baseUrl, topic, null);
+ if (!success) {
+ console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for anonymous user`);
+ props.onNeedsLogin();
+ return;
+ }
+ console.log(`[SubscribeDialog] Successful login to ${topicUrl(baseUrl, topic)} for anonymous user`);
+ props.onSuccess(baseUrl, topic, null);
+ };
return (
<>
Subscribe to topic
@@ -79,7 +87,7 @@ const SubscribePage = (props) => {
-
+
>
);
@@ -93,14 +101,15 @@ const LoginPage = (props) => {
const baseUrl = props.baseUrl;
const topic = props.topic;
const handleLogin = async () => {
- const user = {username: username, password: password};
+ const user = new User(baseUrl, username, password);
const success = await api.auth(baseUrl, topic, user);
if (!success) {
console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`);
setErrorText(`User ${username} not authorized`);
return;
}
- console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} successful for user ${username}`);
+ console.log(`[SubscribeDialog] Successful login to ${topicUrl(baseUrl, topic)} for user ${username}`);
+ props.onSuccess(baseUrl, topic, user);
};
return (
<>