@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,1220 @@
|
|
|
1
|
+
// The LinkedIn Community Management surface an organization POSTS through, twinned: an org's
|
|
2
|
+
// company page, the admin's access control, the Posts API (create, get, batch get, find by author,
|
|
3
|
+
// delete), the Images API and the Videos API (initialize, the signed-URL upload PUTs, finalize, and
|
|
4
|
+
// the asset read that walks PROCESSING to AVAILABLE), the follower count, and the member's OpenID
|
|
5
|
+
// userinfo — offline, against local state.
|
|
6
|
+
//
|
|
7
|
+
// SCOPE. What this pack exists for: a company posts release videos to its LinkedIn page, and each
|
|
8
|
+
// post must be previewable as linkedin.com shows it before a gated deploy sends it. So the write half
|
|
9
|
+
// is an organization's post with commentary and one image, one video or an article; the read half is
|
|
10
|
+
// everything a page (and the mirror, linkedin-mirror-ui.ts) needs to show it. Comments, reactions,
|
|
11
|
+
// social metadata, polls, multi-image, documents, reshares, targeting, dark posts, member (person)
|
|
12
|
+
// posting and every analytics surface are real LinkedIn this pack does not model: each has a
|
|
13
|
+
// manifest todo, and a request that reaches one is refused by name (linkedin-errors.ts `unmodeled`).
|
|
14
|
+
//
|
|
15
|
+
// GROUNDING. learn.microsoft.com/linkedin, the Marketing (li-lms) pages at version 202609, read for
|
|
16
|
+
// this build: Posts API and Post Schema, Images API, Videos API, Organization Access Control by
|
|
17
|
+
// Role, Organization Lookup (incl. networkSizes), Versioning, Error Responses, Rate Limiting, and
|
|
18
|
+
// Sign In with LinkedIn using OpenID Connect (userinfo). Shapes below quote those pages' samples.
|
|
19
|
+
//
|
|
20
|
+
// VERSIONED. Every `/rest` call must carry `Linkedin-Version: YYYYMM`; a missing header is the
|
|
21
|
+
// vendor's 400 VERSION_MISSING and a version outside the active set its 426 NONEXISTENT_VERSION.
|
|
22
|
+
// The active set is the one the docs' version selector lists on 2026-09-26 (202510 … 202609; there
|
|
23
|
+
// is no 202512). `/v2/userinfo` is the unversioned OpenID surface and takes no version.
|
|
24
|
+
//
|
|
25
|
+
// STATE. Subjects are stored in LinkedIn's own response shape (architecture "shape parity"):
|
|
26
|
+
// a post IS its `GET /rest/posts/{urn}` body, keyed by its URN, with bookkeeping under `_` fields
|
|
27
|
+
// that never reach the wire. Uploaded assets are not kernel state (linkedin-media.ts explains why).
|
|
28
|
+
//
|
|
29
|
+
// DETERMINISM. Every served answer is a function of (request, stored state). Times are the write's
|
|
30
|
+
// `occurredAt`; a video's processing is a fold of its finalize instant and the request's (PROCESSING_MS), never a timer and never a read that writes.
|
|
31
|
+
import { applyTwinWrite, applyTwinWriteAtomic, blobDigest, ownFields, projectResources, resolveSubjectId, subjectAliases } from '@volter/world-core';
|
|
32
|
+
import { accessDenied, emptyAccessToken, invalidAccessToken, invalidUrnId, invalidUrnType, invalidValue, missingField, noContent, notFound, ok, problem, readOnlyRefusal, unmodeled, versionInactive, versionMissing, } from "./linkedin-errors.js";
|
|
33
|
+
import { aspectRatio, clearParts, isAssetId, listAssetIds, MAX_IMAGE_PIXELS, MAX_VIDEO_BYTES, MAX_VIDEO_MS, MIN_VIDEO_BYTES, MIN_VIDEO_MS, putLinkedinBlob, putPart, readAsset, readPart, assetAsOf, newUploadSecret, signUpload, withAsset, assetIsOwn, hasUploadSecret, sniffImage, sniffVideo, UPLOAD_URL_LIFETIME_MS, verifyUpload, VIDEO_PART_BYTES, writeAsset, } from "./linkedin-media.js";
|
|
34
|
+
const SERVICE = 'linkedin';
|
|
35
|
+
/** The API versions LinkedIn serves on 2026-09-26 (the versioned docs' moniker list). */
|
|
36
|
+
export const ACTIVE_VERSIONS = ['202510', '202511', '202601', '202602', '202603', '202604', '202605', '202606', '202607', '202608', '202609'];
|
|
37
|
+
/** LinkedIn's own hosts, minted when no served base is known. */
|
|
38
|
+
const UPLOAD_HOST = 'https://www.linkedin.com';
|
|
39
|
+
const IMAGE_HOST = 'https://media.licdn.com';
|
|
40
|
+
const VIDEO_HOST = 'https://dms.licdn.com';
|
|
41
|
+
/** Post ids are 19-digit numbers inside a URN. A minted id must never collide with one PULLED from
|
|
42
|
+
* the real vendor and never come from a row count: scan every id held and step past the highest,
|
|
43
|
+
* starting above today's real id space (~7.3e18 in 2026). */
|
|
44
|
+
const POST_ID_FLOOR = 9000000000000000000n;
|
|
45
|
+
/** Twin-minted asset ids: `C5T10AQ` + a 12-digit ordinal — the vendor's length, a prefix no real
|
|
46
|
+
* asset carries, so a pulled asset id and a minted one cannot meet. */
|
|
47
|
+
const ASSET_PREFIX = 'C5T10AQ';
|
|
48
|
+
// ── the documented closed sets ──────────────────────────────────────────────────────────────
|
|
49
|
+
/** OAuth scopes a seeded token may carry — the permissions these pages name. */
|
|
50
|
+
const SCOPES_DOCUMENTED = new Set([
|
|
51
|
+
'w_organization_social', 'r_organization_social', 'rw_organization_admin', 'r_organization_admin',
|
|
52
|
+
'w_member_social', 'r_member_social', 'rw_ads', 'r_ads', 'openid', 'profile', 'email',
|
|
53
|
+
]);
|
|
54
|
+
/** Organization Access Control by Role: the role types and role states. */
|
|
55
|
+
const ROLES = new Set(['ADMINISTRATOR', 'DIRECT_SPONSORED_CONTENT_POSTER', 'RECRUITING_POSTER', 'LEAD_CAPTURE_ADMINISTRATOR', 'LEAD_GEN_FORMS_MANAGER', 'ANALYST', 'CURATOR', 'CONTENT_ADMINISTRATOR']);
|
|
56
|
+
const ROLE_STATES = new Set(['APPROVED', 'REJECTED', 'REQUESTED', 'REVOKED']);
|
|
57
|
+
/** Posts API permissions table: w/r_organization_social are "restricted to organizations where the
|
|
58
|
+
* authenticated member has one of the following company page roles". */
|
|
59
|
+
const POSTING_ROLES = new Set(['ADMINISTRATOR', 'DIRECT_SPONSORED_CONTENT_POSTER', 'CONTENT_ADMINISTRATOR']);
|
|
60
|
+
/** Post Schema: `visibility` (MemberNetworkVisibility) and `distribution.feedDistribution`. */
|
|
61
|
+
const VISIBILITIES = new Set(['CONNECTIONS', 'PUBLIC', 'LOGGED_IN', 'CONTAINER']);
|
|
62
|
+
const FEED_DISTRIBUTIONS = new Set(['NONE', 'MAIN_FEED']);
|
|
63
|
+
/** Post Schema: the content kinds. `media` and `article` are modelled. */
|
|
64
|
+
const CONTENT_KINDS = new Set(['media', 'poll', 'multiImage', 'article', 'carousel', 'celebration', 'reference']);
|
|
65
|
+
const POST_FIELDS = new Set([
|
|
66
|
+
'author', 'adContext', 'commentary', 'container', 'content', 'contentLandingPage', 'contentCallToActionLabel', 'distribution',
|
|
67
|
+
'isReshareDisabledByAuthor', 'lifecycleState', 'lifecycleStateInfo', 'publishedAt', 'reshareContext', 'visibility',
|
|
68
|
+
]);
|
|
69
|
+
// ── helpers ─────────────────────────────────────────────────────────────────────────────────
|
|
70
|
+
function parseJson(body) {
|
|
71
|
+
if (body === undefined || body.trim() === '')
|
|
72
|
+
return { object: {} };
|
|
73
|
+
try {
|
|
74
|
+
const parsed = JSON.parse(body);
|
|
75
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
76
|
+
return { malformed: true };
|
|
77
|
+
return { object: parsed };
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return { malformed: true };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function header(headers, name) {
|
|
84
|
+
if (!headers)
|
|
85
|
+
return undefined;
|
|
86
|
+
for (const [k, v] of Object.entries(headers))
|
|
87
|
+
if (k.toLowerCase() === name)
|
|
88
|
+
return v;
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
function resources(root) {
|
|
92
|
+
return projectResources(SERVICE, root);
|
|
93
|
+
}
|
|
94
|
+
const ofType = (all, type) => all.filter((r) => r.type === type);
|
|
95
|
+
const fields = (r) => ownFields(r);
|
|
96
|
+
const nowMs = (req) => Date.parse(req.occurredAt ?? new Date().toISOString());
|
|
97
|
+
const at = (req) => req.occurredAt ?? new Date().toISOString();
|
|
98
|
+
const ORG_URN = /^urn:li:organization:(\d{1,19})$/;
|
|
99
|
+
const PERSON_URN = /^urn:li:person:([A-Za-z0-9_-]{1,40})$/;
|
|
100
|
+
const POST_URN = /^urn:li:(share|ugcPost):(\d{1,19})$/;
|
|
101
|
+
const IMAGE_URN = /^urn:li:image:([A-Za-z0-9_-]{8,40})$/;
|
|
102
|
+
const VIDEO_URN = /^urn:li:video:([A-Za-z0-9_-]{8,40})$/;
|
|
103
|
+
/** A URN from a path segment or a query value: percent-decoded once (Rest.li protocol 2.0.0). */
|
|
104
|
+
function urnFrom(raw) {
|
|
105
|
+
try {
|
|
106
|
+
return decodeURIComponent(raw);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return raw;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/** `List(a,b,c)` — Rest.li 2.0's list syntax in a query value. */
|
|
113
|
+
function restliList(raw) {
|
|
114
|
+
if (raw === null)
|
|
115
|
+
return undefined;
|
|
116
|
+
const m = /^List\((.*)\)$/.exec(raw.trim());
|
|
117
|
+
if (!m)
|
|
118
|
+
return undefined;
|
|
119
|
+
return m[1].split(',').map((v) => urnFrom(v.trim())).filter((v) => v !== '');
|
|
120
|
+
}
|
|
121
|
+
// ── the little text format: hashtags ─────────────────────────────────────────────────────────
|
|
122
|
+
// The Posts API "Hashtag a keyword" sample posts `Follow best practices #coding` and reads back
|
|
123
|
+
// `Follow best practices {hashtag|\#|coding}`: LinkedIn stores a hashtag as the little-format
|
|
124
|
+
// template. The twin applies the same rewrite on write, so the stored commentary is what LinkedIn
|
|
125
|
+
// would answer. A `#` already inside a template, or glued to a word, is left alone.
|
|
126
|
+
export function templateHashtags(commentary) {
|
|
127
|
+
return commentary.replace(/(^|[\s(])#([\p{L}\p{N}_]+)/gu, (_m, lead, tag) => `${lead}{hashtag|\\#|${tag}}`);
|
|
128
|
+
}
|
|
129
|
+
function authorize(all, headers) {
|
|
130
|
+
const raw = header(headers, 'authorization');
|
|
131
|
+
const match = raw ? /^Bearer\s+(\S+)\s*$/i.exec(raw.trim()) : null;
|
|
132
|
+
if (!match)
|
|
133
|
+
return emptyAccessToken();
|
|
134
|
+
const row = ofType(all, 'token').find((t) => String(t.id) === match[1]);
|
|
135
|
+
if (!row)
|
|
136
|
+
return invalidAccessToken();
|
|
137
|
+
const f = fields(row);
|
|
138
|
+
return { token: match[1], member: String(f.member), scopes: new Set(Array.isArray(f.scopes) ? f.scopes.map(String) : []) };
|
|
139
|
+
}
|
|
140
|
+
const isResponse = (v) => typeof v === 'object' && v !== null && 'status' in v && 'body' in v;
|
|
141
|
+
function needScope(auth, anyOf, what) {
|
|
142
|
+
if (anyOf.some((s) => auth.scopes.has(s)))
|
|
143
|
+
return undefined;
|
|
144
|
+
return accessDenied(`Not enough permissions to access: ${what}. This token holds none of: ${anyOf.join(', ')}`);
|
|
145
|
+
}
|
|
146
|
+
/** The approved roles `member` holds on `organization` (both URNs). */
|
|
147
|
+
function rolesOn(all, member, organization) {
|
|
148
|
+
const out = new Set();
|
|
149
|
+
for (const acl of ofType(all, 'organization_acl')) {
|
|
150
|
+
const f = fields(acl);
|
|
151
|
+
if (f.roleAssignee === member && f.organization === organization && f.state === 'APPROVED')
|
|
152
|
+
out.add(String(f.role));
|
|
153
|
+
}
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
function canPostAs(all, auth, organization) {
|
|
157
|
+
return [...rolesOn(all, auth.member, organization)].some((r) => POSTING_ROLES.has(r));
|
|
158
|
+
}
|
|
159
|
+
function versionGate(headers) {
|
|
160
|
+
const version = header(headers, 'linkedin-version');
|
|
161
|
+
if (version === undefined || version.trim() === '')
|
|
162
|
+
return versionMissing();
|
|
163
|
+
if (!ACTIVE_VERSIONS.includes(version.trim()))
|
|
164
|
+
return versionInactive(version.trim());
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
// ── ids ───────────────────────────────────────────────────────────────────────────────────────
|
|
168
|
+
/**
|
|
169
|
+
* A new post URN, 19 digits in 9.0e18 … 9.2e18 (above today's real id space): a digest of the write
|
|
170
|
+
* itself — its instant, author and body, and the posts the tree already holds — re-drawn with the next
|
|
171
|
+
* attempt number while any held post id, or any local id a landing re-keyed to the vendor's URN (an
|
|
172
|
+
* alias), matches. So a URN a client was once answered is never handed out again; two clones of one
|
|
173
|
+
* World that each post before either pushes mint different URNs (their posts differ in instant or
|
|
174
|
+
* body), where a high-water mark + 1 gave both the same next URN and the origin one clone's post under
|
|
175
|
+
* the other's; and two identical fresh worlds replaying the same writes serve the same URNs (R9).
|
|
176
|
+
*/
|
|
177
|
+
function mintPostUrn(all, root, seed) {
|
|
178
|
+
const taken = new Set(ofType(all, 'post').map((p) => String(p.id)));
|
|
179
|
+
for (const key of subjectAliases(SERVICE, root).keys())
|
|
180
|
+
if (key.startsWith('post:'))
|
|
181
|
+
taken.add(key.slice('post:'.length));
|
|
182
|
+
for (let attempt = 0;; attempt += 1) {
|
|
183
|
+
const digest = blobDigest(new TextEncoder().encode(`${seed}|${taken.size}|${attempt}`));
|
|
184
|
+
const n = POST_ID_FLOOR + BigInt(`0x${digest.slice(0, 16)}`) % 200000000000000000n;
|
|
185
|
+
const urn = `urn:li:share:${n}`;
|
|
186
|
+
if (!taken.has(urn))
|
|
187
|
+
return urn;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/** A new asset id: `C5T10AQ` + 12 digits of entropy, re-drawn while any id this branch or its
|
|
191
|
+
* ancestors hold matches. Entropy, not an ordinal: an ordinal collides between sibling branches, a
|
|
192
|
+
* parent minting after its child, and two processes on one root; a random draw collides with none. */
|
|
193
|
+
async function mintAssetId(root) {
|
|
194
|
+
const held = new Set(await listAssetIds(root));
|
|
195
|
+
for (;;) {
|
|
196
|
+
const digits = crypto.getRandomValues(new Uint32Array(2));
|
|
197
|
+
const id = `${ASSET_PREFIX}${String(digits[0] % 1_000_000).padStart(6, '0')}${String(digits[1] % 1_000_000).padStart(6, '0')}`;
|
|
198
|
+
if (!held.has(id))
|
|
199
|
+
return id;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// ── projection: THE one place a stored subject becomes a response body ────────────────────────
|
|
203
|
+
/** A stored post as LinkedIn answers it: its vendor fields, no `_` bookkeeping. */
|
|
204
|
+
export function projectPost(post) {
|
|
205
|
+
const f = fields(post);
|
|
206
|
+
const out = {};
|
|
207
|
+
for (const [k, v] of Object.entries(f))
|
|
208
|
+
if (!k.startsWith('_'))
|
|
209
|
+
out[k] = v;
|
|
210
|
+
out.id = String(post.id);
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
/** Deleted here, or tombstoned by the kernel from a refresh (`deleted: true`). */
|
|
214
|
+
const isDeleted = (post) => fields(post)._deleted === true || fields(post).deleted === true;
|
|
215
|
+
/** A post by the URN a client presented: resolved through the aliases at the request boundary, so a
|
|
216
|
+
* local URN still reads the post after a landing re-keyed it to the vendor's. */
|
|
217
|
+
function findPost(all, urn, root) {
|
|
218
|
+
const id = resolveSubjectId(SERVICE, 'post', urn, root);
|
|
219
|
+
return ofType(all, 'post').find((p) => String(p.id) === id);
|
|
220
|
+
}
|
|
221
|
+
function livePost(all, urn, root) {
|
|
222
|
+
const post = findPost(all, urn, root);
|
|
223
|
+
return post && !isDeleted(post) ? post : undefined;
|
|
224
|
+
}
|
|
225
|
+
function orgNumber(urn) {
|
|
226
|
+
const m = ORG_URN.exec(urn);
|
|
227
|
+
return m ? Number(m[1]) : undefined;
|
|
228
|
+
}
|
|
229
|
+
function findOrg(all, id) {
|
|
230
|
+
return ofType(all, 'organization').find((o) => String(o.id) === id);
|
|
231
|
+
}
|
|
232
|
+
/** The non-admin fields (Organization Lookup: "APIs that don't require administrative access will
|
|
233
|
+
* only return" id, name, localizedName, localizedWebsite, vanityName, logoV2, locations,
|
|
234
|
+
* primaryOrganizationType). */
|
|
235
|
+
function projectOrgPublic(org) {
|
|
236
|
+
const f = fields(org);
|
|
237
|
+
return {
|
|
238
|
+
id: Number(org.id),
|
|
239
|
+
name: { localized: { en_US: String(f.localizedName) }, preferredLocale: { country: 'US', language: 'en' } },
|
|
240
|
+
localizedName: String(f.localizedName),
|
|
241
|
+
...(typeof f.localizedWebsite === 'string' ? { localizedWebsite: f.localizedWebsite } : {}),
|
|
242
|
+
vanityName: String(f.vanityName),
|
|
243
|
+
...(typeof f.logo === 'string' ? { logoV2: { cropped: f.logo, original: f.logo, cropInfo: { x: 0, y: 0, width: 0, height: 0 } } } : {}),
|
|
244
|
+
locations: [],
|
|
245
|
+
primaryOrganizationType: 'NONE',
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
/** The administered organization: the public fields plus the admin-only ones this twin holds. */
|
|
249
|
+
function projectOrgAdmin(org) {
|
|
250
|
+
const f = fields(org);
|
|
251
|
+
return {
|
|
252
|
+
...projectOrgPublic(org),
|
|
253
|
+
...(typeof f.localizedDescription === 'string'
|
|
254
|
+
? { localizedDescription: f.localizedDescription, description: { localized: { en_US: f.localizedDescription }, preferredLocale: { country: 'US', language: 'en' } } }
|
|
255
|
+
: {}),
|
|
256
|
+
...(typeof f.localizedWebsite === 'string' ? { website: { localized: { en_US: f.localizedWebsite }, preferredLocale: { country: 'US', language: 'en' } } } : {}),
|
|
257
|
+
...(Array.isArray(f.industries) ? { industries: f.industries } : {}),
|
|
258
|
+
defaultLocale: { country: 'US', language: 'en' },
|
|
259
|
+
$URN: `urn:li:organization:${org.id}`,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
function parsePaging(url, defaultCount, maxCount) {
|
|
263
|
+
const rawStart = url.searchParams.get('start');
|
|
264
|
+
const rawCount = url.searchParams.get('count');
|
|
265
|
+
const start = rawStart === null ? 0 : Number(rawStart);
|
|
266
|
+
const count = rawCount === null ? defaultCount : Number(rawCount);
|
|
267
|
+
if (!Number.isInteger(start) || start < 0)
|
|
268
|
+
return invalidValue('start', rawStart);
|
|
269
|
+
if (!Number.isInteger(count) || count < 1 || count > maxCount)
|
|
270
|
+
return invalidValue('count', rawCount);
|
|
271
|
+
return { start, count };
|
|
272
|
+
}
|
|
273
|
+
/** A finder page: `{ paging: { start, count, links }, elements }`, with the vendor's `next` link. */
|
|
274
|
+
function finderPage(elements, paging, url) {
|
|
275
|
+
const slice = elements.slice(paging.start, paging.start + paging.count);
|
|
276
|
+
const links = [];
|
|
277
|
+
if (paging.start + paging.count < elements.length) {
|
|
278
|
+
const next = new URLSearchParams(url.searchParams);
|
|
279
|
+
next.set('start', String(paging.start + paging.count));
|
|
280
|
+
next.set('count', String(paging.count));
|
|
281
|
+
links.push({ type: 'application/json', rel: 'next', href: `${url.pathname}?${next.toString()}` });
|
|
282
|
+
}
|
|
283
|
+
return ok({ paging: { start: paging.start, count: paging.count, links }, elements: slice });
|
|
284
|
+
}
|
|
285
|
+
// organizationAcls ─────────────────────────────────────────────────────────────────────────────
|
|
286
|
+
function findAcls(all, auth, url) {
|
|
287
|
+
const refusal = needScope(auth, ['rw_organization_admin', 'r_organization_admin'], 'organizationAcls');
|
|
288
|
+
if (refusal)
|
|
289
|
+
return refusal;
|
|
290
|
+
const q = url.searchParams.get('q');
|
|
291
|
+
const role = url.searchParams.get('role');
|
|
292
|
+
const state = url.searchParams.get('state');
|
|
293
|
+
if (role !== null && !ROLES.has(role))
|
|
294
|
+
return invalidValue('role', role);
|
|
295
|
+
if (state !== null && !ROLE_STATES.has(state))
|
|
296
|
+
return invalidValue('state', state);
|
|
297
|
+
const paging = parsePaging(url, 10, 100);
|
|
298
|
+
if (isResponse(paging))
|
|
299
|
+
return paging;
|
|
300
|
+
const acls = ofType(all, 'organization_acl').map(fields)
|
|
301
|
+
.filter((f) => (role === null || f.role === role) && (state === null || f.state === state))
|
|
302
|
+
.sort((a, b) => `${a.organization}|${a.role}`.localeCompare(`${b.organization}|${b.role}`));
|
|
303
|
+
if (q === 'roleAssignee') {
|
|
304
|
+
// "Find a Member's Organization Access Control Information": the authenticated member's own.
|
|
305
|
+
const mine = acls.filter((f) => f.roleAssignee === auth.member)
|
|
306
|
+
.map((f) => ({ role: f.role, organization: f.organization, roleAssignee: f.roleAssignee, state: f.state }));
|
|
307
|
+
return finderPage(mine, paging, url);
|
|
308
|
+
}
|
|
309
|
+
if (q === 'organization') {
|
|
310
|
+
const organization = url.searchParams.get('organization');
|
|
311
|
+
if (organization === null)
|
|
312
|
+
return missingField('organization');
|
|
313
|
+
if (!ORG_URN.test(organization))
|
|
314
|
+
return invalidUrnType('organization', organization, 'organization');
|
|
315
|
+
if (!rolesOn(all, auth.member, organization).has('ADMINISTRATOR')) {
|
|
316
|
+
return accessDenied(`Not enough permissions to access: organizationAcls for ${organization}`);
|
|
317
|
+
}
|
|
318
|
+
const theirs = acls.filter((f) => f.organization === organization)
|
|
319
|
+
.map((f) => ({ state: f.state, role: f.role, roleAssignee: f.roleAssignee, organizationTarget: f.organization }));
|
|
320
|
+
return finderPage(theirs, paging, url);
|
|
321
|
+
}
|
|
322
|
+
if (q === null)
|
|
323
|
+
return missingField('q');
|
|
324
|
+
return invalidValue('q', q);
|
|
325
|
+
}
|
|
326
|
+
// organizations ───────────────────────────────────────────────────────────────────────────────
|
|
327
|
+
function getOrganization(all, auth, id) {
|
|
328
|
+
const refusal = needScope(auth, ['rw_organization_admin', 'r_organization_admin'], 'organizations');
|
|
329
|
+
if (refusal)
|
|
330
|
+
return refusal;
|
|
331
|
+
if (!/^\d{1,19}$/.test(id))
|
|
332
|
+
return invalidUrnId(id);
|
|
333
|
+
const org = findOrg(all, id);
|
|
334
|
+
if (!org)
|
|
335
|
+
return notFound(`Organization ${id} is inactive`);
|
|
336
|
+
// "Retrieve an Administered Organization": a caller without the ADMINISTRATOR role gets 403.
|
|
337
|
+
if (!rolesOn(all, auth.member, `urn:li:organization:${id}`).has('ADMINISTRATOR')) {
|
|
338
|
+
return accessDenied(`Viewer don't have permission to the ADMIN_ONLY VisibilityReduction for urn:li:organization:${id}`);
|
|
339
|
+
}
|
|
340
|
+
return ok(projectOrgAdmin(org));
|
|
341
|
+
}
|
|
342
|
+
function findOrganizations(all, url) {
|
|
343
|
+
const q = url.searchParams.get('q');
|
|
344
|
+
if (q === 'vanityName') {
|
|
345
|
+
const vanity = url.searchParams.get('vanityName');
|
|
346
|
+
if (vanity === null || vanity.trim() === '')
|
|
347
|
+
return missingField('vanityName');
|
|
348
|
+
// "All strings are case-insensitive."
|
|
349
|
+
const hits = ofType(all, 'organization').filter((o) => String(fields(o).vanityName).toLowerCase() === vanity.trim().toLowerCase());
|
|
350
|
+
return ok({ paging: { start: 0, count: 10, links: [], total: hits.length }, elements: hits.map(projectOrgPublic) });
|
|
351
|
+
}
|
|
352
|
+
if (q === 'parentOrganization')
|
|
353
|
+
return unmodeled('The parentOrganization finder on /rest/organizations');
|
|
354
|
+
if (q === null) {
|
|
355
|
+
if (url.searchParams.get('ids') !== null)
|
|
356
|
+
return unmodeled('Batch get of /rest/organizations');
|
|
357
|
+
return missingField('q');
|
|
358
|
+
}
|
|
359
|
+
return invalidValue('q', q);
|
|
360
|
+
}
|
|
361
|
+
/** organizationsLookup?ids=List(...): organizations the member need not administer, with only the
|
|
362
|
+
* non-admin fields, as a batch (`results` / `statuses` / `errors`). */
|
|
363
|
+
function lookupOrganizations(all, url) {
|
|
364
|
+
const ids = restliList(url.searchParams.get('ids'));
|
|
365
|
+
if (ids === undefined)
|
|
366
|
+
return missingField('ids');
|
|
367
|
+
const results = {};
|
|
368
|
+
const statuses = {};
|
|
369
|
+
const errors = {};
|
|
370
|
+
for (const id of ids) {
|
|
371
|
+
const org = /^\d{1,19}$/.test(id) ? findOrg(all, id) : undefined;
|
|
372
|
+
if (!org) {
|
|
373
|
+
statuses[id] = 404;
|
|
374
|
+
errors[id] = { status: 404, code: 'NOT_FOUND', message: `Organization ${id} is inactive` };
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
results[id] = projectOrgPublic(org);
|
|
378
|
+
statuses[id] = 200;
|
|
379
|
+
}
|
|
380
|
+
return ok({ results, statuses, errors });
|
|
381
|
+
}
|
|
382
|
+
function networkSize(all, rawUrn, url) {
|
|
383
|
+
const urn = urnFrom(rawUrn);
|
|
384
|
+
const edgeType = url.searchParams.get('edgeType');
|
|
385
|
+
if (edgeType === null)
|
|
386
|
+
return missingField('edgeType');
|
|
387
|
+
// COMPANY_FOLLOWED_BY_MEMBER from v202305; the older CompanyFollowedByMember is not retrocompatible.
|
|
388
|
+
if (edgeType !== 'COMPANY_FOLLOWED_BY_MEMBER')
|
|
389
|
+
return invalidValue('edgeType', edgeType);
|
|
390
|
+
const id = orgNumber(urn);
|
|
391
|
+
if (id === undefined)
|
|
392
|
+
return invalidUrnType('networkSizes key', urn, 'organization');
|
|
393
|
+
const org = findOrg(all, String(id));
|
|
394
|
+
if (!org)
|
|
395
|
+
return notFound(`Organization ${id} is inactive`);
|
|
396
|
+
const size = fields(org).followerCount;
|
|
397
|
+
return ok({ firstDegreeSize: typeof size === 'number' ? size : 0 });
|
|
398
|
+
}
|
|
399
|
+
/** A content asset URN, resolved to a usable asset of `owner` — or the vendor's refusal. */
|
|
400
|
+
async function resolveAsset(field, urn, owner, allowVideo, atMs, root) {
|
|
401
|
+
if (typeof urn !== 'string' || urn === '')
|
|
402
|
+
return missingField(field);
|
|
403
|
+
const image = IMAGE_URN.exec(urn);
|
|
404
|
+
const video = VIDEO_URN.exec(urn);
|
|
405
|
+
if (!image && !(allowVideo && video)) {
|
|
406
|
+
if (/^urn:li:document:/.test(urn))
|
|
407
|
+
return unmodeled('Document content (urn:li:document) on a post');
|
|
408
|
+
return invalidUrnType(field, urn, allowVideo ? 'image or video' : 'image');
|
|
409
|
+
}
|
|
410
|
+
const stored = await readAsset((image ?? video)[1], root);
|
|
411
|
+
if (!stored || stored.kind !== (image ? 'image' : 'video'))
|
|
412
|
+
return invalidUrnId(urn);
|
|
413
|
+
const asset = assetAsOf(stored, atMs);
|
|
414
|
+
if (asset.owner !== owner)
|
|
415
|
+
return accessDenied(`Accessing this ${asset.kind} resource is forbidden. Please check your permissions for this resource`);
|
|
416
|
+
if (asset.status === 'WAITING_UPLOAD')
|
|
417
|
+
return problem(400, 'MEDIA_ASSET_WAITING_UPLOAD', 'Media asset is waiting upload');
|
|
418
|
+
if (asset.status === 'PROCESSING_FAILED')
|
|
419
|
+
return problem(400, 'MEDIA_ASSET_PROCESSING_FAILED', 'Media asset failed processing');
|
|
420
|
+
// LinkedIn would accept a still-processing video and hold the post at PUBLISH_REQUESTED; the twin
|
|
421
|
+
// does not model that lifecycle (linkedin.posts.publish_requested) and refuses rather than pretend.
|
|
422
|
+
if (asset.status !== 'AVAILABLE')
|
|
423
|
+
return unmodeled('A post naming a video that is still PROCESSING (the PUBLISH_REQUESTED lifecycle)');
|
|
424
|
+
return { kind: image ? 'image' : 'video', asset };
|
|
425
|
+
}
|
|
426
|
+
function assetBookkeeping(media) {
|
|
427
|
+
const a = media.asset;
|
|
428
|
+
return {
|
|
429
|
+
kind: media.kind, id: a.id, owner: a.owner, sha256: a.sha256, size: a.size, media_type: a.media_type,
|
|
430
|
+
...(a.width !== undefined ? { width: a.width, height: a.height } : {}),
|
|
431
|
+
...(a.duration_ms !== undefined ? { duration_ms: a.duration_ms } : {}),
|
|
432
|
+
...(a.thumbnail_sha256 ? { thumbnail_sha256: a.thumbnail_sha256, thumbnail_media_type: a.thumbnail_media_type } : {}),
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
async function createPost(req, all, auth, body) {
|
|
436
|
+
const refusal = needScope(auth, ['w_organization_social'], 'posts CREATE on behalf of an organization');
|
|
437
|
+
if (refusal)
|
|
438
|
+
return refusal;
|
|
439
|
+
for (const key of Object.keys(body)) {
|
|
440
|
+
if (!POST_FIELDS.has(key))
|
|
441
|
+
return problem(400, 'UNRECOGNIZED_FIELD', `Unrecognized field ${key} on a post`);
|
|
442
|
+
}
|
|
443
|
+
for (const key of ['reshareContext', 'container', 'adContext', 'contentLandingPage', 'contentCallToActionLabel']) {
|
|
444
|
+
if (body[key] !== undefined)
|
|
445
|
+
return unmodeled(`\`${key}\` on a post`);
|
|
446
|
+
}
|
|
447
|
+
// required fields (Posts API error table: "Ensure all required fields such as author, visibility,
|
|
448
|
+
// distribution, and lifecycleState are present"; Post Schema: commentary is required)
|
|
449
|
+
if (body.author === undefined)
|
|
450
|
+
return missingField('author');
|
|
451
|
+
if (typeof body.author !== 'string')
|
|
452
|
+
return invalidValue('author', body.author);
|
|
453
|
+
if (PERSON_URN.test(body.author))
|
|
454
|
+
return unmodeled('Posting as a member (a person author, w_member_social)');
|
|
455
|
+
if (!ORG_URN.test(body.author))
|
|
456
|
+
return invalidUrnType('author', body.author, 'organization or person');
|
|
457
|
+
if (body.commentary === undefined)
|
|
458
|
+
return missingField('commentary');
|
|
459
|
+
if (typeof body.commentary !== 'string')
|
|
460
|
+
return invalidValue('commentary', body.commentary);
|
|
461
|
+
if (body.visibility === undefined)
|
|
462
|
+
return missingField('visibility');
|
|
463
|
+
if (!VISIBILITIES.has(body.visibility))
|
|
464
|
+
return invalidValue('visibility', body.visibility);
|
|
465
|
+
if (body.visibility === 'CONTAINER')
|
|
466
|
+
return unmodeled('CONTAINER visibility');
|
|
467
|
+
if (body.distribution === undefined)
|
|
468
|
+
return missingField('distribution');
|
|
469
|
+
if (typeof body.distribution !== 'object' || body.distribution === null || Array.isArray(body.distribution))
|
|
470
|
+
return invalidValue('distribution', body.distribution);
|
|
471
|
+
const feed = body.distribution.feedDistribution;
|
|
472
|
+
if (feed === undefined)
|
|
473
|
+
return missingField('distribution.feedDistribution');
|
|
474
|
+
if (!FEED_DISTRIBUTIONS.has(feed))
|
|
475
|
+
return invalidValue('distribution.feedDistribution', feed);
|
|
476
|
+
const targets = body.distribution.targetEntities;
|
|
477
|
+
if (targets !== undefined && (!Array.isArray(targets) || targets.length > 0))
|
|
478
|
+
return unmodeled('Targeted organic posts (distribution.targetEntities)');
|
|
479
|
+
const channels = body.distribution.thirdPartyDistributionChannels;
|
|
480
|
+
if (channels !== undefined && (!Array.isArray(channels) || channels.length > 0))
|
|
481
|
+
return unmodeled('distribution.thirdPartyDistributionChannels');
|
|
482
|
+
if (body.lifecycleState === undefined)
|
|
483
|
+
return missingField('lifecycleState');
|
|
484
|
+
// "PUBLISHED is the only accepted field during creation."
|
|
485
|
+
if (body.lifecycleState !== 'PUBLISHED')
|
|
486
|
+
return invalidValue('lifecycleState', body.lifecycleState);
|
|
487
|
+
if (body.isReshareDisabledByAuthor !== undefined && typeof body.isReshareDisabledByAuthor !== 'boolean')
|
|
488
|
+
return invalidValue('isReshareDisabledByAuthor', body.isReshareDisabledByAuthor);
|
|
489
|
+
if (body.lifecycleStateInfo !== undefined || body.publishedAt !== undefined)
|
|
490
|
+
return problem(400, 'FIELD_NOT_ALLOWED', 'lifecycleStateInfo and publishedAt are read-only');
|
|
491
|
+
const author = body.author;
|
|
492
|
+
if (!canPostAs(all, auth, author)) {
|
|
493
|
+
return accessDenied(`Not enough permissions to post as ${author}: the member holds no ADMINISTRATOR, DIRECT_SPONSORED_CONTENT_POSTER or CONTENT_ADMINISTRATOR role on it`);
|
|
494
|
+
}
|
|
495
|
+
// content: at most one kind, and only media or article are modelled.
|
|
496
|
+
let content;
|
|
497
|
+
let media;
|
|
498
|
+
if (body.content !== undefined) {
|
|
499
|
+
if (typeof body.content !== 'object' || body.content === null || Array.isArray(body.content))
|
|
500
|
+
return invalidValue('content', body.content);
|
|
501
|
+
const kinds = Object.keys(body.content);
|
|
502
|
+
for (const k of kinds)
|
|
503
|
+
if (!CONTENT_KINDS.has(k))
|
|
504
|
+
return problem(400, 'UNRECOGNIZED_FIELD', `Unrecognized field content.${k}`);
|
|
505
|
+
if (kinds.length > 1)
|
|
506
|
+
return invalidValue('content', kinds.join(','));
|
|
507
|
+
const kind = kinds[0];
|
|
508
|
+
if (kind === 'media') {
|
|
509
|
+
const m = body.content.media;
|
|
510
|
+
if (typeof m !== 'object' || m === null)
|
|
511
|
+
return invalidValue('content.media', m);
|
|
512
|
+
for (const k of Object.keys(m))
|
|
513
|
+
if (!['id', 'title', 'altText'].includes(k))
|
|
514
|
+
return problem(400, 'UNRECOGNIZED_FIELD', `Unrecognized field content.media.${k}`);
|
|
515
|
+
const resolved = await resolveAsset('content.media.id', m.id, author, true, nowMs(req), req.root);
|
|
516
|
+
if (isResponse(resolved))
|
|
517
|
+
return resolved;
|
|
518
|
+
media = resolved;
|
|
519
|
+
for (const k of ['title', 'altText'])
|
|
520
|
+
if (m[k] !== undefined && typeof m[k] !== 'string')
|
|
521
|
+
return invalidValue(`content.media.${k}`, m[k]);
|
|
522
|
+
content = { media: { ...(typeof m.title === 'string' ? { title: m.title } : {}), ...(typeof m.altText === 'string' ? { altText: m.altText } : {}), id: m.id } };
|
|
523
|
+
}
|
|
524
|
+
else if (kind === 'article') {
|
|
525
|
+
const a = body.content.article;
|
|
526
|
+
if (typeof a !== 'object' || a === null)
|
|
527
|
+
return invalidValue('content.article', a);
|
|
528
|
+
if (typeof a.source !== 'string' || a.source === '')
|
|
529
|
+
return missingField('content.article.source');
|
|
530
|
+
if (!/^https?:\/\/\S+$/i.test(a.source))
|
|
531
|
+
return problem(400, 'INVALID_URL', `${a.source} is an invalid URL`);
|
|
532
|
+
for (const k of Object.keys(a))
|
|
533
|
+
if (!['source', 'thumbnail', 'thumbnailAltText', 'title', 'description'].includes(k))
|
|
534
|
+
return problem(400, 'UNRECOGNIZED_FIELD', `Unrecognized field content.article.${k}`);
|
|
535
|
+
for (const k of ['title', 'description', 'thumbnailAltText'])
|
|
536
|
+
if (a[k] !== undefined && typeof a[k] !== 'string')
|
|
537
|
+
return invalidValue(`content.article.${k}`, a[k]);
|
|
538
|
+
if (a.thumbnail !== undefined) {
|
|
539
|
+
const resolved = await resolveAsset('content.article.thumbnail', a.thumbnail, author, false, nowMs(req), req.root);
|
|
540
|
+
if (isResponse(resolved))
|
|
541
|
+
return resolved;
|
|
542
|
+
media = resolved;
|
|
543
|
+
}
|
|
544
|
+
content = { article: { source: a.source, ...(a.thumbnail !== undefined ? { thumbnail: a.thumbnail } : {}), ...(a.thumbnailAltText !== undefined ? { thumbnailAltText: a.thumbnailAltText } : {}), ...(a.title !== undefined ? { title: a.title } : {}), ...(a.description !== undefined ? { description: a.description } : {}) } };
|
|
545
|
+
}
|
|
546
|
+
else if (kind !== undefined) {
|
|
547
|
+
return unmodeled(`\`content.${kind}\` on a post`);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
const ms = nowMs(req);
|
|
551
|
+
// The mint and the append are one step under the kernel's lock: two concurrent creates never share a URN.
|
|
552
|
+
const { value: urn } = await applyTwinWriteAtomic(SERVICE, (resources) => {
|
|
553
|
+
const minted = mintPostUrn(resources, req.root, `${at(req)}|${author}|${JSON.stringify(body)}`);
|
|
554
|
+
return {
|
|
555
|
+
kind: 'write',
|
|
556
|
+
value: minted,
|
|
557
|
+
write: {
|
|
558
|
+
operation: 'linkedin.post.create',
|
|
559
|
+
subjectType: 'post',
|
|
560
|
+
subjectId: minted,
|
|
561
|
+
fields: {
|
|
562
|
+
author,
|
|
563
|
+
commentary: templateHashtags(body.commentary),
|
|
564
|
+
visibility: body.visibility,
|
|
565
|
+
distribution: { feedDistribution: feed, thirdPartyDistributionChannels: [] },
|
|
566
|
+
...(content ? { content } : {}),
|
|
567
|
+
lifecycleState: 'PUBLISHED',
|
|
568
|
+
lifecycleStateInfo: { isEditedByAuthor: false },
|
|
569
|
+
isReshareDisabledByAuthor: body.isReshareDisabledByAuthor === true,
|
|
570
|
+
createdAt: ms,
|
|
571
|
+
publishedAt: ms,
|
|
572
|
+
lastModifiedAt: ms,
|
|
573
|
+
// The asset by digest: how the mirror's player and a deploy's upload find the bytes, with no
|
|
574
|
+
// staged record needed (bookkeeping — never on the wire).
|
|
575
|
+
...(media ? { _media: assetBookkeeping(media) } : {}),
|
|
576
|
+
_deleted: false,
|
|
577
|
+
},
|
|
578
|
+
occurredAt: at(req),
|
|
579
|
+
actor: { kind: 'agent', id: auth.member },
|
|
580
|
+
},
|
|
581
|
+
};
|
|
582
|
+
}, req.root);
|
|
583
|
+
// "You should get a 201 response and the response header x-restli-id contains the Post ID."
|
|
584
|
+
return { status: 201, body: null, headers: { 'x-restli-id': urn, 'x-restli-protocol-version': '2.0.0' } };
|
|
585
|
+
}
|
|
586
|
+
function readablePost(all, auth, urn, root) {
|
|
587
|
+
const post = livePost(all, urn, root);
|
|
588
|
+
if (!post)
|
|
589
|
+
return notFound(`Post ${urn} not found`);
|
|
590
|
+
if (!canPostAs(all, auth, String(fields(post).author)))
|
|
591
|
+
return accessDenied(`Not enough permissions to access: posts GET for ${urn}`);
|
|
592
|
+
return post;
|
|
593
|
+
}
|
|
594
|
+
function parseViewContext(url) {
|
|
595
|
+
const view = url.searchParams.get('viewContext');
|
|
596
|
+
if (view !== null && view !== 'READER' && view !== 'AUTHOR')
|
|
597
|
+
return invalidValue('viewContext', view);
|
|
598
|
+
return undefined;
|
|
599
|
+
}
|
|
600
|
+
function getPost(all, auth, rawUrn, url, root) {
|
|
601
|
+
const refusal = needScope(auth, ['r_organization_social'], 'posts GET');
|
|
602
|
+
if (refusal)
|
|
603
|
+
return refusal;
|
|
604
|
+
const bad = parseViewContext(url);
|
|
605
|
+
if (bad)
|
|
606
|
+
return bad;
|
|
607
|
+
const urn = urnFrom(rawUrn);
|
|
608
|
+
if (!POST_URN.test(urn))
|
|
609
|
+
return invalidUrnType('id', urn, 'share or ugcPost');
|
|
610
|
+
const post = readablePost(all, auth, urn, root);
|
|
611
|
+
return isResponse(post) ? post : ok(projectPost(post));
|
|
612
|
+
}
|
|
613
|
+
function batchGetPosts(all, auth, ids, root) {
|
|
614
|
+
const results = {};
|
|
615
|
+
const statuses = {};
|
|
616
|
+
const errors = {};
|
|
617
|
+
for (const urn of ids) {
|
|
618
|
+
if (!POST_URN.test(urn)) {
|
|
619
|
+
statuses[urn] = 400;
|
|
620
|
+
errors[urn] = { status: 400, code: 'INVALID_URN_TYPE', message: `id value ${urn} must be a share or ugcPost URN` };
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
const post = readablePost(all, auth, urn, root);
|
|
624
|
+
if (isResponse(post)) {
|
|
625
|
+
statuses[urn] = post.status;
|
|
626
|
+
errors[urn] = post.body;
|
|
627
|
+
continue;
|
|
628
|
+
}
|
|
629
|
+
results[urn] = projectPost(post);
|
|
630
|
+
statuses[urn] = 200;
|
|
631
|
+
}
|
|
632
|
+
return ok({ results, statuses, errors });
|
|
633
|
+
}
|
|
634
|
+
function findPostsByAuthor(all, auth, url) {
|
|
635
|
+
const refusal = needScope(auth, ['r_organization_social'], 'posts FINDER author');
|
|
636
|
+
if (refusal)
|
|
637
|
+
return refusal;
|
|
638
|
+
const author = url.searchParams.get('author');
|
|
639
|
+
if (author === null)
|
|
640
|
+
return missingField('author');
|
|
641
|
+
if (PERSON_URN.test(author))
|
|
642
|
+
return unmodeled("A member's posts (the author finder over a person URN, r_member_social)");
|
|
643
|
+
if (!ORG_URN.test(author))
|
|
644
|
+
return invalidUrnType('author', author, 'organization or person');
|
|
645
|
+
const bad = parseViewContext(url);
|
|
646
|
+
if (bad)
|
|
647
|
+
return bad;
|
|
648
|
+
const sortBy = url.searchParams.get('sortBy') ?? 'LAST_MODIFIED';
|
|
649
|
+
if (sortBy !== 'LAST_MODIFIED' && sortBy !== 'CREATED')
|
|
650
|
+
return invalidValue('sortBy', sortBy);
|
|
651
|
+
// "The default is 10 and maximum allowed count is 100."
|
|
652
|
+
const paging = parsePaging(url, 10, 100);
|
|
653
|
+
if (isResponse(paging))
|
|
654
|
+
return paging;
|
|
655
|
+
if (!canPostAs(all, auth, author))
|
|
656
|
+
return accessDenied(`Not enough permissions to access: posts FINDER author for ${author}`);
|
|
657
|
+
const key = sortBy === 'CREATED' ? 'createdAt' : 'lastModifiedAt';
|
|
658
|
+
// descending by the sort key; a tie (one instant) newest-written first: the tree keeps write order
|
|
659
|
+
const posts = ofType(all, 'post').map((p, i) => ({ p, i })).filter(({ p }) => !isDeleted(p) && fields(p).author === author)
|
|
660
|
+
.sort((a, b) => (Number(fields(b.p)[key]) - Number(fields(a.p)[key])) || (b.i - a.i)).map(({ p }) => p);
|
|
661
|
+
return finderPage(posts.map(projectPost), paging, url);
|
|
662
|
+
}
|
|
663
|
+
async function deletePost(req, all, auth, rawUrn) {
|
|
664
|
+
const refusal = needScope(auth, ['w_organization_social'], 'posts DELETE');
|
|
665
|
+
if (refusal)
|
|
666
|
+
return refusal;
|
|
667
|
+
const urn = urnFrom(rawUrn);
|
|
668
|
+
if (!POST_URN.test(urn))
|
|
669
|
+
return invalidUrnType('id', urn, 'share or ugcPost');
|
|
670
|
+
const post = findPost(all, urn, req.root);
|
|
671
|
+
if (!post)
|
|
672
|
+
return notFound(`Post ${urn} not found`);
|
|
673
|
+
const f = fields(post);
|
|
674
|
+
if (!canPostAs(all, auth, String(f.author)))
|
|
675
|
+
return accessDenied(`Not enough permissions to access: posts DELETE for ${urn}`);
|
|
676
|
+
// "Post deletions are idempotent. Deletion requests for a previously deleted UGC Post will return
|
|
677
|
+
// a 204" — and write nothing a second time.
|
|
678
|
+
if (isDeleted(post))
|
|
679
|
+
return noContent();
|
|
680
|
+
await applyTwinWrite(SERVICE, {
|
|
681
|
+
operation: 'linkedin.post.delete',
|
|
682
|
+
subjectType: 'post',
|
|
683
|
+
subjectId: String(post.id),
|
|
684
|
+
fields: { ...f, _deleted: true },
|
|
685
|
+
occurredAt: at(req),
|
|
686
|
+
actor: { kind: 'agent', id: auth.member },
|
|
687
|
+
}, req.root);
|
|
688
|
+
return noContent();
|
|
689
|
+
}
|
|
690
|
+
// assets: images and videos ───────────────────────────────────────────────────────────────────
|
|
691
|
+
function assetOwnerCheck(all, auth, owner, kind) {
|
|
692
|
+
if (typeof owner !== 'string' || owner === '')
|
|
693
|
+
return missingField('initializeUploadRequest.owner');
|
|
694
|
+
if (PERSON_URN.test(owner))
|
|
695
|
+
return unmodeled(`A member-owned ${kind} (a person owner)`);
|
|
696
|
+
if (!ORG_URN.test(owner))
|
|
697
|
+
return invalidUrnType('initializeUploadRequest.owner', owner, 'organization or person');
|
|
698
|
+
// "For videos [images] with company URN owners, the caller needs to have ADMIN or DSC permissions."
|
|
699
|
+
// (Narrower than posting, which CONTENT_ADMINISTRATOR may do: the Videos and Images pages name only these two.)
|
|
700
|
+
const roles = rolesOn(all, auth.member, owner);
|
|
701
|
+
if (!roles.has('ADMINISTRATOR') && !roles.has('DIRECT_SPONSORED_CONTENT_POSTER'))
|
|
702
|
+
return accessDenied(`Accessing this ${kind} resource is forbidden. Please check your permissions for this resource`);
|
|
703
|
+
return undefined;
|
|
704
|
+
}
|
|
705
|
+
const uploadBase = (req) => (req.publicBase ?? UPLOAD_HOST).replace(/\/+$/, '');
|
|
706
|
+
async function uploadUrl(req, asset, slot) {
|
|
707
|
+
const ut = await signUpload(asset, slot, asset.upload_expires_ms);
|
|
708
|
+
return `${uploadBase(req)}/dms-uploads/${asset.id}/${slot}?ca=vector_ads&cn=uploads&sync=0&v=beta&e=${asset.upload_expires_ms}&ut=${ut}`;
|
|
709
|
+
}
|
|
710
|
+
async function initializeImageUpload(req, all, auth, body) {
|
|
711
|
+
const refusal = needScope(auth, ['w_organization_social', 'rw_ads', 'w_member_social'], 'images initializeUpload');
|
|
712
|
+
if (refusal)
|
|
713
|
+
return refusal;
|
|
714
|
+
const init = body.initializeUploadRequest;
|
|
715
|
+
if (typeof init !== 'object' || init === null)
|
|
716
|
+
return missingField('initializeUploadRequest');
|
|
717
|
+
if (init.mediaLibraryMetadata !== undefined)
|
|
718
|
+
return unmodeled('The media library (initializeUploadRequest.mediaLibraryMetadata)');
|
|
719
|
+
const denied = assetOwnerCheck(all, auth, init.owner, 'image');
|
|
720
|
+
if (denied)
|
|
721
|
+
return denied;
|
|
722
|
+
const id = await mintAssetId(req.root);
|
|
723
|
+
const created = nowMs(req);
|
|
724
|
+
const expires = created + UPLOAD_URL_LIFETIME_MS;
|
|
725
|
+
const asset = { kind: 'image', id, owner: init.owner, status: 'WAITING_UPLOAD', created_ms: created, upload_expires_ms: expires, upload_secret: newUploadSecret() };
|
|
726
|
+
await writeAsset(asset, req.root);
|
|
727
|
+
return ok({ value: { uploadUrlExpiresAt: expires, uploadUrl: await uploadUrl(req, asset, 'uploaded-image/0'), image: `urn:li:image:${id}` } });
|
|
728
|
+
}
|
|
729
|
+
async function initializeVideoUpload(req, all, auth, body) {
|
|
730
|
+
const refusal = needScope(auth, ['w_organization_social', 'rw_ads', 'w_member_social'], 'videos initializeUpload');
|
|
731
|
+
if (refusal)
|
|
732
|
+
return refusal;
|
|
733
|
+
const init = body.initializeUploadRequest;
|
|
734
|
+
if (typeof init !== 'object' || init === null)
|
|
735
|
+
return missingField('initializeUploadRequest');
|
|
736
|
+
for (const k of ['mediaLibraryMetadata', 'templateName', 'linkbackContext']) {
|
|
737
|
+
if (init[k] !== undefined)
|
|
738
|
+
return unmodeled(`initializeUploadRequest.${k}`);
|
|
739
|
+
}
|
|
740
|
+
if (init.uploadCaptions === true)
|
|
741
|
+
return unmodeled('Caption upload (initializeUploadRequest.uploadCaptions)');
|
|
742
|
+
const denied = assetOwnerCheck(all, auth, init.owner, 'video');
|
|
743
|
+
if (denied)
|
|
744
|
+
return denied;
|
|
745
|
+
const size = init.fileSizeBytes;
|
|
746
|
+
if (size === undefined)
|
|
747
|
+
return missingField('initializeUploadRequest.fileSizeBytes');
|
|
748
|
+
if (typeof size !== 'number' || !Number.isInteger(size) || size <= 0)
|
|
749
|
+
return invalidValue('initializeUploadRequest.fileSizeBytes', size);
|
|
750
|
+
// "Maximum allowed Videos size is 5GB." (the feed ceiling of 500MB is checked on the bytes)
|
|
751
|
+
if (size > 5 * 1024 ** 3)
|
|
752
|
+
return problem(400, 'FIELD_VALUE_TOO_HIGH', `initializeUploadRequest.fileSizeBytes value ${size} can't be higher than ${5 * 1024 ** 3}`);
|
|
753
|
+
// A feed video is at most 500 MB; the larger (ads) videos LinkedIn takes are surface this twin does
|
|
754
|
+
// not model — and a finalize assembles the file in memory, so it is refused before any byte arrives.
|
|
755
|
+
if (size > MAX_VIDEO_BYTES)
|
|
756
|
+
return unmodeled(`A video over the feed's 500 MB (${size} bytes; the ads videos up to 5 GB)`);
|
|
757
|
+
for (const k of ['uploadCaptions', 'uploadThumbnail'])
|
|
758
|
+
if (init[k] !== undefined && typeof init[k] !== 'boolean')
|
|
759
|
+
return invalidValue(`initializeUploadRequest.${k}`, init[k]);
|
|
760
|
+
const id = await mintAssetId(req.root);
|
|
761
|
+
const created = nowMs(req);
|
|
762
|
+
const expires = created + UPLOAD_URL_LIFETIME_MS;
|
|
763
|
+
const parts = [];
|
|
764
|
+
for (let first = 0; first < size; first += VIDEO_PART_BYTES)
|
|
765
|
+
parts.push({ firstByte: first, lastByte: Math.min(size, first + VIDEO_PART_BYTES) - 1 });
|
|
766
|
+
const thumbnail = init.uploadThumbnail === true;
|
|
767
|
+
const asset = {
|
|
768
|
+
kind: 'video', id, owner: init.owner, status: 'WAITING_UPLOAD', created_ms: created, upload_expires_ms: expires,
|
|
769
|
+
file_size_bytes: size, parts, upload_thumbnail: thumbnail, upload_captions: false, upload_secret: newUploadSecret(),
|
|
770
|
+
};
|
|
771
|
+
await writeAsset(asset, req.root);
|
|
772
|
+
const instructions = [];
|
|
773
|
+
for (let i = 0; i < parts.length; i += 1) {
|
|
774
|
+
instructions.push({ uploadUrl: await uploadUrl(req, asset, `uploadedVideo/${i}`), lastByte: parts[i].lastByte, firstByte: parts[i].firstByte });
|
|
775
|
+
}
|
|
776
|
+
return ok({
|
|
777
|
+
value: {
|
|
778
|
+
uploadUrlsExpireAt: expires,
|
|
779
|
+
video: `urn:li:video:${id}`,
|
|
780
|
+
uploadInstructions: instructions,
|
|
781
|
+
// The docs' samples answer an empty token for these uploads; the twin does the same, and
|
|
782
|
+
// finalize accepts exactly what initialize answered.
|
|
783
|
+
uploadToken: '',
|
|
784
|
+
...(thumbnail ? { thumbnailUploadUrl: await uploadUrl(req, asset, 'ads-video-userUploadedThumbnail/0') } : {}),
|
|
785
|
+
},
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
async function finalizeVideoUpload(req, all, auth, body) {
|
|
789
|
+
const refusal = needScope(auth, ['w_organization_social', 'rw_ads', 'w_member_social'], 'videos finalizeUpload');
|
|
790
|
+
if (refusal)
|
|
791
|
+
return refusal;
|
|
792
|
+
const fin = body.finalizeUploadRequest;
|
|
793
|
+
if (typeof fin !== 'object' || fin === null)
|
|
794
|
+
return missingField('finalizeUploadRequest');
|
|
795
|
+
const m = typeof fin.video === 'string' ? VIDEO_URN.exec(fin.video) : null;
|
|
796
|
+
if (!m)
|
|
797
|
+
return fin.video === undefined ? missingField('finalizeUploadRequest.video') : invalidUrnType('finalizeUploadRequest.video', fin.video, 'video');
|
|
798
|
+
// in the asset's chain: no part PUT can land between reading the parts and writing PROCESSING
|
|
799
|
+
return withAsset(m[1], req.root, () => finalizeLocked(req, all, auth, fin, m[1]));
|
|
800
|
+
}
|
|
801
|
+
async function finalizeLocked(req, all, auth, fin, videoId) {
|
|
802
|
+
const asset = await readAsset(videoId, req.root);
|
|
803
|
+
if (!asset || asset.kind !== 'video')
|
|
804
|
+
return problem(400, 'INVALID_VIDEO_ID', 'This Video ID is invalid');
|
|
805
|
+
if (!(await assetIsOwn(videoId, req.root)))
|
|
806
|
+
return anotherWorldsUpload(videoId);
|
|
807
|
+
const denied = assetOwnerCheck(all, auth, asset.owner, 'video');
|
|
808
|
+
if (denied)
|
|
809
|
+
return denied;
|
|
810
|
+
if (asset.status !== 'WAITING_UPLOAD')
|
|
811
|
+
return invalidValue('finalizeUploadRequest.video', `${fin.video} (already ${asset.status})`);
|
|
812
|
+
if (fin.uploadToken !== undefined && fin.uploadToken !== '')
|
|
813
|
+
return invalidValue('finalizeUploadRequest.uploadToken', fin.uploadToken);
|
|
814
|
+
const ids = fin.uploadedPartIds;
|
|
815
|
+
const parts = asset.parts ?? [];
|
|
816
|
+
// "The order needs to be the same as the order of parts in the upload instructions."
|
|
817
|
+
if (!Array.isArray(ids) || ids.length !== parts.length)
|
|
818
|
+
return invalidValue('finalizeUploadRequest.uploadedPartIds', `${Array.isArray(ids) ? ids.length : 0} ids for ${parts.length} parts`);
|
|
819
|
+
for (let i = 0; i < parts.length; i += 1) {
|
|
820
|
+
if (!parts[i].etag || ids[i] !== parts[i].etag)
|
|
821
|
+
return invalidValue(`finalizeUploadRequest.uploadedPartIds[${i}]`, ids[i]);
|
|
822
|
+
}
|
|
823
|
+
if (asset.upload_thumbnail && !asset.thumbnail_sha256)
|
|
824
|
+
return problem(400, 'MEDIA_ASSET_WAITING_UPLOAD', 'Media asset is waiting upload (the thumbnail requested at initialize was never uploaded)');
|
|
825
|
+
const chunks = [];
|
|
826
|
+
for (let i = 0; i < parts.length; i += 1) {
|
|
827
|
+
const bytes = await readPart(asset.id, i, req.root);
|
|
828
|
+
if (!bytes)
|
|
829
|
+
return invalidValue(`finalizeUploadRequest.uploadedPartIds[${i}]`, ids[i]);
|
|
830
|
+
chunks.push(bytes);
|
|
831
|
+
}
|
|
832
|
+
const whole = new Uint8Array(chunks.reduce((n, c) => n + c.length, 0));
|
|
833
|
+
let offset = 0;
|
|
834
|
+
for (const c of chunks) {
|
|
835
|
+
whole.set(c, offset);
|
|
836
|
+
offset += c.length;
|
|
837
|
+
}
|
|
838
|
+
const stored = await putLinkedinBlob(whole, req.root);
|
|
839
|
+
await clearParts(asset.id, req.root);
|
|
840
|
+
// Processing, as LinkedIn does it after finalize: a file that is not an MP4 with a video track, or
|
|
841
|
+
// outside the feed's size and length bounds, FAILS processing rather than refusing the finalize.
|
|
842
|
+
const info = sniffVideo(whole);
|
|
843
|
+
let failure;
|
|
844
|
+
if (!info)
|
|
845
|
+
failure = 'The file is not an MP4 with a video track.';
|
|
846
|
+
else if (whole.length < MIN_VIDEO_BYTES || whole.length > MAX_VIDEO_BYTES)
|
|
847
|
+
failure = `File size ${whole.length} is outside 75KB to 500MB.`;
|
|
848
|
+
else if (info.durationMs < MIN_VIDEO_MS || info.durationMs > MAX_VIDEO_MS)
|
|
849
|
+
failure = `Length ${info.durationMs}ms is outside three seconds to 30 minutes.`;
|
|
850
|
+
await writeAsset({
|
|
851
|
+
...asset, sha256: stored.sha256, size: whole.length, media_type: 'video/mp4',
|
|
852
|
+
...(info ? { width: info.width, height: info.height, duration_ms: info.durationMs } : {}),
|
|
853
|
+
...(failure ? { status: 'PROCESSING_FAILED', processing_failure_reason: failure } : { status: 'PROCESSING', finalized_ms: nowMs(req) }),
|
|
854
|
+
}, req.root);
|
|
855
|
+
// "A successful response returns the 200 OK status code." — with no body in the docs' sample.
|
|
856
|
+
return { status: 200, body: null, headers: { 'x-restli-protocol-version': '2.0.0' } };
|
|
857
|
+
}
|
|
858
|
+
/** An upload started in an ancestor World (a clone's origin, a branch's parent): finished there, never here. */
|
|
859
|
+
const anotherWorldsUpload = (id) => problem(409, 'CONFLICT', `This upload (${id}) was started in another World; finish it there`);
|
|
860
|
+
const downloadBase = (req, host) => (req.publicBase ?? host).replace(/\/+$/, '');
|
|
861
|
+
/** An asset as the Images / Videos API answers it. */
|
|
862
|
+
function projectAsset(req, asset) {
|
|
863
|
+
const out = { owner: asset.owner, id: `urn:li:${asset.kind}:${asset.id}`, status: asset.status };
|
|
864
|
+
const available = asset.status === 'AVAILABLE';
|
|
865
|
+
if (available && asset.sha256) {
|
|
866
|
+
const expires = asset.created_ms + UPLOAD_URL_LIFETIME_MS;
|
|
867
|
+
if (asset.kind === 'image') {
|
|
868
|
+
out.downloadUrl = `${downloadBase(req, IMAGE_HOST)}/dms/image/${asset.id}/image-shrink_1280/0/${asset.sha256}?e=${expires}`;
|
|
869
|
+
}
|
|
870
|
+
else {
|
|
871
|
+
out.downloadUrl = `${downloadBase(req, VIDEO_HOST)}/playlist/${asset.id}/mp4-${Math.min(asset.width ?? 720, asset.height ?? 720)}p-30fp-crf28/0/${asset.sha256}?e=${expires}`;
|
|
872
|
+
out.thumbnail = asset.thumbnail_sha256
|
|
873
|
+
? `${downloadBase(req, IMAGE_HOST)}/dms/image/${asset.id}/ads-video-thumbnail_720_1280/0/${asset.thumbnail_sha256}?e=${expires}`
|
|
874
|
+
: `${downloadBase(req, IMAGE_HOST)}/dms/image/${asset.id}/ads-video-thumbnail_720_1280/0/system?e=${expires}`;
|
|
875
|
+
if (asset.duration_ms !== undefined)
|
|
876
|
+
out.duration = asset.duration_ms;
|
|
877
|
+
}
|
|
878
|
+
out.downloadUrlExpiresAt = expires;
|
|
879
|
+
if (asset.width && asset.height) {
|
|
880
|
+
const r = aspectRatio(asset.width, asset.height);
|
|
881
|
+
out.aspectRatioWidth = r.w;
|
|
882
|
+
out.aspectRatioHeight = r.h;
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
if (asset.status === 'PROCESSING_FAILED' && asset.processing_failure_reason)
|
|
886
|
+
out.processingFailureReason = asset.processing_failure_reason;
|
|
887
|
+
return out;
|
|
888
|
+
}
|
|
889
|
+
async function getAsset(req, all, auth, kind, rawUrn) {
|
|
890
|
+
// "tokens with only w_member_social permissions would be unable to perform a GET call for rest/images"
|
|
891
|
+
const refusal = needScope(auth, ['w_organization_social', 'r_organization_social', 'rw_ads', 'r_ads'], `${kind}s GET`);
|
|
892
|
+
if (refusal)
|
|
893
|
+
return refusal;
|
|
894
|
+
const urn = urnFrom(rawUrn);
|
|
895
|
+
const m = (kind === 'image' ? IMAGE_URN : VIDEO_URN).exec(urn);
|
|
896
|
+
if (!m)
|
|
897
|
+
return invalidUrnType('id', urn, kind);
|
|
898
|
+
const asset = await readAsset(m[1], req.root);
|
|
899
|
+
if (!asset || asset.kind !== kind)
|
|
900
|
+
return notFound('Could not find entity');
|
|
901
|
+
if (!canPostAs(all, auth, asset.owner))
|
|
902
|
+
return accessDenied(`Accessing this ${kind} resource is forbidden. Please check your permissions for this resource`);
|
|
903
|
+
// PROCESSING -> AVAILABLE is a fold of the stored finalize instant and this request's world instant
|
|
904
|
+
// (PROCESSING_MS later), so a client that polls as LinkedIn asks reaches it, one that posts at once is
|
|
905
|
+
// refused, and READING NEVER MOVES ANYTHING — a preview in the mirror changes no state.
|
|
906
|
+
return ok(projectAsset(req, assetAsOf(asset, nowMs(req))));
|
|
907
|
+
}
|
|
908
|
+
/** A PUT to a signed upload URL: an image, a video part, or a video's thumbnail. A video part or
|
|
909
|
+
* thumbnail carries no bearer — the URL's own signature is its authorization (the Videos API's
|
|
910
|
+
* upload samples send none). An IMAGE upload also needs the member's access token in
|
|
911
|
+
* Authorization, as LinkedIn's image upload does ("Upload the Image" in the Assets / Images docs). */
|
|
912
|
+
async function receiveUpload(req, url) {
|
|
913
|
+
const m = /^\/dms-uploads\/([A-Za-z0-9_-]{8,40})\/(uploaded-image\/0|uploadedVideo\/(\d{1,4})|ads-video-userUploadedThumbnail\/0)$/.exec(url.pathname);
|
|
914
|
+
if (!m || !isAssetId(m[1]))
|
|
915
|
+
return notFound();
|
|
916
|
+
// in the asset's chain: concurrent part PUTs each read the record the previous one wrote
|
|
917
|
+
return withAsset(m[1], req.root, () => receiveUploadLocked(req, url, m));
|
|
918
|
+
}
|
|
919
|
+
async function receiveUploadLocked(req, url, m) {
|
|
920
|
+
const [, id, slot, partIndex] = m;
|
|
921
|
+
const ut = url.searchParams.get('ut') ?? '';
|
|
922
|
+
const expires = Number(url.searchParams.get('e'));
|
|
923
|
+
const asset = await readAsset(id, req.root);
|
|
924
|
+
if (asset && !hasUploadSecret(asset)) {
|
|
925
|
+
return problem(400, 'INVALID_UPLOAD_URL', `The upload of ${id} was started before its upload URLs were signed per asset; initialize a new upload`);
|
|
926
|
+
}
|
|
927
|
+
if (!asset || !Number.isFinite(expires) || expires !== asset.upload_expires_ms || !(await verifyUpload(asset, slot, expires, ut))) {
|
|
928
|
+
return problem(401, 'INVALID_UPLOAD_SIGNATURE', 'The upload URL signature is invalid');
|
|
929
|
+
}
|
|
930
|
+
if (nowMs(req) > expires)
|
|
931
|
+
return problem(400, 'EXPIRED_UPLOAD_URL', 'The Video upload URL is expired');
|
|
932
|
+
// A branch writes only the assets it started: an inherited record (and its parts) stays the ancestor's,
|
|
933
|
+
// so a clone never holds a stale copy of the origin's asset and its push can never roll the origin back.
|
|
934
|
+
if (!(await assetIsOwn(id, req.root)))
|
|
935
|
+
return anotherWorldsUpload(id);
|
|
936
|
+
if (asset.status !== 'WAITING_UPLOAD')
|
|
937
|
+
return problem(400, 'UPLOAD_ALREADY_COMPLETE', `Asset ${id} is ${asset.status}; its upload is complete`);
|
|
938
|
+
const bytes = req.bytes ?? new Uint8Array();
|
|
939
|
+
if (bytes.length === 0)
|
|
940
|
+
return missingField('request body');
|
|
941
|
+
if (slot === 'uploaded-image/0') {
|
|
942
|
+
if (asset.kind !== 'image')
|
|
943
|
+
return notFound();
|
|
944
|
+
const all = resources(req.root);
|
|
945
|
+
const auth = authorize(all, req.headers);
|
|
946
|
+
if (isResponse(auth))
|
|
947
|
+
return auth;
|
|
948
|
+
const scope = needScope(auth, ['w_organization_social', 'rw_ads', 'w_member_social'], 'an image upload');
|
|
949
|
+
if (scope)
|
|
950
|
+
return scope;
|
|
951
|
+
const denied = assetOwnerCheck(all, auth, asset.owner, 'image');
|
|
952
|
+
if (denied)
|
|
953
|
+
return denied;
|
|
954
|
+
const info = sniffImage(bytes);
|
|
955
|
+
if (!info) {
|
|
956
|
+
await writeAsset({ ...asset, status: 'PROCESSING_FAILED', processing_failure_reason: 'Unsupported image format: the Images API takes JPG, GIF and PNG.' }, req.root);
|
|
957
|
+
return { status: 201, body: null, headers: {} };
|
|
958
|
+
}
|
|
959
|
+
const stored = await putLinkedinBlob(bytes, req.root);
|
|
960
|
+
const tooBig = info.width * info.height >= MAX_IMAGE_PIXELS;
|
|
961
|
+
await writeAsset({
|
|
962
|
+
...asset, sha256: stored.sha256, size: bytes.length, media_type: info.mediaType, width: info.width, height: info.height,
|
|
963
|
+
...(tooBig ? { status: 'PROCESSING_FAILED', processing_failure_reason: `The image has ${info.width * info.height} pixels; the limit is under ${MAX_IMAGE_PIXELS}.` } : { status: 'AVAILABLE' }),
|
|
964
|
+
}, req.root);
|
|
965
|
+
return { status: 201, body: null, headers: {} };
|
|
966
|
+
}
|
|
967
|
+
if (asset.kind !== 'video')
|
|
968
|
+
return notFound();
|
|
969
|
+
if (slot === 'ads-video-userUploadedThumbnail/0') {
|
|
970
|
+
if (!asset.upload_thumbnail)
|
|
971
|
+
return notFound();
|
|
972
|
+
const info = sniffImage(bytes);
|
|
973
|
+
if (!info)
|
|
974
|
+
return problem(400, 'INVALID_IMAGE', 'The thumbnail is not a JPG, GIF or PNG image');
|
|
975
|
+
const stored = await putLinkedinBlob(bytes, req.root);
|
|
976
|
+
await writeAsset({ ...asset, thumbnail_sha256: stored.sha256, thumbnail_media_type: info.mediaType }, req.root);
|
|
977
|
+
return { status: 201, body: null, headers: {} };
|
|
978
|
+
}
|
|
979
|
+
const index = Number(partIndex);
|
|
980
|
+
const part = asset.parts?.[index];
|
|
981
|
+
if (!part)
|
|
982
|
+
return notFound();
|
|
983
|
+
const expected = part.lastByte - part.firstByte + 1;
|
|
984
|
+
if (bytes.length !== expected)
|
|
985
|
+
return invalidValue('part', `${bytes.length} bytes for the byte range ${part.firstByte}-${part.lastByte} (${expected} bytes)`);
|
|
986
|
+
await putPart(asset.id, index, bytes, req.root);
|
|
987
|
+
// The ETag names what was stored: a digest of the part, in the vendor's opaque signedId form.
|
|
988
|
+
const digest = blobDigest(bytes);
|
|
989
|
+
const etag = `/ambry-video/signedId/${digest}.bin`;
|
|
990
|
+
const partsNext = [...(asset.parts ?? [])];
|
|
991
|
+
partsNext[index] = { ...part, etag, sha256: digest };
|
|
992
|
+
await writeAsset({ ...asset, parts: partsNext }, req.root);
|
|
993
|
+
return { status: 200, body: null, headers: { etag } };
|
|
994
|
+
}
|
|
995
|
+
// ── /v2/userinfo (OpenID Connect) ─────────────────────────────────────────────────────────────
|
|
996
|
+
function userinfo(all, auth) {
|
|
997
|
+
if (!auth.scopes.has('openid'))
|
|
998
|
+
return accessDenied('Not enough permissions to access: userinfo (the openid scope is required)');
|
|
999
|
+
const id = PERSON_URN.exec(auth.member)?.[1] ?? '';
|
|
1000
|
+
const member = ofType(all, 'member').find((m) => String(m.id) === id);
|
|
1001
|
+
if (!member)
|
|
1002
|
+
return notFound('Member not found');
|
|
1003
|
+
const f = fields(member);
|
|
1004
|
+
const given = String(f.localizedFirstName ?? '');
|
|
1005
|
+
const family = String(f.localizedLastName ?? '');
|
|
1006
|
+
return ok({
|
|
1007
|
+
sub: id,
|
|
1008
|
+
...(auth.scopes.has('profile') ? { name: `${given} ${family}`.trim(), given_name: given, family_name: family, locale: 'en-US' } : {}),
|
|
1009
|
+
...(auth.scopes.has('email') && typeof f.email === 'string' ? { email: f.email, email_verified: true } : {}),
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
// ── the twin control plane ───────────────────────────────────────────────────────────────────
|
|
1013
|
+
// Not vendor surface. `/_twin/*` is how a WORLD seeds what LinkedIn's own UI and OAuth leg would
|
|
1014
|
+
// have made: the company page, the member, the admin role that member holds on it, and the access
|
|
1015
|
+
// token the 3-legged OAuth flow would have minted.
|
|
1016
|
+
async function handleTwinControl(req, all, path, body) {
|
|
1017
|
+
if (req.method !== 'POST')
|
|
1018
|
+
return notFound();
|
|
1019
|
+
if (path === '/_twin/organizations') {
|
|
1020
|
+
const id = String(body.id ?? '');
|
|
1021
|
+
if (!/^\d{1,19}$/.test(id))
|
|
1022
|
+
return invalidValue('id', body.id);
|
|
1023
|
+
if (typeof body.localizedName !== 'string' || body.localizedName.trim() === '')
|
|
1024
|
+
return missingField('localizedName');
|
|
1025
|
+
const vanityName = typeof body.vanityName === 'string' ? body.vanityName : body.localizedName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
1026
|
+
if (!/^[A-Za-z0-9-]{1,100}$/.test(vanityName))
|
|
1027
|
+
return invalidValue('vanityName', vanityName);
|
|
1028
|
+
if (ofType(all, 'organization').some((o) => String(o.id) !== id && String(fields(o).vanityName).toLowerCase() === vanityName.toLowerCase())) {
|
|
1029
|
+
return invalidValue('vanityName', `${vanityName} (already another page's)`);
|
|
1030
|
+
}
|
|
1031
|
+
for (const k of ['localizedWebsite', 'localizedDescription'])
|
|
1032
|
+
if (body[k] !== undefined && typeof body[k] !== 'string')
|
|
1033
|
+
return invalidValue(k, body[k]);
|
|
1034
|
+
if (body.followerCount !== undefined && (!Number.isInteger(body.followerCount) || body.followerCount < 0))
|
|
1035
|
+
return invalidValue('followerCount', body.followerCount);
|
|
1036
|
+
if (body.industries !== undefined && (!Array.isArray(body.industries) || body.industries.some((i) => typeof i !== 'string')))
|
|
1037
|
+
return invalidValue('industries', body.industries);
|
|
1038
|
+
// The logo is an image this org uploaded through the Images API, named by URN as logoV2 names it.
|
|
1039
|
+
if (body.logo !== undefined) {
|
|
1040
|
+
const m = typeof body.logo === 'string' ? IMAGE_URN.exec(body.logo) : null;
|
|
1041
|
+
const asset = m ? await readAsset(m[1], req.root) : undefined;
|
|
1042
|
+
if (!asset || asset.status !== 'AVAILABLE' || asset.owner !== `urn:li:organization:${id}`)
|
|
1043
|
+
return invalidValue('logo', `${String(body.logo)} (an AVAILABLE image owned by urn:li:organization:${id})`);
|
|
1044
|
+
}
|
|
1045
|
+
const f = {
|
|
1046
|
+
localizedName: body.localizedName, vanityName,
|
|
1047
|
+
...(body.localizedWebsite !== undefined ? { localizedWebsite: body.localizedWebsite } : {}),
|
|
1048
|
+
...(body.localizedDescription !== undefined ? { localizedDescription: body.localizedDescription } : {}),
|
|
1049
|
+
...(body.industries !== undefined ? { industries: body.industries } : {}),
|
|
1050
|
+
...(body.logo !== undefined ? { logo: body.logo } : {}),
|
|
1051
|
+
followerCount: body.followerCount ?? 0,
|
|
1052
|
+
};
|
|
1053
|
+
await applyTwinWrite(SERVICE, { operation: 'linkedin.twin.seed_organization', subjectType: 'organization', subjectId: id, fields: f, occurredAt: at(req) }, req.root);
|
|
1054
|
+
return ok({ id: Number(id), $URN: `urn:li:organization:${id}`, ...f });
|
|
1055
|
+
}
|
|
1056
|
+
if (path === '/_twin/members') {
|
|
1057
|
+
const id = String(body.id ?? '');
|
|
1058
|
+
if (!/^[A-Za-z0-9_-]{1,40}$/.test(id))
|
|
1059
|
+
return invalidValue('id', body.id);
|
|
1060
|
+
if (typeof body.localizedFirstName !== 'string' || body.localizedFirstName === '')
|
|
1061
|
+
return missingField('localizedFirstName');
|
|
1062
|
+
if (typeof body.localizedLastName !== 'string')
|
|
1063
|
+
return missingField('localizedLastName');
|
|
1064
|
+
if (typeof body.email !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.email))
|
|
1065
|
+
return invalidValue('email', body.email);
|
|
1066
|
+
if (ofType(all, 'member').some((m) => String(m.id) !== id && String(fields(m).email).toLowerCase() === body.email.toLowerCase()))
|
|
1067
|
+
return invalidValue('email', `${body.email} (already another member's)`);
|
|
1068
|
+
const f = { localizedFirstName: body.localizedFirstName, localizedLastName: body.localizedLastName, email: body.email };
|
|
1069
|
+
await applyTwinWrite(SERVICE, { operation: 'linkedin.twin.seed_member', subjectType: 'member', subjectId: id, fields: f, occurredAt: at(req) }, req.root);
|
|
1070
|
+
return ok({ id, $URN: `urn:li:person:${id}`, ...f });
|
|
1071
|
+
}
|
|
1072
|
+
if (path === '/_twin/organizationAcls') {
|
|
1073
|
+
const { organization, roleAssignee, role } = body;
|
|
1074
|
+
const state = body.state ?? 'APPROVED';
|
|
1075
|
+
if (typeof organization !== 'string' || !ORG_URN.test(organization) || !findOrg(all, String(orgNumber(organization))))
|
|
1076
|
+
return invalidValue('organization', `${String(organization)} (a seeded organization URN)`);
|
|
1077
|
+
const person = typeof roleAssignee === 'string' ? PERSON_URN.exec(roleAssignee) : null;
|
|
1078
|
+
if (!person || !ofType(all, 'member').some((m) => String(m.id) === person[1]))
|
|
1079
|
+
return invalidValue('roleAssignee', `${String(roleAssignee)} (a seeded person URN)`);
|
|
1080
|
+
if (!ROLES.has(role))
|
|
1081
|
+
return invalidValue('role', role);
|
|
1082
|
+
if (!ROLE_STATES.has(state))
|
|
1083
|
+
return invalidValue('state', state);
|
|
1084
|
+
const f = { organization, roleAssignee, role, state };
|
|
1085
|
+
await applyTwinWrite(SERVICE, { operation: 'linkedin.twin.seed_acl', subjectType: 'organization_acl', subjectId: `${organization}|${role}|${roleAssignee}`, fields: f, occurredAt: at(req) }, req.root);
|
|
1086
|
+
return ok(f);
|
|
1087
|
+
}
|
|
1088
|
+
if (path === '/_twin/tokens') {
|
|
1089
|
+
const token = body.token;
|
|
1090
|
+
if (typeof token !== 'string' || !/^[A-Za-z0-9._~-]{8,512}$/.test(token))
|
|
1091
|
+
return invalidValue('token', '(8-512 characters of A-Z a-z 0-9 . _ ~ -)');
|
|
1092
|
+
const person = typeof body.member === 'string' ? PERSON_URN.exec(body.member) : null;
|
|
1093
|
+
if (!person || !ofType(all, 'member').some((m) => String(m.id) === person[1]))
|
|
1094
|
+
return invalidValue('member', `${String(body.member)} (a seeded person URN)`);
|
|
1095
|
+
const scopes = Array.isArray(body.scopes) ? body.scopes.map(String) : String(body.scopes ?? '').split(/[\s,]+/).filter((s) => s !== '');
|
|
1096
|
+
for (const s of scopes)
|
|
1097
|
+
if (!SCOPES_DOCUMENTED.has(s))
|
|
1098
|
+
return invalidValue('scopes', s);
|
|
1099
|
+
await applyTwinWrite(SERVICE, { operation: 'linkedin.twin.seed_token', subjectType: 'token', subjectId: token, fields: { member: body.member, scopes }, occurredAt: at(req) }, req.root);
|
|
1100
|
+
return ok({ member: body.member, scopes });
|
|
1101
|
+
}
|
|
1102
|
+
return notFound();
|
|
1103
|
+
}
|
|
1104
|
+
// ── the router ───────────────────────────────────────────────────────────────────────────────
|
|
1105
|
+
const POST_BY_URN = /^\/rest\/posts\/([^/]+)$/;
|
|
1106
|
+
const IMAGE_BY_URN = /^\/rest\/images\/([^/]+)$/;
|
|
1107
|
+
const VIDEO_BY_URN = /^\/rest\/videos\/([^/]+)$/;
|
|
1108
|
+
const ORG_BY_ID = /^\/rest\/organizations\/([^/]+)$/;
|
|
1109
|
+
const NETWORK_SIZE = /^\/rest\/networkSizes\/([^/]+)$/;
|
|
1110
|
+
export async function handleLinkedinTwinRequest(req) {
|
|
1111
|
+
const url = new URL(req.path, 'http://twin.local');
|
|
1112
|
+
const path = url.pathname.replace(/\/+$/, '') || '/';
|
|
1113
|
+
const method = req.method.toUpperCase();
|
|
1114
|
+
// The signed upload URLs: a PUT of bytes, authorized by the URL's own signature.
|
|
1115
|
+
if (path.startsWith('/dms-uploads/')) {
|
|
1116
|
+
if (method !== 'PUT')
|
|
1117
|
+
return notFound();
|
|
1118
|
+
if (req.readOnly)
|
|
1119
|
+
return readOnlyRefusal();
|
|
1120
|
+
return receiveUpload(req, url);
|
|
1121
|
+
}
|
|
1122
|
+
const parsed = parseJson(req.body);
|
|
1123
|
+
if ('malformed' in parsed)
|
|
1124
|
+
return problem(400, 'INVALID_JSON', 'The request body could not be parsed as a JSON object');
|
|
1125
|
+
const body = parsed.object;
|
|
1126
|
+
const all = resources(req.root);
|
|
1127
|
+
if (path.startsWith('/_twin/')) {
|
|
1128
|
+
if (req.readOnly)
|
|
1129
|
+
return readOnlyRefusal();
|
|
1130
|
+
return handleTwinControl(req, all, path, body);
|
|
1131
|
+
}
|
|
1132
|
+
if (path === '/v2/userinfo' && method === 'GET') {
|
|
1133
|
+
const auth = authorize(all, req.headers);
|
|
1134
|
+
return isResponse(auth) ? auth : userinfo(all, auth);
|
|
1135
|
+
}
|
|
1136
|
+
// Everything else of LinkedIn's is under /rest; anything outside it is the vendor's not-found.
|
|
1137
|
+
if (!path.startsWith('/rest/'))
|
|
1138
|
+
return notFound();
|
|
1139
|
+
const version = versionGate(req.headers);
|
|
1140
|
+
if (version)
|
|
1141
|
+
return version;
|
|
1142
|
+
const auth = authorize(all, req.headers);
|
|
1143
|
+
if (isResponse(auth))
|
|
1144
|
+
return auth;
|
|
1145
|
+
// Rest.li names some methods in a header: PARTIAL_UPDATE of a post rides POST /rest/posts/{urn}.
|
|
1146
|
+
const restliMethod = (header(req.headers, 'x-restli-method') ?? '').toUpperCase();
|
|
1147
|
+
const write = method !== 'GET' && method !== 'HEAD';
|
|
1148
|
+
if (write && req.readOnly)
|
|
1149
|
+
return readOnlyRefusal();
|
|
1150
|
+
if (path === '/rest/posts') {
|
|
1151
|
+
if (method === 'POST')
|
|
1152
|
+
return createPost(req, all, auth, body);
|
|
1153
|
+
if (method === 'GET') {
|
|
1154
|
+
const ids = restliList(url.searchParams.get('ids'));
|
|
1155
|
+
if (ids !== undefined) {
|
|
1156
|
+
const refusal = needScope(auth, ['r_organization_social'], 'posts BATCH_GET');
|
|
1157
|
+
return refusal ?? batchGetPosts(all, auth, ids, req.root);
|
|
1158
|
+
}
|
|
1159
|
+
const q = url.searchParams.get('q');
|
|
1160
|
+
if (q === 'author')
|
|
1161
|
+
return findPostsByAuthor(all, auth, url);
|
|
1162
|
+
if (q === 'dscAdAccount')
|
|
1163
|
+
return unmodeled('The dscAdAccount finder on /rest/posts');
|
|
1164
|
+
return q === null ? missingField('q') : invalidValue('q', q);
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
const postByUrn = POST_BY_URN.exec(path);
|
|
1168
|
+
if (postByUrn) {
|
|
1169
|
+
if (method === 'GET')
|
|
1170
|
+
return getPost(all, auth, postByUrn[1], url, req.root);
|
|
1171
|
+
if (method === 'DELETE')
|
|
1172
|
+
return deletePost(req, all, auth, postByUrn[1]);
|
|
1173
|
+
if (method === 'POST' && restliMethod === 'PARTIAL_UPDATE')
|
|
1174
|
+
return unmodeled('Updating a post (PARTIAL_UPDATE)');
|
|
1175
|
+
if (method === 'POST')
|
|
1176
|
+
return unmodeled('Query tunneling on /rest/posts/{urn}');
|
|
1177
|
+
}
|
|
1178
|
+
if (path === '/rest/images' && method === 'POST') {
|
|
1179
|
+
const action = url.searchParams.get('action');
|
|
1180
|
+
if (action === 'initializeUpload')
|
|
1181
|
+
return initializeImageUpload(req, all, auth, body);
|
|
1182
|
+
return action === null ? missingField('action') : invalidValue('action', action);
|
|
1183
|
+
}
|
|
1184
|
+
if (path === '/rest/videos' && method === 'POST') {
|
|
1185
|
+
const action = url.searchParams.get('action');
|
|
1186
|
+
if (action === 'initializeUpload')
|
|
1187
|
+
return initializeVideoUpload(req, all, auth, body);
|
|
1188
|
+
if (action === 'finalizeUpload')
|
|
1189
|
+
return finalizeVideoUpload(req, all, auth, body);
|
|
1190
|
+
return action === null ? missingField('action') : invalidValue('action', action);
|
|
1191
|
+
}
|
|
1192
|
+
if ((path === '/rest/images' || path === '/rest/videos') && method === 'GET') {
|
|
1193
|
+
return unmodeled(url.searchParams.get('ids') !== null ? `Batch get of ${path}` : `The associatedAccount finder on ${path}`);
|
|
1194
|
+
}
|
|
1195
|
+
const imageByUrn = IMAGE_BY_URN.exec(path);
|
|
1196
|
+
if (imageByUrn && method === 'GET')
|
|
1197
|
+
return getAsset(req, all, auth, 'image', imageByUrn[1]);
|
|
1198
|
+
if (imageByUrn && method === 'POST')
|
|
1199
|
+
return unmodeled('Updating an image (media library metadata)');
|
|
1200
|
+
const videoByUrn = VIDEO_BY_URN.exec(path);
|
|
1201
|
+
if (videoByUrn && method === 'GET')
|
|
1202
|
+
return getAsset(req, all, auth, 'video', videoByUrn[1]);
|
|
1203
|
+
if (videoByUrn && method === 'POST')
|
|
1204
|
+
return unmodeled('Updating a video (media library metadata)');
|
|
1205
|
+
if (path === '/rest/organizationAcls' && method === 'GET')
|
|
1206
|
+
return findAcls(all, auth, url);
|
|
1207
|
+
if (path.startsWith('/rest/organizationAcls/') && method === 'PUT')
|
|
1208
|
+
return unmodeled('Requesting a role (PUT /rest/organizationAcls)');
|
|
1209
|
+
if (path === '/rest/organizations' && method === 'GET')
|
|
1210
|
+
return findOrganizations(all, url);
|
|
1211
|
+
if (path === '/rest/organizationsLookup' && method === 'GET')
|
|
1212
|
+
return lookupOrganizations(all, url);
|
|
1213
|
+
const orgById = ORG_BY_ID.exec(path);
|
|
1214
|
+
if (orgById && method === 'GET')
|
|
1215
|
+
return getOrganization(all, auth, urnFrom(orgById[1]));
|
|
1216
|
+
const size = NETWORK_SIZE.exec(path);
|
|
1217
|
+
if (size && method === 'GET')
|
|
1218
|
+
return networkSize(all, size[1], url);
|
|
1219
|
+
return notFound();
|
|
1220
|
+
}
|