@rmdes/indiekit-endpoint-microsub 1.0.0-beta.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/README.md +111 -0
- package/index.js +140 -0
- package/lib/cache/redis.js +133 -0
- package/lib/controllers/block.js +85 -0
- package/lib/controllers/channels.js +135 -0
- package/lib/controllers/events.js +56 -0
- package/lib/controllers/follow.js +108 -0
- package/lib/controllers/microsub.js +138 -0
- package/lib/controllers/mute.js +124 -0
- package/lib/controllers/preview.js +67 -0
- package/lib/controllers/reader.js +218 -0
- package/lib/controllers/search.js +142 -0
- package/lib/controllers/timeline.js +117 -0
- package/lib/feeds/atom.js +61 -0
- package/lib/feeds/fetcher.js +205 -0
- package/lib/feeds/hfeed.js +177 -0
- package/lib/feeds/jsonfeed.js +43 -0
- package/lib/feeds/normalizer.js +586 -0
- package/lib/feeds/parser.js +124 -0
- package/lib/feeds/rss.js +61 -0
- package/lib/polling/processor.js +201 -0
- package/lib/polling/scheduler.js +128 -0
- package/lib/polling/tier.js +139 -0
- package/lib/realtime/broker.js +241 -0
- package/lib/search/indexer.js +90 -0
- package/lib/search/query.js +197 -0
- package/lib/storage/channels.js +281 -0
- package/lib/storage/feeds.js +286 -0
- package/lib/storage/filters.js +265 -0
- package/lib/storage/items.js +419 -0
- package/lib/storage/read-state.js +109 -0
- package/lib/utils/jf2.js +170 -0
- package/lib/utils/pagination.js +157 -0
- package/lib/utils/validation.js +217 -0
- package/lib/webmention/processor.js +214 -0
- package/lib/webmention/receiver.js +54 -0
- package/lib/webmention/verifier.js +308 -0
- package/lib/websub/discovery.js +129 -0
- package/lib/websub/handler.js +163 -0
- package/lib/websub/subscriber.js +181 -0
- package/locales/en.json +80 -0
- package/package.json +54 -0
- package/views/channel-new.njk +33 -0
- package/views/channel.njk +41 -0
- package/views/compose.njk +61 -0
- package/views/item.njk +85 -0
- package/views/partials/actions.njk +15 -0
- package/views/partials/author.njk +17 -0
- package/views/partials/item-card.njk +65 -0
- package/views/partials/timeline.njk +10 -0
- package/views/reader.njk +37 -0
- package/views/settings.njk +81 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read state tracking utilities
|
|
3
|
+
* @module storage/read-state
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { markItemsRead, markItemsUnread, getUnreadCount } from "./items.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Mark entries as read for a user
|
|
10
|
+
* @param {object} application - Indiekit application
|
|
11
|
+
* @param {string} channelUid - Channel UID
|
|
12
|
+
* @param {Array} entries - Entry IDs to mark as read
|
|
13
|
+
* @param {string} userId - User ID
|
|
14
|
+
* @returns {Promise<number>} Number of entries marked
|
|
15
|
+
*/
|
|
16
|
+
export async function markRead(application, channelUid, entries, userId) {
|
|
17
|
+
const channelsCollection = application.collections.get("microsub_channels");
|
|
18
|
+
const channel = await channelsCollection.findOne({ uid: channelUid });
|
|
19
|
+
|
|
20
|
+
if (!channel) {
|
|
21
|
+
return 0;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return markItemsRead(application, channel._id, entries, userId);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Mark entries as unread for a user
|
|
29
|
+
* @param {object} application - Indiekit application
|
|
30
|
+
* @param {string} channelUid - Channel UID
|
|
31
|
+
* @param {Array} entries - Entry IDs to mark as unread
|
|
32
|
+
* @param {string} userId - User ID
|
|
33
|
+
* @returns {Promise<number>} Number of entries marked
|
|
34
|
+
*/
|
|
35
|
+
export async function markUnread(application, channelUid, entries, userId) {
|
|
36
|
+
const channelsCollection = application.collections.get("microsub_channels");
|
|
37
|
+
const channel = await channelsCollection.findOne({ uid: channelUid });
|
|
38
|
+
|
|
39
|
+
if (!channel) {
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return markItemsUnread(application, channel._id, entries, userId);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Get unread count for a channel
|
|
48
|
+
* @param {object} application - Indiekit application
|
|
49
|
+
* @param {string} channelUid - Channel UID
|
|
50
|
+
* @param {string} userId - User ID
|
|
51
|
+
* @returns {Promise<number>} Unread count
|
|
52
|
+
*/
|
|
53
|
+
export async function getChannelUnreadCount(application, channelUid, userId) {
|
|
54
|
+
const channelsCollection = application.collections.get("microsub_channels");
|
|
55
|
+
const channel = await channelsCollection.findOne({ uid: channelUid });
|
|
56
|
+
|
|
57
|
+
if (!channel) {
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return getUnreadCount(application, channel._id, userId);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Get unread counts for all channels
|
|
66
|
+
* @param {object} application - Indiekit application
|
|
67
|
+
* @param {string} userId - User ID
|
|
68
|
+
* @returns {Promise<Map>} Map of channel UID to unread count
|
|
69
|
+
*/
|
|
70
|
+
export async function getAllUnreadCounts(application, userId) {
|
|
71
|
+
const channelsCollection = application.collections.get("microsub_channels");
|
|
72
|
+
const itemsCollection = application.collections.get("microsub_items");
|
|
73
|
+
|
|
74
|
+
// Aggregate unread counts per channel
|
|
75
|
+
const pipeline = [
|
|
76
|
+
{
|
|
77
|
+
$match: {
|
|
78
|
+
readBy: { $ne: userId },
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
$group: {
|
|
83
|
+
_id: "$channelId",
|
|
84
|
+
count: { $sum: 1 },
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
const results = await itemsCollection.aggregate(pipeline).toArray();
|
|
90
|
+
|
|
91
|
+
// Get channel UIDs
|
|
92
|
+
const channelIds = results.map((r) => r._id);
|
|
93
|
+
const channels = await channelsCollection
|
|
94
|
+
.find({ _id: { $in: channelIds } })
|
|
95
|
+
.toArray();
|
|
96
|
+
|
|
97
|
+
const channelMap = new Map(channels.map((c) => [c._id.toString(), c.uid]));
|
|
98
|
+
|
|
99
|
+
// Build result map
|
|
100
|
+
const unreadCounts = new Map();
|
|
101
|
+
for (const result of results) {
|
|
102
|
+
const uid = channelMap.get(result._id.toString());
|
|
103
|
+
if (uid) {
|
|
104
|
+
unreadCounts.set(uid, result.count);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return unreadCounts;
|
|
109
|
+
}
|
package/lib/utils/jf2.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* jf2 utility functions for Microsub
|
|
3
|
+
* @module utils/jf2
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Generate a unique ID for an item based on feed URL and item identifier
|
|
10
|
+
* @param {string} feedUrl - Feed URL
|
|
11
|
+
* @param {string} itemId - Item ID or URL
|
|
12
|
+
* @returns {string} Unique item ID
|
|
13
|
+
*/
|
|
14
|
+
export function generateItemUid(feedUrl, itemId) {
|
|
15
|
+
const input = `${feedUrl}:${itemId}`;
|
|
16
|
+
return createHash("sha256").update(input).digest("hex").slice(0, 24);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Generate a random channel UID
|
|
21
|
+
* @returns {string} 24-character random string
|
|
22
|
+
*/
|
|
23
|
+
export function generateChannelUid() {
|
|
24
|
+
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
25
|
+
let result = "";
|
|
26
|
+
for (let index = 0; index < 24; index++) {
|
|
27
|
+
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
|
28
|
+
}
|
|
29
|
+
return result;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Create a jf2 Item from normalized feed data
|
|
34
|
+
* @param {object} data - Normalized item data
|
|
35
|
+
* @param {object} source - Feed source metadata
|
|
36
|
+
* @returns {object} jf2 Item object
|
|
37
|
+
*/
|
|
38
|
+
export function createJf2Item(data, source) {
|
|
39
|
+
return {
|
|
40
|
+
type: "entry",
|
|
41
|
+
uid: data.uid,
|
|
42
|
+
url: data.url,
|
|
43
|
+
name: data.name || undefined,
|
|
44
|
+
content: data.content || undefined,
|
|
45
|
+
summary: data.summary || undefined,
|
|
46
|
+
published: data.published,
|
|
47
|
+
updated: data.updated || undefined,
|
|
48
|
+
author: data.author || undefined,
|
|
49
|
+
category: data.category || [],
|
|
50
|
+
photo: data.photo || [],
|
|
51
|
+
video: data.video || [],
|
|
52
|
+
audio: data.audio || [],
|
|
53
|
+
// Interaction types
|
|
54
|
+
"like-of": data.likeOf || [],
|
|
55
|
+
"repost-of": data.repostOf || [],
|
|
56
|
+
"bookmark-of": data.bookmarkOf || [],
|
|
57
|
+
"in-reply-to": data.inReplyTo || [],
|
|
58
|
+
// Internal properties (prefixed with _)
|
|
59
|
+
_id: data._id,
|
|
60
|
+
_is_read: data._is_read || false,
|
|
61
|
+
_source: source,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Create a jf2 Card (author/person)
|
|
67
|
+
* @param {object} data - Author data
|
|
68
|
+
* @returns {object} jf2 Card object
|
|
69
|
+
*/
|
|
70
|
+
export function createJf2Card(data) {
|
|
71
|
+
if (!data) return;
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
type: "card",
|
|
75
|
+
name: data.name || undefined,
|
|
76
|
+
url: data.url || undefined,
|
|
77
|
+
photo: data.photo || undefined,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Create a jf2 Content object
|
|
83
|
+
* @param {string} text - Plain text content
|
|
84
|
+
* @param {string} html - HTML content
|
|
85
|
+
* @returns {object|undefined} jf2 Content object
|
|
86
|
+
*/
|
|
87
|
+
export function createJf2Content(text, html) {
|
|
88
|
+
if (!text && !html) return;
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
text: text || stripHtml(html),
|
|
92
|
+
html: html || undefined,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Strip HTML tags from string
|
|
98
|
+
* @param {string} html - HTML string
|
|
99
|
+
* @returns {string} Plain text
|
|
100
|
+
*/
|
|
101
|
+
export function stripHtml(html) {
|
|
102
|
+
if (!html) return "";
|
|
103
|
+
return html.replaceAll(/<[^>]*>/g, "").trim();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Create a jf2 Feed response
|
|
108
|
+
* @param {object} options - Feed options
|
|
109
|
+
* @param {Array} options.items - Array of jf2 items
|
|
110
|
+
* @param {object} options.paging - Pagination cursors
|
|
111
|
+
* @returns {object} jf2 Feed object
|
|
112
|
+
*/
|
|
113
|
+
export function createJf2Feed({ items, paging }) {
|
|
114
|
+
const feed = {
|
|
115
|
+
items: items || [],
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
if (paging) {
|
|
119
|
+
feed.paging = {};
|
|
120
|
+
if (paging.before) feed.paging.before = paging.before;
|
|
121
|
+
if (paging.after) feed.paging.after = paging.after;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return feed;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Create a Channel response object
|
|
129
|
+
* @param {object} channel - Channel data
|
|
130
|
+
* @param {number} unreadCount - Number of unread items
|
|
131
|
+
* @returns {object} Channel object for API response
|
|
132
|
+
*/
|
|
133
|
+
export function createChannelResponse(channel, unreadCount = 0) {
|
|
134
|
+
return {
|
|
135
|
+
uid: channel.uid,
|
|
136
|
+
name: channel.name,
|
|
137
|
+
unread: unreadCount > 0 ? unreadCount : false,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Create a Feed response object
|
|
143
|
+
* @param {object} feed - Feed data
|
|
144
|
+
* @returns {object} Feed object for API response
|
|
145
|
+
*/
|
|
146
|
+
export function createFeedResponse(feed) {
|
|
147
|
+
return {
|
|
148
|
+
type: "feed",
|
|
149
|
+
url: feed.url,
|
|
150
|
+
name: feed.title || undefined,
|
|
151
|
+
photo: feed.photo || undefined,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Detect interaction type from item properties
|
|
157
|
+
* @param {object} item - jf2 item
|
|
158
|
+
* @returns {string|undefined} Interaction type
|
|
159
|
+
*/
|
|
160
|
+
export function detectInteractionType(item) {
|
|
161
|
+
if (item["like-of"]?.length > 0 || item.likeOf?.length > 0) return "like";
|
|
162
|
+
if (item["repost-of"]?.length > 0 || item.repostOf?.length > 0)
|
|
163
|
+
return "repost";
|
|
164
|
+
if (item["bookmark-of"]?.length > 0 || item.bookmarkOf?.length > 0)
|
|
165
|
+
return "bookmark";
|
|
166
|
+
if (item["in-reply-to"]?.length > 0 || item.inReplyTo?.length > 0)
|
|
167
|
+
return "reply";
|
|
168
|
+
if (item.checkin) return "checkin";
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cursor-based pagination utilities for Microsub
|
|
3
|
+
* @module utils/pagination
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { ObjectId } from "mongodb";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Encode a cursor from timestamp and ID
|
|
10
|
+
* @param {Date} timestamp - Item timestamp
|
|
11
|
+
* @param {string} id - Item ID
|
|
12
|
+
* @returns {string} Base64-encoded cursor
|
|
13
|
+
*/
|
|
14
|
+
export function encodeCursor(timestamp, id) {
|
|
15
|
+
const data = {
|
|
16
|
+
t: timestamp instanceof Date ? timestamp.toISOString() : timestamp,
|
|
17
|
+
i: id.toString(),
|
|
18
|
+
};
|
|
19
|
+
return Buffer.from(JSON.stringify(data)).toString("base64url");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Decode a cursor string
|
|
24
|
+
* @param {string} cursor - Base64-encoded cursor
|
|
25
|
+
* @returns {object|null} Decoded cursor with timestamp and id
|
|
26
|
+
*/
|
|
27
|
+
export function decodeCursor(cursor) {
|
|
28
|
+
if (!cursor) return;
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const decoded = Buffer.from(cursor, "base64url").toString("utf8");
|
|
32
|
+
const data = JSON.parse(decoded);
|
|
33
|
+
return {
|
|
34
|
+
timestamp: new Date(data.t),
|
|
35
|
+
id: data.i,
|
|
36
|
+
};
|
|
37
|
+
} catch {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Build MongoDB query for cursor-based pagination
|
|
44
|
+
* @param {object} options - Pagination options
|
|
45
|
+
* @param {string} [options.before] - Before cursor
|
|
46
|
+
* @param {string} [options.after] - After cursor
|
|
47
|
+
* @param {object} [options.baseQuery] - Base query to extend
|
|
48
|
+
* @returns {object} MongoDB query object
|
|
49
|
+
*/
|
|
50
|
+
export function buildPaginationQuery({ before, after, baseQuery = {} }) {
|
|
51
|
+
const query = { ...baseQuery };
|
|
52
|
+
|
|
53
|
+
if (before) {
|
|
54
|
+
const cursor = decodeCursor(before);
|
|
55
|
+
if (cursor) {
|
|
56
|
+
// Items newer than cursor (for scrolling up)
|
|
57
|
+
query.$or = [
|
|
58
|
+
{ published: { $gt: cursor.timestamp } },
|
|
59
|
+
{
|
|
60
|
+
published: cursor.timestamp,
|
|
61
|
+
_id: { $gt: new ObjectId(cursor.id) },
|
|
62
|
+
},
|
|
63
|
+
];
|
|
64
|
+
}
|
|
65
|
+
} else if (after) {
|
|
66
|
+
const cursor = decodeCursor(after);
|
|
67
|
+
if (cursor) {
|
|
68
|
+
// Items older than cursor (for scrolling down)
|
|
69
|
+
query.$or = [
|
|
70
|
+
{ published: { $lt: cursor.timestamp } },
|
|
71
|
+
{
|
|
72
|
+
published: cursor.timestamp,
|
|
73
|
+
_id: { $lt: new ObjectId(cursor.id) },
|
|
74
|
+
},
|
|
75
|
+
];
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return query;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Build sort options for cursor pagination
|
|
84
|
+
* @param {string} [before] - Before cursor (ascending order)
|
|
85
|
+
* @returns {object} MongoDB sort object
|
|
86
|
+
*/
|
|
87
|
+
export function buildPaginationSort(before) {
|
|
88
|
+
// When using 'before', we fetch newer items, so sort ascending then reverse
|
|
89
|
+
// Otherwise, sort descending (newest first)
|
|
90
|
+
if (before) {
|
|
91
|
+
return { published: 1, _id: 1 };
|
|
92
|
+
}
|
|
93
|
+
return { published: -1, _id: -1 };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Generate pagination cursors from items
|
|
98
|
+
* @param {Array} items - Array of items
|
|
99
|
+
* @param {number} limit - Items per page
|
|
100
|
+
* @param {boolean} hasMore - Whether more items exist
|
|
101
|
+
* @param {string} [before] - Original before cursor
|
|
102
|
+
* @returns {object} Pagination object with before/after cursors
|
|
103
|
+
*/
|
|
104
|
+
export function generatePagingCursors(items, limit, hasMore, before) {
|
|
105
|
+
if (!items || items.length === 0) {
|
|
106
|
+
return {};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const paging = {};
|
|
110
|
+
|
|
111
|
+
// If we fetched with 'before', results are in ascending order
|
|
112
|
+
// Reverse them and set cursors accordingly
|
|
113
|
+
if (before) {
|
|
114
|
+
items.reverse();
|
|
115
|
+
// There are older items (the direction we came from)
|
|
116
|
+
paging.after = encodeCursor(items.at(-1).published, items.at(-1)._id);
|
|
117
|
+
if (hasMore) {
|
|
118
|
+
// There are newer items ahead
|
|
119
|
+
paging.before = encodeCursor(items[0].published, items[0]._id);
|
|
120
|
+
}
|
|
121
|
+
} else {
|
|
122
|
+
// Normal descending order
|
|
123
|
+
if (hasMore) {
|
|
124
|
+
// There are older items
|
|
125
|
+
paging.after = encodeCursor(items.at(-1).published, items.at(-1)._id);
|
|
126
|
+
}
|
|
127
|
+
// If we have items, there might be newer ones
|
|
128
|
+
if (items.length > 0) {
|
|
129
|
+
paging.before = encodeCursor(items[0].published, items[0]._id);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return paging;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Default pagination limit
|
|
138
|
+
*/
|
|
139
|
+
export const DEFAULT_LIMIT = 20;
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Maximum pagination limit
|
|
143
|
+
*/
|
|
144
|
+
export const MAX_LIMIT = 100;
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Parse and validate limit parameter
|
|
148
|
+
* @param {string|number} limit - Requested limit
|
|
149
|
+
* @returns {number} Validated limit
|
|
150
|
+
*/
|
|
151
|
+
export function parseLimit(limit) {
|
|
152
|
+
const parsed = Number.parseInt(limit, 10);
|
|
153
|
+
if (Number.isNaN(parsed) || parsed < 1) {
|
|
154
|
+
return DEFAULT_LIMIT;
|
|
155
|
+
}
|
|
156
|
+
return Math.min(parsed, MAX_LIMIT);
|
|
157
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Input validation utilities for Microsub
|
|
3
|
+
* @module utils/validation
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { IndiekitError } from "@indiekit/error";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Valid Microsub actions
|
|
10
|
+
*/
|
|
11
|
+
export const VALID_ACTIONS = [
|
|
12
|
+
"channels",
|
|
13
|
+
"timeline",
|
|
14
|
+
"follow",
|
|
15
|
+
"unfollow",
|
|
16
|
+
"search",
|
|
17
|
+
"preview",
|
|
18
|
+
"mute",
|
|
19
|
+
"unmute",
|
|
20
|
+
"block",
|
|
21
|
+
"unblock",
|
|
22
|
+
"events",
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Valid channel methods
|
|
27
|
+
*/
|
|
28
|
+
export const VALID_CHANNEL_METHODS = ["delete", "order"];
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Valid timeline methods
|
|
32
|
+
*/
|
|
33
|
+
export const VALID_TIMELINE_METHODS = ["mark_read", "mark_unread", "remove"];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Valid exclude types for channel filtering
|
|
37
|
+
*/
|
|
38
|
+
export const VALID_EXCLUDE_TYPES = [
|
|
39
|
+
"like",
|
|
40
|
+
"repost",
|
|
41
|
+
"bookmark",
|
|
42
|
+
"reply",
|
|
43
|
+
"checkin",
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Validate action parameter
|
|
48
|
+
* @param {string} action - Action to validate
|
|
49
|
+
* @throws {IndiekitError} If action is invalid
|
|
50
|
+
*/
|
|
51
|
+
export function validateAction(action) {
|
|
52
|
+
if (!action) {
|
|
53
|
+
throw new IndiekitError("Missing required parameter: action", {
|
|
54
|
+
status: 400,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (!VALID_ACTIONS.includes(action)) {
|
|
59
|
+
throw new IndiekitError(`Invalid action: ${action}`, {
|
|
60
|
+
status: 400,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Validate channel UID
|
|
67
|
+
* @param {string} channel - Channel UID to validate
|
|
68
|
+
* @param {boolean} [required] - Whether channel is required
|
|
69
|
+
* @throws {IndiekitError} If channel is invalid
|
|
70
|
+
*/
|
|
71
|
+
export function validateChannel(channel, required = true) {
|
|
72
|
+
if (required && !channel) {
|
|
73
|
+
throw new IndiekitError("Missing required parameter: channel", {
|
|
74
|
+
status: 400,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (channel && typeof channel !== "string") {
|
|
79
|
+
throw new IndiekitError("Invalid channel parameter", {
|
|
80
|
+
status: 400,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Validate URL parameter
|
|
87
|
+
* @param {string} url - URL to validate
|
|
88
|
+
* @param {string} [paramName] - Parameter name for error message
|
|
89
|
+
* @param parameterName
|
|
90
|
+
* @throws {IndiekitError} If URL is invalid
|
|
91
|
+
*/
|
|
92
|
+
export function validateUrl(url, parameterName = "url") {
|
|
93
|
+
if (!url) {
|
|
94
|
+
throw new IndiekitError(`Missing required parameter: ${parameterName}`, {
|
|
95
|
+
status: 400,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
new URL(url);
|
|
101
|
+
} catch {
|
|
102
|
+
throw new IndiekitError(`Invalid URL: ${url}`, {
|
|
103
|
+
status: 400,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Validate entry/entries parameter
|
|
110
|
+
* @param {string|Array} entry - Entry ID(s) to validate
|
|
111
|
+
* @returns {Array} Array of entry IDs
|
|
112
|
+
* @throws {IndiekitError} If entry is invalid
|
|
113
|
+
*/
|
|
114
|
+
export function validateEntries(entry) {
|
|
115
|
+
if (!entry) {
|
|
116
|
+
throw new IndiekitError("Missing required parameter: entry", {
|
|
117
|
+
status: 400,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Normalize to array
|
|
122
|
+
const entries = Array.isArray(entry) ? entry : [entry];
|
|
123
|
+
|
|
124
|
+
if (entries.length === 0) {
|
|
125
|
+
throw new IndiekitError("Entry parameter cannot be empty", {
|
|
126
|
+
status: 400,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return entries;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Validate channel name
|
|
135
|
+
* @param {string} name - Channel name to validate
|
|
136
|
+
* @throws {IndiekitError} If name is invalid
|
|
137
|
+
*/
|
|
138
|
+
export function validateChannelName(name) {
|
|
139
|
+
if (!name || typeof name !== "string") {
|
|
140
|
+
throw new IndiekitError("Missing required parameter: name", {
|
|
141
|
+
status: 400,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (name.length > 100) {
|
|
146
|
+
throw new IndiekitError("Channel name must be 100 characters or less", {
|
|
147
|
+
status: 400,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Validate exclude types array
|
|
154
|
+
* @param {Array} types - Array of exclude types
|
|
155
|
+
* @returns {Array} Validated exclude types
|
|
156
|
+
*/
|
|
157
|
+
export function validateExcludeTypes(types) {
|
|
158
|
+
if (!types || !Array.isArray(types)) {
|
|
159
|
+
return [];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return types.filter((type) => VALID_EXCLUDE_TYPES.includes(type));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Validate regex pattern
|
|
167
|
+
* @param {string} pattern - Regex pattern to validate
|
|
168
|
+
* @returns {string|null} Valid pattern or null
|
|
169
|
+
*/
|
|
170
|
+
export function validateExcludeRegex(pattern) {
|
|
171
|
+
if (!pattern || typeof pattern !== "string") {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
new RegExp(pattern);
|
|
177
|
+
return pattern;
|
|
178
|
+
} catch {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Parse array parameter from request
|
|
185
|
+
* Handles both array[] and array[0], array[1] formats
|
|
186
|
+
* @param {object} body - Request body
|
|
187
|
+
* @param {string} paramName - Parameter name
|
|
188
|
+
* @param parameterName
|
|
189
|
+
* @returns {Array} Parsed array
|
|
190
|
+
*/
|
|
191
|
+
export function parseArrayParameter(body, parameterName) {
|
|
192
|
+
// Direct array
|
|
193
|
+
if (Array.isArray(body[parameterName])) {
|
|
194
|
+
return body[parameterName];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Single value
|
|
198
|
+
if (body[parameterName]) {
|
|
199
|
+
return [body[parameterName]];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Indexed values (param[0], param[1], ...)
|
|
203
|
+
const result = [];
|
|
204
|
+
let index = 0;
|
|
205
|
+
while (body[`${parameterName}[${index}]`] !== undefined) {
|
|
206
|
+
result.push(body[`${parameterName}[${index}]`]);
|
|
207
|
+
index++;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Array notation (param[])
|
|
211
|
+
if (body[`${parameterName}[]`]) {
|
|
212
|
+
const values = body[`${parameterName}[]`];
|
|
213
|
+
return Array.isArray(values) ? values : [values];
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return result;
|
|
217
|
+
}
|