@hyperframes/share 0.0.0-stage → 0.9.21
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/LICENSE +190 -0
- package/README.md +51 -2
- package/dist/appOwnedPaths.d.mts +6 -0
- package/dist/fromReview.d.mts +15 -0
- package/dist/grantRefusal.d.mts +8 -0
- package/dist/host.d.mts +29 -0
- package/dist/hosts.d.ts +39 -0
- package/dist/index.d.mts +21 -0
- package/dist/projectLock.d.mts +17 -0
- package/dist/projectSnapshot.d.mts +20 -0
- package/dist/reviewAudience.d.mts +3 -0
- package/dist/reviewClient.d.mts +122 -0
- package/dist/reviewExport.d.mts +172 -0
- package/dist/reviewFiles.d.mts +43 -0
- package/dist/reviewLinkStates.d.mts +18 -0
- package/dist/reviewOpenCalls.d.mts +16 -0
- package/dist/reviewShare.d.mts +106 -0
- package/dist/reviewSwitch.d.mts +53 -0
- package/dist/reviewUpload.d.mts +16 -0
- package/dist/reviewVersionSentences.d.mts +2 -0
- package/dist/signedInRequest.d.mts +56 -0
- package/dist/storage.d.mts +20 -0
- package/dist/types/access.d.ts +12 -0
- package/dist/types/review.d.ts +59 -0
- package/dist/types/reviewWire.d.ts +200 -0
- package/dist/types/share.d.ts +119 -0
- package/dist/types/shareOpen.d.ts +96 -0
- package/package.json +112 -3
- package/scripts/copy-types.mjs +3 -0
- package/src/appOwnedPaths.mjs +27 -0
- package/src/fromReview.mjs +27 -0
- package/src/grantRefusal.mjs +28 -0
- package/src/host.mjs +46 -0
- package/src/hosts.d.ts +39 -0
- package/src/index.mjs +82 -0
- package/src/projectLock.mjs +120 -0
- package/src/projectSnapshot.mjs +120 -0
- package/src/reviewAudience.mjs +24 -0
- package/src/reviewClient.mjs +260 -0
- package/src/reviewExport.mjs +827 -0
- package/src/reviewFiles.mjs +98 -0
- package/src/reviewLinkStates.mjs +47 -0
- package/src/reviewOpenCalls.mjs +40 -0
- package/src/reviewShare.mjs +645 -0
- package/src/reviewSwitch.mjs +430 -0
- package/src/reviewUpload.mjs +98 -0
- package/src/reviewVersionSentences.mjs +48 -0
- package/src/signedInRequest.mjs +125 -0
- package/src/storage.mjs +116 -0
- package/src/types/access.d.ts +12 -0
- package/src/types/review.d.ts +59 -0
- package/src/types/reviewWire.d.ts +200 -0
- package/src/types/share.d.ts +119 -0
- package/src/types/shareOpen.d.ts +96 -0
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// Changing a review link's space (decided 2026-10-13): a new link in the new space, the old one off.
|
|
3
|
+
// The record keeps the old project until the new one has the video; a file beside the record holds the change
|
|
4
|
+
// meanwhile, so the next read of the link finishes one a failure or a crash left half done.
|
|
5
|
+
import { firstReviewAudience } from "./reviewAudience.mjs";
|
|
6
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
7
|
+
import { mkdirInside } from "./storage.mjs";
|
|
8
|
+
import { writeJsonAtomic } from "./storage.mjs";
|
|
9
|
+
import { ReviewFailed } from "./reviewClient.mjs";
|
|
10
|
+
import {
|
|
11
|
+
applyFirstAudience,
|
|
12
|
+
listSpaces,
|
|
13
|
+
oneAtATime,
|
|
14
|
+
projectLine,
|
|
15
|
+
readReviewSwitch as readSwitch,
|
|
16
|
+
readShare,
|
|
17
|
+
rebindRecord,
|
|
18
|
+
reviewRecord,
|
|
19
|
+
reviewSentence,
|
|
20
|
+
reviewSwitchPath,
|
|
21
|
+
shown,
|
|
22
|
+
takenDown,
|
|
23
|
+
} from "./reviewShare.mjs";
|
|
24
|
+
import { rm } from "./storage.mjs";
|
|
25
|
+
|
|
26
|
+
/** @typedef {import("./reviewClient.mjs").ReviewClient} ReviewClient
|
|
27
|
+
* @typedef {import("./types/share.js").ReviewShare} ReviewShare */
|
|
28
|
+
|
|
29
|
+
/** `body`: the registration as sent, so a retry is exact; `revokeFailures`: failed turn-offs of the old link.
|
|
30
|
+
* @typedef {{ id?: string, from: string, fromSpace?: string, spaceId: string, space: string, projectId: string | null,
|
|
31
|
+
* opened?: boolean, paused?: boolean, audience?: import("./types/share.js").AccessMode | undefined, revokeFailures?:
|
|
32
|
+
* number, body: import("./types/reviewWire.js").RegisterProjectRequest }} Switch */
|
|
33
|
+
|
|
34
|
+
/** Puts the newest export on `projectId`; `onUploaded` runs once the server has it. Answers the sentence when it did
|
|
35
|
+
* not go up. @typedef {(projectId: string, onUploaded: () => Promise<void>) => Promise<string | null>} Upload */
|
|
36
|
+
|
|
37
|
+
/** Why the newest export cannot go up now, or null when it can. @typedef {() => Promise<"export_first" |
|
|
38
|
+
* "version_running" | null>} CanUpload */
|
|
39
|
+
|
|
40
|
+
/** A refusal of a change of space, with the space's name and, for a failed upload, why it failed. */
|
|
41
|
+
class SwitchFailed extends ReviewFailed {
|
|
42
|
+
/** @param {number} status @param {string} reason
|
|
43
|
+
* @param {{ space?: string, from?: string, sentence?: string }} said */
|
|
44
|
+
constructor(status, reason, { space = "", from = "", sentence = "" } = {}) {
|
|
45
|
+
super(status, reason);
|
|
46
|
+
this.space = space;
|
|
47
|
+
this.from = from;
|
|
48
|
+
this.sentence = sentence;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const switchPath = reviewSwitchPath;
|
|
53
|
+
|
|
54
|
+
/** Starts a new journal; only ever called once no earlier one holds an old link on. @param {string} dir
|
|
55
|
+
* @param {Switch} change */
|
|
56
|
+
async function startSwitch(dir, change) {
|
|
57
|
+
await oneAtATime(dir, async () => {
|
|
58
|
+
const record = await reviewRecord(dir);
|
|
59
|
+
if (record?.removed) throw new ReviewFailed(410, "review_project_removed");
|
|
60
|
+
if (record?.projectId !== change.from) throw new ReviewFailed(409, "switch_cancelled");
|
|
61
|
+
await mkdirInside(dir, ".hyperframes");
|
|
62
|
+
await writeJsonAtomic(switchPath(dir), change);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Whether the journal on disk is still this change's: a stale step never rewrites or drops a newer one.
|
|
67
|
+
* @param {string} dir @param {Switch} change */
|
|
68
|
+
const owns = async (dir, change) => {
|
|
69
|
+
const saved = await readSwitch(dir);
|
|
70
|
+
return (
|
|
71
|
+
saved !== null &&
|
|
72
|
+
saved.id === change.id &&
|
|
73
|
+
saved.from === change.from &&
|
|
74
|
+
saved.spaceId === change.spaceId &&
|
|
75
|
+
saved.body.client_operation_id === change.body.client_operation_id
|
|
76
|
+
);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** A removed or superseded change cannot publish or replace the current record.
|
|
80
|
+
* @param {string} dir @param {Switch} change */
|
|
81
|
+
async function assertCurrent(dir, change) {
|
|
82
|
+
const record = await reviewRecord(dir);
|
|
83
|
+
if (record?.removed) throw new ReviewFailed(410, "review_project_removed");
|
|
84
|
+
if (
|
|
85
|
+
!(await owns(dir, change)) ||
|
|
86
|
+
!record ||
|
|
87
|
+
(record.projectId !== change.from && record.projectId !== change.projectId)
|
|
88
|
+
)
|
|
89
|
+
throw new ReviewFailed(409, "switch_cancelled");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** @param {string} dir @param {Switch} change */
|
|
93
|
+
async function writeSwitch(dir, change) {
|
|
94
|
+
if (await owns(dir, change)) await writeJsonAtomic(switchPath(dir), change);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** @param {string} dir @param {Switch} change */
|
|
98
|
+
async function advanceSwitch(dir, change) {
|
|
99
|
+
await oneAtATime(dir, async () => {
|
|
100
|
+
await assertCurrent(dir, change);
|
|
101
|
+
await writeSwitch(dir, change);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** @param {string} dir @param {Switch} change */
|
|
106
|
+
async function dropSwitch(dir, change) {
|
|
107
|
+
if (await owns(dir, change)) await rm(switchPath(dir), { force: true });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The same change asks with the same key: the studio project, the old project, the space and the title. The title
|
|
111
|
+
* keeps a later round trip under a new name from replaying an earlier key with another body (operation_conflict).
|
|
112
|
+
* @param {string} studioProjectId @param {string} spaceId @param {string} from @param {string} title */
|
|
113
|
+
export const switchOperationId = (studioProjectId, spaceId, from, title) =>
|
|
114
|
+
`space-${createHash("sha256")
|
|
115
|
+
.update(JSON.stringify([studioProjectId, spaceId, from, title]))
|
|
116
|
+
.digest("hex")
|
|
117
|
+
.slice(0, 48)}`;
|
|
118
|
+
|
|
119
|
+
/** Every step that touches a project's journal (a change, a read's cleanup or resume) runs one after another. Its own
|
|
120
|
+
* line: a change waits inside it on the record's line, so sharing that one would wait on itself. */
|
|
121
|
+
const journal = projectLine();
|
|
122
|
+
const inTurn = journal.inTurn;
|
|
123
|
+
|
|
124
|
+
/** A read's own step only when nothing else holds the project: a read never waits on an upload, and the step that
|
|
125
|
+
* holds it finishes the journal itself. @param {string} dir @param {() => Promise<void>} step */
|
|
126
|
+
const whenIdle = (dir, step) => (journal.busy(dir) ? Promise.resolve() : inTurn(dir, step));
|
|
127
|
+
|
|
128
|
+
/** A space this token can register into, by id; anything else is refused before any write.
|
|
129
|
+
* @param {ReviewClient} client @param {string} spaceId */
|
|
130
|
+
async function targetSpace(client, spaceId) {
|
|
131
|
+
const space = (await listSpaces(client)).find((s) => s.space_id === spaceId);
|
|
132
|
+
if (!space?.can_create_review)
|
|
133
|
+
throw new SwitchFailed(403, "space_forbidden", { space: space?.name ?? "" });
|
|
134
|
+
return space;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Registration answers no retry fixes: the change is dropped and says why. */
|
|
138
|
+
const ENDS_A_CHANGE = new Set(["space_forbidden", "project_bound_to_other_owner", "same_project"]);
|
|
139
|
+
|
|
140
|
+
/** Registers the change's body in its space, once; a refusal there ends the change. So does an answer naming the
|
|
141
|
+
* old project: going on would put the version back on it and then turn its link, the only one, off.
|
|
142
|
+
* @param {ReviewClient} client @param {string} dir @param {Switch} change @returns {Promise<Switch>} */
|
|
143
|
+
async function registered(client, dir, change) {
|
|
144
|
+
if (change.projectId) return change;
|
|
145
|
+
try {
|
|
146
|
+
const { project_id } = await client.register(change.body, change.spaceId);
|
|
147
|
+
if (project_id === change.from) {
|
|
148
|
+
console.error("[review] registration in another space answered the old project:", {
|
|
149
|
+
request: { space: change.spaceId, body: change.body },
|
|
150
|
+
answer: { project_id },
|
|
151
|
+
});
|
|
152
|
+
throw new SwitchFailed(409, "same_project", { space: change.space });
|
|
153
|
+
}
|
|
154
|
+
const next = { ...change, projectId: project_id };
|
|
155
|
+
await advanceSwitch(dir, next);
|
|
156
|
+
return next;
|
|
157
|
+
} catch (error) {
|
|
158
|
+
return registrationRefused(error, dir, change);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** @param {unknown} error @param {string} dir @param {Switch} change @returns {Promise<never>} */
|
|
163
|
+
async function registrationRefused(error, dir, change) {
|
|
164
|
+
const reason = error instanceof ReviewFailed ? error.reason : "";
|
|
165
|
+
if (reason === "review_project_limit_reached" || reason === "review_subscription_required") {
|
|
166
|
+
await advanceSwitch(dir, { ...change, paused: true });
|
|
167
|
+
throw error;
|
|
168
|
+
}
|
|
169
|
+
if (reason === "review_project_removed") {
|
|
170
|
+
await dropSwitch(dir, change);
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
if (!ENDS_A_CHANGE.has(reason)) throw error;
|
|
174
|
+
await dropSwitch(dir, change);
|
|
175
|
+
throw new SwitchFailed(/** @type {ReviewFailed} */ (error).status, reason, {
|
|
176
|
+
space: change.space,
|
|
177
|
+
from: change.fromSpace ?? "",
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The new link on and open to its space, then the old one off; a failed turn-off stays in the file. A takedown of
|
|
182
|
+
* the old review before the new link opened keeps the new one as it is: private, its link not turned on.
|
|
183
|
+
* @param {ReviewClient} client @param {string} dir @param {Switch & { projectId: string }} change */
|
|
184
|
+
async function settle(client, dir, change) {
|
|
185
|
+
return oneAtATime(dir, async () => {
|
|
186
|
+
await assertCurrent(dir, change);
|
|
187
|
+
let share = await readShare(client, change.projectId);
|
|
188
|
+
let now = change;
|
|
189
|
+
if (!now.opened) {
|
|
190
|
+
if (!(await oldTakenDown(client, now)))
|
|
191
|
+
share = await openNew(client, now.projectId, share, now.audience);
|
|
192
|
+
await writeSwitch(dir, (now = { ...now, opened: true }));
|
|
193
|
+
}
|
|
194
|
+
await turnOffOld(client, dir, now);
|
|
195
|
+
return shown(client, share);
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** The new link on and open to its space. A takedown refuses both for good (409 moderation_hold), so the change
|
|
200
|
+
* stops asking and still turns the old link off. @param {ReviewClient} client @param {string} id
|
|
201
|
+
* @param {import("./types/reviewWire.js").ShareDTO} share @param {import("./types/share.js").AccessMode} [audience] */
|
|
202
|
+
async function openNew(client, id, share, audience) {
|
|
203
|
+
if (takenDown(share)) return share;
|
|
204
|
+
try {
|
|
205
|
+
if (share.revoked)
|
|
206
|
+
share = await client.linkOn(id, {
|
|
207
|
+
expected_link_generation: share.link_generation,
|
|
208
|
+
client_operation_id: randomUUID(),
|
|
209
|
+
});
|
|
210
|
+
return await applyFirstAudience(client, id, share, audience);
|
|
211
|
+
} catch (error) {
|
|
212
|
+
if (!(error instanceof ReviewFailed) || error.reason !== "moderation_hold") throw error;
|
|
213
|
+
return readShare(client, id);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Only a 204, or a 404 for a share the server no longer has, ends the cleanup. Anything else keeps it, a lost
|
|
218
|
+
* permission (403) included: that proves nothing about the link, and the owner may need to act.
|
|
219
|
+
* @param {ReviewClient} client @param {string} dir @param {Switch} change */
|
|
220
|
+
async function turnOffOld(client, dir, change) {
|
|
221
|
+
try {
|
|
222
|
+
await client.linkOff(change.from);
|
|
223
|
+
} catch (error) {
|
|
224
|
+
if (!(error instanceof ReviewFailed) || error.status !== 404) {
|
|
225
|
+
console.error("[review] old link not turned off yet:", error);
|
|
226
|
+
const failed =
|
|
227
|
+
(change.revokeFailures ?? 0) +
|
|
228
|
+
(error instanceof ReviewFailed && error.status === 403 ? 2 : 1);
|
|
229
|
+
return writeSwitch(dir, { ...change, revokeFailures: failed });
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
await dropSwitch(dir, change);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** The switched change whose old link is still to turn off, or null. @param {string} dir */
|
|
236
|
+
async function cleanupOf(dir) {
|
|
237
|
+
const change = await readSwitch(dir);
|
|
238
|
+
if (!change?.projectId) return null;
|
|
239
|
+
const at = (await reviewRecord(dir))?.projectId;
|
|
240
|
+
return change.opened || at === change.projectId
|
|
241
|
+
? { ...change, projectId: change.projectId }
|
|
242
|
+
: null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Each read of the link turns off an old link a change left on, in turn with every other journal step.
|
|
246
|
+
* @param {{ projectPath: string, client: ReviewClient }} request @returns {Promise<void>} */
|
|
247
|
+
export const retryOldLink = ({ projectPath, client }) =>
|
|
248
|
+
whenIdle(projectPath, async () => {
|
|
249
|
+
const change = await cleanupOf(projectPath);
|
|
250
|
+
if (change) await settle(client, projectPath, change);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
/** What the popover says of an old link still on: where it is, and whether retries keep failing.
|
|
254
|
+
* @param {string} projectPath @returns {Promise<ReviewShare["oldLink"]>} */
|
|
255
|
+
export async function oldLinkOf(projectPath) {
|
|
256
|
+
const change = await cleanupOf(projectPath);
|
|
257
|
+
if (!change) return null;
|
|
258
|
+
return { space: change.fromSpace ?? "the old space", stuck: (change.revokeFailures ?? 0) >= 2 };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Registered: the video goes up to the new project, the record switches there once it has it, then `settle`.
|
|
262
|
+
* @param {ReviewClient} client @param {string} dir @param {Switch} change @param {Upload} upload */
|
|
263
|
+
async function carryOn(client, dir, change, upload) {
|
|
264
|
+
const at = await registered(client, dir, change);
|
|
265
|
+
await oneAtATime(dir, () => assertCurrent(dir, at));
|
|
266
|
+
await readShare(client, /** @type {string} */ (at.projectId)).catch(async (error) => {
|
|
267
|
+
if (error instanceof ReviewFailed && error.reason === "review_project_removed")
|
|
268
|
+
await dropSwitch(dir, at);
|
|
269
|
+
throw error;
|
|
270
|
+
});
|
|
271
|
+
const projectId = /** @type {string} */ (at.projectId);
|
|
272
|
+
let switched = false;
|
|
273
|
+
const failure = await new Promise((done, failed) => {
|
|
274
|
+
const onUploaded = async () => {
|
|
275
|
+
await oneAtATime(dir, async () => {
|
|
276
|
+
await assertCurrent(dir, at);
|
|
277
|
+
await openNew(client, projectId, await readShare(client, projectId), at.audience);
|
|
278
|
+
await writeSwitch(dir, { ...at, opened: true });
|
|
279
|
+
await rebindRecord(dir, projectId, at.spaceId);
|
|
280
|
+
});
|
|
281
|
+
switched = true;
|
|
282
|
+
done(null);
|
|
283
|
+
};
|
|
284
|
+
upload(projectId, onUploaded).then(done, failed);
|
|
285
|
+
});
|
|
286
|
+
if (!switched)
|
|
287
|
+
throw new SwitchFailed(409, "switch_upload", {
|
|
288
|
+
sentence: failure ?? "Couldn't add this version. Try again.",
|
|
289
|
+
});
|
|
290
|
+
return settle(client, dir, { ...at, projectId });
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** A change that switched but left its old link on finishes first; no new change starts while it stays on.
|
|
294
|
+
* @param {ReviewClient} client @param {string} dir @param {Switch} saved */
|
|
295
|
+
async function oldLinkFirst(client, dir, saved) {
|
|
296
|
+
await settle(client, dir, { ...saved, projectId: /** @type {string} */ (saved.projectId) });
|
|
297
|
+
if (await readSwitch(dir))
|
|
298
|
+
throw new SwitchFailed(409, "old_link_on", { space: saved.fromSpace ?? "" });
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** A change to `spaceId`, refused before anything is written when the space or the export cannot take it.
|
|
302
|
+
* @param {{ client: ReviewClient, record: { studioProjectId: string, projectId: string | null }, title: string,
|
|
303
|
+
* spaceId: string, now: import("./types/reviewWire.js").ShareDTO, canUpload: CanUpload, audience?:
|
|
304
|
+
* import("./types/share.js").AccessMode | undefined }} request
|
|
305
|
+
* @returns {Promise<Switch>} */
|
|
306
|
+
async function newChange({ client, record, title, spaceId, now, canUpload, audience }) {
|
|
307
|
+
const space = await targetSpace(client, spaceId);
|
|
308
|
+
const chosen = firstReviewAudience(space.review_entitlements, audience);
|
|
309
|
+
const blocked = await canUpload();
|
|
310
|
+
if (blocked) throw new SwitchFailed(409, blocked, { space: space.name });
|
|
311
|
+
const from = /** @type {string} */ (record.projectId);
|
|
312
|
+
const named = title.slice(0, 255);
|
|
313
|
+
return {
|
|
314
|
+
id: randomUUID(),
|
|
315
|
+
audience: chosen,
|
|
316
|
+
from,
|
|
317
|
+
fromSpace: now.space?.name ?? "the old space",
|
|
318
|
+
spaceId,
|
|
319
|
+
space: space.name,
|
|
320
|
+
projectId: null,
|
|
321
|
+
body: {
|
|
322
|
+
studio_project_id: record.studioProjectId,
|
|
323
|
+
title: named,
|
|
324
|
+
client_operation_id: switchOperationId(record.studioProjectId, spaceId, from, named),
|
|
325
|
+
},
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** @param {string} projectPath */
|
|
330
|
+
async function sharedRecord(projectPath) {
|
|
331
|
+
const record = await reviewRecord(projectPath);
|
|
332
|
+
if (record?.removed) throw new ReviewFailed(410, "review_project_removed");
|
|
333
|
+
if (!record?.opened || !record.projectId) throw new ReviewFailed(404, "not_shared");
|
|
334
|
+
return { ...record, projectId: record.projectId };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Moves a shared project's link to `spaceId`: a new link there, the newest export on it, the old link off. Its own
|
|
338
|
+
* space answers the link as it is. Refuses before any write when the export cannot go up.
|
|
339
|
+
* @param {{ projectPath: string, title: string, client: ReviewClient, spaceId: string, upload: Upload,
|
|
340
|
+
* canUpload: CanUpload, audience?: import("./types/share.js").AccessMode | undefined }} request @returns
|
|
341
|
+
* {Promise<ReviewShare>} */
|
|
342
|
+
export const changeSpace = ({ projectPath, title, client, spaceId, upload, canUpload, audience }) =>
|
|
343
|
+
inTurn(projectPath, async () => {
|
|
344
|
+
const record = await sharedRecord(projectPath);
|
|
345
|
+
const from = record.projectId;
|
|
346
|
+
const saved = await readSwitch(projectPath);
|
|
347
|
+
if (saved?.projectId === from) await oldLinkFirst(client, projectPath, saved);
|
|
348
|
+
// A change already registered there carries on; one never registered is made again, with the same key.
|
|
349
|
+
if (saved?.from === from && saved.spaceId === spaceId) {
|
|
350
|
+
if (await droppedForTakedown(client, projectPath, saved))
|
|
351
|
+
throw new ReviewFailed(409, "moderation_hold");
|
|
352
|
+
const target = await targetSpace(client, spaceId);
|
|
353
|
+
const retry = {
|
|
354
|
+
...saved,
|
|
355
|
+
audience: firstReviewAudience(target.review_entitlements, audience),
|
|
356
|
+
paused: false,
|
|
357
|
+
};
|
|
358
|
+
await advanceSwitch(projectPath, retry);
|
|
359
|
+
return carryOn(client, projectPath, retry, upload);
|
|
360
|
+
}
|
|
361
|
+
const now = await readShare(client, from);
|
|
362
|
+
if (sameSpace(now, record.spaceId, spaceId)) return shown(client, now);
|
|
363
|
+
if (takenDown(now)) throw new ReviewFailed(409, "moderation_hold");
|
|
364
|
+
const change = await newChange({ client, record, title, spaceId, now, canUpload, audience });
|
|
365
|
+
await startSwitch(projectPath, change);
|
|
366
|
+
return carryOn(client, projectPath, change, upload);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
/** On the next read of the link: finishes the upload of a change a crash or a failure left half done (the old link's
|
|
370
|
+
* turn-off is `retryOldLink`'s). One never switched that the record has moved past is dropped: no link of it is on.
|
|
371
|
+
* @param {{ projectPath: string, client: ReviewClient, upload: Upload, canUpload: CanUpload }} request */
|
|
372
|
+
export const resumeSpaceChange = async ({ projectPath, client, upload, canUpload }) => {
|
|
373
|
+
// An old link still on is retryOldLink's: holding the journal only to see that would make the next read skip it.
|
|
374
|
+
if (await cleanupOf(projectPath)) return;
|
|
375
|
+
return whenIdle(projectPath, async () => {
|
|
376
|
+
const change = await readSwitch(projectPath);
|
|
377
|
+
if (!change || change.paused || (await cleanupOf(projectPath))) return;
|
|
378
|
+
const at = (await reviewRecord(projectPath))?.projectId;
|
|
379
|
+
if (at !== change.from) return dropSwitch(projectPath, change);
|
|
380
|
+
if (await droppedForTakedown(client, projectPath, change)) return;
|
|
381
|
+
if (!(await canUpload())) await carryOn(client, projectPath, change, upload);
|
|
382
|
+
});
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
/** A saved change whose old review staff took down is dropped before it registers, uploads or opens anything: the
|
|
386
|
+
* server takes all three under a takedown, so finishing it would share the review again. A failed read is no
|
|
387
|
+
* takedown and the change goes on as it did. @param {ReviewClient} client @param {string} dir @param {Switch} change */
|
|
388
|
+
async function droppedForTakedown(client, dir, change) {
|
|
389
|
+
try {
|
|
390
|
+
if (!(await oldTakenDown(client, change))) return false;
|
|
391
|
+
} catch (error) {
|
|
392
|
+
if (error instanceof ReviewFailed && error.reason === "review_project_removed")
|
|
393
|
+
await dropSwitch(dir, change);
|
|
394
|
+
throw error;
|
|
395
|
+
}
|
|
396
|
+
await dropSwitch(dir, change);
|
|
397
|
+
return true;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Whether staff took the change's old review down; a failed read says no.
|
|
401
|
+
* @param {ReviewClient} client @param {Switch} change */
|
|
402
|
+
async function oldTakenDown(client, change) {
|
|
403
|
+
const old = await readShare(client, change.from).catch((error) => {
|
|
404
|
+
if (error instanceof ReviewFailed && error.reason === "review_project_removed") throw error;
|
|
405
|
+
return null;
|
|
406
|
+
});
|
|
407
|
+
return Boolean(old && takenDown(old));
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** The sentence for a failed change of space; any other failure reads as the share's, or as `fallback`.
|
|
411
|
+
* @param {unknown} error @param {string} [fallback] */
|
|
412
|
+
export function switchSentence(error, fallback) {
|
|
413
|
+
if (!(error instanceof SwitchFailed)) return reviewSentence(error, fallback);
|
|
414
|
+
const space = error.space || "that space";
|
|
415
|
+
if (error.reason === "space_forbidden") return `You can't share to ${space} with this account.`;
|
|
416
|
+
if (error.reason === "project_bound_to_other_owner")
|
|
417
|
+
return `Someone else already shares this project in ${space}.`;
|
|
418
|
+
if (error.reason === "export_first")
|
|
419
|
+
return `Export an MP4 of the project as it is now, then share it to ${space}.`;
|
|
420
|
+
if (error.reason === "old_link_on")
|
|
421
|
+
return "Couldn’t move the link. Your current link still works.";
|
|
422
|
+
if (error.reason === "same_project")
|
|
423
|
+
return `HyperFrames kept this link in ${error.from || "its space"} instead of moving it to ${space}. The old link still works.`;
|
|
424
|
+
if (error.reason === "version_running")
|
|
425
|
+
return "A version is still going up. Change the space once it's on the page.";
|
|
426
|
+
return `${error.sentence} The old link still works.`;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** @param {import("./types/reviewWire.js").ShareDTO} share @param {string | null} saved @param {string} target */
|
|
430
|
+
const sameSpace = (share, saved, target) => (share.space?.space_id ?? saved) === target;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// The bytes of a review version: its checksum, and the signed PUT to storage.
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { createReadStream } from "node:fs";
|
|
5
|
+
import { reviewUploadOrigins } from "./host.mjs";
|
|
6
|
+
|
|
7
|
+
/** The checksum the export route takes: the SHA-256 digest as padded base64, never hex.
|
|
8
|
+
* @param {string} file @returns {Promise<string>} */
|
|
9
|
+
export function fileSha256Base64(file) {
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
const hash = createHash("sha256");
|
|
12
|
+
createReadStream(file)
|
|
13
|
+
.on("data", (chunk) => hash.update(chunk))
|
|
14
|
+
.on("error", reject)
|
|
15
|
+
.on("end", () => resolve(hash.digest("base64")));
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** @typedef {"sent" | "expired" | "refused" | "offline"} PutOutcome */
|
|
20
|
+
|
|
21
|
+
/** Streams the file to the signed URL through main's fetch (the app's proxy), the server's headers as given plus
|
|
22
|
+
* the length, and no bearer. Nothing sent or answered for `stallMs` ends it as offline, never "Uploading" forever.
|
|
23
|
+
* @param {{ put_url: string, headers: Record<string, string> }} upload @param {string} file @param {number} size
|
|
24
|
+
* @param {(sent: number) => void} onSent @param {{ stallMs?: number, fetchImpl?: typeof fetch }} [options] */
|
|
25
|
+
export async function putFile(
|
|
26
|
+
upload,
|
|
27
|
+
file,
|
|
28
|
+
size,
|
|
29
|
+
onSent,
|
|
30
|
+
{ stallMs = 60_000, fetchImpl = fetch } = {},
|
|
31
|
+
) {
|
|
32
|
+
let target;
|
|
33
|
+
try {
|
|
34
|
+
target = new URL(upload.put_url);
|
|
35
|
+
} catch {
|
|
36
|
+
return "refused";
|
|
37
|
+
}
|
|
38
|
+
const loopback = target.protocol === "http:" && target.hostname === "127.0.0.1";
|
|
39
|
+
if (
|
|
40
|
+
(target.protocol !== "https:" && !loopback) ||
|
|
41
|
+
target.username ||
|
|
42
|
+
target.password ||
|
|
43
|
+
target.hash ||
|
|
44
|
+
!reviewUploadOrigins().includes(target.origin)
|
|
45
|
+
) {
|
|
46
|
+
return "refused";
|
|
47
|
+
}
|
|
48
|
+
const stall = new AbortController();
|
|
49
|
+
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
50
|
+
let timer;
|
|
51
|
+
const arm = () => (clearTimeout(timer), (timer = setTimeout(() => stall.abort(), stallMs)));
|
|
52
|
+
let sent = 0;
|
|
53
|
+
// Pulled, never read ahead: a chunk is read when the network takes it, so the stall timer measures sending.
|
|
54
|
+
const stream = createReadStream(file);
|
|
55
|
+
const chunks = stream[Symbol.asyncIterator]();
|
|
56
|
+
const body = new ReadableStream(
|
|
57
|
+
{
|
|
58
|
+
async pull(controller) {
|
|
59
|
+
const { value, done } = await chunks.next();
|
|
60
|
+
if (done) return controller.close();
|
|
61
|
+
sent += value.length;
|
|
62
|
+
arm();
|
|
63
|
+
onSent(sent);
|
|
64
|
+
controller.enqueue(value);
|
|
65
|
+
},
|
|
66
|
+
cancel: () => void chunks.return?.(),
|
|
67
|
+
},
|
|
68
|
+
{ highWaterMark: 0 },
|
|
69
|
+
);
|
|
70
|
+
arm();
|
|
71
|
+
try {
|
|
72
|
+
/** @type {RequestInit & {duplex: "half"}} */
|
|
73
|
+
const request = {
|
|
74
|
+
method: "PUT",
|
|
75
|
+
headers: { ...upload.headers, "content-length": String(size) },
|
|
76
|
+
body,
|
|
77
|
+
duplex: "half",
|
|
78
|
+
redirect: "manual",
|
|
79
|
+
signal: stall.signal,
|
|
80
|
+
};
|
|
81
|
+
const reply = await fetchImpl(target, request);
|
|
82
|
+
return await outcomeOf(reply);
|
|
83
|
+
} catch (error) {
|
|
84
|
+
console.error("[review] upload failed:", /** @type {Error} */ (error).name);
|
|
85
|
+
return "offline";
|
|
86
|
+
} finally {
|
|
87
|
+
clearTimeout(timer);
|
|
88
|
+
stream.destroy();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** S3 answers 403 both when the signed URL has expired and when the signature does not match; only the first is
|
|
93
|
+
* fixed by fresh instructions. @param {Response} reply @returns {Promise<PutOutcome>} */
|
|
94
|
+
async function outcomeOf(reply) {
|
|
95
|
+
if (reply.status < 300) return (await reply.body?.cancel(), "sent");
|
|
96
|
+
const text = await reply.text().catch(() => "");
|
|
97
|
+
return reply.status === 403 && /expired/i.test(text) ? "expired" : "refused";
|
|
98
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// The sentence a person reads for a version that did not go up; the log keeps the status and reason.
|
|
3
|
+
import { ReviewFailed } from "./reviewClient.mjs";
|
|
4
|
+
|
|
5
|
+
/** A held video is never called rejected: it waits for a person, and people with the link keep the version they had. */
|
|
6
|
+
const HELD_FOR_REVIEW =
|
|
7
|
+
"Almost ready. Someone at HyperFrames needs to take a look first. This can take up to a day.";
|
|
8
|
+
|
|
9
|
+
/** @type {Record<string, string | ((error: ReviewFailed) => string)>} */
|
|
10
|
+
const SAID = {
|
|
11
|
+
export_too_large: "This video is over 2 GB, the most a review page takes. Export a smaller one.",
|
|
12
|
+
export_too_long:
|
|
13
|
+
"This video is over 30 minutes, the most a review page takes. Export a shorter one.",
|
|
14
|
+
export_daily_limit: (error) =>
|
|
15
|
+
`You've added the most versions for today. Try again ${laterIn(error.retryAfterMs)}.`,
|
|
16
|
+
content_rejected: "This video can't be shared.",
|
|
17
|
+
export_blocked: "Adding videos to review pages isn't available for this account.",
|
|
18
|
+
feature_unavailable: "Review pages aren't available for your account yet.",
|
|
19
|
+
permission_denied: "This account can't add videos to this review page yet.",
|
|
20
|
+
upload_expired: "The upload took too long. Try again.",
|
|
21
|
+
storage_refused: "HyperFrames couldn't store the upload. Try again.",
|
|
22
|
+
still_checking:
|
|
23
|
+
"HyperFrames is still checking the video. People with the link can watch once it's ready; open Share to look again.",
|
|
24
|
+
held_for_review: HELD_FOR_REVIEW,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** @param {unknown} error */
|
|
28
|
+
export function versionSentence(error) {
|
|
29
|
+
if (!(error instanceof ReviewFailed)) return "Couldn't add this version. Try again.";
|
|
30
|
+
const said = SAID[error.reason];
|
|
31
|
+
if (said) return typeof said === "function" ? said(error) : said;
|
|
32
|
+
if (error.status === 0) return "Couldn't reach HeyGen. Check your connection and try again.";
|
|
33
|
+
if (error.status === 401) return "Your sign-in has expired. Sign in again, then add the version.";
|
|
34
|
+
// A failed export whose reason has no sentence of its own (export_processing_failed): the server's message is
|
|
35
|
+
// written for developers, so it is never shown.
|
|
36
|
+
if (error.status === 200)
|
|
37
|
+
return "HyperFrames couldn't process this video. Export it again, then try again.";
|
|
38
|
+
return "Couldn't add this version. Try again.";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** "in about 3 hours", from the server's Retry-After. @param {number | undefined} ms */
|
|
42
|
+
function laterIn(ms) {
|
|
43
|
+
if (!ms) return "tomorrow";
|
|
44
|
+
const minutes = Math.ceil(ms / 60_000);
|
|
45
|
+
if (minutes < 60) return `in ${minutes} minute${minutes === 1 ? "" : "s"}`;
|
|
46
|
+
const hours = Math.round(minutes / 60);
|
|
47
|
+
return `in about ${hours} hour${hours === 1 ? "" : "s"}`;
|
|
48
|
+
}
|