@rmdes/indiekit-endpoint-microsub 1.0.0-beta.9 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +17 -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 +133 -16
- package/lib/controllers/search.js +2 -1
- package/lib/controllers/timeline.js +12 -2
- package/lib/media/proxy.js +213 -0
- package/lib/polling/processor.js +30 -2
- package/lib/storage/channels.js +17 -2
- package/lib/storage/feeds.js +15 -2
- package/lib/storage/items.js +87 -29
- package/lib/utils/auth.js +37 -0
- package/lib/webmention/receiver.js +2 -1
- package/locales/en.json +6 -1
- package/package.json +4 -1
- package/views/compose.njk +10 -1
- package/views/settings.njk +14 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Media proxy with caching
|
|
3
|
+
* @module media/proxy
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import crypto from "node:crypto";
|
|
7
|
+
|
|
8
|
+
import { getCache, setCache } from "../cache/redis.js";
|
|
9
|
+
|
|
10
|
+
const MAX_SIZE = 2 * 1024 * 1024; // 2MB max image size
|
|
11
|
+
const CACHE_TTL = 4 * 60 * 60; // 4 hours
|
|
12
|
+
const ALLOWED_TYPES = [
|
|
13
|
+
"image/jpeg",
|
|
14
|
+
"image/png",
|
|
15
|
+
"image/gif",
|
|
16
|
+
"image/webp",
|
|
17
|
+
"image/svg+xml",
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Generate a hash for a URL to use as cache key
|
|
22
|
+
* @param {string} url - Original image URL
|
|
23
|
+
* @returns {string} URL-safe hash
|
|
24
|
+
*/
|
|
25
|
+
export function hashUrl(url) {
|
|
26
|
+
return crypto.createHash("sha256").update(url).digest("hex").slice(0, 16);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Get the proxied URL for an image
|
|
31
|
+
* @param {string} baseUrl - Base URL of the Microsub endpoint
|
|
32
|
+
* @param {string} originalUrl - Original image URL
|
|
33
|
+
* @returns {string} Proxied URL
|
|
34
|
+
*/
|
|
35
|
+
export function getProxiedUrl(baseUrl, originalUrl) {
|
|
36
|
+
if (!originalUrl || !baseUrl) {
|
|
37
|
+
return originalUrl;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Skip data URLs
|
|
41
|
+
if (originalUrl.startsWith("data:")) {
|
|
42
|
+
return originalUrl;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Skip already-proxied URLs
|
|
46
|
+
if (originalUrl.includes("/microsub/media/")) {
|
|
47
|
+
return originalUrl;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const hash = hashUrl(originalUrl);
|
|
51
|
+
return `${baseUrl}/microsub/media/${hash}?url=${encodeURIComponent(originalUrl)}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Rewrite image URLs in an item to use the proxy
|
|
56
|
+
* @param {object} item - JF2 item
|
|
57
|
+
* @param {string} baseUrl - Base URL for proxy
|
|
58
|
+
* @returns {object} Item with proxied URLs
|
|
59
|
+
*/
|
|
60
|
+
export function proxyItemImages(item, baseUrl) {
|
|
61
|
+
if (!baseUrl || !item) {
|
|
62
|
+
return item;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const proxied = { ...item };
|
|
66
|
+
|
|
67
|
+
// Proxy photo URLs
|
|
68
|
+
if (proxied.photo) {
|
|
69
|
+
if (Array.isArray(proxied.photo)) {
|
|
70
|
+
proxied.photo = proxied.photo.map((p) => {
|
|
71
|
+
if (typeof p === "string") {
|
|
72
|
+
return getProxiedUrl(baseUrl, p);
|
|
73
|
+
}
|
|
74
|
+
if (p?.value) {
|
|
75
|
+
return { ...p, value: getProxiedUrl(baseUrl, p.value) };
|
|
76
|
+
}
|
|
77
|
+
return p;
|
|
78
|
+
});
|
|
79
|
+
} else if (typeof proxied.photo === "string") {
|
|
80
|
+
proxied.photo = getProxiedUrl(baseUrl, proxied.photo);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Proxy author photo
|
|
85
|
+
if (proxied.author?.photo) {
|
|
86
|
+
proxied.author = {
|
|
87
|
+
...proxied.author,
|
|
88
|
+
photo: getProxiedUrl(baseUrl, proxied.author.photo),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return proxied;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Fetch and cache an image
|
|
97
|
+
* @param {object} redis - Redis client
|
|
98
|
+
* @param {string} url - Image URL to fetch
|
|
99
|
+
* @returns {Promise<object|null>} Cached image data or null
|
|
100
|
+
*/
|
|
101
|
+
export async function fetchImage(redis, url) {
|
|
102
|
+
const cacheKey = `media:${hashUrl(url)}`;
|
|
103
|
+
|
|
104
|
+
// Try cache first
|
|
105
|
+
if (redis) {
|
|
106
|
+
const cached = await getCache(redis, cacheKey);
|
|
107
|
+
if (cached) {
|
|
108
|
+
return cached;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
// Fetch the image
|
|
114
|
+
const response = await fetch(url, {
|
|
115
|
+
headers: {
|
|
116
|
+
"User-Agent": "Indiekit Microsub/1.0 (+https://getindiekit.com)",
|
|
117
|
+
Accept: "image/*",
|
|
118
|
+
},
|
|
119
|
+
signal: AbortSignal.timeout(10_000), // 10 second timeout
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
if (!response.ok) {
|
|
123
|
+
console.error(`[Microsub] Media proxy fetch failed: ${response.status} for ${url}`);
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Check content type
|
|
128
|
+
const contentType = response.headers.get("content-type")?.split(";")[0];
|
|
129
|
+
if (!ALLOWED_TYPES.includes(contentType)) {
|
|
130
|
+
console.error(`[Microsub] Media proxy rejected type: ${contentType} for ${url}`);
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Check content length
|
|
135
|
+
const contentLength = Number.parseInt(
|
|
136
|
+
response.headers.get("content-length") || "0",
|
|
137
|
+
10,
|
|
138
|
+
);
|
|
139
|
+
if (contentLength > MAX_SIZE) {
|
|
140
|
+
console.error(`[Microsub] Media proxy rejected size: ${contentLength} for ${url}`);
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Read the body
|
|
145
|
+
const buffer = await response.arrayBuffer();
|
|
146
|
+
if (buffer.byteLength > MAX_SIZE) {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const imageData = {
|
|
151
|
+
contentType,
|
|
152
|
+
data: Buffer.from(buffer).toString("base64"),
|
|
153
|
+
size: buffer.byteLength,
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// Cache in Redis
|
|
157
|
+
if (redis) {
|
|
158
|
+
await setCache(redis, cacheKey, imageData, CACHE_TTL);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return imageData;
|
|
162
|
+
} catch (error) {
|
|
163
|
+
console.error(`[Microsub] Media proxy error: ${error.message} for ${url}`);
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Express route handler for media proxy
|
|
170
|
+
* @param {object} request - Express request
|
|
171
|
+
* @param {object} response - Express response
|
|
172
|
+
* @returns {Promise<void>}
|
|
173
|
+
*/
|
|
174
|
+
export async function handleMediaProxy(request, response) {
|
|
175
|
+
const { url } = request.query;
|
|
176
|
+
|
|
177
|
+
if (!url) {
|
|
178
|
+
return response.status(400).send("Missing url parameter");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Validate URL
|
|
182
|
+
try {
|
|
183
|
+
const parsed = new URL(url);
|
|
184
|
+
if (!["http:", "https:"].includes(parsed.protocol)) {
|
|
185
|
+
return response.status(400).send("Invalid URL protocol");
|
|
186
|
+
}
|
|
187
|
+
} catch {
|
|
188
|
+
return response.status(400).send("Invalid URL");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Get Redis client from application
|
|
192
|
+
const { application } = request.app.locals;
|
|
193
|
+
const redis = application.redis;
|
|
194
|
+
|
|
195
|
+
// Fetch or get from cache
|
|
196
|
+
const imageData = await fetchImage(redis, url);
|
|
197
|
+
|
|
198
|
+
if (!imageData) {
|
|
199
|
+
// Redirect to original URL as fallback
|
|
200
|
+
return response.redirect(url);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Set cache headers
|
|
204
|
+
response.set({
|
|
205
|
+
"Content-Type": imageData.contentType,
|
|
206
|
+
"Content-Length": imageData.size,
|
|
207
|
+
"Cache-Control": "public, max-age=14400", // 4 hours
|
|
208
|
+
"X-Proxied-From": url,
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// Send the image
|
|
212
|
+
response.send(Buffer.from(imageData.data, "base64"));
|
|
213
|
+
}
|
package/lib/polling/processor.js
CHANGED
|
@@ -9,6 +9,10 @@ import { getChannel } from "../storage/channels.js";
|
|
|
9
9
|
import { updateFeedAfterFetch, updateFeedWebsub } from "../storage/feeds.js";
|
|
10
10
|
import { passesRegexFilter, passesTypeFilter } from "../storage/filters.js";
|
|
11
11
|
import { addItem } from "../storage/items.js";
|
|
12
|
+
import {
|
|
13
|
+
subscribe as websubSubscribe,
|
|
14
|
+
getCallbackUrl,
|
|
15
|
+
} from "../websub/subscriber.js";
|
|
12
16
|
|
|
13
17
|
import { calculateNewTier } from "./tier.js";
|
|
14
18
|
|
|
@@ -123,13 +127,37 @@ export async function processFeed(application, feed) {
|
|
|
123
127
|
updateData,
|
|
124
128
|
);
|
|
125
129
|
|
|
126
|
-
// Handle WebSub hub discovery
|
|
130
|
+
// Handle WebSub hub discovery and auto-subscription
|
|
127
131
|
if (parsed.hub && (!feed.websub || feed.websub.hub !== parsed.hub)) {
|
|
128
132
|
await updateFeedWebsub(application, feed._id, {
|
|
129
133
|
hub: parsed.hub,
|
|
130
134
|
topic: parsed.self || feed.url,
|
|
131
135
|
});
|
|
132
|
-
|
|
136
|
+
|
|
137
|
+
// Auto-subscribe to WebSub hub if we have a callback URL
|
|
138
|
+
const baseUrl = application.url;
|
|
139
|
+
if (baseUrl) {
|
|
140
|
+
const callbackUrl = getCallbackUrl(baseUrl, feed._id.toString());
|
|
141
|
+
const updatedFeed = {
|
|
142
|
+
...feed,
|
|
143
|
+
websub: { hub: parsed.hub, topic: parsed.self || feed.url },
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
websubSubscribe(application, updatedFeed, callbackUrl)
|
|
147
|
+
.then((subscribed) => {
|
|
148
|
+
if (subscribed) {
|
|
149
|
+
console.info(
|
|
150
|
+
`[Microsub] WebSub subscription initiated for ${feed.url}`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
})
|
|
154
|
+
.catch((error) => {
|
|
155
|
+
console.error(
|
|
156
|
+
`[Microsub] WebSub subscription error for ${feed.url}:`,
|
|
157
|
+
error.message,
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
}
|
|
133
161
|
}
|
|
134
162
|
|
|
135
163
|
result.success = true;
|
package/lib/storage/channels.js
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
|
|
6
6
|
import { ObjectId } from "mongodb";
|
|
7
7
|
|
|
8
|
+
import { deleteFeedsForChannel } from "./feeds.js";
|
|
9
|
+
import { deleteItemsForChannel } from "./items.js";
|
|
8
10
|
import { generateChannelUid } from "../utils/jf2.js";
|
|
9
11
|
|
|
10
12
|
/**
|
|
@@ -184,7 +186,7 @@ export async function updateChannel(application, uid, updates, userId) {
|
|
|
184
186
|
}
|
|
185
187
|
|
|
186
188
|
/**
|
|
187
|
-
* Delete a channel
|
|
189
|
+
* Delete a channel and all its feeds and items
|
|
188
190
|
* @param {object} application - Indiekit application
|
|
189
191
|
* @param {string} uid - Channel UID
|
|
190
192
|
* @param {string} [userId] - User ID
|
|
@@ -200,7 +202,20 @@ export async function deleteChannel(application, uid, userId) {
|
|
|
200
202
|
return false;
|
|
201
203
|
}
|
|
202
204
|
|
|
203
|
-
|
|
205
|
+
// Find the channel first to get its ObjectId
|
|
206
|
+
const channel = await collection.findOne(query);
|
|
207
|
+
if (!channel) {
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Cascade delete: items first, then feeds, then channel
|
|
212
|
+
const itemsDeleted = await deleteItemsForChannel(application, channel._id);
|
|
213
|
+
const feedsDeleted = await deleteFeedsForChannel(application, channel._id);
|
|
214
|
+
console.info(
|
|
215
|
+
`[Microsub] Deleted channel ${uid}: ${feedsDeleted} feeds, ${itemsDeleted} items`,
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
const result = await collection.deleteOne({ _id: channel._id });
|
|
204
219
|
return result.deletedCount > 0;
|
|
205
220
|
}
|
|
206
221
|
|
package/lib/storage/feeds.js
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
|
|
6
6
|
import { ObjectId } from "mongodb";
|
|
7
7
|
|
|
8
|
+
import { deleteItemsForFeed } from "./items.js";
|
|
9
|
+
|
|
8
10
|
/**
|
|
9
11
|
* Get feeds collection from application
|
|
10
12
|
* @param {object} application - Indiekit application
|
|
@@ -122,7 +124,7 @@ export async function updateFeed(application, id, updates) {
|
|
|
122
124
|
}
|
|
123
125
|
|
|
124
126
|
/**
|
|
125
|
-
* Delete a feed subscription
|
|
127
|
+
* Delete a feed subscription and all its items
|
|
126
128
|
* @param {object} application - Indiekit application
|
|
127
129
|
* @param {ObjectId|string} channelId - Channel ObjectId
|
|
128
130
|
* @param {string} url - Feed URL
|
|
@@ -133,7 +135,18 @@ export async function deleteFeed(application, channelId, url) {
|
|
|
133
135
|
const objectId =
|
|
134
136
|
typeof channelId === "string" ? new ObjectId(channelId) : channelId;
|
|
135
137
|
|
|
136
|
-
|
|
138
|
+
// Find the feed first to get its ID for cascade delete
|
|
139
|
+
const feed = await collection.findOne({ channelId: objectId, url });
|
|
140
|
+
if (!feed) {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Delete all items from this feed
|
|
145
|
+
const itemsDeleted = await deleteItemsForFeed(application, feed._id);
|
|
146
|
+
console.info(`[Microsub] Deleted ${itemsDeleted} items from feed ${url}`);
|
|
147
|
+
|
|
148
|
+
// Delete the feed itself
|
|
149
|
+
const result = await collection.deleteOne({ _id: feed._id });
|
|
137
150
|
return result.deletedCount > 0;
|
|
138
151
|
}
|
|
139
152
|
|
package/lib/storage/items.js
CHANGED
|
@@ -195,7 +195,7 @@ export async function getItemsByUids(application, uids, userId) {
|
|
|
195
195
|
* Mark items as read
|
|
196
196
|
* @param {object} application - Indiekit application
|
|
197
197
|
* @param {ObjectId|string} channelId - Channel ObjectId
|
|
198
|
-
* @param {Array} entryIds - Array of entry IDs to mark as read
|
|
198
|
+
* @param {Array} entryIds - Array of entry IDs to mark as read (can be ObjectId, uid, or URL)
|
|
199
199
|
* @param {string} userId - User ID
|
|
200
200
|
* @returns {Promise<number>} Number of items updated
|
|
201
201
|
*/
|
|
@@ -204,6 +204,12 @@ export async function markItemsRead(application, channelId, entryIds, userId) {
|
|
|
204
204
|
const channelObjectId =
|
|
205
205
|
typeof channelId === "string" ? new ObjectId(channelId) : channelId;
|
|
206
206
|
|
|
207
|
+
console.info(
|
|
208
|
+
`[Microsub] markItemsRead called for channel ${channelId}, entries:`,
|
|
209
|
+
entryIds,
|
|
210
|
+
`userId: ${userId}`,
|
|
211
|
+
);
|
|
212
|
+
|
|
207
213
|
// Handle "last-read-entry" special value
|
|
208
214
|
if (entryIds.includes("last-read-entry")) {
|
|
209
215
|
// Mark all items in channel as read
|
|
@@ -211,26 +217,39 @@ export async function markItemsRead(application, channelId, entryIds, userId) {
|
|
|
211
217
|
{ channelId: channelObjectId },
|
|
212
218
|
{ $addToSet: { readBy: userId } },
|
|
213
219
|
);
|
|
220
|
+
console.info(
|
|
221
|
+
`[Microsub] Marked all items as read: ${result.modifiedCount} updated`,
|
|
222
|
+
);
|
|
214
223
|
return result.modifiedCount;
|
|
215
224
|
}
|
|
216
225
|
|
|
217
226
|
// Convert string IDs to ObjectIds where possible
|
|
218
|
-
const objectIds = entryIds
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
227
|
+
const objectIds = entryIds
|
|
228
|
+
.map((id) => {
|
|
229
|
+
try {
|
|
230
|
+
return new ObjectId(id);
|
|
231
|
+
} catch {
|
|
232
|
+
return undefined;
|
|
233
|
+
}
|
|
234
|
+
})
|
|
235
|
+
.filter(Boolean);
|
|
225
236
|
|
|
237
|
+
// Build query to match by _id, uid, or url (Microsub spec uses URLs as entry identifiers)
|
|
226
238
|
const result = await collection.updateMany(
|
|
227
239
|
{
|
|
228
240
|
channelId: channelObjectId,
|
|
229
|
-
$or: [
|
|
241
|
+
$or: [
|
|
242
|
+
...(objectIds.length > 0 ? [{ _id: { $in: objectIds } }] : []),
|
|
243
|
+
{ uid: { $in: entryIds } },
|
|
244
|
+
{ url: { $in: entryIds } },
|
|
245
|
+
],
|
|
230
246
|
},
|
|
231
247
|
{ $addToSet: { readBy: userId } },
|
|
232
248
|
);
|
|
233
249
|
|
|
250
|
+
console.info(
|
|
251
|
+
`[Microsub] markItemsRead result: ${result.modifiedCount} items updated`,
|
|
252
|
+
);
|
|
234
253
|
return result.modifiedCount;
|
|
235
254
|
}
|
|
236
255
|
|
|
@@ -238,7 +257,7 @@ export async function markItemsRead(application, channelId, entryIds, userId) {
|
|
|
238
257
|
* Mark items as unread
|
|
239
258
|
* @param {object} application - Indiekit application
|
|
240
259
|
* @param {ObjectId|string} channelId - Channel ObjectId
|
|
241
|
-
* @param {Array} entryIds - Array of entry IDs to mark as unread
|
|
260
|
+
* @param {Array} entryIds - Array of entry IDs to mark as unread (can be ObjectId, uid, or URL)
|
|
242
261
|
* @param {string} userId - User ID
|
|
243
262
|
* @returns {Promise<number>} Number of items updated
|
|
244
263
|
*/
|
|
@@ -252,18 +271,26 @@ export async function markItemsUnread(
|
|
|
252
271
|
const channelObjectId =
|
|
253
272
|
typeof channelId === "string" ? new ObjectId(channelId) : channelId;
|
|
254
273
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
274
|
+
// Convert string IDs to ObjectIds where possible
|
|
275
|
+
const objectIds = entryIds
|
|
276
|
+
.map((id) => {
|
|
277
|
+
try {
|
|
278
|
+
return new ObjectId(id);
|
|
279
|
+
} catch {
|
|
280
|
+
return undefined;
|
|
281
|
+
}
|
|
282
|
+
})
|
|
283
|
+
.filter(Boolean);
|
|
262
284
|
|
|
285
|
+
// Match by _id, uid, or url
|
|
263
286
|
const result = await collection.updateMany(
|
|
264
287
|
{
|
|
265
288
|
channelId: channelObjectId,
|
|
266
|
-
$or: [
|
|
289
|
+
$or: [
|
|
290
|
+
...(objectIds.length > 0 ? [{ _id: { $in: objectIds } }] : []),
|
|
291
|
+
{ uid: { $in: entryIds } },
|
|
292
|
+
{ url: { $in: entryIds } },
|
|
293
|
+
],
|
|
267
294
|
},
|
|
268
295
|
{ $pull: { readBy: userId } },
|
|
269
296
|
);
|
|
@@ -275,7 +302,7 @@ export async function markItemsUnread(
|
|
|
275
302
|
* Remove items from channel
|
|
276
303
|
* @param {object} application - Indiekit application
|
|
277
304
|
* @param {ObjectId|string} channelId - Channel ObjectId
|
|
278
|
-
* @param {Array} entryIds - Array of entry IDs to remove
|
|
305
|
+
* @param {Array} entryIds - Array of entry IDs to remove (can be ObjectId, uid, or URL)
|
|
279
306
|
* @returns {Promise<number>} Number of items removed
|
|
280
307
|
*/
|
|
281
308
|
export async function removeItems(application, channelId, entryIds) {
|
|
@@ -283,17 +310,25 @@ export async function removeItems(application, channelId, entryIds) {
|
|
|
283
310
|
const channelObjectId =
|
|
284
311
|
typeof channelId === "string" ? new ObjectId(channelId) : channelId;
|
|
285
312
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
313
|
+
// Convert string IDs to ObjectIds where possible
|
|
314
|
+
const objectIds = entryIds
|
|
315
|
+
.map((id) => {
|
|
316
|
+
try {
|
|
317
|
+
return new ObjectId(id);
|
|
318
|
+
} catch {
|
|
319
|
+
return undefined;
|
|
320
|
+
}
|
|
321
|
+
})
|
|
322
|
+
.filter(Boolean);
|
|
293
323
|
|
|
324
|
+
// Match by _id, uid, or url
|
|
294
325
|
const result = await collection.deleteMany({
|
|
295
326
|
channelId: channelObjectId,
|
|
296
|
-
$or: [
|
|
327
|
+
$or: [
|
|
328
|
+
...(objectIds.length > 0 ? [{ _id: { $in: objectIds } }] : []),
|
|
329
|
+
{ uid: { $in: entryIds } },
|
|
330
|
+
{ url: { $in: entryIds } },
|
|
331
|
+
],
|
|
297
332
|
});
|
|
298
333
|
|
|
299
334
|
return result.deletedCount;
|
|
@@ -411,11 +446,34 @@ export async function deleteItemsByAuthorUrl(application, userId, authorUrl) {
|
|
|
411
446
|
export async function createIndexes(application) {
|
|
412
447
|
const collection = getCollection(application);
|
|
413
448
|
|
|
449
|
+
// Primary query indexes
|
|
414
450
|
await collection.createIndex({ channelId: 1, published: -1 });
|
|
415
451
|
await collection.createIndex({ channelId: 1, uid: 1 }, { unique: true });
|
|
416
452
|
await collection.createIndex({ feedId: 1 });
|
|
453
|
+
|
|
454
|
+
// URL matching index for mark_read operations
|
|
455
|
+
await collection.createIndex({ channelId: 1, url: 1 });
|
|
456
|
+
|
|
457
|
+
// Full-text search index with weights
|
|
458
|
+
// Higher weight = more importance in relevance scoring
|
|
417
459
|
await collection.createIndex(
|
|
418
|
-
{
|
|
419
|
-
|
|
460
|
+
{
|
|
461
|
+
name: "text",
|
|
462
|
+
"content.text": "text",
|
|
463
|
+
"content.html": "text",
|
|
464
|
+
summary: "text",
|
|
465
|
+
"author.name": "text",
|
|
466
|
+
},
|
|
467
|
+
{
|
|
468
|
+
name: "text_search",
|
|
469
|
+
weights: {
|
|
470
|
+
name: 10, // Titles most important
|
|
471
|
+
summary: 5, // Summaries second
|
|
472
|
+
"content.text": 3, // Content third
|
|
473
|
+
"content.html": 2, // HTML content lower
|
|
474
|
+
"author.name": 1, // Author names lowest
|
|
475
|
+
},
|
|
476
|
+
default_language: "english",
|
|
477
|
+
},
|
|
420
478
|
);
|
|
421
479
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authentication utilities for Microsub
|
|
3
|
+
* @module utils/auth
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Get the user ID from request context
|
|
8
|
+
*
|
|
9
|
+
* In Indiekit, the userId can come from:
|
|
10
|
+
* 1. request.session.userId (if explicitly set)
|
|
11
|
+
* 2. request.session.me (from token introspection)
|
|
12
|
+
* 3. application.publication.me (single-user fallback)
|
|
13
|
+
*
|
|
14
|
+
* @param {object} request - Express request
|
|
15
|
+
* @returns {string|undefined} User ID
|
|
16
|
+
*/
|
|
17
|
+
export function getUserId(request) {
|
|
18
|
+
// Check session for explicit userId
|
|
19
|
+
if (request.session?.userId) {
|
|
20
|
+
return request.session.userId;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Check session for me URL from token introspection
|
|
24
|
+
if (request.session?.me) {
|
|
25
|
+
return request.session.me;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Fall back to publication me URL (single-user mode)
|
|
29
|
+
const { application } = request.app.locals;
|
|
30
|
+
if (application?.publication?.me) {
|
|
31
|
+
return application.publication.me;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Final fallback: use "default" as user ID for single-user instances
|
|
35
|
+
// This ensures read state is tracked even without explicit user identity
|
|
36
|
+
return "default";
|
|
37
|
+
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* @module webmention/receiver
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { getUserId } from "../utils/auth.js";
|
|
6
7
|
import { processWebmention } from "./processor.js";
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -33,7 +34,7 @@ export async function receive(request, response) {
|
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
const { application } = request.app.locals;
|
|
36
|
-
const userId = request
|
|
37
|
+
const userId = getUserId(request);
|
|
37
38
|
|
|
38
39
|
// Return 202 Accepted immediately (processing asynchronously)
|
|
39
40
|
response.status(202).json({
|
package/locales/en.json
CHANGED
|
@@ -46,7 +46,8 @@
|
|
|
46
46
|
"cancel": "Cancel",
|
|
47
47
|
"replyTo": "Replying to",
|
|
48
48
|
"likeOf": "Liking",
|
|
49
|
-
"repostOf": "Reposting"
|
|
49
|
+
"repostOf": "Reposting",
|
|
50
|
+
"bookmarkOf": "Bookmarking"
|
|
50
51
|
},
|
|
51
52
|
"settings": {
|
|
52
53
|
"title": "{{channel}} settings",
|
|
@@ -55,6 +56,10 @@
|
|
|
55
56
|
"excludeRegex": "Exclude pattern",
|
|
56
57
|
"excludeRegexHelp": "Regular expression to filter out matching content",
|
|
57
58
|
"save": "Save settings",
|
|
59
|
+
"dangerZone": "Danger zone",
|
|
60
|
+
"deleteWarning": "Deleting this channel will permanently remove all feeds and items. This action cannot be undone.",
|
|
61
|
+
"deleteConfirm": "Are you sure you want to delete this channel and all its content?",
|
|
62
|
+
"delete": "Delete channel",
|
|
58
63
|
"types": {
|
|
59
64
|
"like": "Likes",
|
|
60
65
|
"repost": "Reposts",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmdes/indiekit-endpoint-microsub",
|
|
3
|
-
"version": "1.0.0
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"description": "Microsub endpoint for Indiekit. Enables subscribing to feeds and reading content using the Microsub protocol.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"indiekit",
|
|
@@ -21,6 +21,9 @@
|
|
|
21
21
|
},
|
|
22
22
|
"type": "module",
|
|
23
23
|
"main": "index.js",
|
|
24
|
+
"scripts": {
|
|
25
|
+
"test": "node --test test/unit/*.js"
|
|
26
|
+
},
|
|
24
27
|
"files": [
|
|
25
28
|
"lib",
|
|
26
29
|
"locales",
|
package/views/compose.njk
CHANGED
|
@@ -24,6 +24,12 @@
|
|
|
24
24
|
</div>
|
|
25
25
|
{% endif %}
|
|
26
26
|
|
|
27
|
+
{% if bookmarkOf %}
|
|
28
|
+
<div class="compose__context">
|
|
29
|
+
{{ icon("bookmark") }} {{ __("microsub.compose.bookmarkOf") }}: <a href="{{ bookmarkOf }}">{{ bookmarkOf }}</a>
|
|
30
|
+
</div>
|
|
31
|
+
{% endif %}
|
|
32
|
+
|
|
27
33
|
<form method="post" action="{{ baseUrl }}/compose">
|
|
28
34
|
{% if replyTo %}
|
|
29
35
|
<input type="hidden" name="in-reply-to" value="{{ replyTo }}">
|
|
@@ -34,8 +40,11 @@
|
|
|
34
40
|
{% if repostOf %}
|
|
35
41
|
<input type="hidden" name="repost-of" value="{{ repostOf }}">
|
|
36
42
|
{% endif %}
|
|
43
|
+
{% if bookmarkOf %}
|
|
44
|
+
<input type="hidden" name="bookmark-of" value="{{ bookmarkOf }}">
|
|
45
|
+
{% endif %}
|
|
37
46
|
|
|
38
|
-
{% if not likeOf and not repostOf %}
|
|
47
|
+
{% if not likeOf and not repostOf and not bookmarkOf %}
|
|
39
48
|
{{ textarea({
|
|
40
49
|
label: __("microsub.compose.content"),
|
|
41
50
|
id: "content",
|
package/views/settings.njk
CHANGED
|
@@ -55,5 +55,19 @@
|
|
|
55
55
|
</a>
|
|
56
56
|
</div>
|
|
57
57
|
</form>
|
|
58
|
+
|
|
59
|
+
{% if channel.uid !== "notifications" %}
|
|
60
|
+
<hr class="divider">
|
|
61
|
+
<div class="danger-zone">
|
|
62
|
+
<h3>{{ __("microsub.settings.dangerZone") }}</h3>
|
|
63
|
+
<p class="hint">{{ __("microsub.settings.deleteWarning") }}</p>
|
|
64
|
+
<form method="post" action="{{ baseUrl }}/channels/{{ channel.uid }}/delete" onsubmit="return confirm('{{ __("microsub.settings.deleteConfirm") }}');">
|
|
65
|
+
{{ button({
|
|
66
|
+
text: __("microsub.settings.delete"),
|
|
67
|
+
classes: "button--danger"
|
|
68
|
+
}) }}
|
|
69
|
+
</form>
|
|
70
|
+
</div>
|
|
71
|
+
{% endif %}
|
|
58
72
|
</div>
|
|
59
73
|
{% endblock %}
|