@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.
@@ -1,88 +1,72 @@
1
- // In memory pubsub, for either client-only testing, or server-websocket testing
2
- const { v4:uuidv4 } = await import('uuid');
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 SUBSCRIPTION_TIMEOUT = 0; // No need, because we run deleteSubscriber on disconnect.
28
- const PUBLISH_TIMEOUT = 24 * 60 * 60e3; // Delete after 24 hours.
29
- const timeouts = {pub: {}, sub: {}};
30
- function expire(type, topicId, subject, remover, timeout) { // Cancellably schedule remover() to fire at timeout.
31
- if (!timeout) return;
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
- function deleteSub(topicId, subject) {
49
- return removeBucket(data, 'sub', topicId, subject);
50
- }
51
- function normalizeTopic({name, region, owner, write = 'open'} = {}) {
52
- if (typeof(region) === 'string') region = parseInt(region);;
53
- return {name, region, owner, write};
54
- }
55
- function deriveTopicId(topic) {
56
- return JSON.stringify(normalizeTopic(topic)); // No need to hash in this implementation.
57
- }
58
- function delay(ms = 0) {
59
- return ms && new Promise(resolve => setTimeout(resolve, ms));
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
- const throttleMS = 30; // Just to yield to other stuff.
67
- async function pauseInvoke(...rest) {
68
- invoke(...rest);
69
- await new Promise(resolve => setTimeout(resolve, throttleMS));
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(topicName, nodeTag, {since = 'all'}) {
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
- const topicId = deriveTopicId(topicName);
56
+
57
+ const {name, region, owner, write, topicId} = await resolveTopic(topic);
75
58
  const id = uuidv4();
76
- cancel('sub', topicId, id);
77
- expire('sub', topicId, id, () => deleteSub(topicId, id), SUBSCRIPTION_TIMEOUT);
78
- setBucket(data, 'sub', topicId, nodeTag, id);
79
- if (since) { // invoke handler on any sticky data, but only after we have told client the subscription id.
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 getDataValues('pub', topicId)) {
66
+ for (const envelope of await store.values('pub', topicId)) {
83
67
  switch (since) {
84
68
  case 'all':
85
- await pauseInvoke(nodeTag, id, envelope);
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 pauseInvoke(nodeTag, id, envelope);
78
+ if (envelope.ts === since) await fireEvent(nodeTag, id, envelope);
95
79
  }
96
80
  }
97
- if (lastEnvelope) invoke(nodeTag, id, 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, options) {
104
- const topicId = deriveTopicId(topic);
105
- cancel('sub', topicId, nodeTag);
106
- const id = deleteSub(topicId, nodeTag);
107
- return {ok: true, id}; // Axona doesn't return the id(s) of the subscription(s), but it is convenient for us to do so.
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
- for (const topicId in data.sub) {
112
- const keySubs = data.sub[topicId];
113
- for (const [subject, value] of Object.entries(keySubs)) {
114
- if (nodeTag === subject) {
115
- deleteSub(topicId, subject, keySubs);
116
- cancel('sub', topicId, subject);
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
- const hasBuffer = typeof Buffer !== 'undefined';
123
- let toHex = hasBuffer ? u8 => Buffer.from(u8).toString('hex') : u8 => u8.toHex();
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
- const topicId = deriveTopicId(topic);
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 hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(payload));
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
- for (const [nodeTag, id] of getDataEntries('sub', topicId)) await pauseInvoke(nodeTag, id, envelope);
132
- setBucket(data, 'pub', topicId, msgId, envelope);
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
- const topicId = deriveTopicId(topic);
139
- cancel('pub', topicId, msgId);
140
- const envelope = removeBucket(data, 'pub', topicId, msgId);
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
- for (const [nodeTag, id] of getDataEntries('sub', topicId)) await pauseInvoke(nodeTag, id, envelope);
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
+ }
@@ -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
- const registration = await navigator.serviceWorker.ready;
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(registration) { // Ask the service worker to send back it's version, which will trigger a compare.
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
- registration.active.postMessage({method: 'version', params: appVersion});
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(registration);
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(registration);
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
- navigator.serviceWorker.ready.then(getServiceVersion);
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 see the source code and documentation for serving copies of this app in another tab.']: {es: "Haz clic para ver el código fuente y la documentación para servir copias de esta aplicación en otra pestaña."},
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.io data.", es: "Eliminar todos los datos de CivilDefense.io."}
142
+ ['#wipe']: {en: "Wipe all CivilDefense data.", es: "Eliminar todos los datos de CivilDefense."},
143
+ ['Installed']: {es: "Instalada"}
144
144
  };