@yz-social/civildefense.io 4.5.28 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +1 -0
- package/package.json +4 -2
- package/public/about/en.html +42 -9
- package/public/about/identity.html +33 -0
- package/public/about/index.html +1 -1
- package/public/about/notification.html +33 -0
- package/public/about/pwa.html +57 -0
- package/public/about/style.css +6 -0
- package/public/about/template.html +28 -0
- package/public/about/topics.png +0 -0
- package/public/index.html +17 -9
- package/public/javascripts/alert.js +140 -84
- package/public/javascripts/browser-push.js +56 -0
- package/public/javascripts/conversation.js +6 -3
- package/public/javascripts/display.js +2 -1
- package/public/javascripts/hashtags.js +2 -1
- package/public/javascripts/main.js +94 -60
- package/public/javascripts/map.js +5 -3
- package/public/javascripts/p2pWebNetwork.js +37 -4
- package/public/javascripts/protocol.js +3 -3
- package/public/javascripts/pubsub.js +107 -98
- package/public/javascripts/push.js +28 -0
- package/public/javascripts/s2.js +24 -2
- package/public/javascripts/service-manager.js +11 -7
- package/public/javascripts/store-memory.js +74 -0
- package/public/javascripts/store-redis.js +101 -0
- package/public/javascripts/translations.js +6 -6
- package/public/service-worker.js +50 -5
- package/public/stylesheets/style.css +10 -1
- package/server/getLocation.js +2 -2
- package/server/websocket.js +8 -4
|
@@ -1,88 +1,72 @@
|
|
|
1
|
-
//
|
|
2
|
-
|
|
1
|
+
// Centralized pubsub, for either client-only testing, or server-websocket testing.
|
|
2
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
3
|
+
// CivilDefense dht=-1 has no networking at all and loads this in browser, but isn't running when push would fire.
|
|
4
|
+
const { push } = globalThis.process ? await import('./push.js') : {};
|
|
3
5
|
const { TextEncoder, crypto, Buffer } = globalThis;
|
|
4
|
-
const publicationRolloverLimit = 1000;
|
|
5
|
-
|
|
6
|
-
function setBucket(collection, type, topicId, subject, value) { // Set value in the collection.
|
|
7
|
-
const bucket = collection[type][topicId] ||= {};
|
|
8
|
-
if ((collection === data) && (type === 'pub')) {
|
|
9
|
-
const keys = Object.keys(bucket);
|
|
10
|
-
const size = keys.length;
|
|
11
|
-
if (size >= publicationRolloverLimit) {
|
|
12
|
-
console.warn('Over pub limit on topic', topicId, size); // TODO: rotate out the earliest received.
|
|
13
|
-
delete bucket[keys[0]];
|
|
14
|
-
}
|
|
15
|
-
}
|
|
16
|
-
bucket[subject] = value;
|
|
17
|
-
}
|
|
18
|
-
function removeBucket(collection, type, topicId, subject) { // Return the value and stop storing it.
|
|
19
|
-
const bucket = collection[type][topicId];
|
|
20
|
-
if (!bucket) return null;
|
|
21
|
-
const value = bucket[subject];
|
|
22
|
-
delete bucket[subject];
|
|
23
|
-
if (!Object.keys(bucket).length) delete collection[type][topicId];
|
|
24
|
-
return value;
|
|
25
|
-
}
|
|
26
6
|
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
setBucket(timeouts, type, topicId, subject, setTimeout(() => { remover(); cancel(type, topicId, subject); }, timeout));
|
|
33
|
-
}
|
|
34
|
-
function cancel(type, topicId, subject) { // Cancel a sheduled expiration.
|
|
35
|
-
clearTimeout(removeBucket(timeouts, type, topicId, subject));
|
|
7
|
+
const hasBuffer = typeof Buffer !== 'undefined';
|
|
8
|
+
let toHex = hasBuffer ? u8 => Buffer.from(u8).toString('hex') : u8 => u8.toHex();
|
|
9
|
+
async function hash2Hex(text) { // Promise hex-coded SHA256(text).
|
|
10
|
+
const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
|
|
11
|
+
return toHex(new Uint8Array(hash));
|
|
36
12
|
}
|
|
13
|
+
export async function resolveTopic(descriptor) { // Normalize like axona.
|
|
14
|
+
if (typeof(descriptor) !== 'object') return {topicId: descriptor};
|
|
15
|
+
let {name, region, owner = null, write = owner ? 'owner' : 'open'} = descriptor;
|
|
16
|
+
if (typeof(region) === 'string') region = parseInt(region);;
|
|
17
|
+
const normalized = {name, region, owner, write};
|
|
18
|
+
const topicId = await hash2Hex(JSON.stringify(normalized));
|
|
19
|
+
return {name, region, owner, write, topicId};
|
|
20
|
+
};
|
|
21
|
+
// Promise a string.
|
|
22
|
+
export const deriveTopicId = descriptor => resolveTopic(descriptor).then(resolved => resolved.topicId);
|
|
37
23
|
|
|
38
|
-
// pub maps eventName => {[subject]: storageItem, ...}, where subject is the message id. Entries purged after PUBLISH_TIMEOUT.
|
|
39
|
-
// sub maps eventName => {[subject]: ws, ...}, where subject is the subscriber id. Entries purged after SUBSCRIPTION_TIMEOUT.
|
|
40
|
-
const data = {pub: {}, sub: {}};
|
|
41
|
-
function getDataValues(type, topicId) { // For all subjects
|
|
42
|
-
return Object.values(data[type][topicId] || {});
|
|
43
|
-
}
|
|
44
|
-
function getDataEntries(type, topicId) {
|
|
45
|
-
return Object.entries(data[type][topicId] || {});
|
|
46
|
-
}
|
|
47
24
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
25
|
+
// All storage (the type -> topicId -> subject -> value buckets, and their
|
|
26
|
+
// expiry) lives behind this interface. Swap the import below for a
|
|
27
|
+
// different implementation of the same interface (e.g. store-memory.js for
|
|
28
|
+
// local testing) -- nothing else in this file needs to change. Every store
|
|
29
|
+
// call is awaited: that's a no-op for a synchronous backend and required
|
|
30
|
+
// for an async one like Redis, so the same file works with either.
|
|
31
|
+
const storeSource = globalThis.process?.env.REDIS_URL ? './store-redis.js' : './store-memory.js';
|
|
32
|
+
const { store } = await import(storeSource);
|
|
33
|
+
console.log('store source', storeSource);
|
|
34
|
+
|
|
35
|
+
const SUBSCRIPTION_TIMEOUT = 0; // No need, because we run deleteSubscriber on disconnect.
|
|
36
|
+
const PUBLISH_TIMEOUT = 24 * 60 * 60e3; // Delete after 24 hours.
|
|
37
|
+
const TRACK_TIMEOUT = PUBLISH_TIMEOUT;
|
|
61
38
|
|
|
62
39
|
let invoke;
|
|
63
|
-
export function setReceiver(receiver) {
|
|
64
|
-
invoke = receiver;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
40
|
+
export function setReceiver(receiver) { // Set the means by which we fire events over a connection.
|
|
41
|
+
invoke = receiver; // E.g. (nodeId, handlerId, envelope) => getSocket(nodeId).send(JSON.stringify([handlerId, envelope]));
|
|
42
|
+
}
|
|
43
|
+
async function fireEvent(...rest) { // Send envelope to a subscribed hander.
|
|
44
|
+
try {
|
|
45
|
+
invoke(...rest);
|
|
46
|
+
} catch (error) { // Error sending, e.g., nodeTag is gone and we had not yet noticed.
|
|
47
|
+
const [nodeTag, id, envelope] = rest;
|
|
48
|
+
await deleteSubscriber(nodeTag);
|
|
49
|
+
const subscription = await store.get('sub', await deriveTopicId(envelope.topic), id);
|
|
50
|
+
if (subscription) push(envelope, subscription, PUBLISH_TIMEOUT);
|
|
51
|
+
}
|
|
70
52
|
}
|
|
71
53
|
|
|
72
|
-
export function subscribe(
|
|
54
|
+
export async function subscribe(topic, nodeTag, {since = 'all', pushData = null}) {
|
|
73
55
|
// Axona allows multiple handlers on the same topic, but we don't use that in civildefense, and do not implement it here.
|
|
74
|
-
|
|
56
|
+
|
|
57
|
+
const {name, region, owner, write, topicId} = await resolveTopic(topic);
|
|
75
58
|
const id = uuidv4();
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
59
|
+
await store.set('sub', topicId, nodeTag, id, SUBSCRIPTION_TIMEOUT);
|
|
60
|
+
// If pushData, store it separately by nodeTag until it needs to be activated.
|
|
61
|
+
if (pushData) await store.set('track', topicId, nodeTag, pushData, TRACK_TIMEOUT);
|
|
62
|
+
else store.remove('track', topicId, nodeTag);
|
|
63
|
+
if (since) { // invoke handler on any sticky data, but only after we have told client the subscription id. TODO: is there a better way?
|
|
80
64
|
setTimeout(async () => {
|
|
81
65
|
let lastEnvelope = null, lastTime = 0;
|
|
82
|
-
for (const envelope of
|
|
66
|
+
for (const envelope of await store.values('pub', topicId)) {
|
|
83
67
|
switch (since) {
|
|
84
68
|
case 'all':
|
|
85
|
-
await
|
|
69
|
+
await fireEvent(nodeTag, id, envelope);
|
|
86
70
|
break;
|
|
87
71
|
case 'latest':
|
|
88
72
|
if (envelope.ts > lastTime) {
|
|
@@ -91,56 +75,81 @@ export function subscribe(topicName, nodeTag, {since = 'all'}) {
|
|
|
91
75
|
}
|
|
92
76
|
break;
|
|
93
77
|
default: // Must be a timestamp
|
|
94
|
-
if (envelope.ts === since) await
|
|
78
|
+
if (envelope.ts === since) await fireEvent(nodeTag, id, envelope);
|
|
95
79
|
}
|
|
96
80
|
}
|
|
97
|
-
if (lastEnvelope)
|
|
81
|
+
if (lastEnvelope) fireEvent(nodeTag, id, lastEnvelope);
|
|
98
82
|
}, 100);
|
|
99
83
|
}
|
|
100
|
-
return {topicName, topicId, id};
|
|
84
|
+
return {topicName: {name, region, owner, write}, topicId, id};
|
|
101
85
|
}
|
|
102
86
|
|
|
103
|
-
export function unsubscribe(topic, nodeTag,
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
87
|
+
export async function unsubscribe(topic, nodeTag, {pushId}) {
|
|
88
|
+
// Unsubscribe subscription and any unactivated tracking subscription from the specified node.
|
|
89
|
+
// Additionally, an activated sticky pushId will be removed if specified - it is the nodeTag from a previous session).
|
|
90
|
+
|
|
91
|
+
const topicId = await deriveTopicId(topic);
|
|
92
|
+
let id = await store.remove('sub', topicId, nodeTag);
|
|
93
|
+
// Also remove any sticky push subscription:
|
|
94
|
+
// If not yet promoted to sub, it's in track under our current, protectable nodeTag.
|
|
95
|
+
await store.remove('track', topicId, nodeTag);
|
|
96
|
+
// Otherwise, it might have been activated in sub under pushId (i.e., a previous session nodeTag).
|
|
97
|
+
// TODO: pushId should probably be a JWS that was signed in the subscribing node's previous session by the pushId,
|
|
98
|
+
// thus proving that the request actually came from the succcessor to that node.
|
|
99
|
+
if (pushId) pushId = await store.remove('sub', topicId, pushId); // pushId must match that used during activation.
|
|
100
|
+
return {ok: !!id, id, pushId}; // Axona doesn't return the id(s) of the subscription(s), but it is convenient for us to do so.
|
|
108
101
|
}
|
|
109
102
|
|
|
110
|
-
export function deleteSubscriber(nodeTag) {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
103
|
+
export async function deleteSubscriber(nodeTag) {
|
|
104
|
+
// The node is gone: lost connection, or fireEvent failed.
|
|
105
|
+
|
|
106
|
+
for (const topicId of await store.topics('sub')) { // Remove in all topics.
|
|
107
|
+
await store.remove('sub', topicId, nodeTag);
|
|
108
|
+
// Activate pending push subscription, if any, by moving it from 'track' to active 'sub'.
|
|
109
|
+
const pushSubscription = await store.remove('track', topicId, nodeTag);
|
|
110
|
+
if (pushSubscription) {
|
|
111
|
+
//console.log('activiting', topicId, nodeTag, pushSubscription);
|
|
112
|
+
await store.set('sub', topicId, nodeTag, pushSubscription, TRACK_TIMEOUT);
|
|
118
113
|
}
|
|
119
114
|
}
|
|
120
115
|
}
|
|
121
116
|
|
|
122
|
-
|
|
123
|
-
|
|
117
|
+
function isConnectedSubscription(handlerInfo) { // True if a it's a direct-connect subscription, false if sticky (push).
|
|
118
|
+
return 'string' === typeof(handlerInfo);
|
|
119
|
+
}
|
|
120
|
+
async function handleEvents(topicId, envelope) {
|
|
121
|
+
const subs = await store.entries('sub', topicId);
|
|
122
|
+
// Each sub's handlerInfo can be an ordinary subscription nodeId, or a sticky push subscription data.
|
|
123
|
+
return Promise.all(subs.map(([nodeTag, handlerInfo]) => {
|
|
124
|
+
if (isConnectedSubscription(handlerInfo)) { // nodeId
|
|
125
|
+
return fireEvent(nodeTag, handlerInfo, envelope);
|
|
126
|
+
} else { // activated sticky push subscription data object.
|
|
127
|
+
return push(envelope, handlerInfo, PUBLISH_TIMEOUT);
|
|
128
|
+
}
|
|
129
|
+
}));
|
|
130
|
+
}
|
|
131
|
+
|
|
124
132
|
export async function publish(topic, message, {signWith}) {
|
|
125
|
-
|
|
133
|
+
// Publish message to any existing subscription.
|
|
134
|
+
|
|
135
|
+
const topicId = await deriveTopicId(topic);
|
|
126
136
|
const signerPubkey = signWith?.authorId || undefined;
|
|
127
137
|
const payload = JSON.stringify({message, publisher: signerPubkey});
|
|
128
|
-
const
|
|
129
|
-
const msgId = toHex(new Uint8Array(hash));
|
|
138
|
+
const msgId = await hash2Hex(payload);
|
|
130
139
|
const envelope = {msgId, topic, ts: Date.now(), message, signerPubkey};
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
expire('pub', topicId, msgId, () => removeBucket(data, 'pub', topicId, msgId), PUBLISH_TIMEOUT);
|
|
140
|
+
await store.set('pub', topicId, msgId, envelope, PUBLISH_TIMEOUT);
|
|
141
|
+
await handleEvents(topicId, envelope);
|
|
134
142
|
return msgId;
|
|
135
143
|
}
|
|
136
144
|
|
|
137
145
|
export async function unpublish(topic, msgId, {signWith}) {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
const
|
|
146
|
+
// Kill a publication by removing it from persistence, and firing removal event for existing subs.
|
|
147
|
+
|
|
148
|
+
const topicId = await deriveTopicId(topic);
|
|
149
|
+
const envelope = await store.remove('pub', topicId, msgId);
|
|
141
150
|
if (!envelope) return {ok: false}; // we didn't have it.
|
|
142
151
|
envelope.deleted = true;
|
|
143
152
|
envelope.message = null;
|
|
144
|
-
|
|
153
|
+
await handleEvents(topicId, envelope);
|
|
145
154
|
return {ok: true};
|
|
146
155
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import webpush from 'web-push';
|
|
2
|
+
// const vapidKeys = generateVAPIDKeys?.() || {};
|
|
3
|
+
// console.log({vapidKeys});
|
|
4
|
+
// setVapidDetails?.(
|
|
5
|
+
// 'mailto:example@yourdomain.org',
|
|
6
|
+
// vapidKeys.publicKey,
|
|
7
|
+
// vapidKeys.privateKey
|
|
8
|
+
// );
|
|
9
|
+
export async function push(envelope, {applicationServerKey, applicationId, ...subscription}, TTLms) {
|
|
10
|
+
// Prepare and post eneelope to the push-service specified in the subscription data.
|
|
11
|
+
const options = {
|
|
12
|
+
// It would be nice to include a topic tag, but that is application-specific.
|
|
13
|
+
// Maybe it could be specified by pub options, but that's getting a bit weird.
|
|
14
|
+
// Besides, I bet some services don't even use it.
|
|
15
|
+
TTL: TTLms * 1e-3,
|
|
16
|
+
vapidDetails: {
|
|
17
|
+
subject: `mailto:${applicationId}@example.org`,
|
|
18
|
+
publicKey: applicationServerKey,
|
|
19
|
+
privateKey: applicationId
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
// Consider delaying this by a random amount, to make it that much harder for someone
|
|
23
|
+
// to correlate known topic activity with someone's push records obtained from the service.
|
|
24
|
+
const reply = await webpush.sendNotification?.(subscription, JSON.stringify(envelope), options)
|
|
25
|
+
.catch(error => console.error(`${error.message} for push ${subscription?.endpoint}.`));
|
|
26
|
+
//console.log('push', {envelope, subscription, options, reply});
|
|
27
|
+
return reply;
|
|
28
|
+
}
|
package/public/javascripts/s2.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { s2, s1, r1 } from 's2js';
|
|
2
|
-
const { cellid, LatLng, Point, Cell, Cap, RegionCoverer } = s2;
|
|
1
|
+
import { s2, s1, r1, geojson } from 's2js';
|
|
2
|
+
const { cellid, LatLng, LatLngRect, Point, Cell, Cap, RegionCoverer } = s2;
|
|
3
3
|
import { cellHex } from './versions.js';
|
|
4
4
|
import { geoCellCenter/*, geoCellId*/ } from '@axona/protocol';
|
|
5
5
|
const { BigInt } = globalThis;
|
|
@@ -55,6 +55,15 @@ export function getSmallestCellId(lat, lng, level = MAX_MAP_LEVEL) { // Answer s
|
|
|
55
55
|
const userLocCellId = Cell.fromPoint(userPt).id; // This is at MAX_S2_LEVEL
|
|
56
56
|
return cellid.parent(userLocCellId, level);
|
|
57
57
|
}
|
|
58
|
+
export function getCellCorners(cell) { // Corners of BigInt cell, as [...[lat, lng]]
|
|
59
|
+
const s2cell = cellFromCellID(cell);
|
|
60
|
+
const radiansToDegrees = 180 / Math.PI;
|
|
61
|
+
return Array.from({ length: 4 }, (_, i) => {
|
|
62
|
+
const point = s2cell.vertex(i);
|
|
63
|
+
const latLng = s2.LatLng.fromPoint(point);
|
|
64
|
+
return [latLng.lat * radiansToDegrees, latLng.lng * radiansToDegrees];
|
|
65
|
+
});
|
|
66
|
+
}
|
|
58
67
|
|
|
59
68
|
// Return a list of the cell ids that contain the point, from region to MAX_S2_LEVEL
|
|
60
69
|
// Note that the first of the cells (the region
|
|
@@ -86,3 +95,16 @@ export function findCoverCellsByMinMaxLatLng({minLat, maxLat, minLng, maxLng, fu
|
|
|
86
95
|
const coverer = new RegionCoverer({minLevel, maxLevel, maxCells});
|
|
87
96
|
return coverer.covering(rect); // a CellUnion — array-like of bigint cell IDs, already normalized/minimal
|
|
88
97
|
}
|
|
98
|
+
|
|
99
|
+
export function getSmallestCellSurrounding(centerLat, centerLng, halfSizeDegrees = 0.05) {
|
|
100
|
+
// Returns the bigint cellid of the smallest single S2 cell that completely
|
|
101
|
+
// covers a size x size box centered on the given lat/lng.
|
|
102
|
+
|
|
103
|
+
const minLat = centerLat - halfSizeDegrees;
|
|
104
|
+
const maxLat = centerLat + halfSizeDegrees;
|
|
105
|
+
const minLng = centerLng - halfSizeDegrees;
|
|
106
|
+
const maxLng = centerLng + halfSizeDegrees;
|
|
107
|
+
|
|
108
|
+
// The center could be near a cell corner, so ask for up to four.
|
|
109
|
+
return findCoverCellsByMinMaxLatLng({minLat, maxLat, minLng, maxLng, options: {maxCells: 4}});
|
|
110
|
+
}
|
|
@@ -43,13 +43,17 @@ import { Int } from './translations.js';
|
|
|
43
43
|
If the user clears cache and reloads (even if there is no source/worker update, or a stale worker), they get the currently hosted versions.
|
|
44
44
|
*/
|
|
45
45
|
|
|
46
|
+
export async function postServiceMessage(method, params) {
|
|
47
|
+
const registration = await navigator.serviceWorker.ready;
|
|
48
|
+
return registration.active.postMessage({method, params});
|
|
49
|
+
}
|
|
50
|
+
|
|
46
51
|
let resolveCached;
|
|
47
52
|
async function cacheSource(version) {
|
|
48
53
|
console.log(`service-manager ${appVersion} requesting service-worker to cache source in ${version}.`);
|
|
49
54
|
const {promise, resolve} = Promise.withResolvers();
|
|
50
55
|
resolveCached = resolve;
|
|
51
|
-
|
|
52
|
-
registration.active.postMessage({method: 'cacheSource', params: version});
|
|
56
|
+
await postServiceMessage('cacheSource', version);
|
|
53
57
|
await promise;
|
|
54
58
|
console.log(`source ${version} is cached.`);
|
|
55
59
|
}
|
|
@@ -59,9 +63,9 @@ const updateText = document.getElementById('updateStatus');
|
|
|
59
63
|
const downloadButton = document.getElementById('downloadUpdates');
|
|
60
64
|
const downloadButton2 = document.getElementById('downloadUpdates2');
|
|
61
65
|
|
|
62
|
-
function getServiceVersion(
|
|
66
|
+
function getServiceVersion() { // Ask the service worker to send back it's version, which will trigger a compare.
|
|
63
67
|
console.log('requesting service-worker version');
|
|
64
|
-
|
|
68
|
+
postServiceMessage('version', appVersion);
|
|
65
69
|
}
|
|
66
70
|
function newVersionAvailable(newVersion) {
|
|
67
71
|
// Set up all the buttons and displays in case the user declines the popup,
|
|
@@ -111,14 +115,14 @@ await navigator.serviceWorker
|
|
|
111
115
|
console.log('statechange', newWorker.state, navigator.serviceWorker, navigator.serviceWorker.controller);
|
|
112
116
|
// We don't want to nag/confuse the user when installing fresh/first-time. There will not be a controller that time.
|
|
113
117
|
// if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
|
114
|
-
// getServiceVersion(
|
|
118
|
+
// getServiceVersion();
|
|
115
119
|
// }
|
|
116
120
|
};
|
|
117
121
|
};
|
|
118
122
|
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
|
119
123
|
console.log('controllerchange', navigator.serviceWorker, navigator.serviceWorker.controller);
|
|
120
124
|
if (!navigator.serviceWorker.controller) return;
|
|
121
|
-
getServiceVersion(
|
|
125
|
+
getServiceVersion();
|
|
122
126
|
});
|
|
123
127
|
// addEventListener, allowing other code to listen for other messages.
|
|
124
128
|
navigator.serviceWorker.addEventListener('message', async event => {
|
|
@@ -143,7 +147,7 @@ await navigator.serviceWorker
|
|
|
143
147
|
console.error('Unrecognized message from service worker', event.data);
|
|
144
148
|
}
|
|
145
149
|
});
|
|
146
|
-
|
|
150
|
+
getServiceVersion();
|
|
147
151
|
});
|
|
148
152
|
new BroadcastChannel('site_control').onmessage = event => {
|
|
149
153
|
const {method, params} = event.data;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// In-memory implementation of the pubsub storage interface.
|
|
2
|
+
//
|
|
3
|
+
// Interface (all methods synchronous here, but callers should treat them as
|
|
4
|
+
// potentially async-capable so a future backend, e.g. Redis, can be dropped
|
|
5
|
+
// in without touching pubsub.js):
|
|
6
|
+
// set(type, topicId, subject, value, ttlMs) -> void
|
|
7
|
+
// remove(type, topicId, subject) -> value | null
|
|
8
|
+
// values(type, topicId) -> value[]
|
|
9
|
+
// entries(type, topicId) -> [subject, value][]
|
|
10
|
+
// topics(type) -> topicId[]
|
|
11
|
+
//
|
|
12
|
+
// `type` is always 'pub' or 'sub'. Expiry (ttlMs) is owned entirely by the
|
|
13
|
+
// backend: this implementation uses setTimeout; a Redis backend would use
|
|
14
|
+
// native key/field TTL (e.g. HEXPIRE) instead, and wouldn't need `timers`.
|
|
15
|
+
|
|
16
|
+
const publicationRolloverLimit = 1000;
|
|
17
|
+
|
|
18
|
+
const data = {pub: {}, sub: {}, track: {}};
|
|
19
|
+
const timers = {pub: {}, sub: {}, track: {}};
|
|
20
|
+
|
|
21
|
+
function bucketFor(collection, type, topicId) {
|
|
22
|
+
return collection[type][topicId] ||= {};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function cancelTimer(type, topicId, subject) {
|
|
26
|
+
const bucket = timers[type][topicId];
|
|
27
|
+
if (!bucket) return;
|
|
28
|
+
clearTimeout(bucket[subject]);
|
|
29
|
+
delete bucket[subject];
|
|
30
|
+
if (!Object.keys(bucket).length) delete timers[type][topicId];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function get(type, topicId, subject) {
|
|
34
|
+
return bucketFor(data, type, topicId)?.[subject];
|
|
35
|
+
}
|
|
36
|
+
function set(type, topicId, subject, value, ttlMs) {
|
|
37
|
+
cancelTimer(type, topicId, subject); // replacing a value resets its expiry
|
|
38
|
+
const bucket = bucketFor(data, type, topicId);
|
|
39
|
+
if (type === 'pub') {
|
|
40
|
+
const keys = Object.keys(bucket);
|
|
41
|
+
if (keys.length >= publicationRolloverLimit) {
|
|
42
|
+
console.warn('Over pub limit on topic', topicId, keys.length);
|
|
43
|
+
delete bucket[keys[0]];
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
bucket[subject] = value;
|
|
47
|
+
if (ttlMs) {
|
|
48
|
+
bucketFor(timers, type, topicId)[subject] = setTimeout(() => remove(type, topicId, subject), ttlMs);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function remove(type, topicId, subject) {
|
|
53
|
+
cancelTimer(type, topicId, subject);
|
|
54
|
+
const bucket = data[type][topicId];
|
|
55
|
+
if (!bucket) return null;
|
|
56
|
+
const value = bucket[subject];
|
|
57
|
+
delete bucket[subject];
|
|
58
|
+
if (!Object.keys(bucket).length) delete data[type][topicId];
|
|
59
|
+
return value ?? null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function values(type, topicId) {
|
|
63
|
+
return Object.values(data[type][topicId] || {});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function entries(type, topicId) {
|
|
67
|
+
return Object.entries(data[type][topicId] || {});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function topics(type) {
|
|
71
|
+
return Object.keys(data[type]);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const store = {set, get, remove, values, entries, topics};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Redis implementation of the pubsub storage interface (see store-memory.js
|
|
2
|
+
// for the interface contract). Drop-in replacement: pubsub.js only needs to
|
|
3
|
+
// change which of these two modules it imports `store` from.
|
|
4
|
+
//
|
|
5
|
+
// Design notes:
|
|
6
|
+
// - Each (type, topicId, subject) item is its own Redis string key, so its
|
|
7
|
+
// 24h (or whatever) expiry is native Redis key TTL (`PX`) -- Redis reclaims
|
|
8
|
+
// the memory itself, no client-side timers, no rehydration needed on
|
|
9
|
+
// restart of this process.
|
|
10
|
+
// - A per-(type, topicId) sorted set (`idx:...`) tracks which subjects exist,
|
|
11
|
+
// scored by insertion time. This gives real, correctly-ordered "oldest
|
|
12
|
+
// first" eviction for the publication rollover limit.
|
|
13
|
+
// - Reads (`values`/`entries`) tolerate the index referencing an item that
|
|
14
|
+
// has since expired: they filter it out and lazily prune the index entry.
|
|
15
|
+
// - This intentionally avoids Redis 7.4+'s per-hash-field TTL (HEXPIRE), so
|
|
16
|
+
// it works on any reasonably recent Redis/Valkey. If you're guaranteed
|
|
17
|
+
// 7.4+, storing each bucket as one hash with HEXPIRE per field is a viable
|
|
18
|
+
// alternative with fewer keys -- not needed here.
|
|
19
|
+
|
|
20
|
+
import { createClient } from 'redis';
|
|
21
|
+
|
|
22
|
+
const publicationRolloverLimit = 1000;
|
|
23
|
+
|
|
24
|
+
const REDIS_URL = process.env.REDIS_URL || 'redis://localhost:6379';
|
|
25
|
+
const client = createClient({ url: REDIS_URL });
|
|
26
|
+
client.on('error', err => console.error('Redis client error', err));
|
|
27
|
+
await client.connect();
|
|
28
|
+
|
|
29
|
+
function itemKey(type, topicId, subject) {
|
|
30
|
+
return `civildefense.io:${type}:${topicId}:${subject}`;
|
|
31
|
+
}
|
|
32
|
+
function indexPrefix(type) {
|
|
33
|
+
return `civildefense.io:idx:${type}:`;
|
|
34
|
+
}
|
|
35
|
+
function indexKey(type, topicId) {
|
|
36
|
+
return `civildefense.io:idx:${type}:${topicId}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function get(type, topicId, subject) {
|
|
40
|
+
return client.get(itemKey(type, topicId, subject));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function set(type, topicId, subject, value, ttlMs) {
|
|
44
|
+
const idxKey = indexKey(type, topicId);
|
|
45
|
+
if (type === 'pub') { // Limit pubs to rollover limit.
|
|
46
|
+
const size = await client.zCard(idxKey);
|
|
47
|
+
if (size >= publicationRolloverLimit) {
|
|
48
|
+
console.warn('Over pub limit on topic', topicId, size);
|
|
49
|
+
const oldest = await client.zPopMin(idxKey); // {value, score} for the lowest-scored (earliest) member
|
|
50
|
+
if (oldest?.value) await client.del(itemKey(type, topicId, oldest.value));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
await client.set(itemKey(type, topicId, subject), JSON.stringify(value), ttlMs ? { PX: ttlMs } : undefined);
|
|
54
|
+
await client.zAdd(idxKey, { score: Date.now(), value: subject });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function remove(type, topicId, subject) { // Returns old value, or null.
|
|
58
|
+
const idxKey = indexKey(type, topicId);
|
|
59
|
+
const key = itemKey(type, topicId, subject);
|
|
60
|
+
const raw = await client.get(key); // TODO: consider pipelining this and next to be atomic.
|
|
61
|
+
await client.del(key);
|
|
62
|
+
await client.zRem(idxKey, subject);
|
|
63
|
+
return raw == null ? null : JSON.parse(raw);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function entries(type, topicId) {
|
|
67
|
+
const idxKey = indexKey(type, topicId);
|
|
68
|
+
const subjects = await client.zRange(idxKey, 0, -1); // TODO: consider pipelining this and following to be atomic.
|
|
69
|
+
if (!subjects.length) return [];
|
|
70
|
+
const raws = await client.mGet(subjects.map(subject => itemKey(type, topicId, subject)));
|
|
71
|
+
const result = [];
|
|
72
|
+
const stale = [];
|
|
73
|
+
subjects.forEach((subject, i) => {
|
|
74
|
+
if (raws[i] == null) stale.push(subject); // item's native TTL fired since we indexed it
|
|
75
|
+
else result.push([subject, JSON.parse(raws[i])]);
|
|
76
|
+
});
|
|
77
|
+
if (stale.length) await client.zRem(idxKey, stale);
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function values(type, topicId) {
|
|
82
|
+
return (await entries(type, topicId)).map(([, value]) => value);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function topics(type) {
|
|
86
|
+
const prefix = indexPrefix(type);
|
|
87
|
+
const ids = [];
|
|
88
|
+
// scanIterator yields batches (arrays of keys), not individual keys, on
|
|
89
|
+
// this client version -- normalize so this works across client versions.
|
|
90
|
+
for await (const batch of client.scanIterator({ MATCH: `${prefix}*` })) {
|
|
91
|
+
for (const key of [].concat(batch)) ids.push(key.slice(prefix.length));
|
|
92
|
+
}
|
|
93
|
+
return ids;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export const store = { get, set, remove, values, entries, topics };
|
|
97
|
+
|
|
98
|
+
// Not part of the shared interface -- useful for tests/graceful shutdown.
|
|
99
|
+
export async function close() {
|
|
100
|
+
await client.quit();
|
|
101
|
+
}
|
|
@@ -62,10 +62,6 @@ const translations = {
|
|
|
62
62
|
['#aboutReport']: {en: "Report immediate concerns to the public by tapping their location on the map.", es: "Informa de cualquier problema inmediato al público pulsando su ubicación en el mapa."},
|
|
63
63
|
['#aboutShared']: {en: "These locations are shared over anonymous p2p with other users in your area.", es: "Estas ubicaciones se comparten a través de redes P2P anónimas con otros usuarios de tu zona."},
|
|
64
64
|
['#aboutFade']: {en: "Reported concerns will fade away over 24 hours.", es: "Las preocupaciones manifestadas se disiparán en 24 horas."},
|
|
65
|
-
['#aboutAnyone1']: {en: "A connected mirror of this app can be", es: "Una réplica conectada de esta aplicación puede ser"},
|
|
66
|
-
['#aboutAnyone2']: {en: "run by anyone,", es: "administrado por cualquier persona,"},
|
|
67
|
-
['#aboutAnyone3']: {en: "in case this site is taken down.", es: "en caso de que este sitio sea dado de baja."},
|
|
68
|
-
['#learnMore']: {en: 'Learn More', es: "Más Información"},
|
|
69
65
|
['#version']: {en: "Version", es: "Versión"},
|
|
70
66
|
['#checkForUpdates']: {en: "check for updates", es: "Buscar actualizaciones"},
|
|
71
67
|
['#downloadUpdates']: {en: "install update", es: "Instalar actualización"},
|
|
@@ -104,7 +100,10 @@ const translations = {
|
|
|
104
100
|
['" alerts in the map. You can delete the topic altogether with the X.']: {es: "» en el mapa. Puedes eliminar el tema por completo con la X."},
|
|
105
101
|
|
|
106
102
|
['Click to see more information in another tab about what you can do with this app and how it is resistant to tracking, censorship, and takedown.']: {es: "Haz clic para ver más información en otra pestaña sobre lo que puedes hacer con esta aplicación y cómo resiste el rastreo, la censura y la retirada de contenido."},
|
|
107
|
-
['Click to
|
|
103
|
+
['Click to learn about receiving operating system notifications.']: {es: "Haz clic para obtener información sobre cómo recibir notificaciones del sistema operativo."},
|
|
104
|
+
['Click to learn about how you can choose to label yourself and how to recognize other users.']: {es: "Haz clic para saber cómo puedes elegir etiquetarte y cómo reconocer a otros usuarios."},
|
|
105
|
+
['Click to learn about installation, updates, and removal.']: {es: "Haga clic para obtener información sobre la instalación, las actualizaciones y la eliminación."},
|
|
106
|
+
|
|
108
107
|
['Show conversation for this ']: {es: "Mostrar la conversación de esta alerta de "},
|
|
109
108
|
[' alert.']: {es: "."},
|
|
110
109
|
['Click to download.']: {es: "Haz clic para descargar."},
|
|
@@ -140,5 +139,6 @@ const translations = {
|
|
|
140
139
|
|
|
141
140
|
|
|
142
141
|
['Too many posts. (5 allowed every 5 minutes.) Removing oldest from this period.']: {es: "Demasiadas publicaciones. (Se permiten 5 cada 5 minutos). Eliminando las más antiguas de este período."},
|
|
143
|
-
['#wipe']: {en: "Wipe all CivilDefense
|
|
142
|
+
['#wipe']: {en: "Wipe all CivilDefense data.", es: "Eliminar todos los datos de CivilDefense."},
|
|
143
|
+
['Installed']: {es: "Instalada"}
|
|
144
144
|
};
|