Run prettier
This commit is contained in:
parent
206ea312bf
commit
6f6a2d1f69
49 changed files with 22902 additions and 6633 deletions
|
@ -1,429 +1,442 @@
|
|||
import {
|
||||
accountBillingPortalUrl,
|
||||
accountBillingSubscriptionUrl,
|
||||
accountPasswordUrl,
|
||||
accountPhoneUrl,
|
||||
accountPhoneVerifyUrl,
|
||||
accountReservationSingleUrl,
|
||||
accountReservationUrl,
|
||||
accountSettingsUrl,
|
||||
accountSubscriptionUrl,
|
||||
accountTokenUrl,
|
||||
accountUrl,
|
||||
maybeWithBearerAuth,
|
||||
tiersUrl,
|
||||
withBasicAuth,
|
||||
withBearerAuth
|
||||
accountBillingPortalUrl,
|
||||
accountBillingSubscriptionUrl,
|
||||
accountPasswordUrl,
|
||||
accountPhoneUrl,
|
||||
accountPhoneVerifyUrl,
|
||||
accountReservationSingleUrl,
|
||||
accountReservationUrl,
|
||||
accountSettingsUrl,
|
||||
accountSubscriptionUrl,
|
||||
accountTokenUrl,
|
||||
accountUrl,
|
||||
maybeWithBearerAuth,
|
||||
tiersUrl,
|
||||
withBasicAuth,
|
||||
withBearerAuth,
|
||||
} from "./utils";
|
||||
import session from "./Session";
|
||||
import subscriptionManager from "./SubscriptionManager";
|
||||
import i18n from "i18next";
|
||||
import prefs from "./Prefs";
|
||||
import routes from "../components/routes";
|
||||
import {fetchOrThrow, UnauthorizedError} from "./errors";
|
||||
import { fetchOrThrow, UnauthorizedError } from "./errors";
|
||||
|
||||
const delayMillis = 45000; // 45 seconds
|
||||
const intervalMillis = 900000; // 15 minutes
|
||||
|
||||
class AccountApi {
|
||||
constructor() {
|
||||
this.timer = null;
|
||||
this.listener = null; // Fired when account is fetched from remote
|
||||
this.tiers = null; // Cached
|
||||
}
|
||||
constructor() {
|
||||
this.timer = null;
|
||||
this.listener = null; // Fired when account is fetched from remote
|
||||
this.tiers = null; // Cached
|
||||
}
|
||||
|
||||
registerListener(listener) {
|
||||
this.listener = listener;
|
||||
}
|
||||
registerListener(listener) {
|
||||
this.listener = listener;
|
||||
}
|
||||
|
||||
resetListener() {
|
||||
this.listener = null;
|
||||
}
|
||||
resetListener() {
|
||||
this.listener = null;
|
||||
}
|
||||
|
||||
async login(user) {
|
||||
const url = accountTokenUrl(config.base_url);
|
||||
console.log(`[AccountApi] Checking auth for ${url}`);
|
||||
const response = await fetchOrThrow(url, {
|
||||
method: "POST",
|
||||
headers: withBasicAuth({}, user.username, user.password)
|
||||
});
|
||||
const json = await response.json(); // May throw SyntaxError
|
||||
if (!json.token) {
|
||||
throw new Error(`Unexpected server response: Cannot find token`);
|
||||
async login(user) {
|
||||
const url = accountTokenUrl(config.base_url);
|
||||
console.log(`[AccountApi] Checking auth for ${url}`);
|
||||
const response = await fetchOrThrow(url, {
|
||||
method: "POST",
|
||||
headers: withBasicAuth({}, user.username, user.password),
|
||||
});
|
||||
const json = await response.json(); // May throw SyntaxError
|
||||
if (!json.token) {
|
||||
throw new Error(`Unexpected server response: Cannot find token`);
|
||||
}
|
||||
return json.token;
|
||||
}
|
||||
|
||||
async logout() {
|
||||
const url = accountTokenUrl(config.base_url);
|
||||
console.log(
|
||||
`[AccountApi] Logging out from ${url} using token ${session.token()}`
|
||||
);
|
||||
await fetchOrThrow(url, {
|
||||
method: "DELETE",
|
||||
headers: withBearerAuth({}, session.token()),
|
||||
});
|
||||
}
|
||||
|
||||
async create(username, password) {
|
||||
const url = accountUrl(config.base_url);
|
||||
const body = JSON.stringify({
|
||||
username: username,
|
||||
password: password,
|
||||
});
|
||||
console.log(`[AccountApi] Creating user account ${url}`);
|
||||
await fetchOrThrow(url, {
|
||||
method: "POST",
|
||||
body: body,
|
||||
});
|
||||
}
|
||||
|
||||
async get() {
|
||||
const url = accountUrl(config.base_url);
|
||||
console.log(`[AccountApi] Fetching user account ${url}`);
|
||||
const response = await fetchOrThrow(url, {
|
||||
headers: maybeWithBearerAuth({}, session.token()), // GET /v1/account endpoint can be called by anonymous
|
||||
});
|
||||
const account = await response.json(); // May throw SyntaxError
|
||||
console.log(`[AccountApi] Account`, account);
|
||||
if (this.listener) {
|
||||
this.listener(account);
|
||||
}
|
||||
return account;
|
||||
}
|
||||
|
||||
async delete(password) {
|
||||
const url = accountUrl(config.base_url);
|
||||
console.log(`[AccountApi] Deleting user account ${url}`);
|
||||
await fetchOrThrow(url, {
|
||||
method: "DELETE",
|
||||
headers: withBearerAuth({}, session.token()),
|
||||
body: JSON.stringify({
|
||||
password: password,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async changePassword(currentPassword, newPassword) {
|
||||
const url = accountPasswordUrl(config.base_url);
|
||||
console.log(`[AccountApi] Changing account password ${url}`);
|
||||
await fetchOrThrow(url, {
|
||||
method: "POST",
|
||||
headers: withBearerAuth({}, session.token()),
|
||||
body: JSON.stringify({
|
||||
password: currentPassword,
|
||||
new_password: newPassword,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async createToken(label, expires) {
|
||||
const url = accountTokenUrl(config.base_url);
|
||||
const body = {
|
||||
label: label,
|
||||
expires: expires > 0 ? Math.floor(Date.now() / 1000) + expires : 0,
|
||||
};
|
||||
console.log(`[AccountApi] Creating user access token ${url}`);
|
||||
await fetchOrThrow(url, {
|
||||
method: "POST",
|
||||
headers: withBearerAuth({}, session.token()),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async updateToken(token, label, expires) {
|
||||
const url = accountTokenUrl(config.base_url);
|
||||
const body = {
|
||||
token: token,
|
||||
label: label,
|
||||
};
|
||||
if (expires > 0) {
|
||||
body.expires = Math.floor(Date.now() / 1000) + expires;
|
||||
}
|
||||
console.log(`[AccountApi] Creating user access token ${url}`);
|
||||
await fetchOrThrow(url, {
|
||||
method: "PATCH",
|
||||
headers: withBearerAuth({}, session.token()),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async extendToken() {
|
||||
const url = accountTokenUrl(config.base_url);
|
||||
console.log(`[AccountApi] Extending user access token ${url}`);
|
||||
await fetchOrThrow(url, {
|
||||
method: "PATCH",
|
||||
headers: withBearerAuth({}, session.token()),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteToken(token) {
|
||||
const url = accountTokenUrl(config.base_url);
|
||||
console.log(`[AccountApi] Deleting user access token ${url}`);
|
||||
await fetchOrThrow(url, {
|
||||
method: "DELETE",
|
||||
headers: withBearerAuth({ "X-Token": token }, session.token()),
|
||||
});
|
||||
}
|
||||
|
||||
async updateSettings(payload) {
|
||||
const url = accountSettingsUrl(config.base_url);
|
||||
const body = JSON.stringify(payload);
|
||||
console.log(`[AccountApi] Updating user account ${url}: ${body}`);
|
||||
await fetchOrThrow(url, {
|
||||
method: "PATCH",
|
||||
headers: withBearerAuth({}, session.token()),
|
||||
body: body,
|
||||
});
|
||||
}
|
||||
|
||||
async addSubscription(baseUrl, topic) {
|
||||
const url = accountSubscriptionUrl(config.base_url);
|
||||
const body = JSON.stringify({
|
||||
base_url: baseUrl,
|
||||
topic: topic,
|
||||
});
|
||||
console.log(`[AccountApi] Adding user subscription ${url}: ${body}`);
|
||||
const response = await fetchOrThrow(url, {
|
||||
method: "POST",
|
||||
headers: withBearerAuth({}, session.token()),
|
||||
body: body,
|
||||
});
|
||||
const subscription = await response.json(); // May throw SyntaxError
|
||||
console.log(`[AccountApi] Subscription`, subscription);
|
||||
return subscription;
|
||||
}
|
||||
|
||||
async updateSubscription(baseUrl, topic, payload) {
|
||||
const url = accountSubscriptionUrl(config.base_url);
|
||||
const body = JSON.stringify({
|
||||
base_url: baseUrl,
|
||||
topic: topic,
|
||||
...payload,
|
||||
});
|
||||
console.log(`[AccountApi] Updating user subscription ${url}: ${body}`);
|
||||
const response = await fetchOrThrow(url, {
|
||||
method: "PATCH",
|
||||
headers: withBearerAuth({}, session.token()),
|
||||
body: body,
|
||||
});
|
||||
const subscription = await response.json(); // May throw SyntaxError
|
||||
console.log(`[AccountApi] Subscription`, subscription);
|
||||
return subscription;
|
||||
}
|
||||
|
||||
async deleteSubscription(baseUrl, topic) {
|
||||
const url = accountSubscriptionUrl(config.base_url);
|
||||
console.log(`[AccountApi] Removing user subscription ${url}`);
|
||||
const headers = {
|
||||
"X-BaseURL": baseUrl,
|
||||
"X-Topic": topic,
|
||||
};
|
||||
await fetchOrThrow(url, {
|
||||
method: "DELETE",
|
||||
headers: withBearerAuth(headers, session.token()),
|
||||
});
|
||||
}
|
||||
|
||||
async upsertReservation(topic, everyone) {
|
||||
const url = accountReservationUrl(config.base_url);
|
||||
console.log(
|
||||
`[AccountApi] Upserting user access to topic ${topic}, everyone=${everyone}`
|
||||
);
|
||||
await fetchOrThrow(url, {
|
||||
method: "POST",
|
||||
headers: withBearerAuth({}, session.token()),
|
||||
body: JSON.stringify({
|
||||
topic: topic,
|
||||
everyone: everyone,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteReservation(topic, deleteMessages) {
|
||||
const url = accountReservationSingleUrl(config.base_url, topic);
|
||||
console.log(`[AccountApi] Removing topic reservation ${url}`);
|
||||
const headers = {
|
||||
"X-Delete-Messages": deleteMessages ? "true" : "false",
|
||||
};
|
||||
await fetchOrThrow(url, {
|
||||
method: "DELETE",
|
||||
headers: withBearerAuth(headers, session.token()),
|
||||
});
|
||||
}
|
||||
|
||||
async billingTiers() {
|
||||
if (this.tiers) {
|
||||
return this.tiers;
|
||||
}
|
||||
const url = tiersUrl(config.base_url);
|
||||
console.log(`[AccountApi] Fetching billing tiers`);
|
||||
const response = await fetchOrThrow(url); // No auth needed!
|
||||
this.tiers = await response.json(); // May throw SyntaxError
|
||||
return this.tiers;
|
||||
}
|
||||
|
||||
async createBillingSubscription(tier, interval) {
|
||||
console.log(
|
||||
`[AccountApi] Creating billing subscription with ${tier} and interval ${interval}`
|
||||
);
|
||||
return await this.upsertBillingSubscription("POST", tier, interval);
|
||||
}
|
||||
|
||||
async updateBillingSubscription(tier, interval) {
|
||||
console.log(
|
||||
`[AccountApi] Updating billing subscription with ${tier} and interval ${interval}`
|
||||
);
|
||||
return await this.upsertBillingSubscription("PUT", tier, interval);
|
||||
}
|
||||
|
||||
async upsertBillingSubscription(method, tier, interval) {
|
||||
const url = accountBillingSubscriptionUrl(config.base_url);
|
||||
const response = await fetchOrThrow(url, {
|
||||
method: method,
|
||||
headers: withBearerAuth({}, session.token()),
|
||||
body: JSON.stringify({
|
||||
tier: tier,
|
||||
interval: interval,
|
||||
}),
|
||||
});
|
||||
return await response.json(); // May throw SyntaxError
|
||||
}
|
||||
|
||||
async deleteBillingSubscription() {
|
||||
const url = accountBillingSubscriptionUrl(config.base_url);
|
||||
console.log(`[AccountApi] Cancelling billing subscription`);
|
||||
await fetchOrThrow(url, {
|
||||
method: "DELETE",
|
||||
headers: withBearerAuth({}, session.token()),
|
||||
});
|
||||
}
|
||||
|
||||
async createBillingPortalSession() {
|
||||
const url = accountBillingPortalUrl(config.base_url);
|
||||
console.log(`[AccountApi] Creating billing portal session`);
|
||||
const response = await fetchOrThrow(url, {
|
||||
method: "POST",
|
||||
headers: withBearerAuth({}, session.token()),
|
||||
});
|
||||
return await response.json(); // May throw SyntaxError
|
||||
}
|
||||
|
||||
async verifyPhoneNumber(phoneNumber, channel) {
|
||||
const url = accountPhoneVerifyUrl(config.base_url);
|
||||
console.log(`[AccountApi] Sending phone verification ${url}`);
|
||||
await fetchOrThrow(url, {
|
||||
method: "PUT",
|
||||
headers: withBearerAuth({}, session.token()),
|
||||
body: JSON.stringify({
|
||||
number: phoneNumber,
|
||||
channel: channel,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async addPhoneNumber(phoneNumber, code) {
|
||||
const url = accountPhoneUrl(config.base_url);
|
||||
console.log(
|
||||
`[AccountApi] Adding phone number with verification code ${url}`
|
||||
);
|
||||
await fetchOrThrow(url, {
|
||||
method: "PUT",
|
||||
headers: withBearerAuth({}, session.token()),
|
||||
body: JSON.stringify({
|
||||
number: phoneNumber,
|
||||
code: code,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async deletePhoneNumber(phoneNumber, code) {
|
||||
const url = accountPhoneUrl(config.base_url);
|
||||
console.log(`[AccountApi] Deleting phone number ${url}`);
|
||||
await fetchOrThrow(url, {
|
||||
method: "DELETE",
|
||||
headers: withBearerAuth({}, session.token()),
|
||||
body: JSON.stringify({
|
||||
number: phoneNumber,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async sync() {
|
||||
try {
|
||||
if (!session.token()) {
|
||||
return null;
|
||||
}
|
||||
console.log(`[AccountApi] Syncing account`);
|
||||
const account = await this.get();
|
||||
if (account.language) {
|
||||
await i18n.changeLanguage(account.language);
|
||||
}
|
||||
if (account.notification) {
|
||||
if (account.notification.sound) {
|
||||
await prefs.setSound(account.notification.sound);
|
||||
}
|
||||
return json.token;
|
||||
}
|
||||
|
||||
async logout() {
|
||||
const url = accountTokenUrl(config.base_url);
|
||||
console.log(`[AccountApi] Logging out from ${url} using token ${session.token()}`);
|
||||
await fetchOrThrow(url, {
|
||||
method: "DELETE",
|
||||
headers: withBearerAuth({}, session.token())
|
||||
});
|
||||
}
|
||||
|
||||
async create(username, password) {
|
||||
const url = accountUrl(config.base_url);
|
||||
const body = JSON.stringify({
|
||||
username: username,
|
||||
password: password
|
||||
});
|
||||
console.log(`[AccountApi] Creating user account ${url}`);
|
||||
await fetchOrThrow(url, {
|
||||
method: "POST",
|
||||
body: body
|
||||
});
|
||||
}
|
||||
|
||||
async get() {
|
||||
const url = accountUrl(config.base_url);
|
||||
console.log(`[AccountApi] Fetching user account ${url}`);
|
||||
const response = await fetchOrThrow(url, {
|
||||
headers: maybeWithBearerAuth({}, session.token()) // GET /v1/account endpoint can be called by anonymous
|
||||
});
|
||||
const account = await response.json(); // May throw SyntaxError
|
||||
console.log(`[AccountApi] Account`, account);
|
||||
if (this.listener) {
|
||||
this.listener(account);
|
||||
if (account.notification.delete_after) {
|
||||
await prefs.setDeleteAfter(account.notification.delete_after);
|
||||
}
|
||||
return account;
|
||||
}
|
||||
|
||||
async delete(password) {
|
||||
const url = accountUrl(config.base_url);
|
||||
console.log(`[AccountApi] Deleting user account ${url}`);
|
||||
await fetchOrThrow(url, {
|
||||
method: "DELETE",
|
||||
headers: withBearerAuth({}, session.token()),
|
||||
body: JSON.stringify({
|
||||
password: password
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
async changePassword(currentPassword, newPassword) {
|
||||
const url = accountPasswordUrl(config.base_url);
|
||||
console.log(`[AccountApi] Changing account password ${url}`);
|
||||
await fetchOrThrow(url, {
|
||||
method: "POST",
|
||||
headers: withBearerAuth({}, session.token()),
|
||||
body: JSON.stringify({
|
||||
password: currentPassword,
|
||||
new_password: newPassword
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
async createToken(label, expires) {
|
||||
const url = accountTokenUrl(config.base_url);
|
||||
const body = {
|
||||
label: label,
|
||||
expires: (expires > 0) ? Math.floor(Date.now() / 1000) + expires : 0
|
||||
};
|
||||
console.log(`[AccountApi] Creating user access token ${url}`);
|
||||
await fetchOrThrow(url, {
|
||||
method: "POST",
|
||||
headers: withBearerAuth({}, session.token()),
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
}
|
||||
|
||||
async updateToken(token, label, expires) {
|
||||
const url = accountTokenUrl(config.base_url);
|
||||
const body = {
|
||||
token: token,
|
||||
label: label
|
||||
};
|
||||
if (expires > 0) {
|
||||
body.expires = Math.floor(Date.now() / 1000) + expires;
|
||||
if (account.notification.min_priority) {
|
||||
await prefs.setMinPriority(account.notification.min_priority);
|
||||
}
|
||||
console.log(`[AccountApi] Creating user access token ${url}`);
|
||||
await fetchOrThrow(url, {
|
||||
method: "PATCH",
|
||||
headers: withBearerAuth({}, session.token()),
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
}
|
||||
if (account.subscriptions) {
|
||||
await subscriptionManager.syncFromRemote(
|
||||
account.subscriptions,
|
||||
account.reservations
|
||||
);
|
||||
}
|
||||
return account;
|
||||
} catch (e) {
|
||||
console.log(`[AccountApi] Error fetching account`, e);
|
||||
if (e instanceof UnauthorizedError) {
|
||||
session.resetAndRedirect(routes.login);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async extendToken() {
|
||||
const url = accountTokenUrl(config.base_url);
|
||||
console.log(`[AccountApi] Extending user access token ${url}`);
|
||||
await fetchOrThrow(url, {
|
||||
method: "PATCH",
|
||||
headers: withBearerAuth({}, session.token())
|
||||
});
|
||||
startWorker() {
|
||||
if (this.timer !== null) {
|
||||
return;
|
||||
}
|
||||
console.log(`[AccountApi] Starting worker`);
|
||||
this.timer = setInterval(() => this.runWorker(), intervalMillis);
|
||||
setTimeout(() => this.runWorker(), delayMillis);
|
||||
}
|
||||
|
||||
async deleteToken(token) {
|
||||
const url = accountTokenUrl(config.base_url);
|
||||
console.log(`[AccountApi] Deleting user access token ${url}`);
|
||||
await fetchOrThrow(url, {
|
||||
method: "DELETE",
|
||||
headers: withBearerAuth({"X-Token": token}, session.token())
|
||||
});
|
||||
async runWorker() {
|
||||
if (!session.token()) {
|
||||
return;
|
||||
}
|
||||
|
||||
async updateSettings(payload) {
|
||||
const url = accountSettingsUrl(config.base_url);
|
||||
const body = JSON.stringify(payload);
|
||||
console.log(`[AccountApi] Updating user account ${url}: ${body}`);
|
||||
await fetchOrThrow(url, {
|
||||
method: "PATCH",
|
||||
headers: withBearerAuth({}, session.token()),
|
||||
body: body
|
||||
});
|
||||
}
|
||||
|
||||
async addSubscription(baseUrl, topic) {
|
||||
const url = accountSubscriptionUrl(config.base_url);
|
||||
const body = JSON.stringify({
|
||||
base_url: baseUrl,
|
||||
topic: topic
|
||||
});
|
||||
console.log(`[AccountApi] Adding user subscription ${url}: ${body}`);
|
||||
const response = await fetchOrThrow(url, {
|
||||
method: "POST",
|
||||
headers: withBearerAuth({}, session.token()),
|
||||
body: body
|
||||
});
|
||||
const subscription = await response.json(); // May throw SyntaxError
|
||||
console.log(`[AccountApi] Subscription`, subscription);
|
||||
return subscription;
|
||||
}
|
||||
|
||||
async updateSubscription(baseUrl, topic, payload) {
|
||||
const url = accountSubscriptionUrl(config.base_url);
|
||||
const body = JSON.stringify({
|
||||
base_url: baseUrl,
|
||||
topic: topic,
|
||||
...payload
|
||||
});
|
||||
console.log(`[AccountApi] Updating user subscription ${url}: ${body}`);
|
||||
const response = await fetchOrThrow(url, {
|
||||
method: "PATCH",
|
||||
headers: withBearerAuth({}, session.token()),
|
||||
body: body
|
||||
});
|
||||
const subscription = await response.json(); // May throw SyntaxError
|
||||
console.log(`[AccountApi] Subscription`, subscription);
|
||||
return subscription;
|
||||
}
|
||||
|
||||
async deleteSubscription(baseUrl, topic) {
|
||||
const url = accountSubscriptionUrl(config.base_url);
|
||||
console.log(`[AccountApi] Removing user subscription ${url}`);
|
||||
const headers = {
|
||||
"X-BaseURL": baseUrl,
|
||||
"X-Topic": topic,
|
||||
}
|
||||
await fetchOrThrow(url, {
|
||||
method: "DELETE",
|
||||
headers: withBearerAuth(headers, session.token()),
|
||||
});
|
||||
}
|
||||
|
||||
async upsertReservation(topic, everyone) {
|
||||
const url = accountReservationUrl(config.base_url);
|
||||
console.log(`[AccountApi] Upserting user access to topic ${topic}, everyone=${everyone}`);
|
||||
await fetchOrThrow(url, {
|
||||
method: "POST",
|
||||
headers: withBearerAuth({}, session.token()),
|
||||
body: JSON.stringify({
|
||||
topic: topic,
|
||||
everyone: everyone
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
async deleteReservation(topic, deleteMessages) {
|
||||
const url = accountReservationSingleUrl(config.base_url, topic);
|
||||
console.log(`[AccountApi] Removing topic reservation ${url}`);
|
||||
const headers = {
|
||||
"X-Delete-Messages": deleteMessages ? "true" : "false"
|
||||
}
|
||||
await fetchOrThrow(url, {
|
||||
method: "DELETE",
|
||||
headers: withBearerAuth(headers, session.token())
|
||||
});
|
||||
}
|
||||
|
||||
async billingTiers() {
|
||||
if (this.tiers) {
|
||||
return this.tiers;
|
||||
}
|
||||
const url = tiersUrl(config.base_url);
|
||||
console.log(`[AccountApi] Fetching billing tiers`);
|
||||
const response = await fetchOrThrow(url); // No auth needed!
|
||||
this.tiers = await response.json(); // May throw SyntaxError
|
||||
return this.tiers;
|
||||
}
|
||||
|
||||
async createBillingSubscription(tier, interval) {
|
||||
console.log(`[AccountApi] Creating billing subscription with ${tier} and interval ${interval}`);
|
||||
return await this.upsertBillingSubscription("POST", tier, interval)
|
||||
}
|
||||
|
||||
async updateBillingSubscription(tier, interval) {
|
||||
console.log(`[AccountApi] Updating billing subscription with ${tier} and interval ${interval}`);
|
||||
return await this.upsertBillingSubscription("PUT", tier, interval)
|
||||
}
|
||||
|
||||
async upsertBillingSubscription(method, tier, interval) {
|
||||
const url = accountBillingSubscriptionUrl(config.base_url);
|
||||
const response = await fetchOrThrow(url, {
|
||||
method: method,
|
||||
headers: withBearerAuth({}, session.token()),
|
||||
body: JSON.stringify({
|
||||
tier: tier,
|
||||
interval: interval
|
||||
})
|
||||
});
|
||||
return await response.json(); // May throw SyntaxError
|
||||
}
|
||||
|
||||
async deleteBillingSubscription() {
|
||||
const url = accountBillingSubscriptionUrl(config.base_url);
|
||||
console.log(`[AccountApi] Cancelling billing subscription`);
|
||||
await fetchOrThrow(url, {
|
||||
method: "DELETE",
|
||||
headers: withBearerAuth({}, session.token())
|
||||
});
|
||||
}
|
||||
|
||||
async createBillingPortalSession() {
|
||||
const url = accountBillingPortalUrl(config.base_url);
|
||||
console.log(`[AccountApi] Creating billing portal session`);
|
||||
const response = await fetchOrThrow(url, {
|
||||
method: "POST",
|
||||
headers: withBearerAuth({}, session.token())
|
||||
});
|
||||
return await response.json(); // May throw SyntaxError
|
||||
}
|
||||
|
||||
async verifyPhoneNumber(phoneNumber, channel) {
|
||||
const url = accountPhoneVerifyUrl(config.base_url);
|
||||
console.log(`[AccountApi] Sending phone verification ${url}`);
|
||||
await fetchOrThrow(url, {
|
||||
method: "PUT",
|
||||
headers: withBearerAuth({}, session.token()),
|
||||
body: JSON.stringify({
|
||||
number: phoneNumber,
|
||||
channel: channel
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
async addPhoneNumber(phoneNumber, code) {
|
||||
const url = accountPhoneUrl(config.base_url);
|
||||
console.log(`[AccountApi] Adding phone number with verification code ${url}`);
|
||||
await fetchOrThrow(url, {
|
||||
method: "PUT",
|
||||
headers: withBearerAuth({}, session.token()),
|
||||
body: JSON.stringify({
|
||||
number: phoneNumber,
|
||||
code: code
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
async deletePhoneNumber(phoneNumber, code) {
|
||||
const url = accountPhoneUrl(config.base_url);
|
||||
console.log(`[AccountApi] Deleting phone number ${url}`);
|
||||
await fetchOrThrow(url, {
|
||||
method: "DELETE",
|
||||
headers: withBearerAuth({}, session.token()),
|
||||
body: JSON.stringify({
|
||||
number: phoneNumber
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
async sync() {
|
||||
try {
|
||||
if (!session.token()) {
|
||||
return null;
|
||||
}
|
||||
console.log(`[AccountApi] Syncing account`);
|
||||
const account = await this.get();
|
||||
if (account.language) {
|
||||
await i18n.changeLanguage(account.language);
|
||||
}
|
||||
if (account.notification) {
|
||||
if (account.notification.sound) {
|
||||
await prefs.setSound(account.notification.sound);
|
||||
}
|
||||
if (account.notification.delete_after) {
|
||||
await prefs.setDeleteAfter(account.notification.delete_after);
|
||||
}
|
||||
if (account.notification.min_priority) {
|
||||
await prefs.setMinPriority(account.notification.min_priority);
|
||||
}
|
||||
}
|
||||
if (account.subscriptions) {
|
||||
await subscriptionManager.syncFromRemote(account.subscriptions, account.reservations);
|
||||
}
|
||||
return account;
|
||||
} catch (e) {
|
||||
console.log(`[AccountApi] Error fetching account`, e);
|
||||
if (e instanceof UnauthorizedError) {
|
||||
session.resetAndRedirect(routes.login);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
startWorker() {
|
||||
if (this.timer !== null) {
|
||||
return;
|
||||
}
|
||||
console.log(`[AccountApi] Starting worker`);
|
||||
this.timer = setInterval(() => this.runWorker(), intervalMillis);
|
||||
setTimeout(() => this.runWorker(), delayMillis);
|
||||
}
|
||||
|
||||
async runWorker() {
|
||||
if (!session.token()) {
|
||||
return;
|
||||
}
|
||||
console.log(`[AccountApi] Extending user access token`);
|
||||
try {
|
||||
await this.extendToken();
|
||||
} catch (e) {
|
||||
console.log(`[AccountApi] Error extending user access token`, e);
|
||||
}
|
||||
console.log(`[AccountApi] Extending user access token`);
|
||||
try {
|
||||
await this.extendToken();
|
||||
} catch (e) {
|
||||
console.log(`[AccountApi] Error extending user access token`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Maps to user.Role in user/types.go
|
||||
export const Role = {
|
||||
ADMIN: "admin",
|
||||
USER: "user"
|
||||
ADMIN: "admin",
|
||||
USER: "user",
|
||||
};
|
||||
|
||||
// Maps to server.visitorLimitBasis in server/visitor.go
|
||||
export const LimitBasis = {
|
||||
IP: "ip",
|
||||
TIER: "tier"
|
||||
IP: "ip",
|
||||
TIER: "tier",
|
||||
};
|
||||
|
||||
// Maps to stripe.SubscriptionStatus
|
||||
export const SubscriptionStatus = {
|
||||
ACTIVE: "active",
|
||||
PAST_DUE: "past_due"
|
||||
ACTIVE: "active",
|
||||
PAST_DUE: "past_due",
|
||||
};
|
||||
|
||||
// Maps to stripe.PriceRecurringInterval
|
||||
export const SubscriptionInterval = {
|
||||
MONTH: "month",
|
||||
YEAR: "year"
|
||||
MONTH: "month",
|
||||
YEAR: "year",
|
||||
};
|
||||
|
||||
// Maps to user.Permission in user/types.go
|
||||
export const Permission = {
|
||||
READ_WRITE: "read-write",
|
||||
READ_ONLY: "read-only",
|
||||
WRITE_ONLY: "write-only",
|
||||
DENY_ALL: "deny-all"
|
||||
READ_WRITE: "read-write",
|
||||
READ_ONLY: "read-only",
|
||||
WRITE_ONLY: "write-only",
|
||||
DENY_ALL: "deny-all",
|
||||
};
|
||||
|
||||
const accountApi = new AccountApi();
|
||||
|
|
|
@ -1,118 +1,125 @@
|
|||
import {
|
||||
fetchLinesIterator,
|
||||
maybeWithAuth,
|
||||
topicShortUrl,
|
||||
topicUrl,
|
||||
topicUrlAuth,
|
||||
topicUrlJsonPoll,
|
||||
topicUrlJsonPollWithSince
|
||||
fetchLinesIterator,
|
||||
maybeWithAuth,
|
||||
topicShortUrl,
|
||||
topicUrl,
|
||||
topicUrlAuth,
|
||||
topicUrlJsonPoll,
|
||||
topicUrlJsonPollWithSince,
|
||||
} from "./utils";
|
||||
import userManager from "./UserManager";
|
||||
import {fetchOrThrow} from "./errors";
|
||||
import { fetchOrThrow } from "./errors";
|
||||
|
||||
class Api {
|
||||
async poll(baseUrl, topic, since) {
|
||||
const user = await userManager.get(baseUrl);
|
||||
const shortUrl = topicShortUrl(baseUrl, topic);
|
||||
const url = (since)
|
||||
? topicUrlJsonPollWithSince(baseUrl, topic, since)
|
||||
: topicUrlJsonPoll(baseUrl, topic);
|
||||
const messages = [];
|
||||
const headers = maybeWithAuth({}, user);
|
||||
console.log(`[Api] Polling ${url}`);
|
||||
for await (let line of fetchLinesIterator(url, headers)) {
|
||||
const message = JSON.parse(line);
|
||||
if (message.id) {
|
||||
console.log(`[Api, ${shortUrl}] Received message ${line}`);
|
||||
messages.push(message);
|
||||
}
|
||||
}
|
||||
return messages;
|
||||
async poll(baseUrl, topic, since) {
|
||||
const user = await userManager.get(baseUrl);
|
||||
const shortUrl = topicShortUrl(baseUrl, topic);
|
||||
const url = since
|
||||
? topicUrlJsonPollWithSince(baseUrl, topic, since)
|
||||
: topicUrlJsonPoll(baseUrl, topic);
|
||||
const messages = [];
|
||||
const headers = maybeWithAuth({}, user);
|
||||
console.log(`[Api] Polling ${url}`);
|
||||
for await (let line of fetchLinesIterator(url, headers)) {
|
||||
const message = JSON.parse(line);
|
||||
if (message.id) {
|
||||
console.log(`[Api, ${shortUrl}] Received message ${line}`);
|
||||
messages.push(message);
|
||||
}
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
async publish(baseUrl, topic, message, options) {
|
||||
const user = await userManager.get(baseUrl);
|
||||
console.log(`[Api] Publishing message to ${topicUrl(baseUrl, topic)}`);
|
||||
const headers = {};
|
||||
const body = {
|
||||
topic: topic,
|
||||
message: message,
|
||||
...options
|
||||
};
|
||||
await fetchOrThrow(baseUrl, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
headers: maybeWithAuth(headers, user)
|
||||
});
|
||||
}
|
||||
async publish(baseUrl, topic, message, options) {
|
||||
const user = await userManager.get(baseUrl);
|
||||
console.log(`[Api] Publishing message to ${topicUrl(baseUrl, topic)}`);
|
||||
const headers = {};
|
||||
const body = {
|
||||
topic: topic,
|
||||
message: message,
|
||||
...options,
|
||||
};
|
||||
await fetchOrThrow(baseUrl, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
headers: maybeWithAuth(headers, user),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes to a topic using XMLHttpRequest (XHR), and returns a Promise with the active request.
|
||||
* Unfortunately, fetch() does not support a progress hook, which is why XHR has to be used.
|
||||
*
|
||||
* Firefox XHR bug:
|
||||
* Firefox has a bug(?), which returns 0 and "" for all fields of the XHR response in the case of an error,
|
||||
* so we cannot determine the exact error. It also sometimes complains about CORS violations, even when the
|
||||
* correct headers are clearly set. It's quite the odd behavior.
|
||||
*
|
||||
* There is an example, and the bug report here:
|
||||
* - https://bugzilla.mozilla.org/show_bug.cgi?id=1733755
|
||||
* - https://gist.github.com/binwiederhier/627f146d1959799be207ad8c17a8f345
|
||||
*/
|
||||
publishXHR(url, body, headers, onProgress) {
|
||||
console.log(`[Api] Publishing message to ${url}`);
|
||||
const xhr = new XMLHttpRequest();
|
||||
const send = new Promise(function (resolve, reject) {
|
||||
xhr.open("PUT", url);
|
||||
if (body.type) {
|
||||
xhr.overrideMimeType(body.type);
|
||||
/**
|
||||
* Publishes to a topic using XMLHttpRequest (XHR), and returns a Promise with the active request.
|
||||
* Unfortunately, fetch() does not support a progress hook, which is why XHR has to be used.
|
||||
*
|
||||
* Firefox XHR bug:
|
||||
* Firefox has a bug(?), which returns 0 and "" for all fields of the XHR response in the case of an error,
|
||||
* so we cannot determine the exact error. It also sometimes complains about CORS violations, even when the
|
||||
* correct headers are clearly set. It's quite the odd behavior.
|
||||
*
|
||||
* There is an example, and the bug report here:
|
||||
* - https://bugzilla.mozilla.org/show_bug.cgi?id=1733755
|
||||
* - https://gist.github.com/binwiederhier/627f146d1959799be207ad8c17a8f345
|
||||
*/
|
||||
publishXHR(url, body, headers, onProgress) {
|
||||
console.log(`[Api] Publishing message to ${url}`);
|
||||
const xhr = new XMLHttpRequest();
|
||||
const send = new Promise(function (resolve, reject) {
|
||||
xhr.open("PUT", url);
|
||||
if (body.type) {
|
||||
xhr.overrideMimeType(body.type);
|
||||
}
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
xhr.setRequestHeader(key, value);
|
||||
}
|
||||
xhr.upload.addEventListener("progress", onProgress);
|
||||
xhr.addEventListener("readystatechange", () => {
|
||||
if (xhr.readyState === 4 && xhr.status >= 200 && xhr.status <= 299) {
|
||||
console.log(
|
||||
`[Api] Publish successful (HTTP ${xhr.status})`,
|
||||
xhr.response
|
||||
);
|
||||
resolve(xhr.response);
|
||||
} else if (xhr.readyState === 4) {
|
||||
// Firefox bug; see description above!
|
||||
console.log(
|
||||
`[Api] Publish failed (HTTP ${xhr.status})`,
|
||||
xhr.responseText
|
||||
);
|
||||
let errorText;
|
||||
try {
|
||||
const error = JSON.parse(xhr.responseText);
|
||||
if (error.code && error.error) {
|
||||
errorText = `Error ${error.code}: ${error.error}`;
|
||||
}
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
xhr.setRequestHeader(key, value);
|
||||
}
|
||||
xhr.upload.addEventListener("progress", onProgress);
|
||||
xhr.addEventListener('readystatechange', () => {
|
||||
if (xhr.readyState === 4 && xhr.status >= 200 && xhr.status <= 299) {
|
||||
console.log(`[Api] Publish successful (HTTP ${xhr.status})`, xhr.response);
|
||||
resolve(xhr.response);
|
||||
} else if (xhr.readyState === 4) {
|
||||
// Firefox bug; see description above!
|
||||
console.log(`[Api] Publish failed (HTTP ${xhr.status})`, xhr.responseText);
|
||||
let errorText;
|
||||
try {
|
||||
const error = JSON.parse(xhr.responseText);
|
||||
if (error.code && error.error) {
|
||||
errorText = `Error ${error.code}: ${error.error}`;
|
||||
}
|
||||
} catch (e) {
|
||||
// Nothing
|
||||
}
|
||||
xhr.abort();
|
||||
reject(errorText ?? "An error occurred");
|
||||
}
|
||||
})
|
||||
xhr.send(body);
|
||||
});
|
||||
send.abort = () => {
|
||||
console.log(`[Api] Publish aborted by user`);
|
||||
xhr.abort();
|
||||
} catch (e) {
|
||||
// Nothing
|
||||
}
|
||||
xhr.abort();
|
||||
reject(errorText ?? "An error occurred");
|
||||
}
|
||||
return send;
|
||||
}
|
||||
});
|
||||
xhr.send(body);
|
||||
});
|
||||
send.abort = () => {
|
||||
console.log(`[Api] Publish aborted by user`);
|
||||
xhr.abort();
|
||||
};
|
||||
return send;
|
||||
}
|
||||
|
||||
async topicAuth(baseUrl, topic, user) {
|
||||
const url = topicUrlAuth(baseUrl, topic);
|
||||
console.log(`[Api] Checking auth for ${url}`);
|
||||
const response = await fetch(url, {
|
||||
headers: maybeWithAuth({}, user)
|
||||
});
|
||||
if (response.status >= 200 && response.status <= 299) {
|
||||
return true;
|
||||
} else if (response.status === 401 || response.status === 403) { // See server/server.go
|
||||
return false;
|
||||
}
|
||||
throw new Error(`Unexpected server response ${response.status}`);
|
||||
async topicAuth(baseUrl, topic, user) {
|
||||
const url = topicUrlAuth(baseUrl, topic);
|
||||
console.log(`[Api] Checking auth for ${url}`);
|
||||
const response = await fetch(url, {
|
||||
headers: maybeWithAuth({}, user),
|
||||
});
|
||||
if (response.status >= 200 && response.status <= 299) {
|
||||
return true;
|
||||
} else if (response.status === 401 || response.status === 403) {
|
||||
// See server/server.go
|
||||
return false;
|
||||
}
|
||||
throw new Error(`Unexpected server response ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
const api = new Api();
|
||||
|
|
|
@ -1,4 +1,10 @@
|
|||
import {basicAuth, bearerAuth, encodeBase64Url, topicShortUrl, topicUrlWs} from "./utils";
|
||||
import {
|
||||
basicAuth,
|
||||
bearerAuth,
|
||||
encodeBase64Url,
|
||||
topicShortUrl,
|
||||
topicUrlWs,
|
||||
} from "./utils";
|
||||
|
||||
const retryBackoffSeconds = [5, 10, 20, 30, 60, 120];
|
||||
|
||||
|
@ -9,110 +15,142 @@ const retryBackoffSeconds = [5, 10, 20, 30, 60, 120];
|
|||
* Incoming messages and state changes are forwarded via listeners.
|
||||
*/
|
||||
class Connection {
|
||||
constructor(connectionId, subscriptionId, baseUrl, topic, user, since, onNotification, onStateChanged) {
|
||||
this.connectionId = connectionId;
|
||||
this.subscriptionId = subscriptionId;
|
||||
this.baseUrl = baseUrl;
|
||||
this.topic = topic;
|
||||
this.user = user;
|
||||
this.since = since;
|
||||
this.shortUrl = topicShortUrl(baseUrl, topic);
|
||||
this.onNotification = onNotification;
|
||||
this.onStateChanged = onStateChanged;
|
||||
constructor(
|
||||
connectionId,
|
||||
subscriptionId,
|
||||
baseUrl,
|
||||
topic,
|
||||
user,
|
||||
since,
|
||||
onNotification,
|
||||
onStateChanged
|
||||
) {
|
||||
this.connectionId = connectionId;
|
||||
this.subscriptionId = subscriptionId;
|
||||
this.baseUrl = baseUrl;
|
||||
this.topic = topic;
|
||||
this.user = user;
|
||||
this.since = since;
|
||||
this.shortUrl = topicShortUrl(baseUrl, topic);
|
||||
this.onNotification = onNotification;
|
||||
this.onStateChanged = onStateChanged;
|
||||
this.ws = null;
|
||||
this.retryCount = 0;
|
||||
this.retryTimeout = null;
|
||||
}
|
||||
|
||||
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.wsUrl();
|
||||
console.log(
|
||||
`[Connection, ${this.shortUrl}, ${this.connectionId}] Opening connection to ${wsUrl}`
|
||||
);
|
||||
|
||||
this.ws = new WebSocket(wsUrl);
|
||||
this.ws.onopen = (event) => {
|
||||
console.log(
|
||||
`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection established`,
|
||||
event
|
||||
);
|
||||
this.retryCount = 0;
|
||||
this.onStateChanged(this.subscriptionId, ConnectionState.Connected);
|
||||
};
|
||||
this.ws.onmessage = (event) => {
|
||||
console.log(
|
||||
`[Connection, ${this.shortUrl}, ${this.connectionId}] Message received from server: ${event.data}`
|
||||
);
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.event === "open") {
|
||||
return;
|
||||
}
|
||||
const relevantAndValid =
|
||||
data.event === "message" &&
|
||||
"id" in data &&
|
||||
"time" in data &&
|
||||
"message" in data;
|
||||
if (!relevantAndValid) {
|
||||
console.log(
|
||||
`[Connection, ${this.shortUrl}, ${this.connectionId}] Unexpected message. Ignoring.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.since = data.id;
|
||||
this.onNotification(this.subscriptionId, data);
|
||||
} catch (e) {
|
||||
console.log(
|
||||
`[Connection, ${this.shortUrl}, ${this.connectionId}] Error handling message: ${e}`
|
||||
);
|
||||
}
|
||||
};
|
||||
this.ws.onclose = (event) => {
|
||||
if (event.wasClean) {
|
||||
console.log(
|
||||
`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection closed cleanly, code=${event.code} reason=${event.reason}`
|
||||
);
|
||||
this.ws = null;
|
||||
this.retryCount = 0;
|
||||
this.retryTimeout = null;
|
||||
} else {
|
||||
const retrySeconds =
|
||||
retryBackoffSeconds[
|
||||
Math.min(this.retryCount, retryBackoffSeconds.length - 1)
|
||||
];
|
||||
this.retryCount++;
|
||||
console.log(
|
||||
`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection died, retrying in ${retrySeconds} seconds`
|
||||
);
|
||||
this.retryTimeout = setTimeout(() => this.start(), retrySeconds * 1000);
|
||||
this.onStateChanged(this.subscriptionId, ConnectionState.Connecting);
|
||||
}
|
||||
};
|
||||
this.ws.onerror = (event) => {
|
||||
console.log(
|
||||
`[Connection, ${this.shortUrl}, ${this.connectionId}] Error occurred: ${event}`,
|
||||
event
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
close() {
|
||||
console.log(
|
||||
`[Connection, ${this.shortUrl}, ${this.connectionId}] Closing connection`
|
||||
);
|
||||
const socket = this.ws;
|
||||
const retryTimeout = this.retryTimeout;
|
||||
if (socket !== null) {
|
||||
socket.close();
|
||||
}
|
||||
|
||||
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.wsUrl();
|
||||
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Opening connection to ${wsUrl}`);
|
||||
|
||||
this.ws = new WebSocket(wsUrl);
|
||||
this.ws.onopen = (event) => {
|
||||
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection established`, event);
|
||||
this.retryCount = 0;
|
||||
this.onStateChanged(this.subscriptionId, ConnectionState.Connected);
|
||||
}
|
||||
this.ws.onmessage = (event) => {
|
||||
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Message received from server: ${event.data}`);
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.event === 'open') {
|
||||
return;
|
||||
}
|
||||
const relevantAndValid =
|
||||
data.event === 'message' &&
|
||||
'id' in data &&
|
||||
'time' in data &&
|
||||
'message' in data;
|
||||
if (!relevantAndValid) {
|
||||
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Unexpected message. Ignoring.`);
|
||||
return;
|
||||
}
|
||||
this.since = data.id;
|
||||
this.onNotification(this.subscriptionId, data);
|
||||
} catch (e) {
|
||||
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Error handling message: ${e}`);
|
||||
}
|
||||
};
|
||||
this.ws.onclose = (event) => {
|
||||
if (event.wasClean) {
|
||||
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection closed cleanly, code=${event.code} reason=${event.reason}`);
|
||||
this.ws = null;
|
||||
} else {
|
||||
const retrySeconds = retryBackoffSeconds[Math.min(this.retryCount, retryBackoffSeconds.length-1)];
|
||||
this.retryCount++;
|
||||
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection died, retrying in ${retrySeconds} seconds`);
|
||||
this.retryTimeout = setTimeout(() => this.start(), retrySeconds * 1000);
|
||||
this.onStateChanged(this.subscriptionId, ConnectionState.Connecting);
|
||||
}
|
||||
};
|
||||
this.ws.onerror = (event) => {
|
||||
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Error occurred: ${event}`, event);
|
||||
};
|
||||
if (retryTimeout !== null) {
|
||||
clearTimeout(retryTimeout);
|
||||
}
|
||||
this.retryTimeout = null;
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
close() {
|
||||
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Closing connection`);
|
||||
const socket = this.ws;
|
||||
const retryTimeout = this.retryTimeout;
|
||||
if (socket !== null) {
|
||||
socket.close();
|
||||
}
|
||||
if (retryTimeout !== null) {
|
||||
clearTimeout(retryTimeout);
|
||||
}
|
||||
this.retryTimeout = null;
|
||||
this.ws = null;
|
||||
wsUrl() {
|
||||
const params = [];
|
||||
if (this.since) {
|
||||
params.push(`since=${this.since}`);
|
||||
}
|
||||
if (this.user) {
|
||||
params.push(`auth=${this.authParam()}`);
|
||||
}
|
||||
const wsUrl = topicUrlWs(this.baseUrl, this.topic);
|
||||
return params.length === 0 ? wsUrl : `${wsUrl}?${params.join("&")}`;
|
||||
}
|
||||
|
||||
wsUrl() {
|
||||
const params = [];
|
||||
if (this.since) {
|
||||
params.push(`since=${this.since}`);
|
||||
}
|
||||
if (this.user) {
|
||||
params.push(`auth=${this.authParam()}`);
|
||||
}
|
||||
const wsUrl = topicUrlWs(this.baseUrl, this.topic);
|
||||
return (params.length === 0) ? wsUrl : `${wsUrl}?${params.join('&')}`;
|
||||
}
|
||||
|
||||
authParam() {
|
||||
if (this.user.password) {
|
||||
return encodeBase64Url(basicAuth(this.user.username, this.user.password));
|
||||
}
|
||||
return encodeBase64Url(bearerAuth(this.user.token));
|
||||
authParam() {
|
||||
if (this.user.password) {
|
||||
return encodeBase64Url(basicAuth(this.user.username, this.user.password));
|
||||
}
|
||||
return encodeBase64Url(bearerAuth(this.user.token));
|
||||
}
|
||||
}
|
||||
|
||||
export class ConnectionState {
|
||||
static Connected = "connected";
|
||||
static Connecting = "connecting";
|
||||
static Connected = "connected";
|
||||
static Connecting = "connecting";
|
||||
}
|
||||
|
||||
export default Connection;
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import Connection from "./Connection";
|
||||
import {hashCode} from "./utils";
|
||||
import { hashCode } from "./utils";
|
||||
|
||||
/**
|
||||
* The connection manager keeps track of active connections (WebSocket connections, see Connection).
|
||||
|
@ -8,110 +8,130 @@ import {hashCode} from "./utils";
|
|||
* as required. This is done pretty much exactly the same way as in the Android app.
|
||||
*/
|
||||
class ConnectionManager {
|
||||
constructor() {
|
||||
this.connections = new Map(); // ConnectionId -> Connection (hash, see below)
|
||||
this.stateListener = null; // Fired when connection state changes
|
||||
this.messageListener = null; // Fired when new notifications arrive
|
||||
constructor() {
|
||||
this.connections = new Map(); // ConnectionId -> Connection (hash, see below)
|
||||
this.stateListener = null; // Fired when connection state changes
|
||||
this.messageListener = null; // Fired when new notifications arrive
|
||||
}
|
||||
|
||||
registerStateListener(listener) {
|
||||
this.stateListener = listener;
|
||||
}
|
||||
|
||||
resetStateListener() {
|
||||
this.stateListener = null;
|
||||
}
|
||||
|
||||
registerMessageListener(listener) {
|
||||
this.messageListener = listener;
|
||||
}
|
||||
|
||||
resetMessageListener() {
|
||||
this.messageListener = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* This function figures out which websocket connections should be running by comparing the
|
||||
* current state of the world (connections) with the target state (targetIds).
|
||||
*
|
||||
* It uses a "connectionId", which is sha256($subscriptionId|$username|$password) to identify
|
||||
* connections. If any of them change, the connection is closed/replaced.
|
||||
*/
|
||||
async refresh(subscriptions, users) {
|
||||
if (!subscriptions || !users) {
|
||||
return;
|
||||
}
|
||||
console.log(`[ConnectionManager] Refreshing connections`);
|
||||
const subscriptionsWithUsersAndConnectionId = await Promise.all(
|
||||
subscriptions.map(async (s) => {
|
||||
const [user] = users.filter((u) => u.baseUrl === s.baseUrl);
|
||||
const connectionId = await makeConnectionId(s, user);
|
||||
return { ...s, user, connectionId };
|
||||
})
|
||||
);
|
||||
const targetIds = subscriptionsWithUsersAndConnectionId.map(
|
||||
(s) => s.connectionId
|
||||
);
|
||||
const deletedIds = Array.from(this.connections.keys()).filter(
|
||||
(id) => !targetIds.includes(id)
|
||||
);
|
||||
|
||||
registerStateListener(listener) {
|
||||
this.stateListener = listener;
|
||||
// Create and add new connections
|
||||
subscriptionsWithUsersAndConnectionId.forEach((subscription) => {
|
||||
const subscriptionId = subscription.id;
|
||||
const connectionId = subscription.connectionId;
|
||||
const added = !this.connections.get(connectionId);
|
||||
if (added) {
|
||||
const baseUrl = subscription.baseUrl;
|
||||
const topic = subscription.topic;
|
||||
const user = subscription.user;
|
||||
const since = subscription.last;
|
||||
const connection = new Connection(
|
||||
connectionId,
|
||||
subscriptionId,
|
||||
baseUrl,
|
||||
topic,
|
||||
user,
|
||||
since,
|
||||
(subscriptionId, notification) =>
|
||||
this.notificationReceived(subscriptionId, notification),
|
||||
(subscriptionId, state) => this.stateChanged(subscriptionId, state)
|
||||
);
|
||||
this.connections.set(connectionId, connection);
|
||||
console.log(
|
||||
`[ConnectionManager] Starting new connection ${connectionId} (subscription ${subscriptionId} with user ${
|
||||
user ? user.username : "anonymous"
|
||||
})`
|
||||
);
|
||||
connection.start();
|
||||
}
|
||||
});
|
||||
|
||||
// Delete old connections
|
||||
deletedIds.forEach((id) => {
|
||||
console.log(`[ConnectionManager] Closing connection ${id}`);
|
||||
const connection = this.connections.get(id);
|
||||
this.connections.delete(id);
|
||||
connection.close();
|
||||
});
|
||||
}
|
||||
|
||||
stateChanged(subscriptionId, state) {
|
||||
if (this.stateListener) {
|
||||
try {
|
||||
this.stateListener(subscriptionId, state);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`[ConnectionManager] Error updating state of ${subscriptionId} to ${state}`,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resetStateListener() {
|
||||
this.stateListener = null;
|
||||
}
|
||||
|
||||
registerMessageListener(listener) {
|
||||
this.messageListener = listener;
|
||||
}
|
||||
|
||||
resetMessageListener() {
|
||||
this.messageListener = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* This function figures out which websocket connections should be running by comparing the
|
||||
* current state of the world (connections) with the target state (targetIds).
|
||||
*
|
||||
* It uses a "connectionId", which is sha256($subscriptionId|$username|$password) to identify
|
||||
* connections. If any of them change, the connection is closed/replaced.
|
||||
*/
|
||||
async refresh(subscriptions, users) {
|
||||
if (!subscriptions || !users) {
|
||||
return;
|
||||
}
|
||||
console.log(`[ConnectionManager] Refreshing connections`);
|
||||
const subscriptionsWithUsersAndConnectionId = await Promise.all(subscriptions
|
||||
.map(async s => {
|
||||
const [user] = users.filter(u => u.baseUrl === s.baseUrl);
|
||||
const connectionId = await makeConnectionId(s, user);
|
||||
return {...s, user, connectionId};
|
||||
}));
|
||||
const targetIds = subscriptionsWithUsersAndConnectionId.map(s => s.connectionId);
|
||||
const deletedIds = Array.from(this.connections.keys()).filter(id => !targetIds.includes(id));
|
||||
|
||||
// Create and add new connections
|
||||
subscriptionsWithUsersAndConnectionId.forEach(subscription => {
|
||||
const subscriptionId = subscription.id;
|
||||
const connectionId = subscription.connectionId;
|
||||
const added = !this.connections.get(connectionId)
|
||||
if (added) {
|
||||
const baseUrl = subscription.baseUrl;
|
||||
const topic = subscription.topic;
|
||||
const user = subscription.user;
|
||||
const since = subscription.last;
|
||||
const connection = new Connection(
|
||||
connectionId,
|
||||
subscriptionId,
|
||||
baseUrl,
|
||||
topic,
|
||||
user,
|
||||
since,
|
||||
(subscriptionId, notification) => this.notificationReceived(subscriptionId, notification),
|
||||
(subscriptionId, state) => this.stateChanged(subscriptionId, state)
|
||||
);
|
||||
this.connections.set(connectionId, connection);
|
||||
console.log(`[ConnectionManager] Starting new connection ${connectionId} (subscription ${subscriptionId} with user ${user ? user.username : "anonymous"})`);
|
||||
connection.start();
|
||||
}
|
||||
});
|
||||
|
||||
// Delete old connections
|
||||
deletedIds.forEach(id => {
|
||||
console.log(`[ConnectionManager] Closing connection ${id}`);
|
||||
const connection = this.connections.get(id);
|
||||
this.connections.delete(id);
|
||||
connection.close();
|
||||
});
|
||||
}
|
||||
|
||||
stateChanged(subscriptionId, state) {
|
||||
if (this.stateListener) {
|
||||
try {
|
||||
this.stateListener(subscriptionId, state);
|
||||
} catch (e) {
|
||||
console.error(`[ConnectionManager] Error updating state of ${subscriptionId} to ${state}`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
notificationReceived(subscriptionId, notification) {
|
||||
if (this.messageListener) {
|
||||
try {
|
||||
this.messageListener(subscriptionId, notification);
|
||||
} catch (e) {
|
||||
console.error(`[ConnectionManager] Error handling notification for ${subscriptionId}`, e);
|
||||
}
|
||||
}
|
||||
notificationReceived(subscriptionId, notification) {
|
||||
if (this.messageListener) {
|
||||
try {
|
||||
this.messageListener(subscriptionId, notification);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`[ConnectionManager] Error handling notification for ${subscriptionId}`,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const makeConnectionId = async (subscription, user) => {
|
||||
return (user)
|
||||
? hashCode(`${subscription.id}|${user.username}|${user.password ?? ""}|${user.token ?? ""}`)
|
||||
: hashCode(`${subscription.id}`);
|
||||
}
|
||||
return user
|
||||
? hashCode(
|
||||
`${subscription.id}|${user.username}|${user.password ?? ""}|${
|
||||
user.token ?? ""
|
||||
}`
|
||||
)
|
||||
: hashCode(`${subscription.id}`);
|
||||
};
|
||||
|
||||
const connectionManager = new ConnectionManager();
|
||||
export default connectionManager;
|
||||
|
|
|
@ -1,4 +1,11 @@
|
|||
import {formatMessage, formatTitleWithDefault, openUrl, playSound, topicDisplayName, topicShortUrl} from "./utils";
|
||||
import {
|
||||
formatMessage,
|
||||
formatTitleWithDefault,
|
||||
openUrl,
|
||||
playSound,
|
||||
topicDisplayName,
|
||||
topicShortUrl,
|
||||
} from "./utils";
|
||||
import prefs from "./Prefs";
|
||||
import subscriptionManager from "./SubscriptionManager";
|
||||
import logo from "../img/ntfy.png";
|
||||
|
@ -8,89 +15,93 @@ import logo from "../img/ntfy.png";
|
|||
* support this; most importantly, all iOS browsers do not support window.Notification.
|
||||
*/
|
||||
class Notifier {
|
||||
async notify(subscriptionId, notification, onClickFallback) {
|
||||
if (!this.supported()) {
|
||||
return;
|
||||
}
|
||||
const subscription = await subscriptionManager.get(subscriptionId);
|
||||
const shouldNotify = await this.shouldNotify(subscription, notification);
|
||||
if (!shouldNotify) {
|
||||
return;
|
||||
}
|
||||
const shortUrl = topicShortUrl(subscription.baseUrl, subscription.topic);
|
||||
const displayName = topicDisplayName(subscription);
|
||||
const message = formatMessage(notification);
|
||||
const title = formatTitleWithDefault(notification, displayName);
|
||||
async notify(subscriptionId, notification, onClickFallback) {
|
||||
if (!this.supported()) {
|
||||
return;
|
||||
}
|
||||
const subscription = await subscriptionManager.get(subscriptionId);
|
||||
const shouldNotify = await this.shouldNotify(subscription, notification);
|
||||
if (!shouldNotify) {
|
||||
return;
|
||||
}
|
||||
const shortUrl = topicShortUrl(subscription.baseUrl, subscription.topic);
|
||||
const displayName = topicDisplayName(subscription);
|
||||
const message = formatMessage(notification);
|
||||
const title = formatTitleWithDefault(notification, displayName);
|
||||
|
||||
// Show notification
|
||||
console.log(`[Notifier, ${shortUrl}] Displaying notification ${notification.id}: ${message}`);
|
||||
const n = new Notification(title, {
|
||||
body: message,
|
||||
icon: logo
|
||||
});
|
||||
if (notification.click) {
|
||||
n.onclick = (e) => openUrl(notification.click);
|
||||
} else {
|
||||
n.onclick = () => onClickFallback(subscription);
|
||||
}
|
||||
|
||||
// Play sound
|
||||
const sound = await prefs.sound();
|
||||
if (sound && sound !== "none") {
|
||||
try {
|
||||
await playSound(sound);
|
||||
} catch (e) {
|
||||
console.log(`[Notifier, ${shortUrl}] Error playing audio`, e);
|
||||
}
|
||||
}
|
||||
// Show notification
|
||||
console.log(
|
||||
`[Notifier, ${shortUrl}] Displaying notification ${notification.id}: ${message}`
|
||||
);
|
||||
const n = new Notification(title, {
|
||||
body: message,
|
||||
icon: logo,
|
||||
});
|
||||
if (notification.click) {
|
||||
n.onclick = (e) => openUrl(notification.click);
|
||||
} else {
|
||||
n.onclick = () => onClickFallback(subscription);
|
||||
}
|
||||
|
||||
granted() {
|
||||
return this.supported() && Notification.permission === 'granted';
|
||||
// Play sound
|
||||
const sound = await prefs.sound();
|
||||
if (sound && sound !== "none") {
|
||||
try {
|
||||
await playSound(sound);
|
||||
} catch (e) {
|
||||
console.log(`[Notifier, ${shortUrl}] Error playing audio`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
maybeRequestPermission(cb) {
|
||||
if (!this.supported()) {
|
||||
cb(false);
|
||||
return;
|
||||
}
|
||||
if (!this.granted()) {
|
||||
Notification.requestPermission().then((permission) => {
|
||||
const granted = permission === 'granted';
|
||||
cb(granted);
|
||||
});
|
||||
}
|
||||
}
|
||||
granted() {
|
||||
return this.supported() && Notification.permission === "granted";
|
||||
}
|
||||
|
||||
async shouldNotify(subscription, notification) {
|
||||
if (subscription.mutedUntil === 1) {
|
||||
return false;
|
||||
}
|
||||
const priority = (notification.priority) ? notification.priority : 3;
|
||||
const minPriority = await prefs.minPriority();
|
||||
if (priority < minPriority) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
maybeRequestPermission(cb) {
|
||||
if (!this.supported()) {
|
||||
cb(false);
|
||||
return;
|
||||
}
|
||||
if (!this.granted()) {
|
||||
Notification.requestPermission().then((permission) => {
|
||||
const granted = permission === "granted";
|
||||
cb(granted);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
supported() {
|
||||
return this.browserSupported() && this.contextSupported();
|
||||
async shouldNotify(subscription, notification) {
|
||||
if (subscription.mutedUntil === 1) {
|
||||
return false;
|
||||
}
|
||||
const priority = notification.priority ? notification.priority : 3;
|
||||
const minPriority = await prefs.minPriority();
|
||||
if (priority < minPriority) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
browserSupported() {
|
||||
return 'Notification' in window;
|
||||
}
|
||||
supported() {
|
||||
return this.browserSupported() && this.contextSupported();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if this is a HTTPS site, or served over localhost. Otherwise the Notification API
|
||||
* is not supported, see https://developer.mozilla.org/en-US/docs/Web/API/notification
|
||||
*/
|
||||
contextSupported() {
|
||||
return location.protocol === 'https:'
|
||||
|| location.hostname.match('^127.')
|
||||
|| location.hostname === 'localhost';
|
||||
}
|
||||
browserSupported() {
|
||||
return "Notification" in window;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if this is a HTTPS site, or served over localhost. Otherwise the Notification API
|
||||
* is not supported, see https://developer.mozilla.org/en-US/docs/Web/API/notification
|
||||
*/
|
||||
contextSupported() {
|
||||
return (
|
||||
location.protocol === "https:" ||
|
||||
location.hostname.match("^127.") ||
|
||||
location.hostname === "localhost"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const notifier = new Notifier();
|
||||
|
|
|
@ -5,54 +5,60 @@ const delayMillis = 2000; // 2 seconds
|
|||
const intervalMillis = 300000; // 5 minutes
|
||||
|
||||
class Poller {
|
||||
constructor() {
|
||||
this.timer = null;
|
||||
}
|
||||
constructor() {
|
||||
this.timer = null;
|
||||
}
|
||||
|
||||
startWorker() {
|
||||
if (this.timer !== null) {
|
||||
return;
|
||||
}
|
||||
console.log(`[Poller] Starting worker`);
|
||||
this.timer = setInterval(() => this.pollAll(), intervalMillis);
|
||||
setTimeout(() => this.pollAll(), delayMillis);
|
||||
startWorker() {
|
||||
if (this.timer !== null) {
|
||||
return;
|
||||
}
|
||||
console.log(`[Poller] Starting worker`);
|
||||
this.timer = setInterval(() => this.pollAll(), intervalMillis);
|
||||
setTimeout(() => this.pollAll(), delayMillis);
|
||||
}
|
||||
|
||||
async pollAll() {
|
||||
console.log(`[Poller] Polling all subscriptions`);
|
||||
const subscriptions = await subscriptionManager.all();
|
||||
for (const s of subscriptions) {
|
||||
try {
|
||||
await this.poll(s);
|
||||
} catch (e) {
|
||||
console.log(`[Poller] Error polling ${s.id}`, e);
|
||||
}
|
||||
}
|
||||
async pollAll() {
|
||||
console.log(`[Poller] Polling all subscriptions`);
|
||||
const subscriptions = await subscriptionManager.all();
|
||||
for (const s of subscriptions) {
|
||||
try {
|
||||
await this.poll(s);
|
||||
} catch (e) {
|
||||
console.log(`[Poller] Error polling ${s.id}`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async poll(subscription) {
|
||||
console.log(`[Poller] Polling ${subscription.id}`);
|
||||
async poll(subscription) {
|
||||
console.log(`[Poller] Polling ${subscription.id}`);
|
||||
|
||||
const since = subscription.last;
|
||||
const notifications = await api.poll(subscription.baseUrl, subscription.topic, since);
|
||||
if (!notifications || notifications.length === 0) {
|
||||
console.log(`[Poller] No new notifications found for ${subscription.id}`);
|
||||
return;
|
||||
}
|
||||
console.log(`[Poller] Adding ${notifications.length} notification(s) for ${subscription.id}`);
|
||||
await subscriptionManager.addNotifications(subscription.id, notifications);
|
||||
const since = subscription.last;
|
||||
const notifications = await api.poll(
|
||||
subscription.baseUrl,
|
||||
subscription.topic,
|
||||
since
|
||||
);
|
||||
if (!notifications || notifications.length === 0) {
|
||||
console.log(`[Poller] No new notifications found for ${subscription.id}`);
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
`[Poller] Adding ${notifications.length} notification(s) for ${subscription.id}`
|
||||
);
|
||||
await subscriptionManager.addNotifications(subscription.id, notifications);
|
||||
}
|
||||
|
||||
pollInBackground(subscription) {
|
||||
const fn = async () => {
|
||||
try {
|
||||
await this.poll(subscription);
|
||||
} catch (e) {
|
||||
console.error(`[App] Error polling subscription ${subscription.id}`, e);
|
||||
}
|
||||
};
|
||||
setTimeout(() => fn(), 0);
|
||||
}
|
||||
pollInBackground(subscription) {
|
||||
const fn = async () => {
|
||||
try {
|
||||
await this.poll(subscription);
|
||||
} catch (e) {
|
||||
console.error(`[App] Error polling subscription ${subscription.id}`, e);
|
||||
}
|
||||
};
|
||||
setTimeout(() => fn(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
const poller = new Poller();
|
||||
|
|
|
@ -1,32 +1,32 @@
|
|||
import db from "./db";
|
||||
|
||||
class Prefs {
|
||||
async setSound(sound) {
|
||||
db.prefs.put({key: 'sound', value: sound.toString()});
|
||||
}
|
||||
async setSound(sound) {
|
||||
db.prefs.put({ key: "sound", value: sound.toString() });
|
||||
}
|
||||
|
||||
async sound() {
|
||||
const sound = await db.prefs.get('sound');
|
||||
return (sound) ? sound.value : "ding";
|
||||
}
|
||||
async sound() {
|
||||
const sound = await db.prefs.get("sound");
|
||||
return sound ? sound.value : "ding";
|
||||
}
|
||||
|
||||
async setMinPriority(minPriority) {
|
||||
db.prefs.put({key: 'minPriority', value: minPriority.toString()});
|
||||
}
|
||||
async setMinPriority(minPriority) {
|
||||
db.prefs.put({ key: "minPriority", value: minPriority.toString() });
|
||||
}
|
||||
|
||||
async minPriority() {
|
||||
const minPriority = await db.prefs.get('minPriority');
|
||||
return (minPriority) ? Number(minPriority.value) : 1;
|
||||
}
|
||||
async minPriority() {
|
||||
const minPriority = await db.prefs.get("minPriority");
|
||||
return minPriority ? Number(minPriority.value) : 1;
|
||||
}
|
||||
|
||||
async setDeleteAfter(deleteAfter) {
|
||||
db.prefs.put({key:'deleteAfter', value: deleteAfter.toString()});
|
||||
}
|
||||
async setDeleteAfter(deleteAfter) {
|
||||
db.prefs.put({ key: "deleteAfter", value: deleteAfter.toString() });
|
||||
}
|
||||
|
||||
async deleteAfter() {
|
||||
const deleteAfter = await db.prefs.get('deleteAfter');
|
||||
return (deleteAfter) ? Number(deleteAfter.value) : 604800; // Default is one week
|
||||
}
|
||||
async deleteAfter() {
|
||||
const deleteAfter = await db.prefs.get("deleteAfter");
|
||||
return deleteAfter ? Number(deleteAfter.value) : 604800; // Default is one week
|
||||
}
|
||||
}
|
||||
|
||||
const prefs = new Prefs();
|
||||
|
|
|
@ -5,33 +5,36 @@ const delayMillis = 25000; // 25 seconds
|
|||
const intervalMillis = 1800000; // 30 minutes
|
||||
|
||||
class Pruner {
|
||||
constructor() {
|
||||
this.timer = null;
|
||||
}
|
||||
constructor() {
|
||||
this.timer = null;
|
||||
}
|
||||
|
||||
startWorker() {
|
||||
if (this.timer !== null) {
|
||||
return;
|
||||
}
|
||||
console.log(`[Pruner] Starting worker`);
|
||||
this.timer = setInterval(() => this.prune(), intervalMillis);
|
||||
setTimeout(() => this.prune(), delayMillis);
|
||||
startWorker() {
|
||||
if (this.timer !== null) {
|
||||
return;
|
||||
}
|
||||
console.log(`[Pruner] Starting worker`);
|
||||
this.timer = setInterval(() => this.prune(), intervalMillis);
|
||||
setTimeout(() => this.prune(), delayMillis);
|
||||
}
|
||||
|
||||
async prune() {
|
||||
const deleteAfterSeconds = await prefs.deleteAfter();
|
||||
const pruneThresholdTimestamp = Math.round(Date.now()/1000) - deleteAfterSeconds;
|
||||
if (deleteAfterSeconds === 0) {
|
||||
console.log(`[Pruner] Pruning is disabled. Skipping.`);
|
||||
return;
|
||||
}
|
||||
console.log(`[Pruner] Pruning notifications older than ${deleteAfterSeconds}s (timestamp ${pruneThresholdTimestamp})`);
|
||||
try {
|
||||
await subscriptionManager.pruneNotifications(pruneThresholdTimestamp);
|
||||
} catch (e) {
|
||||
console.log(`[Pruner] Error pruning old subscriptions`, e);
|
||||
}
|
||||
async prune() {
|
||||
const deleteAfterSeconds = await prefs.deleteAfter();
|
||||
const pruneThresholdTimestamp =
|
||||
Math.round(Date.now() / 1000) - deleteAfterSeconds;
|
||||
if (deleteAfterSeconds === 0) {
|
||||
console.log(`[Pruner] Pruning is disabled. Skipping.`);
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
`[Pruner] Pruning notifications older than ${deleteAfterSeconds}s (timestamp ${pruneThresholdTimestamp})`
|
||||
);
|
||||
try {
|
||||
await subscriptionManager.pruneNotifications(pruneThresholdTimestamp);
|
||||
} catch (e) {
|
||||
console.log(`[Pruner] Error pruning old subscriptions`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const pruner = new Pruner();
|
||||
|
|
|
@ -1,30 +1,30 @@
|
|||
class Session {
|
||||
store(username, token) {
|
||||
localStorage.setItem("user", username);
|
||||
localStorage.setItem("token", token);
|
||||
}
|
||||
store(username, token) {
|
||||
localStorage.setItem("user", username);
|
||||
localStorage.setItem("token", token);
|
||||
}
|
||||
|
||||
reset() {
|
||||
localStorage.removeItem("user");
|
||||
localStorage.removeItem("token");
|
||||
}
|
||||
reset() {
|
||||
localStorage.removeItem("user");
|
||||
localStorage.removeItem("token");
|
||||
}
|
||||
|
||||
resetAndRedirect(url) {
|
||||
this.reset();
|
||||
window.location.href = url;
|
||||
}
|
||||
resetAndRedirect(url) {
|
||||
this.reset();
|
||||
window.location.href = url;
|
||||
}
|
||||
|
||||
exists() {
|
||||
return this.username() && this.token();
|
||||
}
|
||||
exists() {
|
||||
return this.username() && this.token();
|
||||
}
|
||||
|
||||
username() {
|
||||
return localStorage.getItem("user");
|
||||
}
|
||||
username() {
|
||||
return localStorage.getItem("user");
|
||||
}
|
||||
|
||||
token() {
|
||||
return localStorage.getItem("token");
|
||||
}
|
||||
token() {
|
||||
return localStorage.getItem("token");
|
||||
}
|
||||
}
|
||||
|
||||
const session = new Session();
|
||||
|
|
|
@ -1,192 +1,193 @@
|
|||
import db from "./db";
|
||||
import {topicUrl} from "./utils";
|
||||
import { topicUrl } from "./utils";
|
||||
|
||||
class SubscriptionManager {
|
||||
/** All subscriptions, including "new count"; this is a JOIN, see https://dexie.org/docs/API-Reference#joining */
|
||||
async all() {
|
||||
const subscriptions = await db.subscriptions.toArray();
|
||||
await Promise.all(subscriptions.map(async s => {
|
||||
s.new = await db.notifications
|
||||
.where({ subscriptionId: s.id, new: 1 })
|
||||
.count();
|
||||
}));
|
||||
return subscriptions;
|
||||
/** All subscriptions, including "new count"; this is a JOIN, see https://dexie.org/docs/API-Reference#joining */
|
||||
async all() {
|
||||
const subscriptions = await db.subscriptions.toArray();
|
||||
await Promise.all(
|
||||
subscriptions.map(async (s) => {
|
||||
s.new = await db.notifications
|
||||
.where({ subscriptionId: s.id, new: 1 })
|
||||
.count();
|
||||
})
|
||||
);
|
||||
return subscriptions;
|
||||
}
|
||||
|
||||
async get(subscriptionId) {
|
||||
return await db.subscriptions.get(subscriptionId);
|
||||
}
|
||||
|
||||
async add(baseUrl, topic, internal) {
|
||||
const id = topicUrl(baseUrl, topic);
|
||||
const existingSubscription = await this.get(id);
|
||||
if (existingSubscription) {
|
||||
return existingSubscription;
|
||||
}
|
||||
const subscription = {
|
||||
id: topicUrl(baseUrl, topic),
|
||||
baseUrl: baseUrl,
|
||||
topic: topic,
|
||||
mutedUntil: 0,
|
||||
last: null,
|
||||
internal: internal || false,
|
||||
};
|
||||
await db.subscriptions.put(subscription);
|
||||
return subscription;
|
||||
}
|
||||
|
||||
async syncFromRemote(remoteSubscriptions, remoteReservations) {
|
||||
console.log(
|
||||
`[SubscriptionManager] Syncing subscriptions from remote`,
|
||||
remoteSubscriptions
|
||||
);
|
||||
|
||||
// Add remote subscriptions
|
||||
let remoteIds = []; // = topicUrl(baseUrl, topic)
|
||||
for (let i = 0; i < remoteSubscriptions.length; i++) {
|
||||
const remote = remoteSubscriptions[i];
|
||||
const local = await this.add(remote.base_url, remote.topic, false);
|
||||
const reservation =
|
||||
remoteReservations?.find(
|
||||
(r) => remote.base_url === config.base_url && remote.topic === r.topic
|
||||
) || null;
|
||||
await this.update(local.id, {
|
||||
displayName: remote.display_name, // May be undefined
|
||||
reservation: reservation, // May be null!
|
||||
});
|
||||
remoteIds.push(local.id);
|
||||
}
|
||||
|
||||
async get(subscriptionId) {
|
||||
return await db.subscriptions.get(subscriptionId)
|
||||
// Remove local subscriptions that do not exist remotely
|
||||
const localSubscriptions = await db.subscriptions.toArray();
|
||||
for (let i = 0; i < localSubscriptions.length; i++) {
|
||||
const local = localSubscriptions[i];
|
||||
const remoteExists = remoteIds.includes(local.id);
|
||||
if (!local.internal && !remoteExists) {
|
||||
await this.remove(local.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async add(baseUrl, topic, internal) {
|
||||
const id = topicUrl(baseUrl, topic);
|
||||
const existingSubscription = await this.get(id);
|
||||
if (existingSubscription) {
|
||||
return existingSubscription;
|
||||
}
|
||||
const subscription = {
|
||||
id: topicUrl(baseUrl, topic),
|
||||
baseUrl: baseUrl,
|
||||
topic: topic,
|
||||
mutedUntil: 0,
|
||||
last: null,
|
||||
internal: internal || false
|
||||
};
|
||||
await db.subscriptions.put(subscription);
|
||||
return subscription;
|
||||
async updateState(subscriptionId, state) {
|
||||
db.subscriptions.update(subscriptionId, { state: state });
|
||||
}
|
||||
|
||||
async remove(subscriptionId) {
|
||||
await db.subscriptions.delete(subscriptionId);
|
||||
await db.notifications.where({ subscriptionId: subscriptionId }).delete();
|
||||
}
|
||||
|
||||
async first() {
|
||||
return db.subscriptions.toCollection().first(); // May be undefined
|
||||
}
|
||||
|
||||
async getNotifications(subscriptionId) {
|
||||
// This is quite awkward, but it is the recommended approach as per the Dexie docs.
|
||||
// It's actually fine, because the reading and filtering is quite fast. The rendering is what's
|
||||
// killing performance. See https://dexie.org/docs/Collection/Collection.offset()#a-better-paging-approach
|
||||
|
||||
return db.notifications
|
||||
.orderBy("time") // Sort by time first
|
||||
.filter((n) => n.subscriptionId === subscriptionId)
|
||||
.reverse()
|
||||
.toArray();
|
||||
}
|
||||
|
||||
async getAllNotifications() {
|
||||
return db.notifications
|
||||
.orderBy("time") // Efficient, see docs
|
||||
.reverse()
|
||||
.toArray();
|
||||
}
|
||||
|
||||
/** Adds notification, or returns false if it already exists */
|
||||
async addNotification(subscriptionId, notification) {
|
||||
const exists = await db.notifications.get(notification.id);
|
||||
if (exists) {
|
||||
return false;
|
||||
}
|
||||
|
||||
async syncFromRemote(remoteSubscriptions, remoteReservations) {
|
||||
console.log(`[SubscriptionManager] Syncing subscriptions from remote`, remoteSubscriptions);
|
||||
|
||||
// Add remote subscriptions
|
||||
let remoteIds = []; // = topicUrl(baseUrl, topic)
|
||||
for (let i = 0; i < remoteSubscriptions.length; i++) {
|
||||
const remote = remoteSubscriptions[i];
|
||||
const local = await this.add(remote.base_url, remote.topic, false);
|
||||
const reservation = remoteReservations?.find(r => remote.base_url === config.base_url && remote.topic === r.topic) || null;
|
||||
await this.update(local.id, {
|
||||
displayName: remote.display_name, // May be undefined
|
||||
reservation: reservation // May be null!
|
||||
});
|
||||
remoteIds.push(local.id);
|
||||
}
|
||||
|
||||
// Remove local subscriptions that do not exist remotely
|
||||
const localSubscriptions = await db.subscriptions.toArray();
|
||||
for (let i = 0; i < localSubscriptions.length; i++) {
|
||||
const local = localSubscriptions[i];
|
||||
const remoteExists = remoteIds.includes(local.id);
|
||||
if (!local.internal && !remoteExists) {
|
||||
await this.remove(local.id);
|
||||
}
|
||||
}
|
||||
try {
|
||||
notification.new = 1; // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation
|
||||
await db.notifications.add({ ...notification, subscriptionId }); // FIXME consider put() for double tab
|
||||
await db.subscriptions.update(subscriptionId, {
|
||||
last: notification.id,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(`[SubscriptionManager] Error adding notification`, e);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async updateState(subscriptionId, state) {
|
||||
db.subscriptions.update(subscriptionId, { state: state });
|
||||
/** Adds/replaces notifications, will not throw if they exist */
|
||||
async addNotifications(subscriptionId, notifications) {
|
||||
const notificationsWithSubscriptionId = notifications.map(
|
||||
(notification) => ({ ...notification, subscriptionId })
|
||||
);
|
||||
const lastNotificationId = notifications.at(-1).id;
|
||||
await db.notifications.bulkPut(notificationsWithSubscriptionId);
|
||||
await db.subscriptions.update(subscriptionId, {
|
||||
last: lastNotificationId,
|
||||
});
|
||||
}
|
||||
|
||||
async updateNotification(notification) {
|
||||
const exists = await db.notifications.get(notification.id);
|
||||
if (!exists) {
|
||||
return false;
|
||||
}
|
||||
|
||||
async remove(subscriptionId) {
|
||||
await db.subscriptions.delete(subscriptionId);
|
||||
await db.notifications
|
||||
.where({subscriptionId: subscriptionId})
|
||||
.delete();
|
||||
try {
|
||||
await db.notifications.put({ ...notification });
|
||||
} catch (e) {
|
||||
console.error(`[SubscriptionManager] Error updating notification`, e);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async first() {
|
||||
return db.subscriptions.toCollection().first(); // May be undefined
|
||||
}
|
||||
async deleteNotification(notificationId) {
|
||||
await db.notifications.delete(notificationId);
|
||||
}
|
||||
|
||||
async getNotifications(subscriptionId) {
|
||||
// This is quite awkward, but it is the recommended approach as per the Dexie docs.
|
||||
// It's actually fine, because the reading and filtering is quite fast. The rendering is what's
|
||||
// killing performance. See https://dexie.org/docs/Collection/Collection.offset()#a-better-paging-approach
|
||||
async deleteNotifications(subscriptionId) {
|
||||
await db.notifications.where({ subscriptionId: subscriptionId }).delete();
|
||||
}
|
||||
|
||||
return db.notifications
|
||||
.orderBy("time") // Sort by time first
|
||||
.filter(n => n.subscriptionId === subscriptionId)
|
||||
.reverse()
|
||||
.toArray();
|
||||
}
|
||||
async markNotificationRead(notificationId) {
|
||||
await db.notifications.where({ id: notificationId }).modify({ new: 0 });
|
||||
}
|
||||
|
||||
async getAllNotifications() {
|
||||
return db.notifications
|
||||
.orderBy("time") // Efficient, see docs
|
||||
.reverse()
|
||||
.toArray();
|
||||
}
|
||||
async markNotificationsRead(subscriptionId) {
|
||||
await db.notifications
|
||||
.where({ subscriptionId: subscriptionId, new: 1 })
|
||||
.modify({ new: 0 });
|
||||
}
|
||||
|
||||
/** Adds notification, or returns false if it already exists */
|
||||
async addNotification(subscriptionId, notification) {
|
||||
const exists = await db.notifications.get(notification.id);
|
||||
if (exists) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
notification.new = 1; // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation
|
||||
await db.notifications.add({ ...notification, subscriptionId }); // FIXME consider put() for double tab
|
||||
await db.subscriptions.update(subscriptionId, {
|
||||
last: notification.id
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(`[SubscriptionManager] Error adding notification`, e);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
async setMutedUntil(subscriptionId, mutedUntil) {
|
||||
await db.subscriptions.update(subscriptionId, {
|
||||
mutedUntil: mutedUntil,
|
||||
});
|
||||
}
|
||||
|
||||
/** Adds/replaces notifications, will not throw if they exist */
|
||||
async addNotifications(subscriptionId, notifications) {
|
||||
const notificationsWithSubscriptionId = notifications
|
||||
.map(notification => ({ ...notification, subscriptionId }));
|
||||
const lastNotificationId = notifications.at(-1).id;
|
||||
await db.notifications.bulkPut(notificationsWithSubscriptionId);
|
||||
await db.subscriptions.update(subscriptionId, {
|
||||
last: lastNotificationId
|
||||
});
|
||||
}
|
||||
async setDisplayName(subscriptionId, displayName) {
|
||||
await db.subscriptions.update(subscriptionId, {
|
||||
displayName: displayName,
|
||||
});
|
||||
}
|
||||
|
||||
async updateNotification(notification) {
|
||||
const exists = await db.notifications.get(notification.id);
|
||||
if (!exists) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await db.notifications.put({ ...notification });
|
||||
} catch (e) {
|
||||
console.error(`[SubscriptionManager] Error updating notification`, e);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
async setReservation(subscriptionId, reservation) {
|
||||
await db.subscriptions.update(subscriptionId, {
|
||||
reservation: reservation,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteNotification(notificationId) {
|
||||
await db.notifications.delete(notificationId);
|
||||
}
|
||||
async update(subscriptionId, params) {
|
||||
await db.subscriptions.update(subscriptionId, params);
|
||||
}
|
||||
|
||||
async deleteNotifications(subscriptionId) {
|
||||
await db.notifications
|
||||
.where({subscriptionId: subscriptionId})
|
||||
.delete();
|
||||
}
|
||||
|
||||
async markNotificationRead(notificationId) {
|
||||
await db.notifications
|
||||
.where({id: notificationId})
|
||||
.modify({new: 0});
|
||||
}
|
||||
|
||||
async markNotificationsRead(subscriptionId) {
|
||||
await db.notifications
|
||||
.where({subscriptionId: subscriptionId, new: 1})
|
||||
.modify({new: 0});
|
||||
}
|
||||
|
||||
async setMutedUntil(subscriptionId, mutedUntil) {
|
||||
await db.subscriptions.update(subscriptionId, {
|
||||
mutedUntil: mutedUntil
|
||||
});
|
||||
}
|
||||
|
||||
async setDisplayName(subscriptionId, displayName) {
|
||||
await db.subscriptions.update(subscriptionId, {
|
||||
displayName: displayName
|
||||
});
|
||||
}
|
||||
|
||||
async setReservation(subscriptionId, reservation) {
|
||||
await db.subscriptions.update(subscriptionId, {
|
||||
reservation: reservation
|
||||
});
|
||||
}
|
||||
|
||||
async update(subscriptionId, params) {
|
||||
await db.subscriptions.update(subscriptionId, params);
|
||||
}
|
||||
|
||||
async pruneNotifications(thresholdTimestamp) {
|
||||
await db.notifications
|
||||
.where("time").below(thresholdTimestamp)
|
||||
.delete();
|
||||
}
|
||||
async pruneNotifications(thresholdTimestamp) {
|
||||
await db.notifications.where("time").below(thresholdTimestamp).delete();
|
||||
}
|
||||
}
|
||||
|
||||
const subscriptionManager = new SubscriptionManager();
|
||||
|
|
|
@ -2,45 +2,45 @@ import db from "./db";
|
|||
import session from "./Session";
|
||||
|
||||
class UserManager {
|
||||
async all() {
|
||||
const users = await db.users.toArray();
|
||||
if (session.exists()) {
|
||||
users.unshift(this.localUser());
|
||||
}
|
||||
return users;
|
||||
async all() {
|
||||
const users = await db.users.toArray();
|
||||
if (session.exists()) {
|
||||
users.unshift(this.localUser());
|
||||
}
|
||||
return users;
|
||||
}
|
||||
|
||||
async get(baseUrl) {
|
||||
if (session.exists() && baseUrl === config.base_url) {
|
||||
return this.localUser();
|
||||
}
|
||||
return db.users.get(baseUrl);
|
||||
async get(baseUrl) {
|
||||
if (session.exists() && baseUrl === config.base_url) {
|
||||
return this.localUser();
|
||||
}
|
||||
return db.users.get(baseUrl);
|
||||
}
|
||||
|
||||
async save(user) {
|
||||
if (session.exists() && user.baseUrl === config.base_url) {
|
||||
return;
|
||||
}
|
||||
await db.users.put(user);
|
||||
async save(user) {
|
||||
if (session.exists() && user.baseUrl === config.base_url) {
|
||||
return;
|
||||
}
|
||||
await db.users.put(user);
|
||||
}
|
||||
|
||||
async delete(baseUrl) {
|
||||
if (session.exists() && baseUrl === config.base_url) {
|
||||
return;
|
||||
}
|
||||
await db.users.delete(baseUrl);
|
||||
async delete(baseUrl) {
|
||||
if (session.exists() && baseUrl === config.base_url) {
|
||||
return;
|
||||
}
|
||||
await db.users.delete(baseUrl);
|
||||
}
|
||||
|
||||
localUser() {
|
||||
if (!session.exists()) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
baseUrl: config.base_url,
|
||||
username: session.username(),
|
||||
token: session.token() // Not "password"!
|
||||
};
|
||||
localUser() {
|
||||
if (!session.exists()) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
baseUrl: config.base_url,
|
||||
username: session.username(),
|
||||
token: session.token(), // Not "password"!
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const userManager = new UserManager();
|
||||
|
|
|
@ -3,7 +3,7 @@ const config = window.config;
|
|||
// The backend returns an empty base_url for the config struct,
|
||||
// so the frontend (hey, that's us!) can use the current location.
|
||||
if (!config.base_url || config.base_url === "") {
|
||||
config.base_url = window.location.origin;
|
||||
config.base_url = window.location.origin;
|
||||
}
|
||||
|
||||
export default config;
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import Dexie from 'dexie';
|
||||
import Dexie from "dexie";
|
||||
import session from "./Session";
|
||||
|
||||
// Uses Dexie.js
|
||||
|
@ -8,14 +8,14 @@ import session from "./Session";
|
|||
// - As per docs, we only declare the indexable columns, not all columns
|
||||
|
||||
// The IndexedDB database name is based on the logged-in user
|
||||
const dbName = (session.username()) ? `ntfy-${session.username()}` : "ntfy";
|
||||
const dbName = session.username() ? `ntfy-${session.username()}` : "ntfy";
|
||||
const db = new Dexie(dbName);
|
||||
|
||||
db.version(1).stores({
|
||||
subscriptions: '&id,baseUrl',
|
||||
notifications: '&id,subscriptionId,time,new,[subscriptionId+new]', // compound key for query performance
|
||||
users: '&baseUrl,username',
|
||||
prefs: '&key'
|
||||
subscriptions: "&id,baseUrl",
|
||||
notifications: "&id,subscriptionId,time,new,[subscriptionId+new]", // compound key for query performance
|
||||
users: "&baseUrl,username",
|
||||
prefs: "&key",
|
||||
});
|
||||
|
||||
export default db;
|
||||
|
|
14499
web/src/app/emojis.js
14499
web/src/app/emojis.js
File diff suppressed because one or more lines are too long
|
@ -1,66 +1,80 @@
|
|||
// This is a subset of, and the counterpart to errors.go
|
||||
|
||||
export const fetchOrThrow = async (url, options) => {
|
||||
const response = await fetch(url, options);
|
||||
if (response.status !== 200) {
|
||||
await throwAppError(response);
|
||||
}
|
||||
return response; // Promise!
|
||||
const response = await fetch(url, options);
|
||||
if (response.status !== 200) {
|
||||
await throwAppError(response);
|
||||
}
|
||||
return response; // Promise!
|
||||
};
|
||||
|
||||
export const throwAppError = async (response) => {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
console.log(`[Error] HTTP ${response.status}`, response);
|
||||
throw new UnauthorizedError();
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
console.log(`[Error] HTTP ${response.status}`, response);
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
const error = await maybeToJson(response);
|
||||
if (error?.code) {
|
||||
console.log(
|
||||
`[Error] HTTP ${response.status}, ntfy error ${error.code}: ${
|
||||
error.error || ""
|
||||
}`,
|
||||
response
|
||||
);
|
||||
if (error.code === UserExistsError.CODE) {
|
||||
throw new UserExistsError();
|
||||
} else if (error.code === TopicReservedError.CODE) {
|
||||
throw new TopicReservedError();
|
||||
} else if (error.code === AccountCreateLimitReachedError.CODE) {
|
||||
throw new AccountCreateLimitReachedError();
|
||||
} else if (error.code === IncorrectPasswordError.CODE) {
|
||||
throw new IncorrectPasswordError();
|
||||
} else if (error?.error) {
|
||||
throw new Error(`Error ${error.code}: ${error.error}`);
|
||||
}
|
||||
const error = await maybeToJson(response);
|
||||
if (error?.code) {
|
||||
console.log(`[Error] HTTP ${response.status}, ntfy error ${error.code}: ${error.error || ""}`, response);
|
||||
if (error.code === UserExistsError.CODE) {
|
||||
throw new UserExistsError();
|
||||
} else if (error.code === TopicReservedError.CODE) {
|
||||
throw new TopicReservedError();
|
||||
} else if (error.code === AccountCreateLimitReachedError.CODE) {
|
||||
throw new AccountCreateLimitReachedError();
|
||||
} else if (error.code === IncorrectPasswordError.CODE) {
|
||||
throw new IncorrectPasswordError();
|
||||
} else if (error?.error) {
|
||||
throw new Error(`Error ${error.code}: ${error.error}`);
|
||||
}
|
||||
}
|
||||
console.log(`[Error] HTTP ${response.status}, not a ntfy error`, response);
|
||||
throw new Error(`Unexpected response ${response.status}`);
|
||||
}
|
||||
console.log(`[Error] HTTP ${response.status}, not a ntfy error`, response);
|
||||
throw new Error(`Unexpected response ${response.status}`);
|
||||
};
|
||||
|
||||
const maybeToJson = async (response) => {
|
||||
try {
|
||||
return await response.json();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
try {
|
||||
return await response.json();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export class UnauthorizedError extends Error {
|
||||
constructor() { super("Unauthorized"); }
|
||||
constructor() {
|
||||
super("Unauthorized");
|
||||
}
|
||||
}
|
||||
|
||||
export class UserExistsError extends Error {
|
||||
static CODE = 40901; // errHTTPConflictUserExists
|
||||
constructor() { super("Username already exists"); }
|
||||
static CODE = 40901; // errHTTPConflictUserExists
|
||||
constructor() {
|
||||
super("Username already exists");
|
||||
}
|
||||
}
|
||||
|
||||
export class TopicReservedError extends Error {
|
||||
static CODE = 40902; // errHTTPConflictTopicReserved
|
||||
constructor() { super("Topic already reserved"); }
|
||||
static CODE = 40902; // errHTTPConflictTopicReserved
|
||||
constructor() {
|
||||
super("Topic already reserved");
|
||||
}
|
||||
}
|
||||
|
||||
export class AccountCreateLimitReachedError extends Error {
|
||||
static CODE = 42906; // errHTTPTooManyRequestsLimitAccountCreation
|
||||
constructor() { super("Account creation limit reached"); }
|
||||
static CODE = 42906; // errHTTPTooManyRequestsLimitAccountCreation
|
||||
constructor() {
|
||||
super("Account creation limit reached");
|
||||
}
|
||||
}
|
||||
|
||||
export class IncorrectPasswordError extends Error {
|
||||
static CODE = 40026; // errHTTPBadRequestIncorrectPasswordConfirmation
|
||||
constructor() { super("Password incorrect"); }
|
||||
static CODE = 40026; // errHTTPBadRequestIncorrectPasswordConfirmation
|
||||
constructor() {
|
||||
super("Password incorrect");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import {rawEmojis} from "./emojis";
|
||||
import { rawEmojis } from "./emojis";
|
||||
import beep from "../sounds/beep.mp3";
|
||||
import juntos from "../sounds/juntos.mp3";
|
||||
import pristine from "../sounds/pristine.mp3";
|
||||
|
@ -7,300 +7,316 @@ import dadum from "../sounds/dadum.mp3";
|
|||
import pop from "../sounds/pop.mp3";
|
||||
import popSwoosh from "../sounds/pop-swoosh.mp3";
|
||||
import config from "./config";
|
||||
import {Base64} from 'js-base64';
|
||||
import { Base64 } from "js-base64";
|
||||
|
||||
export const topicUrl = (baseUrl, topic) => `${baseUrl}/${topic}`;
|
||||
export const topicUrlWs = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/ws`
|
||||
export const topicUrlWs = (baseUrl, topic) =>
|
||||
`${topicUrl(baseUrl, topic)}/ws`
|
||||
.replaceAll("https://", "wss://")
|
||||
.replaceAll("http://", "ws://");
|
||||
export const topicUrlJson = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/json`;
|
||||
export const topicUrlJsonPoll = (baseUrl, topic) => `${topicUrlJson(baseUrl, topic)}?poll=1`;
|
||||
export const topicUrlJsonPollWithSince = (baseUrl, topic, since) => `${topicUrlJson(baseUrl, topic)}?poll=1&since=${since}`;
|
||||
export const topicUrlAuth = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/auth`;
|
||||
export const topicShortUrl = (baseUrl, topic) => shortUrl(topicUrl(baseUrl, topic));
|
||||
export const topicUrlJson = (baseUrl, topic) =>
|
||||
`${topicUrl(baseUrl, topic)}/json`;
|
||||
export const topicUrlJsonPoll = (baseUrl, topic) =>
|
||||
`${topicUrlJson(baseUrl, topic)}?poll=1`;
|
||||
export const topicUrlJsonPollWithSince = (baseUrl, topic, since) =>
|
||||
`${topicUrlJson(baseUrl, topic)}?poll=1&since=${since}`;
|
||||
export const topicUrlAuth = (baseUrl, topic) =>
|
||||
`${topicUrl(baseUrl, topic)}/auth`;
|
||||
export const topicShortUrl = (baseUrl, topic) =>
|
||||
shortUrl(topicUrl(baseUrl, topic));
|
||||
export const accountUrl = (baseUrl) => `${baseUrl}/v1/account`;
|
||||
export const accountPasswordUrl = (baseUrl) => `${baseUrl}/v1/account/password`;
|
||||
export const accountTokenUrl = (baseUrl) => `${baseUrl}/v1/account/token`;
|
||||
export const accountSettingsUrl = (baseUrl) => `${baseUrl}/v1/account/settings`;
|
||||
export const accountSubscriptionUrl = (baseUrl) => `${baseUrl}/v1/account/subscription`;
|
||||
export const accountReservationUrl = (baseUrl) => `${baseUrl}/v1/account/reservation`;
|
||||
export const accountReservationSingleUrl = (baseUrl, topic) => `${baseUrl}/v1/account/reservation/${topic}`;
|
||||
export const accountBillingSubscriptionUrl = (baseUrl) => `${baseUrl}/v1/account/billing/subscription`;
|
||||
export const accountBillingPortalUrl = (baseUrl) => `${baseUrl}/v1/account/billing/portal`;
|
||||
export const accountSubscriptionUrl = (baseUrl) =>
|
||||
`${baseUrl}/v1/account/subscription`;
|
||||
export const accountReservationUrl = (baseUrl) =>
|
||||
`${baseUrl}/v1/account/reservation`;
|
||||
export const accountReservationSingleUrl = (baseUrl, topic) =>
|
||||
`${baseUrl}/v1/account/reservation/${topic}`;
|
||||
export const accountBillingSubscriptionUrl = (baseUrl) =>
|
||||
`${baseUrl}/v1/account/billing/subscription`;
|
||||
export const accountBillingPortalUrl = (baseUrl) =>
|
||||
`${baseUrl}/v1/account/billing/portal`;
|
||||
export const accountPhoneUrl = (baseUrl) => `${baseUrl}/v1/account/phone`;
|
||||
export const accountPhoneVerifyUrl = (baseUrl) => `${baseUrl}/v1/account/phone/verify`;
|
||||
export const accountPhoneVerifyUrl = (baseUrl) =>
|
||||
`${baseUrl}/v1/account/phone/verify`;
|
||||
export const tiersUrl = (baseUrl) => `${baseUrl}/v1/tiers`;
|
||||
export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, "");
|
||||
export const expandUrl = (url) => [`https://${url}`, `http://${url}`];
|
||||
export const expandSecureUrl = (url) => `https://${url}`;
|
||||
|
||||
export const validUrl = (url) => {
|
||||
return url.match(/^https?:\/\/.+/);
|
||||
}
|
||||
return url.match(/^https?:\/\/.+/);
|
||||
};
|
||||
|
||||
export const validTopic = (topic) => {
|
||||
if (disallowedTopic(topic)) {
|
||||
return false;
|
||||
}
|
||||
return topic.match(/^([-_a-zA-Z0-9]{1,64})$/); // Regex must match Go & Android app!
|
||||
}
|
||||
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.disallowed_topics.includes(topic);
|
||||
}
|
||||
return config.disallowed_topics.includes(topic);
|
||||
};
|
||||
|
||||
export const topicDisplayName = (subscription) => {
|
||||
if (subscription.displayName) {
|
||||
return subscription.displayName;
|
||||
} else if (subscription.baseUrl === config.base_url) {
|
||||
return subscription.topic;
|
||||
}
|
||||
return topicShortUrl(subscription.baseUrl, subscription.topic);
|
||||
if (subscription.displayName) {
|
||||
return subscription.displayName;
|
||||
} else if (subscription.baseUrl === config.base_url) {
|
||||
return subscription.topic;
|
||||
}
|
||||
return topicShortUrl(subscription.baseUrl, subscription.topic);
|
||||
};
|
||||
|
||||
// Format emojis (see emoji.js)
|
||||
const emojis = {};
|
||||
rawEmojis.forEach(emoji => {
|
||||
emoji.aliases.forEach(alias => {
|
||||
emojis[alias] = emoji.emoji;
|
||||
});
|
||||
rawEmojis.forEach((emoji) => {
|
||||
emoji.aliases.forEach((alias) => {
|
||||
emojis[alias] = emoji.emoji;
|
||||
});
|
||||
});
|
||||
|
||||
const toEmojis = (tags) => {
|
||||
if (!tags) return [];
|
||||
else return tags.filter(tag => tag in emojis).map(tag => emojis[tag]);
|
||||
}
|
||||
if (!tags) return [];
|
||||
else return tags.filter((tag) => tag in emojis).map((tag) => emojis[tag]);
|
||||
};
|
||||
|
||||
export const formatTitleWithDefault = (m, fallback) => {
|
||||
if (m.title) {
|
||||
return formatTitle(m);
|
||||
}
|
||||
return fallback;
|
||||
if (m.title) {
|
||||
return formatTitle(m);
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
export const formatTitle = (m) => {
|
||||
const emojiList = toEmojis(m.tags);
|
||||
if (emojiList.length > 0) {
|
||||
return `${emojiList.join(" ")} ${m.title}`;
|
||||
} else {
|
||||
return m.title;
|
||||
}
|
||||
const emojiList = toEmojis(m.tags);
|
||||
if (emojiList.length > 0) {
|
||||
return `${emojiList.join(" ")} ${m.title}`;
|
||||
} else {
|
||||
return m.title;
|
||||
}
|
||||
};
|
||||
|
||||
export const formatMessage = (m) => {
|
||||
if (m.title) {
|
||||
return m.message;
|
||||
if (m.title) {
|
||||
return m.message;
|
||||
} else {
|
||||
const emojiList = toEmojis(m.tags);
|
||||
if (emojiList.length > 0) {
|
||||
return `${emojiList.join(" ")} ${m.message}`;
|
||||
} else {
|
||||
const emojiList = toEmojis(m.tags);
|
||||
if (emojiList.length > 0) {
|
||||
return `${emojiList.join(" ")} ${m.message}`;
|
||||
} else {
|
||||
return m.message;
|
||||
}
|
||||
return m.message;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const unmatchedTags = (tags) => {
|
||||
if (!tags) return [];
|
||||
else return tags.filter(tag => !(tag in emojis));
|
||||
}
|
||||
if (!tags) return [];
|
||||
else return tags.filter((tag) => !(tag in emojis));
|
||||
};
|
||||
|
||||
export const maybeWithAuth = (headers, user) => {
|
||||
if (user && user.password) {
|
||||
return withBasicAuth(headers, user.username, user.password);
|
||||
} else if (user && user.token) {
|
||||
return withBearerAuth(headers, user.token);
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
if (user && user.password) {
|
||||
return withBasicAuth(headers, user.username, user.password);
|
||||
} else if (user && user.token) {
|
||||
return withBearerAuth(headers, user.token);
|
||||
}
|
||||
return headers;
|
||||
};
|
||||
|
||||
export const maybeWithBearerAuth = (headers, token) => {
|
||||
if (token) {
|
||||
return withBearerAuth(headers, token);
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
if (token) {
|
||||
return withBearerAuth(headers, token);
|
||||
}
|
||||
return headers;
|
||||
};
|
||||
|
||||
export const withBasicAuth = (headers, username, password) => {
|
||||
headers['Authorization'] = basicAuth(username, password);
|
||||
return headers;
|
||||
}
|
||||
headers["Authorization"] = basicAuth(username, password);
|
||||
return headers;
|
||||
};
|
||||
|
||||
export const basicAuth = (username, password) => {
|
||||
return `Basic ${encodeBase64(`${username}:${password}`)}`;
|
||||
}
|
||||
return `Basic ${encodeBase64(`${username}:${password}`)}`;
|
||||
};
|
||||
|
||||
export const withBearerAuth = (headers, token) => {
|
||||
headers['Authorization'] = bearerAuth(token);
|
||||
return headers;
|
||||
}
|
||||
headers["Authorization"] = bearerAuth(token);
|
||||
return headers;
|
||||
};
|
||||
|
||||
export const bearerAuth = (token) => {
|
||||
return `Bearer ${token}`;
|
||||
}
|
||||
return `Bearer ${token}`;
|
||||
};
|
||||
|
||||
export const encodeBase64 = (s) => {
|
||||
return Base64.encode(s);
|
||||
}
|
||||
return Base64.encode(s);
|
||||
};
|
||||
|
||||
export const encodeBase64Url = (s) => {
|
||||
return Base64.encodeURI(s);
|
||||
}
|
||||
return Base64.encodeURI(s);
|
||||
};
|
||||
|
||||
export const maybeAppendActionErrors = (message, notification) => {
|
||||
const actionErrors = (notification.actions ?? [])
|
||||
.map(action => action.error)
|
||||
.filter(action => !!action)
|
||||
.join("\n")
|
||||
if (actionErrors.length === 0) {
|
||||
return message;
|
||||
} else {
|
||||
return `${message}\n\n${actionErrors}`;
|
||||
}
|
||||
}
|
||||
const actionErrors = (notification.actions ?? [])
|
||||
.map((action) => action.error)
|
||||
.filter((action) => !!action)
|
||||
.join("\n");
|
||||
if (actionErrors.length === 0) {
|
||||
return message;
|
||||
} else {
|
||||
return `${message}\n\n${actionErrors}`;
|
||||
}
|
||||
};
|
||||
|
||||
export const shuffle = (arr) => {
|
||||
let j, x;
|
||||
for (let index = arr.length - 1; index > 0; index--) {
|
||||
j = Math.floor(Math.random() * (index + 1));
|
||||
x = arr[index];
|
||||
arr[index] = arr[j];
|
||||
arr[j] = x;
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
let j, x;
|
||||
for (let index = arr.length - 1; index > 0; index--) {
|
||||
j = Math.floor(Math.random() * (index + 1));
|
||||
x = arr[index];
|
||||
arr[index] = arr[j];
|
||||
arr[j] = x;
|
||||
}
|
||||
return arr;
|
||||
};
|
||||
|
||||
export const splitNoEmpty = (s, delimiter) => {
|
||||
return s
|
||||
.split(delimiter)
|
||||
.map(x => x.trim())
|
||||
.filter(x => x !== "");
|
||||
}
|
||||
return s
|
||||
.split(delimiter)
|
||||
.map((x) => x.trim())
|
||||
.filter((x) => x !== "");
|
||||
};
|
||||
|
||||
/** Non-cryptographic hash function, see https://stackoverflow.com/a/8831937/1440785 */
|
||||
export const hashCode = async (s) => {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const char = s.charCodeAt(i);
|
||||
hash = ((hash<<5)-hash)+char;
|
||||
hash = hash & hash; // Convert to 32bit integer
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
let hash = 0;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const char = s.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + char;
|
||||
hash = hash & hash; // Convert to 32bit integer
|
||||
}
|
||||
return hash;
|
||||
};
|
||||
|
||||
export const formatShortDateTime = (timestamp) => {
|
||||
return new Intl.DateTimeFormat('default', {dateStyle: 'short', timeStyle: 'short'})
|
||||
.format(new Date(timestamp * 1000));
|
||||
}
|
||||
return new Intl.DateTimeFormat("default", {
|
||||
dateStyle: "short",
|
||||
timeStyle: "short",
|
||||
}).format(new Date(timestamp * 1000));
|
||||
};
|
||||
|
||||
export const formatShortDate = (timestamp) => {
|
||||
return new Intl.DateTimeFormat('default', {dateStyle: 'short'})
|
||||
.format(new Date(timestamp * 1000));
|
||||
}
|
||||
return new Intl.DateTimeFormat("default", { dateStyle: "short" }).format(
|
||||
new Date(timestamp * 1000)
|
||||
);
|
||||
};
|
||||
|
||||
export const formatBytes = (bytes, decimals = 2) => {
|
||||
if (bytes === 0) return '0 bytes';
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||
}
|
||||
if (bytes === 0) return "0 bytes";
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ["bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
|
||||
};
|
||||
|
||||
export const formatNumber = (n) => {
|
||||
if (n === 0) {
|
||||
return n;
|
||||
} else if (n % 1000 === 0) {
|
||||
return `${n/1000}k`;
|
||||
}
|
||||
return n.toLocaleString();
|
||||
}
|
||||
if (n === 0) {
|
||||
return n;
|
||||
} else if (n % 1000 === 0) {
|
||||
return `${n / 1000}k`;
|
||||
}
|
||||
return n.toLocaleString();
|
||||
};
|
||||
|
||||
export const formatPrice = (n) => {
|
||||
if (n % 100 === 0) {
|
||||
return `$${n/100}`;
|
||||
}
|
||||
return `$${(n/100).toPrecision(2)}`;
|
||||
}
|
||||
if (n % 100 === 0) {
|
||||
return `$${n / 100}`;
|
||||
}
|
||||
return `$${(n / 100).toPrecision(2)}`;
|
||||
};
|
||||
|
||||
export const openUrl = (url) => {
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
export const sounds = {
|
||||
"ding": {
|
||||
file: ding,
|
||||
label: "Ding"
|
||||
},
|
||||
"juntos": {
|
||||
file: juntos,
|
||||
label: "Juntos"
|
||||
},
|
||||
"pristine": {
|
||||
file: pristine,
|
||||
label: "Pristine"
|
||||
},
|
||||
"dadum": {
|
||||
file: dadum,
|
||||
label: "Dadum"
|
||||
},
|
||||
"pop": {
|
||||
file: pop,
|
||||
label: "Pop"
|
||||
},
|
||||
"pop-swoosh": {
|
||||
file: popSwoosh,
|
||||
label: "Pop swoosh"
|
||||
},
|
||||
"beep": {
|
||||
file: beep,
|
||||
label: "Beep"
|
||||
}
|
||||
ding: {
|
||||
file: ding,
|
||||
label: "Ding",
|
||||
},
|
||||
juntos: {
|
||||
file: juntos,
|
||||
label: "Juntos",
|
||||
},
|
||||
pristine: {
|
||||
file: pristine,
|
||||
label: "Pristine",
|
||||
},
|
||||
dadum: {
|
||||
file: dadum,
|
||||
label: "Dadum",
|
||||
},
|
||||
pop: {
|
||||
file: pop,
|
||||
label: "Pop",
|
||||
},
|
||||
"pop-swoosh": {
|
||||
file: popSwoosh,
|
||||
label: "Pop swoosh",
|
||||
},
|
||||
beep: {
|
||||
file: beep,
|
||||
label: "Beep",
|
||||
},
|
||||
};
|
||||
|
||||
export const playSound = async (id) => {
|
||||
const audio = new Audio(sounds[id].file);
|
||||
return audio.play();
|
||||
const audio = new Audio(sounds[id].file);
|
||||
return audio.play();
|
||||
};
|
||||
|
||||
// From: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch
|
||||
export async function* fetchLinesIterator(fileURL, headers) {
|
||||
const utf8Decoder = new TextDecoder('utf-8');
|
||||
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) : '';
|
||||
const utf8Decoder = new TextDecoder("utf-8");
|
||||
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) : "";
|
||||
|
||||
const re = /\n|\r|\r\n/gm;
|
||||
let startIndex = 0;
|
||||
const re = /\n|\r|\r\n/gm;
|
||||
let startIndex = 0;
|
||||
|
||||
for (;;) {
|
||||
let result = re.exec(chunk);
|
||||
if (!result) {
|
||||
if (readerDone) {
|
||||
break;
|
||||
}
|
||||
let remainder = chunk.substr(startIndex);
|
||||
({ value: chunk, done: readerDone } = await reader.read());
|
||||
chunk = remainder + (chunk ? utf8Decoder.decode(chunk) : '');
|
||||
startIndex = re.lastIndex = 0;
|
||||
continue;
|
||||
}
|
||||
yield chunk.substring(startIndex, result.index);
|
||||
startIndex = re.lastIndex;
|
||||
}
|
||||
if (startIndex < chunk.length) {
|
||||
yield chunk.substr(startIndex); // last line didn't end in a newline char
|
||||
for (;;) {
|
||||
let result = re.exec(chunk);
|
||||
if (!result) {
|
||||
if (readerDone) {
|
||||
break;
|
||||
}
|
||||
let remainder = chunk.substr(startIndex);
|
||||
({ value: chunk, done: readerDone } = await reader.read());
|
||||
chunk = remainder + (chunk ? utf8Decoder.decode(chunk) : "");
|
||||
startIndex = re.lastIndex = 0;
|
||||
continue;
|
||||
}
|
||||
yield chunk.substring(startIndex, result.index);
|
||||
startIndex = re.lastIndex;
|
||||
}
|
||||
if (startIndex < chunk.length) {
|
||||
yield chunk.substr(startIndex); // last line didn't end in a newline char
|
||||
}
|
||||
}
|
||||
|
||||
export const randomAlphanumericString = (len) => {
|
||||
const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
let id = "";
|
||||
for (let i = 0; i < len; i++) {
|
||||
id += alphabet[(Math.random() * alphabet.length) | 0];
|
||||
}
|
||||
return id;
|
||||
}
|
||||
const alphabet =
|
||||
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
let id = "";
|
||||
for (let i = 0; i < len; i++) {
|
||||
id += alphabet[(Math.random() * alphabet.length) | 0];
|
||||
}
|
||||
return id;
|
||||
};
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue