querysub 0.157.0 → 0.158.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "querysub",
3
- "version": "0.157.0",
3
+ "version": "0.158.0",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "note1": "note on node-forge fork, see https://github.com/digitalbazaar/forge/issues/744 for details",
@@ -24,7 +24,7 @@
24
24
  "node-forge": "https://github.com/sliftist/forge#e618181b469b07bdc70b968b0391beb8ef5fecd6",
25
25
  "pako": "^2.1.0",
26
26
  "preact": "^10.11.3",
27
- "socket-function": "^0.89.0",
27
+ "socket-function": "^0.90.0",
28
28
  "terser": "^5.31.0",
29
29
  "typesafecss": "^0.6.3",
30
30
  "yaml": "^2.5.0",
@@ -4,15 +4,16 @@ import { lazy } from "socket-function/src/caching";
4
4
  import { getStorageDir, getSubFolder } from "../fs";
5
5
  import fs from "fs";
6
6
  import { isNodeTrue } from "socket-function/src/misc";
7
- export const keys = isNodeTrue() && getArchives("keys");
7
+
8
+ export const keys = lazy(() => getArchives("keys"));
8
9
 
9
10
  export const getCloudflareCreds = lazy(async (): Promise<{ key: string; email: string }> => {
10
- let credsJSON = await keys.get("cloudflare.json");
11
+ let credsJSON = await keys().get("cloudflare.json");
11
12
  if (!credsJSON) {
12
13
  let localPath = getStorageDir() + "cloudflare.json";
13
14
  if (fs.existsSync(localPath)) {
14
15
  credsJSON = fs.readFileSync(localPath);
15
- await keys.set("cloudflare.json", credsJSON);
16
+ await keys().set("cloudflare.json", credsJSON);
16
17
  } else {
17
18
  throw new Error(`b2:/keys/cloudflare.json is missing. It should contain { "key": "your-key", "email": "your-email" }`);
18
19
  }
@@ -16,8 +16,6 @@ const DNS_TTLSeconds = {
16
16
  "A": 60,
17
17
  };
18
18
 
19
- export const keys = isNodeTrue() && getArchives("keys");
20
-
21
19
  export const hasDNSWritePermissions = lazy(async () => {
22
20
  if (!isNode()) return false;
23
21
  if (isClient()) return false;
@@ -6,8 +6,9 @@ import { ignoreErrors, logErrors, timeoutToUndefinedSilent } from "../../errors"
6
6
  import { green, magenta } from "socket-function/src/formatting/logColors";
7
7
  import { devDebugbreak } from "../../config";
8
8
  import { sort } from "socket-function/src/misc";
9
+ import { lazy } from "socket-function/src/caching";
9
10
 
10
- const snapshots = getArchives("snapshots");
11
+ const snapshots = lazy(() => getArchives("snapshots"));
11
12
 
12
13
  export type ArchiveSnapshotOverview = {
13
14
  // If live, it isn't a real file that can be loaded
@@ -54,7 +55,7 @@ export async function saveSnapshot(config: {
54
55
  }) {
55
56
  let { files } = config;
56
57
  let overview = getSnapshotOverview(files);
57
- await snapshots.set(overview.file, Buffer.from(files.join("\n")));
58
+ await snapshots().set(overview.file, Buffer.from(files.join("\n")));
58
59
  }
59
60
  function overviewToFileName(overview: Omit<ArchiveSnapshotOverview, "file">): string {
60
61
  return Object.entries({
@@ -84,7 +85,7 @@ function fileNameToOverview(fileName: string): ArchiveSnapshotOverview {
84
85
 
85
86
  // pathValueArchives.decodeDataPath
86
87
  export async function getSnapshotList(): Promise<ArchiveSnapshotOverview[]> {
87
- let snapshotFiles = await snapshots.find("");
88
+ let snapshotFiles = await snapshots().find("");
88
89
 
89
90
  let overview: ArchiveSnapshotOverview[] = [];
90
91
  let locker = await pathValueArchives.getArchiveLocker();
@@ -128,7 +129,7 @@ async function getSnapshotBase(snapshotFile: string | "live") {
128
129
  files: allFiles.map(x => x.file),
129
130
  };
130
131
  }
131
- let data = await snapshots.get(snapshotFile);
132
+ let data = await snapshots().get(snapshotFile);
132
133
  if (!data) {
133
134
  throw new Error(`Snapshot not found: ${snapshotFile}`);
134
135
  }
@@ -158,7 +159,7 @@ export async function loadSnapshot(config: {
158
159
  console.log(magenta(`Loading snapshot: ${overview.file}`));
159
160
  let locker = await pathValueArchives.getArchiveLocker();
160
161
 
161
- let snapshotData = await snapshots.get(overview.file);
162
+ let snapshotData = await snapshots().get(overview.file);
162
163
  if (!snapshotData) {
163
164
  throw new Error(`Snapshot not found: ${overview.file}`);
164
165
  }
@@ -1768,13 +1768,15 @@ class WriteValidStorage {
1768
1768
  let authorityValue = authorityStorage.getValueExactIgnoreInvalid(write.path, write.time);
1769
1769
  if (authorityValue) {
1770
1770
  authorityValue.valid = write.isValid;
1771
- if (!isCoreQuiet) {
1772
- console.log(`Setting valid state of ${debugPathValuePath(authorityValue)} to ${write.isValid}`);
1773
- }
1771
+ // NOTE: I think it is fine now for us to set the valid state early? Hopefully...
1772
+ // because it happens A LOT.
1773
+ // if (!isCoreQuiet) {
1774
+ // console.log(`Setting valid state of ${debugPathValuePath(authorityValue)} to ${write.isValid}`);
1775
+ // }
1774
1776
  } else {
1775
- if (isNode()) {
1776
- console.error(`Setting valid state of ${write.path}@${debugTime(write.time)} to ${write.isValid}, but the ValuePath was not found. If the ValuePath is found later, it might not have the valid state set correctly.`);
1777
- }
1777
+ // if (isNode()) {
1778
+ // console.error(`Setting valid state of ${write.path}@${debugTime(write.time)} to ${write.isValid}, but the ValuePath was not found. If the ValuePath is found later, it might not have the valid state set correctly.`);
1779
+ // }
1778
1780
  }
1779
1781
  }
1780
1782
 
@@ -162,6 +162,9 @@ interface WatcherOptions<Result> {
162
162
  // Fairly self explanatory. If there are nested createWatchers, instead of forking, we just
163
163
  // run them immediately.
164
164
  inlineNestedWatchers?: boolean;
165
+
166
+ // Temporary indicates after becoming synchronizes it will immediately dispose itself
167
+ temporary?: boolean;
165
168
  }
166
169
 
167
170
  let harvestableReadyLoopCount = 0;
@@ -286,6 +289,8 @@ export type SyncWatcher = {
286
289
  tag: SyncWatcherTag;
287
290
 
288
291
  hackHistory: { message: string; time: number }[];
292
+
293
+ createTime: number;
289
294
  }
290
295
  function addToHistory(watcher: SyncWatcher, message: string) {
291
296
  watcher.hackHistory.push({ message, time: Date.now() });
@@ -989,6 +994,7 @@ export class PathValueProxyWatcher {
989
994
  tag: new SyncWatcherTag(),
990
995
 
991
996
  hackHistory: [],
997
+ createTime: Date.now(),
992
998
  };
993
999
  const SHOULD_TRACE = this.SHOULD_TRACE(watcher);
994
1000
  const proxy = this.proxy;
@@ -1205,11 +1211,13 @@ export class PathValueProxyWatcher {
1205
1211
  debugger;
1206
1212
  // NOTE: Using forceEqualWrites will also fix this a lot of the time, such as when
1207
1213
  // a write contains random numbers or dates.
1208
- let errorMessage = `Too many attempts to sync with different values. If you are reading in a loop, make sure to read all the values, instead of aborting the loop if a value is not synced. ALSO, make sure you don't access paths with Math.random() or Date.now(). This will prevent the sync loop from ever stabilizing.`;
1214
+ let errorMessage = `Too many attempts (${watcher.countSinceLastFullSync}) to sync with different values. If you are reading in a loop, make sure to read all the values, instead of aborting the loop if a value is not synced. ALSO, make sure you don't access paths with Math.random() or Date.now(). This will prevent the sync loop from ever stabilizing.`;
1209
1215
  if (specialPromiseUnsynced) {
1210
1216
  errorMessage += ` A promise is being accessed, so it is possible triggerOnPromiseFinish is being used on a new promise every loop, which cannot work (you MUST cache MaybePromise and replace the value with a non-promise, otherwise it will never be available synchronously!)`;
1211
1217
  }
1218
+ errorMessage += ` (${watcher.debugName}})`;
1212
1219
  console.error(red(errorMessage));
1220
+ logUnsynced();
1213
1221
  result = { error: new Error(errorMessage).stack || "" };
1214
1222
  // Force the watches to be equal, so we stop looping
1215
1223
  watcher.pendingWatches = watcher.lastWatches;
@@ -1648,6 +1656,7 @@ export class PathValueProxyWatcher {
1648
1656
  let anyWrites = false;
1649
1657
  this.createWatcher({
1650
1658
  ...options,
1659
+ temporary: true,
1651
1660
  onResultUpdated: (result, writes, watcher) => {
1652
1661
  if (writes?.length) {
1653
1662
  anyWrites = true;
@@ -1700,6 +1709,7 @@ export class PathValueProxyWatcher {
1700
1709
  ...options,
1701
1710
  canWrite: true,
1702
1711
  dryRun: true,
1712
+ temporary: true,
1703
1713
  onResultUpdated: (result, values, watcher) => {
1704
1714
  watcher.dispose();
1705
1715
  if ("error" in result) {
@@ -199,6 +199,10 @@ export class Schema2Fncs {
199
199
  }
200
200
  return { defaultValue: def.defaultValue, };
201
201
  }
202
+ // OR, if it is an optional object, then the default IS undefined
203
+ if (def?.isMaybeUndefined) {
204
+ return { defaultValue: undefined };
205
+ }
202
206
  return undefined;
203
207
  }
204
208
  public static isObject(schema: Schema2, path: string[]): boolean {
@@ -293,6 +297,7 @@ type InternalTypeDef = {
293
297
 
294
298
  isObject?: boolean;
295
299
  isObjectGC?: boolean;
300
+ isMaybeUndefined?: boolean;
296
301
 
297
302
  isNotAtomic?: boolean;
298
303
 
@@ -340,6 +345,9 @@ function typeDefTypeToInternalType(
340
345
  isObject = true;
341
346
  isObjectGC = false;
342
347
  }
348
+ if (typeDef.path.includes("optionalObject") || typeDef.path.includes("optionalObjectNoGC")) {
349
+ rootResult.isMaybeUndefined = true;
350
+ }
343
351
 
344
352
  let undoRegions = typeDef.pathWithCalls.filter(x => x.key === "undoRegionObject");
345
353
  for (let undoRegion of undoRegions) {
@@ -370,6 +378,7 @@ function typeDefTypeToInternalType(
370
378
  nestedValues.isObjectGC = isObjectGC || isLookupGC;
371
379
  nestedValues.gcDelay = Number(typeDef.pathWithCalls.find(x => x.key === "gcDelay")?.callParams?.[0]) || undefined;
372
380
 
381
+
373
382
  if (isLookup) {
374
383
  let undoRegions = typeDef.pathWithCalls.filter(x => x.key === "undoRegion");
375
384
  for (let undoRegion of undoRegions) {
@@ -550,7 +550,7 @@ export class PathFunctionRunner {
550
550
  }
551
551
  }
552
552
  if (runCount > PathFunctionRunner.MAX_WATCH_LOOPS) {
553
- throw new Error(`MAX_WATCH_LOOPS exceeded for ${getDebugName(callPath, functionSpec, true)}`);
553
+ throw new Error(`MAX_WATCH_LOOPS exceeded for ${getDebugName(callPath, functionSpec, true)}. All accesses have to be consistent. So Querysub.time() instead of Date.now() and Querysub.nextId() instead of nextId() / Math.random(). If you need multiple random numbers, keep track of an index, and pass it to Querysub.nextId() for the nth random number.`);
554
554
  }
555
555
 
556
556
  // We NEED to depend on the function spec, so spec updates can be atomic (which is needed to deploy an update
@@ -140,14 +140,25 @@ export type PermissionsParameters = {
140
140
  };
141
141
  /** A false of false will deny read permissions, resulting in all reads being given value with a value
142
142
  * of undefined, and a time of 0.
143
- * NOTE: Permissions checks at the root (permissions: { READ_PERMISSIONS(config) { } }) will
144
- * be used to verify calls are allowed as well, even if the call doesn't read or write any data
145
- * that is under a permissions check.
143
+
146
144
  */
147
145
  export type PermissionsCallback = (config: PermissionsParameters) => PermissionsCheckResult;
148
146
  export type PermissionsCheckResult = boolean | { allowed: boolean; skipParentChecks?: boolean; };
149
147
  /*
150
148
  NOTE: All ancestor permissions checks are applied as well.
149
+ - This means if the ancestor disallows, it blocks any descendant checks even if they are allowed.
150
+
151
+ NOTE: If no permissions match, then access is disallowed by default.
152
+
153
+ NOTE: Permissions checks at the root (permissions: { READ_PERMISSIONS(config) { } }) will
154
+ be used to verify calls are allowed as well, even if the call doesn't read or write any data
155
+ that is under a permissions check.
156
+ - THIS MEANS that if there is no permission check at the root, calls won't be allowed by any user
157
+ (as we default to disallow).
158
+
159
+ NOTE: Servers watches ignore permissions, as they directly communicate with the underlying PathValueServer,
160
+ skipping the ProxyServer.
161
+ - However function calls by servers are still run by FunctionRunner, which will apply permission checks.
151
162
 
152
163
  IMPORTANT! Wildcards are tested via the "" path. This means if the "" key is provided access to a user, they can run
153
164
  Object.keys()/Object.values() on the data. In order to not accidentally provide list access, never allow "" to be
@@ -2033,7 +2033,7 @@ function blurFixOnMouseDownHack(event: MouseEvent) {
2033
2033
 
2034
2034
  // Looks like we are going to blur, so blur now
2035
2035
  if (selected instanceof HTMLElement && !selected.hasAttribute("data-no-early-blur")) {
2036
- console.info(`Simulating early blur to prevent unblurred inputs from existing after mousedown. You can use data-no-early-blur to opt-out of this feature`, selected);
2036
+ console.info(`Simulating early blur to prevent blur from firing after mousedown. This solves a problem where mousedown changes the UI, and then the blur fires on the wrong element. You can use data-no-early-blur to opt-out of this feature`, selected);
2037
2037
  selected.blur();
2038
2038
  }
2039
2039
  }
@@ -32,7 +32,7 @@ import { pathValueCommitter } from "../0-path-value-core/PathValueCommitter";
32
32
  import debugbreak from "debugbreak";
33
33
  import { extractPublicKey, verifyED25519 } from "../-a-auth/ed25519";
34
34
  import { registerDynamicResource, registerResource } from "../diagnostics/trackResources";
35
- import { registerPeriodic, shutdown } from "../diagnostics/periodic";
35
+ import { registerPeriodic, registerPreshutdownHandler, registerShutdownHandler, shutdown } from "../diagnostics/periodic";
36
36
  import { sha256 } from "js-sha256";
37
37
  import { green, red } from "socket-function/src/formatting/logColors";
38
38
  import { minify_sync } from "terser";
@@ -114,6 +114,7 @@ export class Querysub {
114
114
  public static Fragment = qreact.Fragment;
115
115
  public static t = t;
116
116
  public static shutdown = () => shutdown();
117
+ public static onShutdown = (fnc: () => Promise<void>) => registerPreshutdownHandler(fnc);
117
118
  /**
118
119
  IMPORTANT! New schemas must be deployed by calling `yarn deploy`
119
120
 
@@ -191,7 +192,8 @@ export class Querysub {
191
192
  public static getCallId = () => Querysub.getCallerMachineId() + "_" + Querysub.getCallTime();
192
193
  // Returns a random value between 0 and 1. The value depends on the callId, and index (being identical for
193
194
  // the same callid and index).
194
- public static callRandom = (index: number) => hashRandom(Querysub.getCallId(), index);
195
+ public static callRandom = (index = 0) => hashRandom(Querysub.getCallId(), index);
196
+ public static nextId = (index = 0) => Querysub.getCallId() + "_" + index;
195
197
 
196
198
  public static configRootDiscoveryLocation = configRootDiscoveryLocation;
197
199
 
@@ -274,6 +276,11 @@ export class Querysub {
274
276
  watchFunction: fnc
275
277
  }));
276
278
  }
279
+ public static commitDelayed = (fnc: () => unknown) => {
280
+ setImmediate(() => {
281
+ Querysub.serviceWriteDetached(fnc);
282
+ });
283
+ };
277
284
 
278
285
  public static exit = Querysub.gracefulTerminateProcess;
279
286
  public static async gracefulTerminateProcess() {
@@ -285,6 +292,7 @@ export class Querysub {
285
292
 
286
293
  public static syncedCommit = Querysub.serviceWrite;
287
294
  public static commitSynced = Querysub.serviceWrite;
295
+ public static commitAsync = Querysub.serviceWrite;
288
296
  public static async serviceWrite<T>(fnc: () => T) {
289
297
  return await proxyWatcher.commitFunction({
290
298
  canWrite: true,
@@ -383,7 +391,8 @@ export class Querysub {
383
391
  public static getSelfMachineId = getOwnMachineId;
384
392
 
385
393
  public static getOwnNodeId = getOwnNodeId;
386
- public static getSelfNodeId = getOwnMachineId;
394
+ public static getSelfNodeId = getOwnNodeId;
395
+ public static getOwnThreadId = getOwnNodeId;
387
396
 
388
397
  /** Set ClientWatcher.DEBUG_SOURCES to true for to be populated */
389
398
  public static getTriggerReason() {
@@ -898,7 +907,14 @@ function getSyncedTime() {
898
907
  if (call) {
899
908
  return call.runAtTime.time;
900
909
  }
910
+ let watcher = proxyWatcher.getTriggeredWatcherMaybeUndefined();
911
+ if (watcher?.options.temporary) {
912
+ return watcher.createTime;
913
+ }
901
914
  if (isNode()) {
915
+ if (watcher) {
916
+ throw new Error(`Trying to access time in a serverside non-temporary watcher. Clientside this is allowed, as infinite loops (render every frame) makes sense. Serverside this is not allowed. Did you try to run a call-type operatin in a watcher? If you manually created a watcher, you might want to set "temporary: true" if you will be immediately disposing it. You almost might want to fork your write logic with setImmediate to detach this from your watcher, so your write can access a single non-changing time.`);
917
+ }
902
918
  return Date.now();
903
919
  }
904
920
  initTimeLoop();
@@ -32,6 +32,7 @@ export class Table<RowT extends RowType> extends qreact.Component<TableType<RowT
32
32
  // Line and character limits before we cut off the inner content
33
33
  lineLimit?: number;
34
34
  characterLimit?: number;
35
+ dark?: boolean;
35
36
  }> {
36
37
  state = {
37
38
  limit: this.props.initialLimit || 100,
@@ -47,7 +48,11 @@ export class Table<RowT extends RowType> extends qreact.Component<TableType<RowT
47
48
 
48
49
  return (
49
50
  <table class={css.borderCollapse("collapse") + this.props.class}>
50
- <tr class={css.position("sticky").top(0).hsla(0, 0, 95, 0.9)}>
51
+ <tr class={
52
+ css.position("sticky").top(0)
53
+ + (this.props.dark && css.hsla(0, 0, 10, 0.9))
54
+ + (!this.props.dark && css.hsla(0, 0, 95, 0.9))
55
+ }>
51
56
  <th class={css.whiteSpace("nowrap")}>⧉ {allRows.length}</th>
52
57
  {Object.entries(columns).filter(x => x[1] !== null).map(([columnName, column]) =>
53
58
  <th class={css.pad2(8, 4) + cellClass}>{column?.title || toSpaceCase(columnName)}</th>
@@ -1,6 +1,6 @@
1
- import { runInfinitePoll, runInfinitePollCallAtStart } from "socket-function/src/batching";
1
+ import { delay, runInfinitePoll, runInfinitePollCallAtStart } from "socket-function/src/batching";
2
2
  import { isNode, timeInMinute } from "socket-function/src/misc";
3
- import { logErrors } from "../errors";
3
+ import { logErrors, timeoutToError } from "../errors";
4
4
  import debugbreak from "debugbreak";
5
5
  import { nodeDiscoveryShutdown } from "../-f-node-discovery/NodeDiscovery";
6
6
  import { authorityStorage } from "../0-path-value-core/pathValueCore";
@@ -15,6 +15,10 @@ let shutdownHandlers: (() => Promise<void>)[] = [];
15
15
  export function registerShutdownHandler(fnc: () => Promise<void>) {
16
16
  shutdownHandlers.push(fnc);
17
17
  }
18
+ let preshutdownHandlers: (() => Promise<void>)[] = [];
19
+ export function registerPreshutdownHandler(fnc: () => Promise<void>) {
20
+ preshutdownHandlers.push(fnc);
21
+ }
18
22
 
19
23
  function logAll() {
20
24
  for (let fnc of periodicFncs) {
@@ -24,8 +28,20 @@ function logAll() {
24
28
 
25
29
  logErrors(runInfinitePollCallAtStart(timeInMinute * 5, logAll));
26
30
 
31
+ let shuttingDown = false;
27
32
  export async function shutdown() {
33
+ if (shuttingDown) {
34
+ return;
35
+ }
36
+ shuttingDown = true;
28
37
  const { authorityStorage } = await import("../0-path-value-core/pathValueCore");
38
+ for (let fnc of preshutdownHandlers) {
39
+ try {
40
+ await timeoutToError(timeInMinute, fnc(), () => new Error(`Preshutdown handler ${fnc.name} timed out`));
41
+ } catch (e) {
42
+ console.log(`Error on preshutdown handler ${fnc.name}`, e);
43
+ }
44
+ }
29
45
  try {
30
46
  await authorityStorage.onShutdown();
31
47
  await nodeDiscoveryShutdown();
@@ -34,11 +50,13 @@ export async function shutdown() {
34
50
  }
35
51
  for (let fnc of shutdownHandlers) {
36
52
  try {
37
- await fnc();
53
+ await timeoutToError(timeInMinute, fnc(), () => new Error(`Shutdown handler ${fnc.name} timed out`));
38
54
  } catch (e) {
39
55
  console.log(`Error on shutdown handler ${fnc.name}`, e);
40
56
  }
41
57
  }
58
+ // Wait to allow any logged errors to hopefully be written somewhere?
59
+ await delay(2000);
42
60
  process.exit();
43
61
  }
44
62
 
package/src/server.ts CHANGED
@@ -14,7 +14,6 @@ import { AuthorityPath, pathValueAuthority2 } from "./0-path-value-core/NodePath
14
14
  import { Querysub } from "./4-querysub/QuerysubController";
15
15
  import { ClientWatcher } from "./1-path-client/pathValueClientWatcher";
16
16
  import { delay } from "socket-function/src/batching";
17
- import yargs from "yargs";
18
17
  import { JSONLACKS } from "socket-function/src/JSONLACKS/JSONLACKS";
19
18
  import { green } from "socket-function/src/formatting/logColors";
20
19
  import { formatTime } from "socket-function/src/formatting/format";
@@ -26,6 +25,7 @@ import { getOwnThreadId } from "./-f-node-discovery/NodeDiscovery";
26
25
  import { getOurAuthorities } from "./config2";
27
26
  import { devDebugbreak, isPublic } from "./config";
28
27
 
28
+ import yargs from "yargs";
29
29
  let yargObj = isNodeTrue() && yargs(process.argv)
30
30
  .option("authority", { type: "string", desc: `Defines the base paths we are an authority on (the domain is prepended to them). Either a file path to a JSON(AuthorityPath[]), or a base64 representation of the JSON(AuthorityPath[]).` })
31
31
  .option("verbose", { type: "boolean", desc: "Log all writes and reads" })
@@ -1,7 +1,7 @@
1
1
  import preact from "preact"; import { qreact } from "../4-dom/qreact";
2
2
  import { Button } from "../library-components/Button";
3
3
  import { css } from "../4-dom/css";
4
- import { loginTokenURL, user_data, user_functions } from "./userData";
4
+ import { getUserId, getUserIdAllowUndefined, getUserObj, loginTokenURL, user_data, user_functions } from "./userData";
5
5
  import { Querysub } from "../4-querysub/Querysub";
6
6
  import { isNode, list } from "socket-function/src/misc";
7
7
  import { InputLabel } from "../library-components/InputLabel";
@@ -16,7 +16,7 @@ export class LoginPage extends qreact.Component {
16
16
  render() {
17
17
  const login = () => {
18
18
  user_functions.sendLoginEmail({
19
- userId: this.state.email,
19
+ email: this.state.email,
20
20
  redirectURL: window.location.href,
21
21
  loginTokenRandomness: Buffer.from(list(32).map(() => Math.random() * 256)).toString("base64"),
22
22
  });
@@ -54,6 +54,22 @@ export class LoginPage extends qreact.Component {
54
54
  </div>
55
55
  </div>
56
56
  }
57
+ {getUserIdAllowUndefined() &&
58
+ <div class={css.hbox(10).alignSelf("center").maxWidth("50vw")}>
59
+ <div class={css.fontSize(20).hslcolor(110, 75, 75)}>
60
+ You are logged in as {getUserObj().email}.
61
+ </div>
62
+ <button onClick={() => {
63
+ let call = user_functions.logoutCurrent();
64
+ void Querysub.onCommitPredictFinished(call).finally(async () => {
65
+ await Querysub.gracefulTerminateProcess();
66
+ location.reload();
67
+ });
68
+ }}>
69
+ Logout
70
+ </button>
71
+ </div>
72
+ }
57
73
  </div>
58
74
  );
59
75
  }
@@ -268,7 +268,7 @@ export class UserPage extends qreact.Component {
268
268
  </table>
269
269
  </div>
270
270
  <div>
271
- <div class={css.fontSize(20)}>Latest Page Load By Machine/IP</div>
271
+ <div class={css.fontSize(20)}>Latest Access by Machine/IP</div>
272
272
  <table>
273
273
  <tr>
274
274
  <th>Source</th>
@@ -293,7 +293,7 @@ export class UserPage extends qreact.Component {
293
293
  <h2>Example Login Email</h2>
294
294
  <div key="email" ref2={e => {
295
295
  e.innerHTML = renderToString(generateLoginEmail({
296
- userId,
296
+ email: "user@example.com",
297
297
  loginToken: "loginToken",
298
298
  ip: "127.0.0.1",
299
299
  machineId: getOwnMachineId(),
@@ -14,12 +14,11 @@ async function main() {
14
14
  const email = yargObj.email;
15
15
  if (!email) throw new Error("No email provided." + howToCall);
16
16
  if (!email.includes("@")) throw new Error(`Invalid email ${email}.` + howToCall);
17
- const userId = email;
18
17
 
19
18
  await Querysub.hostService("addSuperUser");
20
19
 
21
20
  await Querysub.commitSynced(() => {
22
- scriptCreateUser({ userId });
21
+ scriptCreateUser({ email });
23
22
  });
24
23
 
25
24
  await pathValueCommitter.waitForValuesToCommit();
@@ -4,7 +4,7 @@ import { list } from "socket-function/src/misc";
4
4
  import { hslToRGB } from "socket-function/src/formatting/colors";
5
5
  import { joinVNodes } from "../misc";
6
6
  export function generateLoginEmail(config: {
7
- userId: string;
7
+ email: string;
8
8
  loginToken: string;
9
9
  ip: string;
10
10
  machineId: string;
@@ -12,10 +12,10 @@ export function generateLoginEmail(config: {
12
12
  timeoutTime: number;
13
13
  noHTMLWrapper?: boolean;
14
14
  }): { subject: string; contents: preact.VNode } {
15
- const { userId, loginToken, ip, machineId, redirectURL } = config;
15
+ const { loginToken, ip, machineId, redirectURL, email } = config;
16
16
  let url = new URL(redirectURL);
17
17
  url.searchParams.set(loginTokenURL.urlKey, loginToken);
18
- const subject = `${userId} Access Request (${new Date().toLocaleString()})`;
18
+ const subject = `${email} Access Request (${new Date().toLocaleString()})`;
19
19
  function center(jsx: preact.ComponentChild) {
20
20
  return (
21
21
  <table style={{ width: "100%" }}>
@@ -79,7 +79,7 @@ export function generateLoginEmail(config: {
79
79
  </table>
80
80
  {line(<div style={{ color: "rgb(255, 255, 255)" }}>
81
81
  {/* Mess up the email so it isn't turned into a link by gmail. */}
82
- Greetings <b>{userId.replaceAll(".", ".​")}</b>. You have requested access to <a href={redirectURL} style={{ color: "rgb(153, 204, 255)" }}>{new URL(redirectURL).hostname}</a>.
82
+ Greetings <b>{email.replaceAll(".", ".​")}</b>. You have requested access to <a href={redirectURL} style={{ color: "rgb(153, 204, 255)" }}>{new URL(redirectURL).hostname}</a>.
83
83
  </div>)}
84
84
  {line(<div style={{ color: "rgb(255, 255, 255)" }}>
85
85
  If you did not make this request, please disregard this email. Your account is safe and no action is required.
@@ -1,7 +1,7 @@
1
1
  import { atomic, atomicObjectWrite } from "../2-proxy/PathValueProxyWatcher";
2
2
  import { Querysub } from "../4-querysub/QuerysubController";
3
3
  import { red } from "socket-function/src/formatting/logColors";
4
- import { isNode, timeInHour } from "socket-function/src/misc";
4
+ import { isNode, sha256Hash, timeInHour } from "socket-function/src/misc";
5
5
  import { registerAliveChecker } from "../2-proxy/garbageCollection";
6
6
  import { generateLoginEmail } from "./loginEmail";
7
7
  import { logErrors } from "../errors";
@@ -15,6 +15,7 @@ import { devDebugbreak, getDomain, isDevDebugbreak } from "../config";
15
15
  import { delay } from "socket-function/src/batching";
16
16
  import { enableErrorNotifications } from "../library-components/errorNotifications";
17
17
  import { clamp } from "../misc";
18
+ import { sha256 } from "js-sha256";
18
19
 
19
20
  /*
20
21
  IMPORTANT!
@@ -186,6 +187,10 @@ const { data, functions } = Querysub.syncSchema<{
186
187
  claimedDisplayNames: {
187
188
  [displayName: string]: string;
188
189
  };
190
+
191
+ emailToUserId: {
192
+ [email: string]: string;
193
+ };
189
194
  };
190
195
  machineSecure: {
191
196
  [machineId: string]: {
@@ -332,6 +337,7 @@ export function wildcard0Owner(config: PermissionsParameters) {
332
337
  }
333
338
 
334
339
  function isAllowedToBeUser(userId: string) {
340
+ userId = String(userId);
335
341
  if (isClient()) {
336
342
  return data().users[userId].userId === userId;
337
343
  }
@@ -532,12 +538,20 @@ function internalCreateUser(config: {
532
538
  return data().users[userId];
533
539
  }
534
540
 
541
+ function createNewUserId() {
542
+ let randBuffer = Buffer.from(sha256(Querysub.nextId()), "hex");
543
+ const alphabet = "abcdefghijklmnopqrstuvwxyz";
544
+ return Array.from(randBuffer)
545
+ .map(x => alphabet[x % alphabet.length])
546
+ .join("")
547
+ .slice(0, 16);
548
+ }
535
549
  function sendLoginEmail(config: {
536
- userId: string;
550
+ email: string;
537
551
  redirectURL: string;
538
552
  loginTokenRandomness: string;
539
553
  }) {
540
- const { userId, redirectURL } = config;
554
+ const { email, redirectURL } = config;
541
555
  {
542
556
  let redirectObj = new URL(redirectURL);
543
557
  redirectObj.port = "443";
@@ -547,19 +561,24 @@ function sendLoginEmail(config: {
547
561
  }
548
562
  }
549
563
  Querysub.ignorePermissionsChecks(() => {
550
- if (!userId.includes("@")) {
551
- throw new Error(`Invalid email ${userId}`);
564
+ if (!email.includes("@")) {
565
+ throw new Error(`Invalid email ${email}`);
552
566
  }
553
- const email = userId;
567
+ let userId = atomic(data().secure.emailToUserId[email]) || createNewUserId();
554
568
 
555
569
  const signupError = `Signups are currently closed, and user doesn't presently exist. ${userId}. Add users with "yarn addsuperuser <email>"`;
556
570
  // NOTE: Using the same errors message makes it a BIT harder to know if you are blocked, so attackers are
557
571
  // less likely to change their IP, and more likely to give up.
558
572
  throwIfBlockedIP(signupError);
559
- if (!atomic(data().secure.signupsOpen) && !(userId in data().users)) {
573
+ if (!atomic(data().secure.signupsOpen) && (!userId || !(userId in data().users))) {
560
574
  throw new Error(signupError);
561
575
  }
562
576
 
577
+ // Create user if it doesn't exist
578
+ if (!(userId in data().users)) {
579
+ internalCreateUser({ userId, email });
580
+ }
581
+
563
582
  const ip = Querysub.getCallerIP();
564
583
  const machineId = Querysub.getCallerMachineId();
565
584
  const now = Querysub.getCallTime();
@@ -605,13 +624,6 @@ function sendLoginEmail(config: {
605
624
  }
606
625
  }
607
626
 
608
- // Create user if it doesn't exist
609
- {
610
- if (!(userId in data().users)) {
611
- internalCreateUser({ userId, email });
612
- }
613
- }
614
-
615
627
  let userObj = data().users[userId];
616
628
 
617
629
  // Per email/user throttle
@@ -644,7 +656,7 @@ function sendLoginEmail(config: {
644
656
  // NOTE: We don't track failed calls. Those should be tracked at a framework level and displayed on another page.
645
657
  logActivity({ type: "sendLoginEmail", fields: { userId, ip, machineId, redirectURL } });
646
658
  const { subject, contents } = generateLoginEmail({
647
- userId, loginToken, ip, machineId, redirectURL,
659
+ email, loginToken, ip, machineId, redirectURL,
648
660
  timeoutTime,
649
661
  });
650
662
  Querysub.onCommitFinished(async () => {
@@ -756,35 +768,37 @@ function registerPageLoadTime() {
756
768
  }
757
769
 
758
770
  function inviteUser(config: { email: string }) {
759
- let curUserObj = getUserObjAssert();
760
- if (config.email in curUserObj.invitedUsers) {
761
- console.info(`User ${config.email} already invited`);
762
- return;
763
- }
764
- // If the user already exists, don't invite
765
- const { email } = config;
766
- let userId = email;
767
- if (userId in data().users) {
768
- console.info(`User ${userId} already exists, no need to invite`);
769
- return;
770
- }
771
+ Querysub.ignorePermissionsChecks(() => {
772
+ let curUserObj = getUserObjAssert();
773
+ if (config.email in curUserObj.invitedUsers) {
774
+ console.info(`User ${config.email} already invited`);
775
+ return;
776
+ }
777
+ // If the user already exists, don't invite
778
+ const { email } = config;
779
+ let userId = atomic(data().secure.emailToUserId[email]) || createNewUserId();
780
+ if (userId in data().users) {
781
+ console.info(`User ${userId} already exists, no need to invite`);
782
+ return;
783
+ }
771
784
 
772
- const invitesRemaining = Number(curUserObj.invitesRemaining);
773
- if (invitesRemaining <= 0) {
774
- throw new Error("No invites remaining");
775
- }
785
+ const invitesRemaining = Number(curUserObj.invitesRemaining);
786
+ if (invitesRemaining <= 0) {
787
+ throw new Error("No invites remaining");
788
+ }
776
789
 
777
- curUserObj.invitedUsers[config.email] = atomicObjectWrite({
778
- time: Querysub.getCallTime(),
779
- });
780
- curUserObj.invitesRemaining--;
790
+ curUserObj.invitedUsers[config.email] = atomicObjectWrite({
791
+ time: Querysub.getCallTime(),
792
+ });
793
+ curUserObj.invitesRemaining--;
781
794
 
782
- if (!(userId in data().users)) {
783
- // Create the user if they don't exist
784
- let newUserObj = internalCreateUser({ userId, email });
785
- newUserObj.invitedBy = curUserObj.userId;
786
- }
787
- logActivity({ type: "inviteUser", fields: { inviter: curUserObj.userId, userId, } });
795
+ if (!(userId in data().users)) {
796
+ // Create the user if they don't exist
797
+ let newUserObj = internalCreateUser({ userId, email });
798
+ newUserObj.invitedBy = curUserObj.userId;
799
+ }
800
+ logActivity({ type: "inviteUser", fields: { inviter: curUserObj.userId, userId, } });
801
+ });
788
802
  }
789
803
 
790
804
  function logoutFromMachine(config: { machineId: string; userId: string }) {
@@ -888,11 +902,11 @@ function specialSetUserType(config: { userId: string; userType: UserType; }) {
888
902
 
889
903
 
890
904
  export function scriptCreateUser(config: {
891
- userId: string;
905
+ email: string;
892
906
  }) {
893
- const { userId } = config;
894
- let email = userId;
895
- data().users[config.userId] = {
907
+ const { email } = config;
908
+ let userId = createNewUserId();
909
+ data().users[userId] = {
896
910
  userId,
897
911
  email,
898
912
  settings: { displayName: userId },
@@ -905,6 +919,7 @@ export function scriptCreateUser(config: {
905
919
  invitedUsers: {},
906
920
  invitesRemaining: 0,
907
921
  };
922
+ data().secure.emailToUserId[email] = userId;
908
923
  }
909
924
 
910
925
  function setPostmarkAPIKey(config: { apiKey: string; }) {