@volter/twin-linkedin 0.1.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/LICENSE +202 -0
- package/README.md +39 -0
- package/dist/client/linkedin-mirror.bundle.js +323 -0
- package/dist/client/linkedin-mirror.d.ts +43 -0
- package/dist/client/linkedin-mirror.js +393 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +30 -0
- package/dist/src/index.d.ts +10 -0
- package/dist/src/index.js +74 -0
- package/dist/src/linkedin-budget.d.ts +36 -0
- package/dist/src/linkedin-budget.js +87 -0
- package/dist/src/linkedin-capabilities.d.ts +4 -0
- package/dist/src/linkedin-capabilities.js +1027 -0
- package/dist/src/linkedin-conformance.d.ts +8 -0
- package/dist/src/linkedin-conformance.js +58 -0
- package/dist/src/linkedin-connector.d.ts +66 -0
- package/dist/src/linkedin-connector.js +326 -0
- package/dist/src/linkedin-errors.d.ts +21 -0
- package/dist/src/linkedin-errors.js +38 -0
- package/dist/src/linkedin-media.d.ts +125 -0
- package/dist/src/linkedin-media.js +331 -0
- package/dist/src/linkedin-mirror-ui.d.ts +59 -0
- package/dist/src/linkedin-mirror-ui.js +174 -0
- package/dist/src/linkedin-server.d.ts +10 -0
- package/dist/src/linkedin-server.js +154 -0
- package/dist/src/linkedin-twin.d.ts +23 -0
- package/dist/src/linkedin-twin.js +1220 -0
- package/package.json +58 -0
- package/src/cli.ts +28 -0
- package/src/index.ts +118 -0
- package/src/linkedin-budget.ts +108 -0
- package/src/linkedin-capabilities.ts +1057 -0
- package/src/linkedin-conformance.ts +73 -0
- package/src/linkedin-connector.ts +314 -0
- package/src/linkedin-errors.ts +58 -0
- package/src/linkedin-media.ts +363 -0
- package/src/linkedin-mirror-ui.ts +182 -0
- package/src/linkedin-server.ts +150 -0
- package/src/linkedin-twin.ts +1143 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
export type AssetStatus = 'WAITING_UPLOAD' | 'PROCESSING' | 'AVAILABLE' | 'PROCESSING_FAILED';
|
|
2
|
+
/** One part of a video's multipart upload, as the upload instructions name it. */
|
|
3
|
+
export type VideoPart = {
|
|
4
|
+
firstByte: number;
|
|
5
|
+
lastByte: number;
|
|
6
|
+
etag?: string;
|
|
7
|
+
sha256?: string;
|
|
8
|
+
};
|
|
9
|
+
/** The stored asset: an image or a video, keyed by its URN's id. */
|
|
10
|
+
export type AssetRecord = {
|
|
11
|
+
kind: 'image' | 'video';
|
|
12
|
+
/** The asset id — the tail of `urn:li:image:<id>` / `urn:li:video:<id>`. */
|
|
13
|
+
id: string;
|
|
14
|
+
/** `urn:li:organization:<id>` (or a person) — who may attach it. */
|
|
15
|
+
owner: string;
|
|
16
|
+
status: AssetStatus;
|
|
17
|
+
created_ms: number;
|
|
18
|
+
/** Epoch ms after which the upload URLs refuse. */
|
|
19
|
+
upload_expires_ms: number;
|
|
20
|
+
/** IMAGE: the whole file. VIDEO: the assembled file once finalized. */
|
|
21
|
+
sha256?: string;
|
|
22
|
+
size?: number;
|
|
23
|
+
media_type?: string;
|
|
24
|
+
width?: number;
|
|
25
|
+
height?: number;
|
|
26
|
+
duration_ms?: number;
|
|
27
|
+
/** VIDEO: the declared size and its parts. */
|
|
28
|
+
file_size_bytes?: number;
|
|
29
|
+
parts?: VideoPart[];
|
|
30
|
+
upload_thumbnail?: boolean;
|
|
31
|
+
upload_captions?: boolean;
|
|
32
|
+
thumbnail_sha256?: string;
|
|
33
|
+
thumbnail_media_type?: string;
|
|
34
|
+
/** VIDEO: the world instant finalize accepted the file; processing completes PROCESSING_MS later. */
|
|
35
|
+
finalized_ms?: number;
|
|
36
|
+
processing_failure_reason?: string;
|
|
37
|
+
/** The asset's own upload secret (hex): the key its upload URLs are signed with. Per asset, so no key
|
|
38
|
+
* is shared between assets, branches or clones, and nothing is ever created on first use. */
|
|
39
|
+
upload_secret: string;
|
|
40
|
+
};
|
|
41
|
+
/** LinkedIn cuts a video into 4 MB parts: 0-4194303, 4194304-8388607, … (videos-api). */
|
|
42
|
+
export declare const VIDEO_PART_BYTES = 4194304;
|
|
43
|
+
/** Videos API, "Video File Size Specifications": "File size: Between 75kb and 500MB." — decimal
|
|
44
|
+
* units as written: 75,000 and 500,000,000 bytes. */
|
|
45
|
+
export declare const MIN_VIDEO_BYTES = 75000;
|
|
46
|
+
export declare const MAX_VIDEO_BYTES = 500000000;
|
|
47
|
+
/** Videos API: "Length: Three seconds to 30 minutes." */
|
|
48
|
+
export declare const MIN_VIDEO_MS = 3000;
|
|
49
|
+
export declare const MAX_VIDEO_MS: number;
|
|
50
|
+
/** Images API: "Images with less than 36,152,320 pixels." */
|
|
51
|
+
export declare const MAX_IMAGE_PIXELS = 36152320;
|
|
52
|
+
/** How long (world time) a finalized video stays PROCESSING before it is AVAILABLE. A read folds the
|
|
53
|
+
* state from the stored instant and the request's own — reading never moves anything. */
|
|
54
|
+
export declare const PROCESSING_MS = 2000;
|
|
55
|
+
/** "Typically URLs expire 30 days from the time an upload is initialized." */
|
|
56
|
+
export declare const UPLOAD_URL_LIFETIME_MS: number;
|
|
57
|
+
/** Where the bytes of a digest live under this service's resources (the key the ranged reads take). */
|
|
58
|
+
export declare const blobKey: (sha256: string) => string;
|
|
59
|
+
export declare function isAssetId(id: string): boolean;
|
|
60
|
+
/** Store bytes content-addressed; a re-upload of identical content is a no-op write. */
|
|
61
|
+
export declare function putLinkedinBlob(bytes: Uint8Array, root?: string): Promise<{
|
|
62
|
+
sha256: string;
|
|
63
|
+
size: number;
|
|
64
|
+
}>;
|
|
65
|
+
/** Stored bytes by digest (this branch, then its ancestors); null when absent or malformed. */
|
|
66
|
+
export declare function readLinkedinBlob(sha256: string, root?: string): Promise<Uint8Array | null>;
|
|
67
|
+
export declare function writeAsset(record: AssetRecord, root?: string): Promise<void>;
|
|
68
|
+
export declare function readAsset(id: string, root?: string): Promise<AssetRecord | undefined>;
|
|
69
|
+
/** Whether this BRANCH holds the asset's record itself (not only an ancestor it reads through). A
|
|
70
|
+
* branch writes only its own assets: an upload an ancestor started is finished there. */
|
|
71
|
+
export declare function assetIsOwn(id: string, root?: string): Promise<boolean>;
|
|
72
|
+
/** Whether a record carries the per-asset upload secret its URLs are signed with (records written
|
|
73
|
+
* before per-asset secrets have none, and are never signed with an empty key). */
|
|
74
|
+
export declare function hasUploadSecret(asset: Pick<AssetRecord, 'upload_secret'>): boolean;
|
|
75
|
+
/** An asset as it stands at `nowMs`: a finalized video is AVAILABLE once PROCESSING_MS of world time
|
|
76
|
+
* has passed since finalize. A pure fold of the stored record and the instant — nothing is written. */
|
|
77
|
+
export declare function assetAsOf(asset: AssetRecord, nowMs: number): AssetRecord;
|
|
78
|
+
/** Every asset id this branch AND its retained ancestors hold (what a read could resolve). */
|
|
79
|
+
export declare function listAssetIds(root?: string): Promise<string[]>;
|
|
80
|
+
/** One video part, stored under its index (a retried PUT overwrites, as a re-upload does). */
|
|
81
|
+
export declare function putPart(id: string, index: number, bytes: Uint8Array, root?: string): Promise<void>;
|
|
82
|
+
/** A part as stored on THIS branch. Parts are staging, not state a branch inherits: an upload is
|
|
83
|
+
* finalized where its parts were PUT (a parent's later PUTs never leak into a child's finalize). */
|
|
84
|
+
export declare function readPart(id: string, index: number, root?: string): Promise<Uint8Array | null>;
|
|
85
|
+
export declare function clearParts(id: string, root?: string): Promise<void>;
|
|
86
|
+
/** A fresh per-asset upload secret: 32 bytes of entropy, hex. */
|
|
87
|
+
export declare function newUploadSecret(): string;
|
|
88
|
+
/** The `ut` a signed upload URL carries for one slot of one asset, keyed by that asset's own secret. */
|
|
89
|
+
export declare function signUpload(asset: Pick<AssetRecord, 'id' | 'upload_secret'>, slot: string, expiresMs: number): Promise<string>;
|
|
90
|
+
/** Constant-time check of a presented `ut` against the one the asset's secret signs. */
|
|
91
|
+
export declare function verifyUpload(asset: Pick<AssetRecord, 'id' | 'upload_secret'>, slot: string, expiresMs: number, presented: string): Promise<boolean>;
|
|
92
|
+
/** Run `fn` after every earlier `withAsset` of the same asset in this process has settled. */
|
|
93
|
+
export declare function withAsset<T>(id: string, root: string | undefined, fn: () => Promise<T>): Promise<T>;
|
|
94
|
+
export type PerformVideo = {
|
|
95
|
+
video: string;
|
|
96
|
+
sha256: string;
|
|
97
|
+
};
|
|
98
|
+
export declare function readPerformVideo(actionId: string, root?: string): Promise<PerformVideo | null>;
|
|
99
|
+
export declare function writePerformVideo(actionId: string, record: PerformVideo, root?: string): Promise<void>;
|
|
100
|
+
export declare function clearPerformVideo(actionId: string, root?: string): Promise<void>;
|
|
101
|
+
export type ImageInfo = {
|
|
102
|
+
mediaType: 'image/png' | 'image/jpeg' | 'image/gif';
|
|
103
|
+
width: number;
|
|
104
|
+
height: number;
|
|
105
|
+
};
|
|
106
|
+
export declare function sniffImage(bytes: Uint8Array): ImageInfo | undefined;
|
|
107
|
+
export type VideoInfo = {
|
|
108
|
+
width: number;
|
|
109
|
+
height: number;
|
|
110
|
+
durationMs: number;
|
|
111
|
+
};
|
|
112
|
+
/** An MP4 (ISO BMFF) read from its boxes: `ftyp`, then `moov` (anywhere) holding `mvhd` (timescale +
|
|
113
|
+
* duration) and a `trak` whose `tkhd` carries the presented size. Nothing is decoded. */
|
|
114
|
+
export declare function sniffVideo(bytes: Uint8Array): VideoInfo | undefined;
|
|
115
|
+
/** The reduced aspect ratio LinkedIn reports (16:9 → 16 and 9). */
|
|
116
|
+
export declare function aspectRatio(width: number, height: number): {
|
|
117
|
+
w: number;
|
|
118
|
+
h: number;
|
|
119
|
+
};
|
|
120
|
+
/**
|
|
121
|
+
* The system thumbnail LinkedIn cuts from a video when the uploader sends none. The twin decodes
|
|
122
|
+
* nothing, so it serves a STAND-IN at the video's own aspect — a dark frame with a play glyph — and
|
|
123
|
+
* says so here; an uploaded thumbnail (`uploadThumbnail: true`) is served as uploaded.
|
|
124
|
+
*/
|
|
125
|
+
export declare function videoPosterSvg(width: number, height: number): string;
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
// Image and video ASSETS for the LinkedIn twin — what `POST /rest/images?action=initializeUpload`,
|
|
2
|
+
// `POST /rest/videos?action=initializeUpload`, the upload PUTs and `action=finalizeUpload` hold
|
|
3
|
+
// before a post names the asset.
|
|
4
|
+
//
|
|
5
|
+
// The method is the slack pack's (slack-blobs.ts), as the X pack transcribed it (x-media.ts). Bytes
|
|
6
|
+
// ride the KERNEL'S BLOB SEAM (`getActiveBlobStore()`, runtime contract R11), content-addressed by
|
|
7
|
+
// sha256, under this service's own `resources` directory. An upload is NOT a kernel action: at
|
|
8
|
+
// LinkedIn an uploaded image or video publishes nothing — it is an asset only a later
|
|
9
|
+
// `POST /rest/posts` makes public. Recording the upload through `applyTwinWrite` would create a
|
|
10
|
+
// durably pending entry with no vendor write of its own to perform, and wedge every deploy behind
|
|
11
|
+
// it. So the asset record (owner, status, parts, finished digest) is a bare pointer record in the
|
|
12
|
+
// same byte annex, carrying no approval weight. What becomes state is the POST: the post's own
|
|
13
|
+
// entry names its asset by URN and DIGEST (linkedin-twin.ts), and that is what a deploy performs
|
|
14
|
+
// (linkedin-connector.ts uploads the bytes to the vendor, then posts).
|
|
15
|
+
//
|
|
16
|
+
// Reads go through `readResourceBlob`, which looks in this branch and then its retained ancestors,
|
|
17
|
+
// so a World branch sees assets its parent uploaded and a perform finds the bytes a post names.
|
|
18
|
+
//
|
|
19
|
+
// UPLOAD URLS ARE SIGNED, as LinkedIn's are. The vendor hands back an upload URL on
|
|
20
|
+
// www.linkedin.com/dms-uploads/… carrying its own authorization in the query; a video part's PUT
|
|
21
|
+
// carries no bearer, and an image's PUT carries the member's access token as well (the handler checks
|
|
22
|
+
// it, linkedin-twin.ts `receiveUpload`). The twin signs the same way: each URL names the asset and the
|
|
23
|
+
// slot (a video part, an image, a thumbnail) and carries `ut`, an HMAC-SHA256 over
|
|
24
|
+
// `<asset>/<slot>/<expiresAt>` keyed by THAT ASSET'S OWN secret, drawn from entropy at initialize and
|
|
25
|
+
// kept in its record. No key is shared: two clones (or branches, or processes) each sign only the assets
|
|
26
|
+
// they created, a push carrying an asset carries the one secret its URLs were signed with, and nothing is
|
|
27
|
+
// created on first use, so there is nothing to race. The PUT is refused unless the signature holds, its
|
|
28
|
+
// expiry is the asset's own, and it has not passed.
|
|
29
|
+
//
|
|
30
|
+
// Every read-modify-write of an asset record runs in that asset's chain (`withAsset`).
|
|
31
|
+
import { join } from 'node:path';
|
|
32
|
+
import { blobDigest, getActiveBlobStore, readBranchMeta, readResourceBlob, worldPaths } from '@volter/world-core';
|
|
33
|
+
const SERVICE = 'linkedin';
|
|
34
|
+
/** LinkedIn cuts a video into 4 MB parts: 0-4194303, 4194304-8388607, … (videos-api). */
|
|
35
|
+
export const VIDEO_PART_BYTES = 4_194_304;
|
|
36
|
+
/** Videos API, "Video File Size Specifications": "File size: Between 75kb and 500MB." — decimal
|
|
37
|
+
* units as written: 75,000 and 500,000,000 bytes. */
|
|
38
|
+
export const MIN_VIDEO_BYTES = 75_000;
|
|
39
|
+
export const MAX_VIDEO_BYTES = 500_000_000;
|
|
40
|
+
/** Videos API: "Length: Three seconds to 30 minutes." */
|
|
41
|
+
export const MIN_VIDEO_MS = 3_000;
|
|
42
|
+
export const MAX_VIDEO_MS = 30 * 60_000;
|
|
43
|
+
/** Images API: "Images with less than 36,152,320 pixels." */
|
|
44
|
+
export const MAX_IMAGE_PIXELS = 36_152_320;
|
|
45
|
+
/** How long (world time) a finalized video stays PROCESSING before it is AVAILABLE. A read folds the
|
|
46
|
+
* state from the stored instant and the request's own — reading never moves anything. */
|
|
47
|
+
export const PROCESSING_MS = 2_000;
|
|
48
|
+
/** "Typically URLs expire 30 days from the time an upload is initialized." */
|
|
49
|
+
export const UPLOAD_URL_LIFETIME_MS = 30 * 86_400_000;
|
|
50
|
+
const resourcesDir = (root) => worldPaths(SERVICE, root).resources;
|
|
51
|
+
/** Where the bytes of a digest live under this service's resources (the key the ranged reads take). */
|
|
52
|
+
export const blobKey = (sha256) => join('blobs', 'sha256', sha256.slice(0, 2), sha256);
|
|
53
|
+
const recordKey = (id) => join('assets', `${safeId(id)}.json`);
|
|
54
|
+
const partKey = (id, index) => join('asset-parts', safeId(id), String(index).padStart(4, '0'));
|
|
55
|
+
export function isAssetId(id) {
|
|
56
|
+
return /^[A-Za-z0-9_-]{8,40}$/.test(id);
|
|
57
|
+
}
|
|
58
|
+
function safeId(id) {
|
|
59
|
+
if (!isAssetId(id))
|
|
60
|
+
throw new Error(`invalid LinkedIn asset id for storage: ${JSON.stringify(id)}`);
|
|
61
|
+
return id;
|
|
62
|
+
}
|
|
63
|
+
/** Store bytes content-addressed; a re-upload of identical content is a no-op write. */
|
|
64
|
+
export async function putLinkedinBlob(bytes, root) {
|
|
65
|
+
const sha256 = blobDigest(bytes);
|
|
66
|
+
const key = join(resourcesDir(root), blobKey(sha256));
|
|
67
|
+
if (!(await getActiveBlobStore().exists(key)))
|
|
68
|
+
await getActiveBlobStore().put(key, bytes);
|
|
69
|
+
return { sha256, size: bytes.length };
|
|
70
|
+
}
|
|
71
|
+
/** Stored bytes by digest (this branch, then its ancestors); null when absent or malformed. */
|
|
72
|
+
export async function readLinkedinBlob(sha256, root) {
|
|
73
|
+
if (!/^[0-9a-f]{64}$/.test(sha256))
|
|
74
|
+
return null;
|
|
75
|
+
return await readResourceBlob(SERVICE, blobKey(sha256), root);
|
|
76
|
+
}
|
|
77
|
+
export async function writeAsset(record, root) {
|
|
78
|
+
await getActiveBlobStore().put(join(resourcesDir(root), recordKey(record.id)), new TextEncoder().encode(JSON.stringify(record)));
|
|
79
|
+
}
|
|
80
|
+
export async function readAsset(id, root) {
|
|
81
|
+
if (!isAssetId(id))
|
|
82
|
+
return undefined;
|
|
83
|
+
try {
|
|
84
|
+
const stored = await readResourceBlob(SERVICE, recordKey(id), root);
|
|
85
|
+
if (stored === null)
|
|
86
|
+
return undefined;
|
|
87
|
+
const parsed = JSON.parse(new TextDecoder().decode(stored));
|
|
88
|
+
return typeof parsed.id === 'string' ? parsed : undefined;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/** Whether this BRANCH holds the asset's record itself (not only an ancestor it reads through). A
|
|
95
|
+
* branch writes only its own assets: an upload an ancestor started is finished there. */
|
|
96
|
+
export async function assetIsOwn(id, root) {
|
|
97
|
+
return isAssetId(id) && (await getActiveBlobStore().exists(join(resourcesDir(root), recordKey(id))));
|
|
98
|
+
}
|
|
99
|
+
/** Whether a record carries the per-asset upload secret its URLs are signed with (records written
|
|
100
|
+
* before per-asset secrets have none, and are never signed with an empty key). */
|
|
101
|
+
export function hasUploadSecret(asset) {
|
|
102
|
+
return typeof asset.upload_secret === 'string' && /^[0-9a-f]{64}$/.test(asset.upload_secret);
|
|
103
|
+
}
|
|
104
|
+
/** An asset as it stands at `nowMs`: a finalized video is AVAILABLE once PROCESSING_MS of world time
|
|
105
|
+
* has passed since finalize. A pure fold of the stored record and the instant — nothing is written. */
|
|
106
|
+
export function assetAsOf(asset, nowMs) {
|
|
107
|
+
if (asset.kind === 'video' && asset.status === 'PROCESSING' && typeof asset.finalized_ms === 'number' && nowMs >= asset.finalized_ms + PROCESSING_MS) {
|
|
108
|
+
return { ...asset, status: 'AVAILABLE' };
|
|
109
|
+
}
|
|
110
|
+
return asset;
|
|
111
|
+
}
|
|
112
|
+
/** Every asset id this branch AND its retained ancestors hold (what a read could resolve). */
|
|
113
|
+
export async function listAssetIds(root) {
|
|
114
|
+
const out = new Set();
|
|
115
|
+
const seen = new Set();
|
|
116
|
+
let current = worldPaths(SERVICE, root).root;
|
|
117
|
+
for (;;) {
|
|
118
|
+
if (seen.has(current))
|
|
119
|
+
break;
|
|
120
|
+
seen.add(current);
|
|
121
|
+
const prefix = `${join(worldPaths(SERVICE, current).resources, 'assets')}/`;
|
|
122
|
+
for (const key of await getActiveBlobStore().list(prefix)) {
|
|
123
|
+
if (key.startsWith(prefix) && !key.slice(prefix.length).includes('/') && key.endsWith('.json'))
|
|
124
|
+
out.add(key.slice(prefix.length, -'.json'.length));
|
|
125
|
+
}
|
|
126
|
+
const parent = readBranchMeta(SERVICE, current)?.parent;
|
|
127
|
+
if (!parent)
|
|
128
|
+
break;
|
|
129
|
+
current = parent.at;
|
|
130
|
+
}
|
|
131
|
+
return [...out];
|
|
132
|
+
}
|
|
133
|
+
/** One video part, stored under its index (a retried PUT overwrites, as a re-upload does). */
|
|
134
|
+
export async function putPart(id, index, bytes, root) {
|
|
135
|
+
await getActiveBlobStore().put(join(resourcesDir(root), partKey(id, index)), bytes);
|
|
136
|
+
}
|
|
137
|
+
/** A part as stored on THIS branch. Parts are staging, not state a branch inherits: an upload is
|
|
138
|
+
* finalized where its parts were PUT (a parent's later PUTs never leak into a child's finalize). */
|
|
139
|
+
export async function readPart(id, index, root) {
|
|
140
|
+
return await getActiveBlobStore().get(join(resourcesDir(root), partKey(id, index)));
|
|
141
|
+
}
|
|
142
|
+
export async function clearParts(id, root) {
|
|
143
|
+
const prefix = `${join(resourcesDir(root), 'asset-parts', safeId(id))}/`;
|
|
144
|
+
for (const key of await getActiveBlobStore().list(prefix)) {
|
|
145
|
+
try {
|
|
146
|
+
await getActiveBlobStore().remove(key);
|
|
147
|
+
}
|
|
148
|
+
catch { /* already gone */ }
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// ── signed upload URLs ────────────────────────────────────────────────────────────────────────
|
|
152
|
+
const hex = (bytes) => [...new Uint8Array(bytes)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
153
|
+
/** A fresh per-asset upload secret: 32 bytes of entropy, hex. */
|
|
154
|
+
export function newUploadSecret() {
|
|
155
|
+
return hex(crypto.getRandomValues(new Uint8Array(32)).buffer);
|
|
156
|
+
}
|
|
157
|
+
/** The `ut` a signed upload URL carries for one slot of one asset, keyed by that asset's own secret. */
|
|
158
|
+
export async function signUpload(asset, slot, expiresMs) {
|
|
159
|
+
// never an empty or missing key (Bun's WebCrypto refuses a zero-length HMAC key with a DataError anyway)
|
|
160
|
+
if (!hasUploadSecret(asset))
|
|
161
|
+
throw new Error(`linkedin: asset ${asset.id} has no upload secret to sign with`);
|
|
162
|
+
const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(asset.upload_secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
|
163
|
+
return hex(await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(`${asset.id}/${slot}/${expiresMs}`)));
|
|
164
|
+
}
|
|
165
|
+
/** Constant-time check of a presented `ut` against the one the asset's secret signs. */
|
|
166
|
+
export async function verifyUpload(asset, slot, expiresMs, presented) {
|
|
167
|
+
const expected = await signUpload(asset, slot, expiresMs);
|
|
168
|
+
if (presented.length !== expected.length)
|
|
169
|
+
return false;
|
|
170
|
+
let diff = 0;
|
|
171
|
+
for (let i = 0; i < expected.length; i += 1)
|
|
172
|
+
diff |= expected.charCodeAt(i) ^ presented.charCodeAt(i);
|
|
173
|
+
return diff === 0;
|
|
174
|
+
}
|
|
175
|
+
// cache: one chain of pending work per asset record (`<resources dir>|<id>`): every read-modify-write
|
|
176
|
+
// of `assets/<id>.json` (a part's ETag, a thumbnail, finalize) runs one after another, so concurrent
|
|
177
|
+
// part PUTs keep every ETag and a late part can never overwrite a finalized record.
|
|
178
|
+
const assetChains = new Map();
|
|
179
|
+
/** Run `fn` after every earlier `withAsset` of the same asset in this process has settled. */
|
|
180
|
+
export async function withAsset(id, root, fn) {
|
|
181
|
+
const key = `${resourcesDir(root)}|${id}`;
|
|
182
|
+
const prior = assetChains.get(key) ?? Promise.resolve();
|
|
183
|
+
const run = prior.then(fn, fn);
|
|
184
|
+
const settled = run.then(() => undefined, () => undefined);
|
|
185
|
+
assetChains.set(key, settled);
|
|
186
|
+
try {
|
|
187
|
+
return await run;
|
|
188
|
+
}
|
|
189
|
+
finally {
|
|
190
|
+
if (assetChains.get(key) === settled)
|
|
191
|
+
assetChains.delete(key);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const performKey = (actionId, root) => join(resourcesDir(root), 'perform-videos', `${blobDigest(new TextEncoder().encode(actionId))}.json`);
|
|
195
|
+
export async function readPerformVideo(actionId, root) {
|
|
196
|
+
const stored = await getActiveBlobStore().get(performKey(actionId, root));
|
|
197
|
+
if (stored === null)
|
|
198
|
+
return null;
|
|
199
|
+
try {
|
|
200
|
+
const v = JSON.parse(new TextDecoder().decode(stored));
|
|
201
|
+
return typeof v.video === 'string' && typeof v.sha256 === 'string' ? v : null;
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
export async function writePerformVideo(actionId, record, root) {
|
|
208
|
+
await getActiveBlobStore().put(performKey(actionId, root), new TextEncoder().encode(JSON.stringify(record)));
|
|
209
|
+
}
|
|
210
|
+
export async function clearPerformVideo(actionId, root) {
|
|
211
|
+
try {
|
|
212
|
+
await getActiveBlobStore().remove(performKey(actionId, root));
|
|
213
|
+
}
|
|
214
|
+
catch { /* none */ }
|
|
215
|
+
}
|
|
216
|
+
export function sniffImage(bytes) {
|
|
217
|
+
const b = bytes;
|
|
218
|
+
const u16be = (i) => (b[i] << 8) | b[i + 1];
|
|
219
|
+
const u16le = (i) => b[i] | (b[i + 1] << 8);
|
|
220
|
+
const u32be = (i) => ((b[i] << 24) >>> 0) + (b[i + 1] << 16) + (b[i + 2] << 8) + b[i + 3];
|
|
221
|
+
if (b.length >= 24 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) {
|
|
222
|
+
return { mediaType: 'image/png', width: u32be(16), height: u32be(20) };
|
|
223
|
+
}
|
|
224
|
+
if (b.length >= 10 && b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x38) {
|
|
225
|
+
return { mediaType: 'image/gif', width: u16le(6), height: u16le(8) };
|
|
226
|
+
}
|
|
227
|
+
if (b.length >= 4 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) {
|
|
228
|
+
let i = 2;
|
|
229
|
+
while (i + 9 < b.length) {
|
|
230
|
+
if (b[i] !== 0xff) {
|
|
231
|
+
i += 1;
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
const marker = b[i + 1];
|
|
235
|
+
if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
|
|
236
|
+
i += 2;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
const length = u16be(i + 2);
|
|
240
|
+
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
|
|
241
|
+
return { mediaType: 'image/jpeg', height: u16be(i + 5), width: u16be(i + 7) };
|
|
242
|
+
}
|
|
243
|
+
i += 2 + length;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return undefined;
|
|
247
|
+
}
|
|
248
|
+
/** An MP4 (ISO BMFF) read from its boxes: `ftyp`, then `moov` (anywhere) holding `mvhd` (timescale +
|
|
249
|
+
* duration) and a `trak` whose `tkhd` carries the presented size. Nothing is decoded. */
|
|
250
|
+
export function sniffVideo(bytes) {
|
|
251
|
+
const b = bytes;
|
|
252
|
+
const view = new DataView(b.buffer, b.byteOffset, b.byteLength);
|
|
253
|
+
const type = (at) => String.fromCharCode(b[at + 4], b[at + 5], b[at + 6], b[at + 7]);
|
|
254
|
+
const boxes = (start, end) => {
|
|
255
|
+
const out = [];
|
|
256
|
+
let at = start;
|
|
257
|
+
while (at + 8 <= end) {
|
|
258
|
+
let size = view.getUint32(at);
|
|
259
|
+
let header = 8;
|
|
260
|
+
if (size === 1) {
|
|
261
|
+
if (at + 16 > end)
|
|
262
|
+
break;
|
|
263
|
+
size = Number(view.getBigUint64(at + 8));
|
|
264
|
+
header = 16;
|
|
265
|
+
}
|
|
266
|
+
else if (size === 0)
|
|
267
|
+
size = end - at;
|
|
268
|
+
if (size < header || at + size > end)
|
|
269
|
+
break;
|
|
270
|
+
out.push([type(at), at + header, at + size]);
|
|
271
|
+
at += size;
|
|
272
|
+
}
|
|
273
|
+
return out;
|
|
274
|
+
};
|
|
275
|
+
const top = boxes(0, b.length);
|
|
276
|
+
if (top[0]?.[0] !== 'ftyp')
|
|
277
|
+
return undefined;
|
|
278
|
+
const moov = top.find(([t]) => t === 'moov');
|
|
279
|
+
if (!moov)
|
|
280
|
+
return undefined;
|
|
281
|
+
let durationMs;
|
|
282
|
+
let width = 0;
|
|
283
|
+
let height = 0;
|
|
284
|
+
for (const [t, start, end] of boxes(moov[1], moov[2])) {
|
|
285
|
+
if (t === 'mvhd' && end - start >= 32) {
|
|
286
|
+
const version = b[start];
|
|
287
|
+
const timescale = version === 1 ? view.getUint32(start + 20) : view.getUint32(start + 12);
|
|
288
|
+
const duration = version === 1 ? Number(view.getBigUint64(start + 24)) : view.getUint32(start + 16);
|
|
289
|
+
if (timescale > 0)
|
|
290
|
+
durationMs = Math.round((duration * 1000) / timescale);
|
|
291
|
+
}
|
|
292
|
+
if (t === 'trak') {
|
|
293
|
+
const tkhd = boxes(start, end).find(([k]) => k === 'tkhd');
|
|
294
|
+
if (!tkhd)
|
|
295
|
+
continue;
|
|
296
|
+
const version = b[tkhd[1]];
|
|
297
|
+
const at = tkhd[1] + (version === 1 ? 88 : 76);
|
|
298
|
+
if (at + 8 > tkhd[2])
|
|
299
|
+
continue;
|
|
300
|
+
const w = view.getUint32(at) >>> 16;
|
|
301
|
+
const h = view.getUint32(at + 4) >>> 16;
|
|
302
|
+
if (w > 0 && h > 0 && width === 0) {
|
|
303
|
+
width = w;
|
|
304
|
+
height = h;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
if (durationMs === undefined || width === 0)
|
|
309
|
+
return undefined;
|
|
310
|
+
return { width, height, durationMs };
|
|
311
|
+
}
|
|
312
|
+
/** The reduced aspect ratio LinkedIn reports (16:9 → 16 and 9). */
|
|
313
|
+
export function aspectRatio(width, height) {
|
|
314
|
+
const gcd = (a, b) => (b === 0 ? a : gcd(b, a % b));
|
|
315
|
+
const g = gcd(width, height) || 1;
|
|
316
|
+
return { w: width / g, h: height / g };
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* The system thumbnail LinkedIn cuts from a video when the uploader sends none. The twin decodes
|
|
320
|
+
* nothing, so it serves a STAND-IN at the video's own aspect — a dark frame with a play glyph — and
|
|
321
|
+
* says so here; an uploaded thumbnail (`uploadThumbnail: true`) is served as uploaded.
|
|
322
|
+
*/
|
|
323
|
+
export function videoPosterSvg(width, height) {
|
|
324
|
+
const w = width > 0 ? width : 1280;
|
|
325
|
+
const h = height > 0 ? height : 720;
|
|
326
|
+
const r = Math.round(Math.min(w, h) * 0.09);
|
|
327
|
+
const cx = w / 2;
|
|
328
|
+
const cy = h / 2;
|
|
329
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"><rect width="${w}" height="${h}" fill="#1d2226"/>`
|
|
330
|
+
+ `<circle cx="${cx}" cy="${cy}" r="${r}" fill="rgba(0,0,0,0.6)" stroke="#fff" stroke-width="${Math.max(2, r * 0.06)}"/><path d="M${cx - r * 0.3} ${cy - r * 0.45}L${cx + r * 0.5} ${cy}L${cx - r * 0.3} ${cy + r * 0.45}Z" fill="#fff"/></svg>`;
|
|
331
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export type LiRow = Record<string, any>;
|
|
2
|
+
/** The version the mirror's reads pin — the twin's newest. */
|
|
3
|
+
export declare const MIRROR_VERSION = "202609";
|
|
4
|
+
/** linkedin.com's feed timestamp: "now", "5m", "3h", "2d", "1w", "3mo", "1yr". `nowMs` is passed in. */
|
|
5
|
+
export declare function relativeTime(ms: unknown, nowMs: number): string;
|
|
6
|
+
/** "12,480 followers" — the follower line under a page's name and a post's author. */
|
|
7
|
+
export declare function followerLine(count: unknown): string;
|
|
8
|
+
export type Segment = {
|
|
9
|
+
kind: 'text' | 'hashtag' | 'mention' | 'url';
|
|
10
|
+
value: string;
|
|
11
|
+
urn?: string;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* A post's commentary as linkedin.com draws it, from the little text format LinkedIn stores:
|
|
15
|
+
* `{hashtag|\#|coding}` is the hashtag `#coding`, `@[Devtestco](urn:li:organization:2414183)` the
|
|
16
|
+
* mention `Devtestco`, a URL a link; everything else plain text (backslash escapes undone).
|
|
17
|
+
*/
|
|
18
|
+
export declare function commentarySegments(commentary: unknown): Segment[];
|
|
19
|
+
/** Whether a commentary needs linkedin.com's "…more" fold: over three lines or ~210 characters. */
|
|
20
|
+
export declare function needsFold(commentary: unknown): boolean;
|
|
21
|
+
/** The kind of asset a post's content names, from its URN. */
|
|
22
|
+
export declare function mediaKind(post: LiRow): 'image' | 'video' | 'article' | undefined;
|
|
23
|
+
/** A URL's host, as an article card prints its source ("github.com"). */
|
|
24
|
+
export declare function sourceHost(url: unknown): string;
|
|
25
|
+
/** A company page as the mirror draws it, from the reads it makes: the public lookup by vanity name,
|
|
26
|
+
* the administered organization (its description; absent when the viewer is not an admin), the
|
|
27
|
+
* follower count, and the logo image. Every field comes from those answers, nothing else. */
|
|
28
|
+
export type MirrorOrg = {
|
|
29
|
+
id: number;
|
|
30
|
+
urn: string;
|
|
31
|
+
name: string;
|
|
32
|
+
vanity: string;
|
|
33
|
+
logoUrl?: string;
|
|
34
|
+
followers?: number;
|
|
35
|
+
description?: string;
|
|
36
|
+
website?: string;
|
|
37
|
+
};
|
|
38
|
+
export declare function orgFromReads(pub: LiRow, admin: LiRow | null, size: LiRow | null, logo: LiRow | null): MirrorOrg;
|
|
39
|
+
/** The mirror's own route for a post: linkedin.com's `/feed/update/<urn>`. */
|
|
40
|
+
export declare function postRoute(urn: string): string;
|
|
41
|
+
/** The finder read a company page's feed draws. */
|
|
42
|
+
export declare function postsQuery(orgUrn: string, start?: number): string;
|
|
43
|
+
/** Build the React/TSX mirror client to browser JS; memoized at module scope, so a pack on its own
|
|
44
|
+
* does exactly one `Bun.build`. */
|
|
45
|
+
export declare function buildLinkedinMirrorClient(): Promise<string>;
|
|
46
|
+
/** Serve the LinkedIn mirror UI (React app) + its backing LinkedIn API on one origin. */
|
|
47
|
+
export declare function createLinkedinMirrorServer(options: {
|
|
48
|
+
root?: string;
|
|
49
|
+
port?: number;
|
|
50
|
+
readOnly?: boolean;
|
|
51
|
+
}): Promise<{
|
|
52
|
+
port: number;
|
|
53
|
+
url: string;
|
|
54
|
+
stop: () => void;
|
|
55
|
+
}>;
|
|
56
|
+
/** The app-shell HTML (pure). */
|
|
57
|
+
export declare function linkedinMirrorHtml(): string;
|
|
58
|
+
/** The mirror's stylesheet, for a host serving the shell's `assets/styles.css` itself (the hosted mirror mount). */
|
|
59
|
+
export declare function linkedinMirrorStyles(): Promise<string>;
|