@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 +14 -0
- package/lib/cache/redis.js +53 -5
- package/lib/controllers/block.js +4 -3
- package/lib/controllers/channels.js +4 -3
- package/lib/controllers/events.js +2 -1
- package/lib/controllers/follow.js +27 -7
- package/lib/controllers/mute.js +4 -3
- package/lib/controllers/reader.js +141 -16
- package/lib/controllers/search.js +2 -1
- package/lib/controllers/timeline.js +12 -2
- package/lib/feeds/fetcher.js +118 -7
- package/lib/feeds/normalizer.js +62 -2
- package/lib/feeds/parser.js +12 -1
- package/lib/media/proxy.js +219 -0
- package/lib/polling/processor.js +30 -2
- package/lib/storage/channels.js +18 -2
- package/lib/storage/feeds.js +15 -2
- package/lib/storage/items.js +87 -29
- package/lib/utils/auth.js +36 -0
- package/lib/webmention/receiver.js +3 -1
- package/locales/en.json +6 -1
- package/package.json +4 -1
- package/views/compose.njk +10 -1
- package/views/settings.njk +14 -0
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",
|
package/lib/cache/redis.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
+
}
|
package/lib/controllers/block.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
package/lib/controllers/mute.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
303
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
70
|
+
const userId = getUserId(request);
|
|
61
71
|
const { method, channel } = request.body;
|
|
62
72
|
|
|
63
73
|
validateChannel(channel);
|