@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,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSub callback handler
|
|
3
|
+
* @module websub/handler
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { parseFeed } from "../feeds/parser.js";
|
|
7
|
+
import { processFeed } from "../polling/processor.js";
|
|
8
|
+
import { getFeedBySubscriptionId, updateFeedWebsub } from "../storage/feeds.js";
|
|
9
|
+
|
|
10
|
+
import { verifySignature } from "./subscriber.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Verify WebSub subscription
|
|
14
|
+
* GET /microsub/websub/:id
|
|
15
|
+
* @param {object} request - Express request
|
|
16
|
+
* @param {object} response - Express response
|
|
17
|
+
*/
|
|
18
|
+
export async function verify(request, response) {
|
|
19
|
+
const { id } = request.params;
|
|
20
|
+
const {
|
|
21
|
+
"hub.topic": topic,
|
|
22
|
+
"hub.challenge": challenge,
|
|
23
|
+
"hub.lease_seconds": leaseSeconds,
|
|
24
|
+
} = request.query;
|
|
25
|
+
|
|
26
|
+
if (!challenge) {
|
|
27
|
+
return response.status(400).send("Missing hub.challenge");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const { application } = request.app.locals;
|
|
31
|
+
const feed = await getFeedBySubscriptionId(application, id);
|
|
32
|
+
|
|
33
|
+
if (!feed) {
|
|
34
|
+
return response.status(404).send("Subscription not found");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Verify topic matches (allow both feed URL and topic URL)
|
|
38
|
+
const expectedTopic = feed.websub?.topic || feed.url;
|
|
39
|
+
if (topic !== feed.url && topic !== expectedTopic) {
|
|
40
|
+
return response.status(400).send("Topic mismatch");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Update lease seconds if provided
|
|
44
|
+
if (leaseSeconds) {
|
|
45
|
+
const seconds = Number.parseInt(leaseSeconds, 10);
|
|
46
|
+
if (seconds > 0) {
|
|
47
|
+
await updateFeedWebsub(application, id, {
|
|
48
|
+
hub: feed.websub?.hub,
|
|
49
|
+
topic: expectedTopic,
|
|
50
|
+
leaseSeconds: seconds,
|
|
51
|
+
secret: feed.websub?.secret,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Mark subscription as active (not pending)
|
|
57
|
+
if (feed.websub?.pending) {
|
|
58
|
+
await updateFeedWebsub(application, id, {
|
|
59
|
+
hub: feed.websub?.hub,
|
|
60
|
+
topic: expectedTopic,
|
|
61
|
+
secret: feed.websub?.secret,
|
|
62
|
+
leaseSeconds: feed.websub?.leaseSeconds,
|
|
63
|
+
pending: false,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
console.log(`[Microsub] WebSub subscription verified for ${feed.url}`);
|
|
68
|
+
|
|
69
|
+
// Return challenge to verify subscription
|
|
70
|
+
response.type("text/plain").send(challenge);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Receive WebSub notification
|
|
75
|
+
* POST /microsub/websub/:id
|
|
76
|
+
* @param {object} request - Express request
|
|
77
|
+
* @param {object} response - Express response
|
|
78
|
+
*/
|
|
79
|
+
export async function receive(request, response) {
|
|
80
|
+
const { id } = request.params;
|
|
81
|
+
const { application } = request.app.locals;
|
|
82
|
+
|
|
83
|
+
const feed = await getFeedBySubscriptionId(application, id);
|
|
84
|
+
if (!feed) {
|
|
85
|
+
return response.status(404).send("Subscription not found");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Verify X-Hub-Signature if we have a secret
|
|
89
|
+
if (feed.websub?.secret) {
|
|
90
|
+
const signature =
|
|
91
|
+
request.headers["x-hub-signature-256"] ||
|
|
92
|
+
request.headers["x-hub-signature"];
|
|
93
|
+
|
|
94
|
+
if (!signature) {
|
|
95
|
+
return response.status(401).send("Missing signature");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Get raw body for signature verification
|
|
99
|
+
const rawBody =
|
|
100
|
+
typeof request.body === "string"
|
|
101
|
+
? request.body
|
|
102
|
+
: JSON.stringify(request.body);
|
|
103
|
+
|
|
104
|
+
if (!verifySignature(signature, rawBody, feed.websub.secret)) {
|
|
105
|
+
console.warn(`[Microsub] Invalid WebSub signature for ${feed.url}`);
|
|
106
|
+
return response.status(401).send("Invalid signature");
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Acknowledge receipt immediately
|
|
111
|
+
response.status(200).send("OK");
|
|
112
|
+
|
|
113
|
+
// Process pushed content in background
|
|
114
|
+
setImmediate(async () => {
|
|
115
|
+
try {
|
|
116
|
+
await processWebsubContent(
|
|
117
|
+
application,
|
|
118
|
+
feed,
|
|
119
|
+
request.headers["content-type"],
|
|
120
|
+
request.body,
|
|
121
|
+
);
|
|
122
|
+
} catch (error) {
|
|
123
|
+
console.error(
|
|
124
|
+
`[Microsub] Error processing WebSub content for ${feed.url}: ${error.message}`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Process WebSub pushed content
|
|
132
|
+
* @param {object} application - Indiekit application
|
|
133
|
+
* @param {object} feed - Feed document
|
|
134
|
+
* @param {string} contentType - Content-Type header
|
|
135
|
+
* @param {string|object} body - Request body
|
|
136
|
+
* @returns {Promise<void>}
|
|
137
|
+
*/
|
|
138
|
+
async function processWebsubContent(application, feed, contentType, body) {
|
|
139
|
+
// Convert body to string if needed
|
|
140
|
+
const content = typeof body === "string" ? body : JSON.stringify(body);
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
// Parse the pushed content
|
|
144
|
+
const parsed = await parseFeed(content, feed.url, { contentType });
|
|
145
|
+
|
|
146
|
+
console.log(
|
|
147
|
+
`[Microsub] Processing ${parsed.items.length} items from WebSub push for ${feed.url}`,
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
// Process like a normal feed fetch but with pre-parsed content
|
|
151
|
+
// This reuses the existing feed processing logic
|
|
152
|
+
await processFeed(application, {
|
|
153
|
+
...feed,
|
|
154
|
+
_websubContent: parsed,
|
|
155
|
+
});
|
|
156
|
+
} catch (error) {
|
|
157
|
+
console.error(
|
|
158
|
+
`[Microsub] Failed to parse WebSub content for ${feed.url}: ${error.message}`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export const websubHandler = { verify, receive };
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSub subscriber
|
|
3
|
+
* @module websub/subscriber
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import crypto from "node:crypto";
|
|
7
|
+
|
|
8
|
+
import { updateFeedWebsub } from "../storage/feeds.js";
|
|
9
|
+
|
|
10
|
+
const DEFAULT_LEASE_SECONDS = 86_400 * 7; // 7 days
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Subscribe to a WebSub hub
|
|
14
|
+
* @param {object} application - Indiekit application
|
|
15
|
+
* @param {object} feed - Feed document with websub.hub
|
|
16
|
+
* @param {string} callbackUrl - Callback URL for this subscription
|
|
17
|
+
* @returns {Promise<boolean>} Whether subscription was initiated
|
|
18
|
+
*/
|
|
19
|
+
export async function subscribe(application, feed, callbackUrl) {
|
|
20
|
+
if (!feed.websub?.hub) {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const topic = feed.websub.topic || feed.url;
|
|
25
|
+
const secret = generateSecret();
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const response = await fetch(feed.websub.hub, {
|
|
29
|
+
method: "POST",
|
|
30
|
+
headers: {
|
|
31
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
32
|
+
},
|
|
33
|
+
body: new URLSearchParams({
|
|
34
|
+
"hub.mode": "subscribe",
|
|
35
|
+
"hub.topic": topic,
|
|
36
|
+
"hub.callback": callbackUrl,
|
|
37
|
+
"hub.secret": secret,
|
|
38
|
+
"hub.lease_seconds": String(DEFAULT_LEASE_SECONDS),
|
|
39
|
+
}),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// 202 Accepted means subscription is pending verification
|
|
43
|
+
// 204 No Content means subscription was immediately accepted
|
|
44
|
+
if (response.status === 202 || response.status === 204) {
|
|
45
|
+
// Store the secret for signature verification
|
|
46
|
+
await updateFeedWebsub(application, feed._id, {
|
|
47
|
+
hub: feed.websub.hub,
|
|
48
|
+
topic,
|
|
49
|
+
secret,
|
|
50
|
+
pending: true,
|
|
51
|
+
});
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
console.error(
|
|
56
|
+
`[Microsub] WebSub subscription failed: ${response.status} ${response.statusText}`,
|
|
57
|
+
);
|
|
58
|
+
return false;
|
|
59
|
+
} catch (error) {
|
|
60
|
+
console.error(`[Microsub] WebSub subscription error: ${error.message}`);
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Unsubscribe from a WebSub hub
|
|
67
|
+
* @param {object} application - Indiekit application
|
|
68
|
+
* @param {object} feed - Feed document with websub.hub
|
|
69
|
+
* @param {string} callbackUrl - Callback URL for this subscription
|
|
70
|
+
* @returns {Promise<boolean>} Whether unsubscription was initiated
|
|
71
|
+
*/
|
|
72
|
+
export async function unsubscribe(application, feed, callbackUrl) {
|
|
73
|
+
if (!feed.websub?.hub) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const topic = feed.websub.topic || feed.url;
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const response = await fetch(feed.websub.hub, {
|
|
81
|
+
method: "POST",
|
|
82
|
+
headers: {
|
|
83
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
84
|
+
},
|
|
85
|
+
body: new URLSearchParams({
|
|
86
|
+
"hub.mode": "unsubscribe",
|
|
87
|
+
"hub.topic": topic,
|
|
88
|
+
"hub.callback": callbackUrl,
|
|
89
|
+
}),
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
if (response.status === 202 || response.status === 204) {
|
|
93
|
+
// Clear WebSub data from feed
|
|
94
|
+
await updateFeedWebsub(application, feed._id, {
|
|
95
|
+
hub: feed.websub.hub,
|
|
96
|
+
topic,
|
|
97
|
+
secret: undefined,
|
|
98
|
+
leaseSeconds: undefined,
|
|
99
|
+
pending: false,
|
|
100
|
+
});
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return false;
|
|
105
|
+
} catch (error) {
|
|
106
|
+
console.error(`[Microsub] WebSub unsubscribe error: ${error.message}`);
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Generate a random secret for signature verification
|
|
113
|
+
* @returns {string} Random hex string
|
|
114
|
+
*/
|
|
115
|
+
function generateSecret() {
|
|
116
|
+
return crypto.randomBytes(32).toString("hex");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Verify WebSub signature
|
|
121
|
+
* @param {string} signature - X-Hub-Signature header value
|
|
122
|
+
* @param {Buffer|string} body - Request body
|
|
123
|
+
* @param {string} secret - Subscription secret
|
|
124
|
+
* @returns {boolean} Whether signature is valid
|
|
125
|
+
*/
|
|
126
|
+
export function verifySignature(signature, body, secret) {
|
|
127
|
+
if (!signature || !secret) {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Signature format: sha1=<hex> or sha256=<hex>
|
|
132
|
+
const [algorithm, hash] = signature.split("=");
|
|
133
|
+
if (!algorithm || !hash) {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Normalize algorithm name
|
|
138
|
+
const algo = algorithm.toLowerCase().replace("sha", "sha");
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
const expectedHash = crypto
|
|
142
|
+
.createHmac(algo, secret)
|
|
143
|
+
.update(body)
|
|
144
|
+
.digest("hex");
|
|
145
|
+
|
|
146
|
+
// Use timing-safe comparison
|
|
147
|
+
return crypto.timingSafeEqual(
|
|
148
|
+
Buffer.from(hash, "hex"),
|
|
149
|
+
Buffer.from(expectedHash, "hex"),
|
|
150
|
+
);
|
|
151
|
+
} catch {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Check if a WebSub subscription is about to expire
|
|
158
|
+
* @param {object} feed - Feed document
|
|
159
|
+
* @param {number} [thresholdSeconds] - Seconds before expiry to consider "expiring"
|
|
160
|
+
* @returns {boolean} Whether subscription is expiring soon
|
|
161
|
+
*/
|
|
162
|
+
export function isSubscriptionExpiring(feed, thresholdSeconds = 86_400) {
|
|
163
|
+
if (!feed.websub?.expiresAt) {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const expiresAt = new Date(feed.websub.expiresAt);
|
|
168
|
+
const threshold = new Date(Date.now() + thresholdSeconds * 1000);
|
|
169
|
+
|
|
170
|
+
return expiresAt <= threshold;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Get callback URL for a feed
|
|
175
|
+
* @param {string} baseUrl - Base URL of the Microsub endpoint
|
|
176
|
+
* @param {string} feedId - Feed ID
|
|
177
|
+
* @returns {string} Callback URL
|
|
178
|
+
*/
|
|
179
|
+
export function getCallbackUrl(baseUrl, feedId) {
|
|
180
|
+
return `${baseUrl}/microsub/websub/${feedId}`;
|
|
181
|
+
}
|
package/locales/en.json
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
{
|
|
2
|
+
"microsub": {
|
|
3
|
+
"reader": {
|
|
4
|
+
"title": "Reader",
|
|
5
|
+
"empty": "No items to display",
|
|
6
|
+
"markAllRead": "Mark all as read",
|
|
7
|
+
"newer": "Newer",
|
|
8
|
+
"older": "Older"
|
|
9
|
+
},
|
|
10
|
+
"channels": {
|
|
11
|
+
"title": "Channels",
|
|
12
|
+
"new": "New channel",
|
|
13
|
+
"create": "Create channel",
|
|
14
|
+
"delete": "Delete channel",
|
|
15
|
+
"settings": "Channel settings",
|
|
16
|
+
"empty": "No channels yet. Create one to get started.",
|
|
17
|
+
"notifications": "Notifications"
|
|
18
|
+
},
|
|
19
|
+
"timeline": {
|
|
20
|
+
"title": "Timeline",
|
|
21
|
+
"empty": "No items in this channel",
|
|
22
|
+
"markRead": "Mark as read",
|
|
23
|
+
"markUnread": "Mark as unread",
|
|
24
|
+
"remove": "Remove"
|
|
25
|
+
},
|
|
26
|
+
"feeds": {
|
|
27
|
+
"title": "Feeds",
|
|
28
|
+
"follow": "Follow",
|
|
29
|
+
"unfollow": "Unfollow",
|
|
30
|
+
"empty": "No feeds followed in this channel"
|
|
31
|
+
},
|
|
32
|
+
"item": {
|
|
33
|
+
"reply": "Reply",
|
|
34
|
+
"like": "Like",
|
|
35
|
+
"repost": "Repost",
|
|
36
|
+
"bookmark": "Bookmark",
|
|
37
|
+
"viewOriginal": "View original"
|
|
38
|
+
},
|
|
39
|
+
"compose": {
|
|
40
|
+
"title": "Compose",
|
|
41
|
+
"content": "What's on your mind?",
|
|
42
|
+
"submit": "Post",
|
|
43
|
+
"cancel": "Cancel",
|
|
44
|
+
"replyTo": "Replying to",
|
|
45
|
+
"likeOf": "Liking",
|
|
46
|
+
"repostOf": "Reposting"
|
|
47
|
+
},
|
|
48
|
+
"settings": {
|
|
49
|
+
"title": "%{channel} settings",
|
|
50
|
+
"excludeTypes": "Exclude interaction types",
|
|
51
|
+
"excludeTypesHelp": "Select types of posts to hide from this channel",
|
|
52
|
+
"excludeRegex": "Exclude pattern",
|
|
53
|
+
"excludeRegexHelp": "Regular expression to filter out matching content",
|
|
54
|
+
"save": "Save settings",
|
|
55
|
+
"types": {
|
|
56
|
+
"like": "Likes",
|
|
57
|
+
"repost": "Reposts",
|
|
58
|
+
"bookmark": "Bookmarks",
|
|
59
|
+
"reply": "Replies",
|
|
60
|
+
"checkin": "Check-ins"
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
"search": {
|
|
64
|
+
"title": "Search",
|
|
65
|
+
"placeholder": "Enter URL or search term",
|
|
66
|
+
"submit": "Search",
|
|
67
|
+
"noResults": "No results found"
|
|
68
|
+
},
|
|
69
|
+
"preview": {
|
|
70
|
+
"title": "Preview",
|
|
71
|
+
"subscribe": "Subscribe to this feed"
|
|
72
|
+
},
|
|
73
|
+
"error": {
|
|
74
|
+
"channelNotFound": "Channel not found",
|
|
75
|
+
"feedNotFound": "Feed not found",
|
|
76
|
+
"invalidUrl": "Invalid URL",
|
|
77
|
+
"invalidAction": "Invalid action"
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rmdes/indiekit-endpoint-microsub",
|
|
3
|
+
"version": "1.0.0-beta.1",
|
|
4
|
+
"description": "Microsub endpoint for Indiekit. Enables subscribing to feeds and reading content using the Microsub protocol.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"indiekit",
|
|
7
|
+
"indiekit-plugin",
|
|
8
|
+
"indieweb",
|
|
9
|
+
"microsub",
|
|
10
|
+
"reader",
|
|
11
|
+
"social-reader"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://github.com/rmdes/indiekit",
|
|
14
|
+
"author": {
|
|
15
|
+
"name": "Ricardo Mendes",
|
|
16
|
+
"url": "https://rmendes.net"
|
|
17
|
+
},
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=20"
|
|
21
|
+
},
|
|
22
|
+
"type": "module",
|
|
23
|
+
"main": "index.js",
|
|
24
|
+
"files": [
|
|
25
|
+
"lib",
|
|
26
|
+
"locales",
|
|
27
|
+
"views",
|
|
28
|
+
"index.js"
|
|
29
|
+
],
|
|
30
|
+
"bugs": {
|
|
31
|
+
"url": "https://github.com/rmdes/indiekit/issues"
|
|
32
|
+
},
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "https://github.com/rmdes/indiekit.git",
|
|
36
|
+
"directory": "packages/endpoint-microsub"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@indiekit/error": "^1.0.0-beta.25",
|
|
40
|
+
"@indiekit/frontend": "^1.0.0-beta.25",
|
|
41
|
+
"@indiekit/util": "^1.0.0-beta.25",
|
|
42
|
+
"debug": "^4.3.2",
|
|
43
|
+
"express": "^5.0.0",
|
|
44
|
+
"feedparser": "^2.2.10",
|
|
45
|
+
"htmlparser2": "^9.0.0",
|
|
46
|
+
"ioredis": "^5.3.0",
|
|
47
|
+
"luxon": "^3.4.0",
|
|
48
|
+
"microformats-parser": "^2.0.0",
|
|
49
|
+
"sanitize-html": "^2.11.0"
|
|
50
|
+
},
|
|
51
|
+
"publishConfig": {
|
|
52
|
+
"access": "public"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{% extends "document.njk" %}
|
|
2
|
+
|
|
3
|
+
{% block content %}
|
|
4
|
+
<div class="channel-new">
|
|
5
|
+
<a href="{{ request.baseUrl }}/channels" class="back-link">
|
|
6
|
+
{{ icon("arrow-left") }} {{ __("microsub.channels.title") }}
|
|
7
|
+
</a>
|
|
8
|
+
|
|
9
|
+
<form method="post" action="{{ request.baseUrl }}/channels/new">
|
|
10
|
+
{{ field({
|
|
11
|
+
label: {
|
|
12
|
+
text: __("microsub.channels.new")
|
|
13
|
+
},
|
|
14
|
+
input: {
|
|
15
|
+
id: "name",
|
|
16
|
+
name: "name",
|
|
17
|
+
required: true,
|
|
18
|
+
autocomplete: "off",
|
|
19
|
+
autofocus: true
|
|
20
|
+
}
|
|
21
|
+
}) }}
|
|
22
|
+
|
|
23
|
+
<div class="button-group">
|
|
24
|
+
{{ button({
|
|
25
|
+
text: __("microsub.channels.create")
|
|
26
|
+
}) }}
|
|
27
|
+
<a href="{{ request.baseUrl }}/channels" class="button button--secondary">
|
|
28
|
+
{{ __("Cancel") }}
|
|
29
|
+
</a>
|
|
30
|
+
</div>
|
|
31
|
+
</form>
|
|
32
|
+
</div>
|
|
33
|
+
{% endblock %}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{% extends "document.njk" %}
|
|
2
|
+
|
|
3
|
+
{% block content %}
|
|
4
|
+
<div class="channel">
|
|
5
|
+
<header class="channel__header">
|
|
6
|
+
<a href="{{ request.baseUrl }}/channels" class="back-link">
|
|
7
|
+
{{ icon("arrow-left") }} {{ __("microsub.channels.title") }}
|
|
8
|
+
</a>
|
|
9
|
+
<div class="channel__actions">
|
|
10
|
+
<a href="{{ request.baseUrl }}/channels/{{ channel.uid }}/settings" class="button button--secondary button--small">
|
|
11
|
+
{{ icon("settings") }} {{ __("microsub.channels.settings") }}
|
|
12
|
+
</a>
|
|
13
|
+
</div>
|
|
14
|
+
</header>
|
|
15
|
+
|
|
16
|
+
{% if items.length > 0 %}
|
|
17
|
+
<div class="timeline">
|
|
18
|
+
{% for item in items %}
|
|
19
|
+
{% include "partials/item-card.njk" %}
|
|
20
|
+
{% endfor %}
|
|
21
|
+
</div>
|
|
22
|
+
|
|
23
|
+
{% if paging %}
|
|
24
|
+
<nav class="timeline__paging" aria-label="Pagination">
|
|
25
|
+
{% if paging.before %}
|
|
26
|
+
<a href="?before={{ paging.before }}" class="button button--secondary">
|
|
27
|
+
{{ icon("arrow-left") }} {{ __("microsub.reader.newer") }}
|
|
28
|
+
</a>
|
|
29
|
+
{% endif %}
|
|
30
|
+
{% if paging.after %}
|
|
31
|
+
<a href="?after={{ paging.after }}" class="button button--secondary">
|
|
32
|
+
{{ __("microsub.reader.older") }} {{ icon("arrow-right") }}
|
|
33
|
+
</a>
|
|
34
|
+
{% endif %}
|
|
35
|
+
</nav>
|
|
36
|
+
{% endif %}
|
|
37
|
+
{% else %}
|
|
38
|
+
{{ prose({ text: __("microsub.timeline.empty") }) }}
|
|
39
|
+
{% endif %}
|
|
40
|
+
</div>
|
|
41
|
+
{% endblock %}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{% extends "document.njk" %}
|
|
2
|
+
|
|
3
|
+
{% block content %}
|
|
4
|
+
<div class="compose">
|
|
5
|
+
<a href="{{ request.headers.referer or request.baseUrl + '/channels' }}" class="back-link">
|
|
6
|
+
{{ icon("arrow-left") }} {{ __("Back") }}
|
|
7
|
+
</a>
|
|
8
|
+
|
|
9
|
+
{% if replyTo %}
|
|
10
|
+
<div class="compose__context">
|
|
11
|
+
{{ icon("reply") }} {{ __("microsub.compose.replyTo") }}: <a href="{{ replyTo }}">{{ replyTo }}</a>
|
|
12
|
+
</div>
|
|
13
|
+
{% endif %}
|
|
14
|
+
|
|
15
|
+
{% if likeOf %}
|
|
16
|
+
<div class="compose__context">
|
|
17
|
+
{{ icon("heart") }} {{ __("microsub.compose.likeOf") }}: <a href="{{ likeOf }}">{{ likeOf }}</a>
|
|
18
|
+
</div>
|
|
19
|
+
{% endif %}
|
|
20
|
+
|
|
21
|
+
{% if repostOf %}
|
|
22
|
+
<div class="compose__context">
|
|
23
|
+
{{ icon("repost") }} {{ __("microsub.compose.repostOf") }}: <a href="{{ repostOf }}">{{ repostOf }}</a>
|
|
24
|
+
</div>
|
|
25
|
+
{% endif %}
|
|
26
|
+
|
|
27
|
+
<form method="post" action="{{ request.baseUrl }}/compose">
|
|
28
|
+
{% if replyTo %}
|
|
29
|
+
<input type="hidden" name="in-reply-to" value="{{ replyTo }}">
|
|
30
|
+
{% endif %}
|
|
31
|
+
{% if likeOf %}
|
|
32
|
+
<input type="hidden" name="like-of" value="{{ likeOf }}">
|
|
33
|
+
{% endif %}
|
|
34
|
+
{% if repostOf %}
|
|
35
|
+
<input type="hidden" name="repost-of" value="{{ repostOf }}">
|
|
36
|
+
{% endif %}
|
|
37
|
+
|
|
38
|
+
{% if not likeOf and not repostOf %}
|
|
39
|
+
{{ textarea({
|
|
40
|
+
label: {
|
|
41
|
+
text: __("microsub.compose.content"),
|
|
42
|
+
classes: "label--visuallyhidden"
|
|
43
|
+
},
|
|
44
|
+
id: "content",
|
|
45
|
+
name: "content",
|
|
46
|
+
rows: 5,
|
|
47
|
+
autofocus: true
|
|
48
|
+
}) }}
|
|
49
|
+
{% endif %}
|
|
50
|
+
|
|
51
|
+
<div class="button-group">
|
|
52
|
+
{{ button({
|
|
53
|
+
text: __("microsub.compose.submit")
|
|
54
|
+
}) }}
|
|
55
|
+
<a href="{{ request.headers.referer or request.baseUrl + '/channels' }}" class="button button--secondary">
|
|
56
|
+
{{ __("microsub.compose.cancel") }}
|
|
57
|
+
</a>
|
|
58
|
+
</div>
|
|
59
|
+
</form>
|
|
60
|
+
</div>
|
|
61
|
+
{% endblock %}
|