flare-plugin-sdk 0.0.0-stage → 0.1.0-beta.2
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 +661 -0
- package/README.md +131 -2
- package/package.json +33 -4
- package/skill/flare-plugin/ABI.md +334 -0
- package/skill/flare-plugin/README.md +204 -0
- package/skill/flare-plugin/SKILL.md +80 -0
- package/skill/flare-plugin/assets/github-template/README.md +71 -0
- package/skill/flare-plugin/assets/github-template/assets/icon.png +0 -0
- package/skill/flare-plugin/assets/github-template/manifest.json +29 -0
- package/skill/flare-plugin/assets/github-template/plugin.js +108 -0
- package/skill/flare-plugin/examples/pixelfed/README.md +78 -0
- package/skill/flare-plugin/examples/pixelfed/assets/icon.png +0 -0
- package/skill/flare-plugin/examples/pixelfed/locales/en.json +24 -0
- package/skill/flare-plugin/examples/pixelfed/locales/zh-Hans.json +24 -0
- package/skill/flare-plugin/examples/pixelfed/manifest.json +211 -0
- package/skill/flare-plugin/examples/pixelfed/plugin.js +793 -0
- package/skill/flare-plugin/flare-plugin.d.ts +501 -0
- package/skill/flare-plugin/pack.mjs +155 -0
- package/skill/flare-plugin/references/authentication.md +239 -0
- package/skill/flare-plugin/references/capabilities.md +88 -0
- package/skill/flare-plugin/references/manifest.md +192 -0
- package/skill/flare-plugin/references/publishing.md +111 -0
- package/skill/flare-plugin/references/recipes.md +328 -0
- package/skill/flare-plugin/references/runtime-http.md +207 -0
- package/skill/flare-plugin/references/testing.md +176 -0
- package/skill/flare-plugin/references/troubleshooting.md +116 -0
- package/skill/flare-plugin/references/wire-data.md +163 -0
|
@@ -0,0 +1,793 @@
|
|
|
1
|
+
const PIXELFED_CAPABILITIES = {
|
|
2
|
+
"flare.datasource.timeline/v1": ["page"],
|
|
3
|
+
"flare.datasource.search/v1": [
|
|
4
|
+
"posts",
|
|
5
|
+
"profiles",
|
|
6
|
+
"discoverPosts",
|
|
7
|
+
"discoverProfiles",
|
|
8
|
+
"discoverHashtags",
|
|
9
|
+
],
|
|
10
|
+
"flare.datasource.profile/v1": ["byId", "byHandle", "timeline", "following", "followers"],
|
|
11
|
+
"flare.datasource.post/v1": ["detail", "context", "delete", "mutate"],
|
|
12
|
+
"flare.datasource.compose/v1": ["publish"],
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
definePlugin({
|
|
16
|
+
detector: {
|
|
17
|
+
async detect(request, context) {
|
|
18
|
+
const origin = requireOrigin(request.origin || context.origin);
|
|
19
|
+
const instance = await apiRequest(origin, "/api/v1/instance");
|
|
20
|
+
if (!isPixelfed(instance.data)) {
|
|
21
|
+
return { match: "none", canonicalOrigin: origin };
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
match: "exact",
|
|
25
|
+
canonicalOrigin: origin,
|
|
26
|
+
software: "Pixelfed",
|
|
27
|
+
instance: mapInstance(instance.data, origin),
|
|
28
|
+
};
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
login: {
|
|
33
|
+
oauth: {
|
|
34
|
+
async begin(request, context) {
|
|
35
|
+
const origin = requireOrigin(request.origin || context.origin);
|
|
36
|
+
const instance = await apiRequest(origin, "/api/v1/instance");
|
|
37
|
+
requirePixelfed(instance.data);
|
|
38
|
+
const application = await apiRequest(origin, "/api/v1/apps", {
|
|
39
|
+
method: "POST",
|
|
40
|
+
body: {
|
|
41
|
+
type: "form",
|
|
42
|
+
values: {
|
|
43
|
+
client_name: "Flare",
|
|
44
|
+
redirect_uris: requiredString(request.redirectUri, "OAuth redirect URI is missing"),
|
|
45
|
+
scopes: "read write",
|
|
46
|
+
website: "https://github.com/DimensionDev/Flare",
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
const clientId = application.data && application.data.client_id;
|
|
51
|
+
const clientSecret = application.data && application.data.client_secret;
|
|
52
|
+
if (!clientId || !clientSecret) invalidResponse("Pixelfed did not return OAuth client credentials");
|
|
53
|
+
const authorizationUrl = addQuery(origin + "/oauth/authorize", {
|
|
54
|
+
client_id: String(clientId),
|
|
55
|
+
redirect_uri: request.redirectUri,
|
|
56
|
+
response_type: "code",
|
|
57
|
+
scope: "read write",
|
|
58
|
+
state: requiredString(request.state, "OAuth state is missing"),
|
|
59
|
+
});
|
|
60
|
+
return {
|
|
61
|
+
type: "externalBrowser",
|
|
62
|
+
url: authorizationUrl,
|
|
63
|
+
pendingPayload: {
|
|
64
|
+
clientId: String(clientId),
|
|
65
|
+
clientSecret: String(clientSecret),
|
|
66
|
+
composeConfig: composeConfig(instance.data),
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
async resume(request, context) {
|
|
72
|
+
const origin = requireOrigin(request.origin || context.origin);
|
|
73
|
+
const pending = request.pendingPayload || {};
|
|
74
|
+
const callback = request.callbackParameters || {};
|
|
75
|
+
if (callback.error) validationError("Pixelfed denied authorization", String(callback.error));
|
|
76
|
+
const code = requiredString(callback.code, "OAuth callback code is missing");
|
|
77
|
+
const clientId = requiredString(pending.clientId, "OAuth client ID is missing");
|
|
78
|
+
const clientSecret = requiredString(pending.clientSecret, "OAuth client secret is missing");
|
|
79
|
+
const tokenResponse = await apiRequest(origin, "/oauth/token", {
|
|
80
|
+
method: "POST",
|
|
81
|
+
timeoutMillis: 120000,
|
|
82
|
+
body: {
|
|
83
|
+
type: "form",
|
|
84
|
+
values: {
|
|
85
|
+
grant_type: "authorization_code",
|
|
86
|
+
code,
|
|
87
|
+
client_id: clientId,
|
|
88
|
+
client_secret: clientSecret,
|
|
89
|
+
redirect_uri: requiredString(request.redirectUri, "OAuth redirect URI is missing"),
|
|
90
|
+
scope: "read write",
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
const accessToken = tokenResponse.data && tokenResponse.data.access_token;
|
|
95
|
+
if (!accessToken) invalidResponse("Pixelfed did not return an access token");
|
|
96
|
+
const accountResponse = await apiRequest(origin, "/api/v1/accounts/verify_credentials", {
|
|
97
|
+
token: String(accessToken),
|
|
98
|
+
});
|
|
99
|
+
const account = accountResponse.data;
|
|
100
|
+
if (!account || account.id == null) invalidResponse("Pixelfed did not return the authenticated account");
|
|
101
|
+
return {
|
|
102
|
+
type: "success",
|
|
103
|
+
value: {
|
|
104
|
+
accountId: String(account.id),
|
|
105
|
+
origin,
|
|
106
|
+
credential: { accessToken: String(accessToken) },
|
|
107
|
+
profile: mapProfile(account, origin),
|
|
108
|
+
capabilities: PIXELFED_CAPABILITIES,
|
|
109
|
+
composeConfig: pending.composeConfig || null,
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
capabilities: {
|
|
117
|
+
timeline: {
|
|
118
|
+
async page(request, context) {
|
|
119
|
+
const timeline = request.timelineId;
|
|
120
|
+
if (timeline === "home") {
|
|
121
|
+
return statusPage(context, "/api/v1/timelines/home", request.page);
|
|
122
|
+
}
|
|
123
|
+
if (timeline === "discover") {
|
|
124
|
+
return statusPage(context, "/api/v1/discover/posts", request.page, {}, unwrapStatuses);
|
|
125
|
+
}
|
|
126
|
+
if (timeline === "local" || timeline === "federated") {
|
|
127
|
+
return statusPage(context, "/api/v1/timelines/public", request.page, {
|
|
128
|
+
local: timeline === "local" ? "true" : "false",
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
if (timeline === "bookmarks") {
|
|
132
|
+
return statusPage(context, "/api/v1/bookmarks", request.page);
|
|
133
|
+
}
|
|
134
|
+
if (timeline === "favourites") {
|
|
135
|
+
return statusPage(context, "/api/v1/favourites", request.page);
|
|
136
|
+
}
|
|
137
|
+
unsupportedError("Unknown Pixelfed timeline", timeline);
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
search: {
|
|
142
|
+
async posts(request, context) {
|
|
143
|
+
return searchPage(context, request, "statuses", mapPost);
|
|
144
|
+
},
|
|
145
|
+
async profiles(request, context) {
|
|
146
|
+
return searchPage(context, request, "accounts", mapProfile);
|
|
147
|
+
},
|
|
148
|
+
async discoverPosts(request, context) {
|
|
149
|
+
return statusPage(context, "/api/v1/discover/posts", request.page, {}, unwrapStatuses);
|
|
150
|
+
},
|
|
151
|
+
async discoverProfiles(request, context) {
|
|
152
|
+
return profilePage(context, "/api/v1.1/discover/accounts/popular", request.page, {}, unwrapProfiles);
|
|
153
|
+
},
|
|
154
|
+
async discoverHashtags(request, context) {
|
|
155
|
+
const response = await pagedGet(context, "/api/v1.1/discover/posts/hashtags", request.page);
|
|
156
|
+
const values = unwrapArray(response.data, ["hashtags", "tags", "data"]);
|
|
157
|
+
const items = values.map((value) => mapHashtag(value, context.origin)).filter((value) => value.name);
|
|
158
|
+
return pageResult(items, cursorFor(response, "/api/v1.1/discover/posts/hashtags", request.page, {}, values));
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
profile: {
|
|
163
|
+
async byId(request, context) {
|
|
164
|
+
const response = await authenticatedRequest(context, "/api/v1/accounts/" + encodeURIComponent(request.key.id));
|
|
165
|
+
return mapProfile(response.data, context.origin);
|
|
166
|
+
},
|
|
167
|
+
async byHandle(request, context) {
|
|
168
|
+
const handle = requiredString(request.handle, "Profile handle is missing").replace(/^@/, "");
|
|
169
|
+
const response = await authenticatedRequest(context, addQuery("/api/v1/accounts/lookup", { acct: handle }));
|
|
170
|
+
return mapProfile(response.data, context.origin);
|
|
171
|
+
},
|
|
172
|
+
async timeline(request, context) {
|
|
173
|
+
const parameters = {};
|
|
174
|
+
if (request.tabId === "gallery" || (request.parameters && request.parameters.onlyMedia === "true")) {
|
|
175
|
+
parameters.only_media = "true";
|
|
176
|
+
}
|
|
177
|
+
return statusPage(
|
|
178
|
+
context,
|
|
179
|
+
"/api/v1/accounts/" + encodeURIComponent(request.profile.id) + "/statuses",
|
|
180
|
+
request.page,
|
|
181
|
+
parameters,
|
|
182
|
+
);
|
|
183
|
+
},
|
|
184
|
+
async following(request, context) {
|
|
185
|
+
return profilePage(
|
|
186
|
+
context,
|
|
187
|
+
"/api/v1/accounts/" + encodeURIComponent(request.key.id) + "/following",
|
|
188
|
+
request.page,
|
|
189
|
+
);
|
|
190
|
+
},
|
|
191
|
+
async followers(request, context) {
|
|
192
|
+
return profilePage(
|
|
193
|
+
context,
|
|
194
|
+
"/api/v1/accounts/" + encodeURIComponent(request.key.id) + "/followers",
|
|
195
|
+
request.page,
|
|
196
|
+
);
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
|
|
200
|
+
post: {
|
|
201
|
+
async detail(request, context) {
|
|
202
|
+
const response = await authenticatedRequest(context, "/api/v1/statuses/" + encodeURIComponent(request.key.id));
|
|
203
|
+
return mapPost(response.data, context.origin, context.accountId);
|
|
204
|
+
},
|
|
205
|
+
async context(request, context) {
|
|
206
|
+
const id = encodeURIComponent(request.key.id);
|
|
207
|
+
const detail = await authenticatedRequest(context, "/api/v1/statuses/" + id);
|
|
208
|
+
const thread = await authenticatedRequest(context, "/api/v1/statuses/" + id + "/context");
|
|
209
|
+
const values = [];
|
|
210
|
+
for (const status of (thread.data && thread.data.ancestors) || []) values.push(status);
|
|
211
|
+
values.push(detail.data);
|
|
212
|
+
for (const status of (thread.data && thread.data.descendants) || []) values.push(status);
|
|
213
|
+
return pageResult(
|
|
214
|
+
values.filter(Boolean).map((status) => mapPost(status, context.origin, context.accountId)),
|
|
215
|
+
null,
|
|
216
|
+
null,
|
|
217
|
+
true,
|
|
218
|
+
);
|
|
219
|
+
},
|
|
220
|
+
async delete(request, context) {
|
|
221
|
+
await authenticatedRequest(context, "/api/v1/statuses/" + encodeURIComponent(request.key.id), {
|
|
222
|
+
method: "DELETE",
|
|
223
|
+
});
|
|
224
|
+
return { type: "deleted" };
|
|
225
|
+
},
|
|
226
|
+
async mutate(request, context) {
|
|
227
|
+
const suffix = {
|
|
228
|
+
Favourite: "favourite",
|
|
229
|
+
Unfavourite: "unfavourite",
|
|
230
|
+
Repost: "reblog",
|
|
231
|
+
Unrepost: "unreblog",
|
|
232
|
+
Bookmark: "bookmark",
|
|
233
|
+
Unbookmark: "unbookmark",
|
|
234
|
+
}[request.action];
|
|
235
|
+
if (!suffix) unsupportedError("Unsupported Pixelfed post action", request.action);
|
|
236
|
+
const response = await authenticatedRequest(
|
|
237
|
+
context,
|
|
238
|
+
"/api/v1/statuses/" + encodeURIComponent(request.key.id) + "/" + suffix,
|
|
239
|
+
{ method: "POST" },
|
|
240
|
+
);
|
|
241
|
+
return { type: "updatedPost", post: mapPost(response.data, context.origin, context.accountId) };
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
|
|
245
|
+
compose: {
|
|
246
|
+
async publish(request, context) {
|
|
247
|
+
const assets = request.assets || [];
|
|
248
|
+
if (!request.replyTo && assets.length === 0) {
|
|
249
|
+
validationError("Pixelfed posts require at least one media attachment", "media_required");
|
|
250
|
+
}
|
|
251
|
+
const mediaIds = [];
|
|
252
|
+
for (const asset of assets) {
|
|
253
|
+
const parts = [
|
|
254
|
+
{
|
|
255
|
+
type: "asset",
|
|
256
|
+
name: "file",
|
|
257
|
+
handle: asset.handle,
|
|
258
|
+
fileName: asset.fileName || "upload",
|
|
259
|
+
contentType: asset.mimeType || "application/octet-stream",
|
|
260
|
+
},
|
|
261
|
+
];
|
|
262
|
+
if (asset.description) {
|
|
263
|
+
parts.push({ type: "text", name: "description", value: asset.description });
|
|
264
|
+
}
|
|
265
|
+
const uploaded = await authenticatedRequest(context, "/api/v1/media", {
|
|
266
|
+
method: "POST",
|
|
267
|
+
timeoutMillis: 120000,
|
|
268
|
+
body: { type: "multipart", parts },
|
|
269
|
+
});
|
|
270
|
+
if (!uploaded.data || uploaded.data.id == null) invalidResponse("Pixelfed did not return a media ID");
|
|
271
|
+
mediaIds.push(String(uploaded.data.id));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const payload = {
|
|
275
|
+
status: request.text || "",
|
|
276
|
+
media_ids: mediaIds,
|
|
277
|
+
visibility: toPixelfedVisibility(request.visibility),
|
|
278
|
+
sensitive: request.sensitive === true,
|
|
279
|
+
spoiler_text: request.spoilerText || "",
|
|
280
|
+
};
|
|
281
|
+
if (request.replyTo) payload.in_reply_to_id = request.replyTo.id;
|
|
282
|
+
const response = await authenticatedRequest(context, "/api/v1/statuses", {
|
|
283
|
+
method: "POST",
|
|
284
|
+
timeoutMillis: 120000,
|
|
285
|
+
headers: { "Idempotency-Key": await flare.crypto.uuid() },
|
|
286
|
+
body: { type: "json", value: payload },
|
|
287
|
+
});
|
|
288
|
+
return { post: mapPost(response.data, context.origin, context.accountId) };
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
async function statusPage(context, path, page, parameters, unwrap) {
|
|
295
|
+
const query = parameters || {};
|
|
296
|
+
const response = await pagedGet(context, path, page, query);
|
|
297
|
+
const values = (unwrap || unwrapStatuses)(response.data);
|
|
298
|
+
const cursor = cursorFor(response, path, page, query, values);
|
|
299
|
+
return pageResult(
|
|
300
|
+
values.map((status) => mapPost(status, context.origin, context.accountId)),
|
|
301
|
+
cursor,
|
|
302
|
+
null,
|
|
303
|
+
cursor == null,
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function profilePage(context, path, page, parameters, unwrap) {
|
|
308
|
+
const query = parameters || {};
|
|
309
|
+
const response = await pagedGet(context, path, page, query);
|
|
310
|
+
const values = (unwrap || unwrapProfiles)(response.data);
|
|
311
|
+
const cursor = cursorFor(response, path, page, query, values);
|
|
312
|
+
return pageResult(
|
|
313
|
+
values.map((profile) => mapProfile(profile, context.origin)),
|
|
314
|
+
cursor,
|
|
315
|
+
null,
|
|
316
|
+
cursor == null,
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function searchPage(context, request, type, mapper) {
|
|
321
|
+
const page = request.page;
|
|
322
|
+
let offset = 0;
|
|
323
|
+
if (page.cursor && page.cursor.indexOf("offset:") === 0) {
|
|
324
|
+
offset = Math.max(0, Number(page.cursor.slice(7)) || 0);
|
|
325
|
+
}
|
|
326
|
+
const query = {
|
|
327
|
+
q: request.query || "",
|
|
328
|
+
type,
|
|
329
|
+
resolve: "true",
|
|
330
|
+
limit: String(page.limit),
|
|
331
|
+
offset: String(offset),
|
|
332
|
+
};
|
|
333
|
+
const path = page.cursor && /^https:\/\//i.test(page.cursor) ? page.cursor : addQuery("/api/v2/search", query);
|
|
334
|
+
const response = await authenticatedRequest(context, path);
|
|
335
|
+
const values = unwrapArray(response.data, [type]);
|
|
336
|
+
const linked = nextLink(response.headers);
|
|
337
|
+
const olderCursor = linked || (values.length >= page.limit ? "offset:" + String(offset + values.length) : null);
|
|
338
|
+
return pageResult(
|
|
339
|
+
values.map((value) => mapper(value, context.origin, context.accountId)),
|
|
340
|
+
olderCursor,
|
|
341
|
+
null,
|
|
342
|
+
olderCursor == null,
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function pagedGet(context, path, page, parameters) {
|
|
347
|
+
let target = path;
|
|
348
|
+
if (page.cursor) {
|
|
349
|
+
if (/^https:\/\//i.test(page.cursor)) {
|
|
350
|
+
target = page.cursor;
|
|
351
|
+
} else if (page.cursor.indexOf("max_id:") === 0) {
|
|
352
|
+
target = addQuery(path, Object.assign({}, parameters || {}, { max_id: page.cursor.slice(7), limit: page.limit }));
|
|
353
|
+
}
|
|
354
|
+
} else {
|
|
355
|
+
target = addQuery(path, Object.assign({}, parameters || {}, { limit: page.limit }));
|
|
356
|
+
}
|
|
357
|
+
return authenticatedRequest(context, target);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function cursorFor(response, path, page, parameters, values) {
|
|
361
|
+
const linked = nextLink(response.headers);
|
|
362
|
+
if (linked) return linked;
|
|
363
|
+
if (values.length === 0 || values.length < page.limit) return null;
|
|
364
|
+
const last = values[values.length - 1];
|
|
365
|
+
return last && last.id != null ? "max_id:" + String(last.id) : null;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function pageResult(items, olderCursor, newerCursor, endReached) {
|
|
369
|
+
return {
|
|
370
|
+
items,
|
|
371
|
+
olderCursor: olderCursor || null,
|
|
372
|
+
newerCursor: newerCursor || null,
|
|
373
|
+
endReached: endReached === true,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async function authenticatedRequest(context, path, options) {
|
|
378
|
+
const credential = await flare.credential.read();
|
|
379
|
+
if (!credential || !credential.accessToken) authenticationError();
|
|
380
|
+
const merged = Object.assign({}, options || {}, { token: String(credential.accessToken) });
|
|
381
|
+
return apiRequest(context.origin, path, merged);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async function apiRequest(originValue, path, options) {
|
|
385
|
+
const origin = requireOrigin(originValue);
|
|
386
|
+
const value = options || {};
|
|
387
|
+
const request = {
|
|
388
|
+
method: value.method || "GET",
|
|
389
|
+
url: absoluteUrl(origin, path),
|
|
390
|
+
headers: Object.assign({ Accept: "application/json" }, value.headers || {}),
|
|
391
|
+
};
|
|
392
|
+
if (value.token) request.authorization = { type: "bearer", token: value.token };
|
|
393
|
+
if (value.body) request.body = value.body;
|
|
394
|
+
if (value.timeoutMillis) request.timeoutMillis = value.timeoutMillis;
|
|
395
|
+
|
|
396
|
+
let response;
|
|
397
|
+
try {
|
|
398
|
+
response = await flare.http.request(request);
|
|
399
|
+
} catch (error) {
|
|
400
|
+
throw pluginFailure("Network", "error.network", "Unable to reach the Pixelfed instance", null, hostErrorCode(error));
|
|
401
|
+
}
|
|
402
|
+
const data = parseResponseBody(response);
|
|
403
|
+
if (response.status >= 200 && response.status < 300) return { data, headers: response.headers || {} };
|
|
404
|
+
|
|
405
|
+
const remoteCode = remoteErrorCode(data);
|
|
406
|
+
if (response.status === 401) authenticationError(remoteCode);
|
|
407
|
+
if (response.status === 404) {
|
|
408
|
+
throw pluginFailure("NotFound", "error.notFound", "The requested Pixelfed content was not found", null, remoteCode);
|
|
409
|
+
}
|
|
410
|
+
if (response.status === 422) {
|
|
411
|
+
throw pluginFailure("Validation", "error.validation", remoteMessage(data, "Pixelfed rejected the request"), null, remoteCode);
|
|
412
|
+
}
|
|
413
|
+
if (response.status === 429) {
|
|
414
|
+
throw pluginFailure(
|
|
415
|
+
"RateLimited",
|
|
416
|
+
"error.rateLimited",
|
|
417
|
+
"Pixelfed is rate limiting requests",
|
|
418
|
+
retryAfterSeconds(response.headers),
|
|
419
|
+
remoteCode,
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
throw pluginFailure("Remote", "error.remote", remoteMessage(data, "Pixelfed returned HTTP " + response.status), null, remoteCode);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function parseResponseBody(response) {
|
|
426
|
+
const body = response && typeof response.body === "string" ? response.body.trim() : "";
|
|
427
|
+
if (!body) return null;
|
|
428
|
+
try {
|
|
429
|
+
return JSON.parse(body);
|
|
430
|
+
} catch (_) {
|
|
431
|
+
invalidResponse("Pixelfed returned invalid JSON");
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function mapInstance(instance, origin) {
|
|
436
|
+
const stats = instance && instance.stats;
|
|
437
|
+
const registrations = instance && instance.registrations;
|
|
438
|
+
return {
|
|
439
|
+
domain: originHost(origin),
|
|
440
|
+
title: stringOr(instance && instance.title, "Pixelfed"),
|
|
441
|
+
description: optionalString((instance && (instance.description || instance.short_description)) || null),
|
|
442
|
+
usersCount: numberOrNull(stats && (stats.user_count || stats.users)) || 0,
|
|
443
|
+
registrationEnabled:
|
|
444
|
+
typeof registrations === "object" && registrations !== null
|
|
445
|
+
? registrations.enabled === true
|
|
446
|
+
: registrations === true,
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function composeConfig(instance) {
|
|
451
|
+
const configuration = (instance && instance.configuration) || {};
|
|
452
|
+
const statuses = configuration.statuses || {};
|
|
453
|
+
const media = configuration.media_attachments || {};
|
|
454
|
+
const maximumBytes = positiveMinimum(
|
|
455
|
+
numberOrNull(media.image_size_limit),
|
|
456
|
+
numberOrNull(media.video_size_limit),
|
|
457
|
+
10 * 1024 * 1024,
|
|
458
|
+
);
|
|
459
|
+
const mimeTypes = Array.isArray(media.supported_mime_types)
|
|
460
|
+
? media.supported_mime_types.filter(validMimeType).slice(0, 128)
|
|
461
|
+
: ["image/jpeg", "image/png", "image/gif", "image/webp", "video/mp4"];
|
|
462
|
+
return {
|
|
463
|
+
text: { maxLength: positiveInteger(statuses.max_characters, 500, 1000000) },
|
|
464
|
+
media: {
|
|
465
|
+
minCountForNew: 1,
|
|
466
|
+
maxCount: positiveInteger(statuses.max_media_attachments, 4, 100),
|
|
467
|
+
maxBytes: maximumBytes,
|
|
468
|
+
supportedMimeTypes: mimeTypes,
|
|
469
|
+
altTextMaxLength: nonNegativeInteger(media.description_limit, 1500, 100000),
|
|
470
|
+
canSensitive: true,
|
|
471
|
+
},
|
|
472
|
+
visibility: { allowed: ["public", "unlisted", "followers"], default: "public" },
|
|
473
|
+
contentWarning: true,
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function mapProfile(account, origin) {
|
|
478
|
+
if (!account || account.id == null) invalidResponse("Pixelfed returned an invalid profile");
|
|
479
|
+
const host = originHost(origin);
|
|
480
|
+
const acct = String(account.acct || account.username || account.id).replace(/^@/, "");
|
|
481
|
+
const handle = "@" + (acct.indexOf("@") >= 0 ? acct : acct + "@" + host);
|
|
482
|
+
const profile = {
|
|
483
|
+
key: { id: String(account.id), host },
|
|
484
|
+
handle,
|
|
485
|
+
displayName: stringOr(account.display_name || account.username, acct),
|
|
486
|
+
description: { format: "html", value: String(account.note || "") },
|
|
487
|
+
followersCount: numberOrNull(account.followers_count),
|
|
488
|
+
followingCount: numberOrNull(account.following_count),
|
|
489
|
+
postsCount: numberOrNull(account.statuses_count),
|
|
490
|
+
locked: account.locked === true,
|
|
491
|
+
bot: account.bot === true,
|
|
492
|
+
fields: [],
|
|
493
|
+
actions: [],
|
|
494
|
+
};
|
|
495
|
+
const avatar = safeHttpsUrl(account.avatar_static || account.avatar, origin);
|
|
496
|
+
const banner = safeHttpsUrl(account.header_static || account.header, origin);
|
|
497
|
+
const url = safeHttpsUrl(account.url, origin);
|
|
498
|
+
if (avatar) profile.avatarUrl = avatar;
|
|
499
|
+
if (banner) profile.bannerUrl = banner;
|
|
500
|
+
if (url) profile.url = url;
|
|
501
|
+
if (Array.isArray(account.fields)) {
|
|
502
|
+
profile.fields = account.fields.slice(0, 128).map((field) => ({
|
|
503
|
+
name: String((field && field.name) || ""),
|
|
504
|
+
value: { format: "html", value: String((field && field.value) || "") },
|
|
505
|
+
}));
|
|
506
|
+
}
|
|
507
|
+
return profile;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function mapPost(status, origin, accountId, depth) {
|
|
511
|
+
if (!status || status.id == null || !status.account) invalidResponse("Pixelfed returned an invalid status");
|
|
512
|
+
const level = depth || 0;
|
|
513
|
+
const host = originHost(origin);
|
|
514
|
+
const post = {
|
|
515
|
+
key: { id: String(status.id), host },
|
|
516
|
+
author: mapProfile(status.account, origin),
|
|
517
|
+
createdAt: String(status.created_at || "1970-01-01T00:00:00Z"),
|
|
518
|
+
content: { format: "html", value: String(status.content || status.caption || "") },
|
|
519
|
+
media: mapMediaList(status.media_attachments, origin),
|
|
520
|
+
sensitive: status.sensitive === true,
|
|
521
|
+
visibility: fromPixelfedVisibility(status.visibility),
|
|
522
|
+
favouritesCount: numberOrNull(status.favourites_count),
|
|
523
|
+
repostsCount: numberOrNull(status.reblogs_count),
|
|
524
|
+
repliesCount: numberOrNull(status.replies_count),
|
|
525
|
+
actions: postActions(status, accountId),
|
|
526
|
+
};
|
|
527
|
+
const url = safeHttpsUrl(status.url || status.uri, origin);
|
|
528
|
+
if (url) post.url = url;
|
|
529
|
+
if (status.in_reply_to_id != null) post.replyTo = { id: String(status.in_reply_to_id), host };
|
|
530
|
+
if (status.spoiler_text) post.spoilerText = String(status.spoiler_text);
|
|
531
|
+
if (status.reblog && level < 4) post.repost = mapPost(status.reblog, origin, accountId, level + 1);
|
|
532
|
+
return post;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function postActions(status, accountId) {
|
|
536
|
+
const actions = [
|
|
537
|
+
{ action: "Reply" },
|
|
538
|
+
{
|
|
539
|
+
action: status.favourited === true ? "Unfavourite" : "Favourite",
|
|
540
|
+
active: status.favourited === true,
|
|
541
|
+
count: numberOrNull(status.favourites_count),
|
|
542
|
+
},
|
|
543
|
+
{
|
|
544
|
+
action: status.reblogged === true ? "Unrepost" : "Repost",
|
|
545
|
+
active: status.reblogged === true,
|
|
546
|
+
count: numberOrNull(status.reblogs_count),
|
|
547
|
+
},
|
|
548
|
+
{
|
|
549
|
+
action: status.bookmarked === true ? "Unbookmark" : "Bookmark",
|
|
550
|
+
active: status.bookmarked === true,
|
|
551
|
+
},
|
|
552
|
+
];
|
|
553
|
+
if (accountId != null && status.account && String(status.account.id) === String(accountId)) {
|
|
554
|
+
actions.push({ action: "Delete" });
|
|
555
|
+
}
|
|
556
|
+
return actions;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function mapMediaList(values, origin) {
|
|
560
|
+
if (!Array.isArray(values)) return [];
|
|
561
|
+
return values.map((value) => mapMedia(value, origin)).filter(Boolean);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function mapMedia(media, origin) {
|
|
565
|
+
if (!media || media.id == null) return null;
|
|
566
|
+
const url = safeHttpsUrl(media.url || media.remote_url, origin);
|
|
567
|
+
if (!url) return null;
|
|
568
|
+
const preview = safeHttpsUrl(media.preview_url || media.url, origin);
|
|
569
|
+
const dimensions = (media.meta && (media.meta.original || media.meta.small)) || {};
|
|
570
|
+
const result = {
|
|
571
|
+
id: String(media.id),
|
|
572
|
+
type: mediaType(media.type),
|
|
573
|
+
url,
|
|
574
|
+
};
|
|
575
|
+
if (preview) result.previewUrl = preview;
|
|
576
|
+
if (media.description || media.alt_text) result.description = String(media.description || media.alt_text);
|
|
577
|
+
const width = positiveNumberOrNull(dimensions.width);
|
|
578
|
+
const height = positiveNumberOrNull(dimensions.height);
|
|
579
|
+
const duration = positiveNumberOrNull(dimensions.duration || (media.meta && media.meta.duration));
|
|
580
|
+
if (width) result.width = Math.round(width);
|
|
581
|
+
if (height) result.height = Math.round(height);
|
|
582
|
+
if (duration) result.durationMillis = Math.round(duration * 1000);
|
|
583
|
+
return result;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function mapHashtag(value, origin) {
|
|
587
|
+
if (typeof value === "string") return { name: value.replace(/^#/, "") };
|
|
588
|
+
const result = { name: String((value && (value.name || value.tag)) || "").replace(/^#/, "") };
|
|
589
|
+
const url = safeHttpsUrl(value && value.url, origin);
|
|
590
|
+
if (url) result.url = url;
|
|
591
|
+
return result;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function unwrapStatuses(value) {
|
|
595
|
+
return unwrapArray(value, ["statuses", "posts", "data"])
|
|
596
|
+
.map((item) => (item && (item.status || item.post)) || item)
|
|
597
|
+
.filter(Boolean);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function unwrapProfiles(value) {
|
|
601
|
+
return unwrapArray(value, ["accounts", "profiles", "data"])
|
|
602
|
+
.map((item) => (item && (item.account || item.profile)) || item)
|
|
603
|
+
.filter(Boolean);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function unwrapArray(value, keys) {
|
|
607
|
+
if (Array.isArray(value)) return value;
|
|
608
|
+
for (const key of keys) {
|
|
609
|
+
if (value && Array.isArray(value[key])) return value[key];
|
|
610
|
+
}
|
|
611
|
+
return [];
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function nextLink(headers) {
|
|
615
|
+
const values = headerValues(headers, "link");
|
|
616
|
+
for (const value of values) {
|
|
617
|
+
const parts = String(value).split(/,\s*(?=<)/);
|
|
618
|
+
for (const part of parts) {
|
|
619
|
+
const match = part.match(/<([^>]+)>\s*;[^,]*\brel\s*=\s*"?next"?/i);
|
|
620
|
+
if (match && /^https:\/\//i.test(match[1])) return match[1];
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
return null;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function headerValues(headers, name) {
|
|
627
|
+
if (!headers) return [];
|
|
628
|
+
for (const key of Object.keys(headers)) {
|
|
629
|
+
if (key.toLowerCase() === name.toLowerCase()) {
|
|
630
|
+
return Array.isArray(headers[key]) ? headers[key] : [String(headers[key])];
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
return [];
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function retryAfterSeconds(headers) {
|
|
637
|
+
const value = headerValues(headers, "retry-after")[0];
|
|
638
|
+
if (value == null) return null;
|
|
639
|
+
const seconds = Number(value);
|
|
640
|
+
return Number.isFinite(seconds) && seconds >= 0 ? Math.min(604800, Math.round(seconds)) : null;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function remoteMessage(data, fallback) {
|
|
644
|
+
const value = data && (data.error_description || data.error || data.message);
|
|
645
|
+
return typeof value === "string" && value.trim() ? value.slice(0, 4096) : fallback;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function remoteErrorCode(data) {
|
|
649
|
+
const value = data && (data.error_code || data.code || data.error);
|
|
650
|
+
return typeof value === "string" && value.length <= 512 ? value : null;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function hostErrorCode(error) {
|
|
654
|
+
return error && typeof error.code === "string" ? error.code.slice(0, 512) : null;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function pluginFailure(code, key, fallback, retryAfterSecondsValue, remoteCode) {
|
|
658
|
+
const value = { code, message: { key, fallback } };
|
|
659
|
+
if (retryAfterSecondsValue != null) value.retryAfterSeconds = retryAfterSecondsValue;
|
|
660
|
+
if (remoteCode) value.remoteCode = remoteCode;
|
|
661
|
+
return flare.error(value);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function authenticationError(remoteCode) {
|
|
665
|
+
throw pluginFailure("AuthenticationRequired", "error.authentication", "Sign in to Pixelfed again", null, remoteCode);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function validationError(message, remoteCode) {
|
|
669
|
+
throw pluginFailure("Validation", "error.validation", message, null, remoteCode || null);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function invalidResponse(message) {
|
|
673
|
+
throw pluginFailure("InvalidResponse", "error.invalidResponse", message, null, null);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function unsupportedError(message, remoteCode) {
|
|
677
|
+
throw pluginFailure("Unsupported", "error.unsupported", message, null, remoteCode == null ? null : String(remoteCode));
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function requiredString(value, message) {
|
|
681
|
+
if (value == null || String(value).trim() === "") validationError(message, "missing_value");
|
|
682
|
+
return String(value);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function requirePixelfed(instance) {
|
|
686
|
+
if (!isPixelfed(instance)) validationError("The selected instance does not identify itself as Pixelfed", "not_pixelfed");
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function isPixelfed(instance) {
|
|
690
|
+
const values = [
|
|
691
|
+
instance && instance.version,
|
|
692
|
+
instance && instance.software && instance.software.name,
|
|
693
|
+
instance && instance.software && instance.software.version,
|
|
694
|
+
];
|
|
695
|
+
return values.some((value) => String(value || "").toLowerCase().indexOf("pixelfed") >= 0);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function requireOrigin(value) {
|
|
699
|
+
const origin = String(value || "").trim().replace(/\/+$/, "");
|
|
700
|
+
if (!/^https:\/\/[^/?#@]+$/i.test(origin)) validationError("Enter a valid HTTPS Pixelfed instance", "invalid_origin");
|
|
701
|
+
return origin;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function originHost(origin) {
|
|
705
|
+
return requireOrigin(origin).slice(8).toLowerCase();
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function absoluteUrl(origin, value) {
|
|
709
|
+
const text = String(value || "");
|
|
710
|
+
if (/^https:\/\//i.test(text)) return text;
|
|
711
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(text)) validationError("Pixelfed returned a non-HTTPS URL", "invalid_url");
|
|
712
|
+
return requireOrigin(origin) + (text.indexOf("/") === 0 ? "" : "/") + text;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function safeHttpsUrl(value, origin) {
|
|
716
|
+
if (value == null || String(value).trim() === "") return null;
|
|
717
|
+
try {
|
|
718
|
+
const result = absoluteUrl(origin, value);
|
|
719
|
+
return /^https:\/\/[^/?#@]+(?:[/?#].*)?$/i.test(result) ? result : null;
|
|
720
|
+
} catch (_) {
|
|
721
|
+
return null;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function addQuery(path, values) {
|
|
726
|
+
const pairs = [];
|
|
727
|
+
for (const key of Object.keys(values || {})) {
|
|
728
|
+
const raw = values[key];
|
|
729
|
+
if (raw == null || raw === "") continue;
|
|
730
|
+
const list = Array.isArray(raw) ? raw : [raw];
|
|
731
|
+
for (const item of list) pairs.push(encodeURIComponent(key) + "=" + encodeURIComponent(String(item)));
|
|
732
|
+
}
|
|
733
|
+
if (pairs.length === 0) return path;
|
|
734
|
+
return path + (String(path).indexOf("?") >= 0 ? "&" : "?") + pairs.join("&");
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function fromPixelfedVisibility(value) {
|
|
738
|
+
if (value === "unlisted") return "unlisted";
|
|
739
|
+
if (value === "private") return "followers";
|
|
740
|
+
if (value === "direct") return "direct";
|
|
741
|
+
return "public";
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function toPixelfedVisibility(value) {
|
|
745
|
+
if (value === "followers") return "private";
|
|
746
|
+
if (value === "unlisted") return "unlisted";
|
|
747
|
+
if (value === "direct") validationError("Pixelfed direct visibility is not supported", "visibility");
|
|
748
|
+
return "public";
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function mediaType(value) {
|
|
752
|
+
const type = String(value || "image").toLowerCase();
|
|
753
|
+
if (type === "gifv") return "gif";
|
|
754
|
+
return ["image", "video", "gif", "audio"].indexOf(type) >= 0 ? type : "image";
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function stringOr(value, fallback) {
|
|
758
|
+
return value == null || String(value) === "" ? String(fallback) : String(value);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function optionalString(value) {
|
|
762
|
+
return value == null || String(value) === "" ? null : String(value);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
function numberOrNull(value) {
|
|
766
|
+
if (value == null || value === "") return null;
|
|
767
|
+
const number = Number(value);
|
|
768
|
+
return Number.isFinite(number) ? number : null;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function positiveNumberOrNull(value) {
|
|
772
|
+
const number = numberOrNull(value);
|
|
773
|
+
return number != null && number > 0 ? number : null;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function positiveInteger(value, fallback, maximum) {
|
|
777
|
+
const number = positiveNumberOrNull(value);
|
|
778
|
+
return Math.min(maximum, Math.max(1, Math.round(number == null ? fallback : number)));
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function nonNegativeInteger(value, fallback, maximum) {
|
|
782
|
+
const number = numberOrNull(value);
|
|
783
|
+
return Math.min(maximum, Math.max(0, Math.round(number == null ? fallback : number)));
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function positiveMinimum(first, second, fallback) {
|
|
787
|
+
const values = [first, second].filter((value) => value != null && value > 0);
|
|
788
|
+
return Math.round(values.length === 0 ? fallback : Math.min.apply(Math, values));
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function validMimeType(value) {
|
|
792
|
+
return typeof value === "string" && /^[A-Za-z0-9!#$&^_.+-]+\/[A-Za-z0-9!#$&^_.+*-]+$/.test(value);
|
|
793
|
+
}
|