Fully support auth in Web UI; persist users in localStorage (for now); add ugly ?auth=... param
This commit is contained in:
parent
6d343c0f1a
commit
530f55c234
16 changed files with 237 additions and 72 deletions
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
9
web/src/app/User.js
Normal file
9
web/src/app/User.js
Normal file
|
@ -0,0 +1,9 @@
|
|||
class User {
|
||||
constructor(baseUrl, username, password) {
|
||||
this.baseUrl = baseUrl;
|
||||
this.username = username;
|
||||
this.password = password;
|
||||
}
|
||||
}
|
||||
|
||||
export default User;
|
36
web/src/app/Users.js
Normal file
36
web/src/app/Users.js
Normal file
|
@ -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;
|
|
@ -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) : '';
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue