@rmdes/indiekit-endpoint-microsub 1.0.0-beta.9 → 1.0.1

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/index.js CHANGED
@@ -4,7 +4,9 @@ import express from "express";
4
4
 
5
5
  import { microsubController } from "./lib/controllers/microsub.js";
6
6
  import { readerController } from "./lib/controllers/reader.js";
7
+ import { handleMediaProxy } from "./lib/media/proxy.js";
7
8
  import { startScheduler, stopScheduler } from "./lib/polling/scheduler.js";
9
+ import { createIndexes } from "./lib/storage/items.js";
8
10
  import { webmentionReceiver } from "./lib/webmention/receiver.js";
9
11
  import { websubHandler } from "./lib/websub/handler.js";
10
12
 
@@ -67,6 +69,9 @@ export default class MicrosubEndpoint {
67
69
  // Webmention receiving endpoint
68
70
  router.post("/webmention", webmentionReceiver.receive);
69
71
 
72
+ // Media proxy endpoint
73
+ router.get("/media/:hash", handleMediaProxy);
74
+
70
75
  // Reader UI routes (mounted as sub-router for correct baseUrl)
71
76
  readerRouter.get("/", readerController.index);
72
77
  readerRouter.get("/channels", readerController.channels);
@@ -78,6 +83,7 @@ export default class MicrosubEndpoint {
78
83
  "/channels/:uid/settings",
79
84
  readerController.updateSettings,
80
85
  );
86
+ readerRouter.post("/channels/:uid/delete", readerController.deleteChannel);
81
87
  readerRouter.get("/channels/:uid/feeds", readerController.feeds);
82
88
  readerRouter.post("/channels/:uid/feeds", readerController.addFeed);
83
89
  readerRouter.post(
@@ -109,6 +115,9 @@ export default class MicrosubEndpoint {
109
115
  // Webmention endpoint must be public
110
116
  publicRouter.post("/webmention", webmentionReceiver.receive);
111
117
 
118
+ // Media proxy must be public for images to load
119
+ publicRouter.get("/media/:hash", handleMediaProxy);
120
+
112
121
  return publicRouter;
113
122
  }
114
123
 
@@ -142,6 +151,11 @@ export default class MicrosubEndpoint {
142
151
  if (indiekit.database) {
143
152
  console.info("[Microsub] Database available, starting scheduler");
144
153
  startScheduler(indiekit);
154
+
155
+ // Create indexes for optimal performance (runs in background)
156
+ createIndexes(indiekit).catch((error) => {
157
+ console.warn("[Microsub] Index creation failed:", error.message);
158
+ });
145
159
  } else {
146
160
  console.warn(
147
161
  "[Microsub] Database not available at init, scheduler not started",
@@ -3,22 +3,56 @@
3
3
  * @module cache/redis
4
4
  */
5
5
 
6
+ import Redis from "ioredis";
7
+
8
+ let redisClient;
9
+
6
10
  /**
7
11
  * Get Redis client from application
8
12
  * @param {object} application - Indiekit application
9
13
  * @returns {object|undefined} Redis client or undefined
10
14
  */
11
15
  export function getRedisClient(application) {
12
- // Check if Redis is configured
16
+ // Check if Redis is already initialized on the application
13
17
  if (application.redis) {
14
18
  return application.redis;
15
19
  }
16
20
 
21
+ // Check if we already created a client
22
+ if (redisClient) {
23
+ return redisClient;
24
+ }
25
+
17
26
  // Check for Redis URL in config
18
- if (application.config?.application?.redisUrl) {
19
- // Lazily create Redis connection
20
- // This will be implemented when Redis support is added to Indiekit core
21
- return;
27
+ const redisUrl = application.config?.application?.redisUrl;
28
+ if (redisUrl) {
29
+ try {
30
+ redisClient = new Redis(redisUrl, {
31
+ maxRetriesPerRequest: 3,
32
+ retryStrategy(times) {
33
+ const delay = Math.min(times * 50, 2000);
34
+ return delay;
35
+ },
36
+ lazyConnect: true,
37
+ });
38
+
39
+ redisClient.on("error", (error) => {
40
+ console.error("[Microsub] Redis error:", error.message);
41
+ });
42
+
43
+ redisClient.on("connect", () => {
44
+ console.info("[Microsub] Redis connected");
45
+ });
46
+
47
+ // Connect asynchronously
48
+ redisClient.connect().catch((error) => {
49
+ console.warn("[Microsub] Redis connection failed:", error.message);
50
+ });
51
+
52
+ return redisClient;
53
+ } catch (error) {
54
+ console.warn("[Microsub] Failed to initialize Redis:", error.message);
55
+ }
22
56
  }
23
57
  }
24
58
 
@@ -131,3 +165,17 @@ export async function subscribeToChannel(redis, channel, callback) {
131
165
  // Ignore subscription errors
132
166
  }
133
167
  }
168
+
169
+ /**
170
+ * Cleanup Redis connection on shutdown
171
+ */
172
+ export async function closeRedis() {
173
+ if (redisClient) {
174
+ try {
175
+ await redisClient.quit();
176
+ redisClient = undefined;
177
+ } catch {
178
+ // Ignore cleanup errors
179
+ }
180
+ }
181
+ }
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import { deleteItemsByAuthorUrl } from "../storage/items.js";
7
+ import { getUserId } from "../utils/auth.js";
7
8
  import { validateUrl } from "../utils/validation.js";
8
9
 
9
10
  /**
@@ -23,7 +24,7 @@ function getCollection(application) {
23
24
  */
24
25
  export async function list(request, response) {
25
26
  const { application } = request.app.locals;
26
- const userId = request.session?.userId;
27
+ const userId = getUserId(request);
27
28
 
28
29
  const collection = getCollection(application);
29
30
  const blocked = await collection.find({ userId }).toArray();
@@ -40,7 +41,7 @@ export async function list(request, response) {
40
41
  */
41
42
  export async function block(request, response) {
42
43
  const { application } = request.app.locals;
43
- const userId = request.session?.userId;
44
+ const userId = getUserId(request);
44
45
  const { url } = request.body;
45
46
 
46
47
  validateUrl(url);
@@ -71,7 +72,7 @@ export async function block(request, response) {
71
72
  */
72
73
  export async function unblock(request, response) {
73
74
  const { application } = request.app.locals;
74
- const userId = request.session?.userId;
75
+ const userId = getUserId(request);
75
76
  const { url } = request.body;
76
77
 
77
78
  validateUrl(url);
@@ -13,6 +13,7 @@ import {
13
13
  deleteChannel,
14
14
  reorderChannels,
15
15
  } from "../storage/channels.js";
16
+ import { getUserId } from "../utils/auth.js";
16
17
  import {
17
18
  validateChannel,
18
19
  validateChannelName,
@@ -27,7 +28,7 @@ import {
27
28
  */
28
29
  export async function list(request, response) {
29
30
  const { application } = request.app.locals;
30
- const userId = request.session?.userId;
31
+ const userId = getUserId(request);
31
32
 
32
33
  const channels = await getChannels(application, userId);
33
34
 
@@ -42,7 +43,7 @@ export async function list(request, response) {
42
43
  */
43
44
  export async function action(request, response) {
44
45
  const { application } = request.app.locals;
45
- const userId = request.session?.userId;
46
+ const userId = getUserId(request);
46
47
  const { method, name, uid } = request.body;
47
48
 
48
49
  // Delete channel
@@ -113,7 +114,7 @@ export async function action(request, response) {
113
114
  */
114
115
  export async function get(request, response) {
115
116
  const { application } = request.app.locals;
116
- const userId = request.session?.userId;
117
+ const userId = getUserId(request);
117
118
  const { uid } = request.params;
118
119
 
119
120
  validateChannel(uid);
@@ -9,6 +9,7 @@ import {
9
9
  sendEvent,
10
10
  subscribeClient,
11
11
  } from "../realtime/broker.js";
12
+ import { getUserId } from "../utils/auth.js";
12
13
 
13
14
  /**
14
15
  * SSE stream endpoint
@@ -18,7 +19,7 @@ import {
18
19
  */
19
20
  export async function stream(request, response) {
20
21
  const { application } = request.app.locals;
21
- const userId = request.session?.userId;
22
+ const userId = getUserId(request);
22
23
 
23
24
  // Set SSE headers
24
25
  response.setHeader("Content-Type", "text/event-stream");
@@ -10,10 +10,16 @@ import { getChannel } from "../storage/channels.js";
10
10
  import {
11
11
  createFeed,
12
12
  deleteFeed,
13
+ getFeedByUrl,
13
14
  getFeedsForChannel,
14
15
  } from "../storage/feeds.js";
16
+ import { getUserId } from "../utils/auth.js";
15
17
  import { createFeedResponse } from "../utils/jf2.js";
16
18
  import { validateChannel, validateUrl } from "../utils/validation.js";
19
+ import {
20
+ unsubscribe as websubUnsubscribe,
21
+ getCallbackUrl,
22
+ } from "../websub/subscriber.js";
17
23
 
18
24
  /**
19
25
  * List followed feeds for a channel
@@ -23,7 +29,7 @@ import { validateChannel, validateUrl } from "../utils/validation.js";
23
29
  */
24
30
  export async function list(request, response) {
25
31
  const { application } = request.app.locals;
26
- const userId = request.session?.userId;
32
+ const userId = getUserId(request);
27
33
  const { channel } = request.query;
28
34
 
29
35
  validateChannel(channel);
@@ -47,7 +53,7 @@ export async function list(request, response) {
47
53
  */
48
54
  export async function follow(request, response) {
49
55
  const { application } = request.app.locals;
50
- const userId = request.session?.userId;
56
+ const userId = getUserId(request);
51
57
  const { channel, url } = request.body;
52
58
 
53
59
  validateChannel(channel);
@@ -67,12 +73,11 @@ export async function follow(request, response) {
67
73
  });
68
74
 
69
75
  // Trigger immediate fetch in background (don't await)
76
+ // This will also discover and subscribe to WebSub hubs
70
77
  refreshFeedNow(application, feed._id).catch((error) => {
71
78
  console.error(`[Microsub] Error fetching new feed ${url}:`, error.message);
72
79
  });
73
80
 
74
- // TODO: Attempt WebSub subscription
75
-
76
81
  response.status(201).json(createFeedResponse(feed));
77
82
  }
78
83
 
@@ -84,7 +89,7 @@ export async function follow(request, response) {
84
89
  */
85
90
  export async function unfollow(request, response) {
86
91
  const { application } = request.app.locals;
87
- const userId = request.session?.userId;
92
+ const userId = getUserId(request);
88
93
  const { channel, url } = request.body;
89
94
 
90
95
  validateChannel(channel);
@@ -95,13 +100,28 @@ export async function unfollow(request, response) {
95
100
  throw new IndiekitError("Channel not found", { status: 404 });
96
101
  }
97
102
 
103
+ // Get feed before deletion to check for WebSub subscription
104
+ const feed = await getFeedByUrl(application, channelDocument._id, url);
105
+
106
+ // Unsubscribe from WebSub hub if active
107
+ if (feed?.websub?.hub) {
108
+ const baseUrl = application.url;
109
+ if (baseUrl) {
110
+ const callbackUrl = getCallbackUrl(baseUrl, feed._id.toString());
111
+ websubUnsubscribe(application, feed, callbackUrl).catch((error) => {
112
+ console.error(
113
+ `[Microsub] WebSub unsubscribe error for ${url}:`,
114
+ error.message,
115
+ );
116
+ });
117
+ }
118
+ }
119
+
98
120
  const deleted = await deleteFeed(application, channelDocument._id, url);
99
121
  if (!deleted) {
100
122
  throw new IndiekitError("Feed not found", { status: 404 });
101
123
  }
102
124
 
103
- // TODO: Cancel WebSub subscription if active
104
-
105
125
  response.json({ result: "ok" });
106
126
  }
107
127
 
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { IndiekitError } from "@indiekit/error";
7
7
 
8
+ import { getUserId } from "../utils/auth.js";
8
9
  import { validateChannel, validateUrl } from "../utils/validation.js";
9
10
 
10
11
  /**
@@ -24,7 +25,7 @@ function getCollection(application) {
24
25
  */
25
26
  export async function list(request, response) {
26
27
  const { application } = request.app.locals;
27
- const userId = request.session?.userId;
28
+ const userId = getUserId(request);
28
29
  const { channel } = request.query;
29
30
 
30
31
  // Channel can be "global" or a specific channel UID
@@ -58,7 +59,7 @@ export async function list(request, response) {
58
59
  */
59
60
  export async function mute(request, response) {
60
61
  const { application } = request.app.locals;
61
- const userId = request.session?.userId;
62
+ const userId = getUserId(request);
62
63
  const { channel, url } = request.body;
63
64
 
64
65
  validateUrl(url);
@@ -99,7 +100,7 @@ export async function mute(request, response) {
99
100
  */
100
101
  export async function unmute(request, response) {
101
102
  const { application } = request.app.locals;
102
- const userId = request.session?.userId;
103
+ const userId = getUserId(request);
103
104
  const { channel, url } = request.body;
104
105
 
105
106
  validateUrl(url);
@@ -10,6 +10,7 @@ import {
10
10
  getChannel,
11
11
  createChannel,
12
12
  updateChannelSettings,
13
+ deleteChannel,
13
14
  } from "../storage/channels.js";
14
15
  import {
15
16
  getFeedsForChannel,
@@ -17,6 +18,7 @@ import {
17
18
  deleteFeed,
18
19
  } from "../storage/feeds.js";
19
20
  import { getTimelineItems, getItemById } from "../storage/items.js";
21
+ import { getUserId } from "../utils/auth.js";
20
22
  import {
21
23
  validateChannelName,
22
24
  validateExcludeTypes,
@@ -39,7 +41,7 @@ export async function index(request, response) {
39
41
  */
40
42
  export async function channels(request, response) {
41
43
  const { application } = request.app.locals;
42
- const userId = request.session?.userId;
44
+ const userId = getUserId(request);
43
45
 
44
46
  const channelList = await getChannels(application, userId);
45
47
 
@@ -69,7 +71,7 @@ export async function newChannel(request, response) {
69
71
  */
70
72
  export async function createChannelAction(request, response) {
71
73
  const { application } = request.app.locals;
72
- const userId = request.session?.userId;
74
+ const userId = getUserId(request);
73
75
  const { name } = request.body;
74
76
 
75
77
  validateChannelName(name);
@@ -87,7 +89,7 @@ export async function createChannelAction(request, response) {
87
89
  */
88
90
  export async function channel(request, response) {
89
91
  const { application } = request.app.locals;
90
- const userId = request.session?.userId;
92
+ const userId = getUserId(request);
91
93
  const { uid } = request.params;
92
94
  const { before, after } = request.query;
93
95
 
@@ -119,7 +121,7 @@ export async function channel(request, response) {
119
121
  */
120
122
  export async function settings(request, response) {
121
123
  const { application } = request.app.locals;
122
- const userId = request.session?.userId;
124
+ const userId = getUserId(request);
123
125
  const { uid } = request.params;
124
126
 
125
127
  const channelDocument = await getChannel(application, uid, userId);
@@ -144,7 +146,7 @@ export async function settings(request, response) {
144
146
  */
145
147
  export async function updateSettings(request, response) {
146
148
  const { application } = request.app.locals;
147
- const userId = request.session?.userId;
149
+ const userId = getUserId(request);
148
150
  const { uid } = request.params;
149
151
  const { excludeTypes, excludeRegex } = request.body;
150
152
 
@@ -171,6 +173,32 @@ export async function updateSettings(request, response) {
171
173
  response.redirect(`${request.baseUrl}/channels/${uid}`);
172
174
  }
173
175
 
176
+ /**
177
+ * Delete channel
178
+ * @param {object} request - Express request
179
+ * @param {object} response - Express response
180
+ * @returns {Promise<void>}
181
+ */
182
+ export async function deleteChannelAction(request, response) {
183
+ const { application } = request.app.locals;
184
+ const userId = getUserId(request);
185
+ const { uid } = request.params;
186
+
187
+ // Don't allow deleting notifications channel
188
+ if (uid === "notifications") {
189
+ return response.redirect(`${request.baseUrl}/channels`);
190
+ }
191
+
192
+ const channelDocument = await getChannel(application, uid, userId);
193
+ if (!channelDocument) {
194
+ return response.status(404).render("404");
195
+ }
196
+
197
+ await deleteChannel(application, uid, userId);
198
+
199
+ response.redirect(`${request.baseUrl}/channels`);
200
+ }
201
+
174
202
  /**
175
203
  * View feeds for a channel
176
204
  * @param {object} request - Express request
@@ -179,7 +207,7 @@ export async function updateSettings(request, response) {
179
207
  */
180
208
  export async function feeds(request, response) {
181
209
  const { application } = request.app.locals;
182
- const userId = request.session?.userId;
210
+ const userId = getUserId(request);
183
211
  const { uid } = request.params;
184
212
 
185
213
  const channelDocument = await getChannel(application, uid, userId);
@@ -205,7 +233,7 @@ export async function feeds(request, response) {
205
233
  */
206
234
  export async function addFeed(request, response) {
207
235
  const { application } = request.app.locals;
208
- const userId = request.session?.userId;
236
+ const userId = getUserId(request);
209
237
  const { uid } = request.params;
210
238
  const { url } = request.body;
211
239
 
@@ -238,7 +266,7 @@ export async function addFeed(request, response) {
238
266
  */
239
267
  export async function removeFeed(request, response) {
240
268
  const { application } = request.app.locals;
241
- const userId = request.session?.userId;
269
+ const userId = getUserId(request);
242
270
  const { uid } = request.params;
243
271
  const { url } = request.body;
244
272
 
@@ -260,7 +288,7 @@ export async function removeFeed(request, response) {
260
288
  */
261
289
  export async function item(request, response) {
262
290
  const { application } = request.app.locals;
263
- const userId = request.session?.userId;
291
+ const userId = getUserId(request);
264
292
  const { id } = request.params;
265
293
 
266
294
  const itemDocument = await getItemById(application, id, userId);
@@ -282,25 +310,121 @@ export async function item(request, response) {
282
310
  * @returns {Promise<void>}
283
311
  */
284
312
  export async function compose(request, response) {
285
- const { replyTo, likeOf, repostOf } = request.query;
313
+ const { replyTo, likeOf, repostOf, bookmarkOf } = request.query;
286
314
 
287
315
  response.render("compose", {
288
316
  title: request.__("microsub.compose.title"),
289
317
  replyTo,
290
318
  likeOf,
291
319
  repostOf,
320
+ bookmarkOf,
292
321
  baseUrl: request.baseUrl,
293
322
  });
294
323
  }
295
324
 
296
325
  /**
297
- * Submit composed response
326
+ * Submit composed response via Micropub
298
327
  * @param {object} request - Express request
299
328
  * @param {object} response - Express response
329
+ * @returns {Promise<void>}
300
330
  */
301
331
  export async function submitCompose(request, response) {
302
- // TODO: Submit via Micropub
303
- response.redirect(`${request.baseUrl}/channels`);
332
+ const { application } = request.app.locals;
333
+ const { content } = request.body;
334
+ const inReplyTo = request.body["in-reply-to"];
335
+ const likeOf = request.body["like-of"];
336
+ const repostOf = request.body["repost-of"];
337
+ const bookmarkOf = request.body["bookmark-of"];
338
+
339
+ // Get Micropub endpoint
340
+ const micropubEndpoint = application.micropubEndpoint;
341
+ if (!micropubEndpoint) {
342
+ return response.status(500).render("error", {
343
+ title: "Error",
344
+ error: { message: "Micropub endpoint not configured" },
345
+ });
346
+ }
347
+
348
+ // Build absolute Micropub URL
349
+ const micropubUrl = micropubEndpoint.startsWith("http")
350
+ ? micropubEndpoint
351
+ : new URL(micropubEndpoint, application.url).href;
352
+
353
+ // Get auth token from session
354
+ const token = request.session?.access_token;
355
+ if (!token) {
356
+ return response.redirect("/session/login?redirect=" + request.originalUrl);
357
+ }
358
+
359
+ // Build Micropub request body
360
+ const micropubData = new URLSearchParams();
361
+ micropubData.append("h", "entry");
362
+
363
+ if (likeOf) {
364
+ // Like post (no content needed)
365
+ micropubData.append("like-of", likeOf);
366
+ } else if (repostOf) {
367
+ // Repost (no content needed)
368
+ micropubData.append("repost-of", repostOf);
369
+ } else if (bookmarkOf) {
370
+ // Bookmark (content optional)
371
+ micropubData.append("bookmark-of", bookmarkOf);
372
+ if (content) {
373
+ micropubData.append("content", content);
374
+ }
375
+ } else if (inReplyTo) {
376
+ // Reply
377
+ micropubData.append("in-reply-to", inReplyTo);
378
+ micropubData.append("content", content || "");
379
+ } else {
380
+ // Regular note
381
+ micropubData.append("content", content || "");
382
+ }
383
+
384
+ try {
385
+ const micropubResponse = await fetch(micropubUrl, {
386
+ method: "POST",
387
+ headers: {
388
+ Authorization: `Bearer ${token}`,
389
+ "Content-Type": "application/x-www-form-urlencoded",
390
+ Accept: "application/json",
391
+ },
392
+ body: micropubData.toString(),
393
+ });
394
+
395
+ if (
396
+ micropubResponse.ok ||
397
+ micropubResponse.status === 201 ||
398
+ micropubResponse.status === 202
399
+ ) {
400
+ // Success - get the Location header for the new post URL
401
+ const location = micropubResponse.headers.get("Location");
402
+ console.info(
403
+ `[Microsub] Created post via Micropub: ${location || "success"}`,
404
+ );
405
+
406
+ // Redirect back to reader with success message
407
+ return response.redirect(`${request.baseUrl}/channels`);
408
+ }
409
+
410
+ // Handle error
411
+ const errorBody = await micropubResponse.text();
412
+ console.error(
413
+ `[Microsub] Micropub error: ${micropubResponse.status} ${errorBody}`,
414
+ );
415
+
416
+ return response.status(micropubResponse.status).render("error", {
417
+ title: "Error",
418
+ error: { message: `Micropub error: ${micropubResponse.statusText}` },
419
+ });
420
+ } catch (error) {
421
+ console.error(`[Microsub] Micropub request failed: ${error.message}`);
422
+
423
+ return response.status(500).render("error", {
424
+ title: "Error",
425
+ error: { message: `Failed to create post: ${error.message}` },
426
+ });
427
+ }
304
428
  }
305
429
 
306
430
  /**
@@ -311,7 +435,7 @@ export async function submitCompose(request, response) {
311
435
  */
312
436
  export async function searchPage(request, response) {
313
437
  const { application } = request.app.locals;
314
- const userId = request.session?.userId;
438
+ const userId = getUserId(request);
315
439
 
316
440
  const channelList = await getChannels(application, userId);
317
441
 
@@ -330,7 +454,7 @@ export async function searchPage(request, response) {
330
454
  */
331
455
  export async function searchFeeds(request, response) {
332
456
  const { application } = request.app.locals;
333
- const userId = request.session?.userId;
457
+ const userId = getUserId(request);
334
458
  const { query } = request.body;
335
459
 
336
460
  const channelList = await getChannels(application, userId);
@@ -362,7 +486,7 @@ export async function searchFeeds(request, response) {
362
486
  */
363
487
  export async function subscribe(request, response) {
364
488
  const { application } = request.app.locals;
365
- const userId = request.session?.userId;
489
+ const userId = getUserId(request);
366
490
  const { url, channel: channelUid } = request.body;
367
491
 
368
492
  const channelDocument = await getChannel(application, channelUid, userId);
@@ -394,6 +518,7 @@ export const readerController = {
394
518
  channel,
395
519
  settings,
396
520
  updateSettings,
521
+ deleteChannel: deleteChannelAction,
397
522
  feeds,
398
523
  addFeed,
399
524
  removeFeed,
@@ -8,6 +8,7 @@ import { IndiekitError } from "@indiekit/error";
8
8
  import { discoverFeeds } from "../feeds/hfeed.js";
9
9
  import { searchWithFallback } from "../search/query.js";
10
10
  import { getChannel } from "../storage/channels.js";
11
+ import { getUserId } from "../utils/auth.js";
11
12
  import { validateChannel, validateUrl } from "../utils/validation.js";
12
13
 
13
14
  /**
@@ -77,7 +78,7 @@ export async function discover(request, response) {
77
78
  */
78
79
  export async function search(request, response) {
79
80
  const { application } = request.app.locals;
80
- const userId = request.session?.userId;
81
+ const userId = getUserId(request);
81
82
  const { query, channel } = request.body;
82
83
 
83
84
  if (!query) {
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { IndiekitError } from "@indiekit/error";
7
7
 
8
+ import { proxyItemImages } from "../media/proxy.js";
8
9
  import { getChannel } from "../storage/channels.js";
9
10
  import {
10
11
  getTimelineItems,
@@ -12,6 +13,7 @@ import {
12
13
  markItemsUnread,
13
14
  removeItems,
14
15
  } from "../storage/items.js";
16
+ import { getUserId } from "../utils/auth.js";
15
17
  import {
16
18
  validateChannel,
17
19
  validateEntries,
@@ -26,7 +28,7 @@ import {
26
28
  */
27
29
  export async function get(request, response) {
28
30
  const { application } = request.app.locals;
29
- const userId = request.session?.userId;
31
+ const userId = getUserId(request);
30
32
  const { channel, before, after, limit } = request.query;
31
33
 
32
34
  validateChannel(channel);
@@ -46,6 +48,14 @@ export async function get(request, response) {
46
48
  userId,
47
49
  });
48
50
 
51
+ // Proxy images if application URL is available
52
+ const baseUrl = application.url;
53
+ if (baseUrl && timeline.items) {
54
+ timeline.items = timeline.items.map((item) =>
55
+ proxyItemImages(item, baseUrl),
56
+ );
57
+ }
58
+
49
59
  response.json(timeline);
50
60
  }
51
61
 
@@ -57,7 +67,7 @@ export async function get(request, response) {
57
67
  */
58
68
  export async function action(request, response) {
59
69
  const { application } = request.app.locals;
60
- const userId = request.session?.userId;
70
+ const userId = getUserId(request);
61
71
  const { method, channel } = request.body;
62
72
 
63
73
  validateChannel(channel);