@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
package/README.md
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# @indiekit/endpoint-microsub
|
|
2
|
+
|
|
3
|
+
Microsub endpoint for Indiekit. Enables subscribing to feeds and reading content using the [Microsub protocol](https://indieweb.org/Microsub).
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Full Microsub API** - Channels, timeline, follow/unfollow, search, preview, mute, block
|
|
8
|
+
- **Multiple feed formats** - RSS 1.0/2.0, Atom, JSON Feed, h-feed (Microformats)
|
|
9
|
+
- **External client support** - Works with Monocle, Together, IndiePass
|
|
10
|
+
- **Built-in reader UI** - Social reading experience in Indiekit admin
|
|
11
|
+
- **Real-time updates** - Server-Sent Events (SSE) and WebSub integration
|
|
12
|
+
- **Adaptive polling** - Tier-based feed fetching inspired by Ekster
|
|
13
|
+
- **Direct webmention receiving** - Notifications channel for mentions
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
`npm install @indiekit/endpoint-microsub`
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
Add `@indiekit/endpoint-microsub` to the list of plugins in your configuration:
|
|
22
|
+
|
|
23
|
+
```js
|
|
24
|
+
export default {
|
|
25
|
+
plugins: ["@indiekit/endpoint-microsub"],
|
|
26
|
+
};
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Options
|
|
30
|
+
|
|
31
|
+
| Option | Type | Description |
|
|
32
|
+
| :----------- | :------- | :--------------------------------------------------------------- |
|
|
33
|
+
| `mountPath` | `string` | Path to mount Microsub API. _Optional_, defaults to `/microsub`. |
|
|
34
|
+
| `readerPath` | `string` | Path to mount reader UI. _Optional_, defaults to `/reader`. |
|
|
35
|
+
|
|
36
|
+
## Endpoints
|
|
37
|
+
|
|
38
|
+
### Microsub API
|
|
39
|
+
|
|
40
|
+
The main Microsub endpoint is mounted at `/microsub` (configurable).
|
|
41
|
+
|
|
42
|
+
**Discovery**: Add this to your site's `<head>`:
|
|
43
|
+
|
|
44
|
+
```html
|
|
45
|
+
<link rel="microsub" href="https://yoursite.com/microsub" />
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Supported actions
|
|
49
|
+
|
|
50
|
+
| Action | GET | POST | Description |
|
|
51
|
+
| :--------- | :-- | :--- | :--------------------------------------------- |
|
|
52
|
+
| `channels` | ✓ | ✓ | List, create, update, delete, reorder channels |
|
|
53
|
+
| `timeline` | ✓ | ✓ | Get timeline, mark read/unread, remove items |
|
|
54
|
+
| `follow` | ✓ | ✓ | List followed feeds, subscribe to new feeds |
|
|
55
|
+
| `unfollow` | - | ✓ | Unsubscribe from feeds |
|
|
56
|
+
| `search` | ✓ | ✓ | Feed discovery and full-text search |
|
|
57
|
+
| `preview` | ✓ | ✓ | Preview feed before subscribing |
|
|
58
|
+
| `mute` | ✓ | ✓ | List muted URLs, mute/unmute |
|
|
59
|
+
| `block` | ✓ | ✓ | List blocked URLs, block/unblock |
|
|
60
|
+
| `events` | ✓ | - | Server-Sent Events stream |
|
|
61
|
+
|
|
62
|
+
### Reader UI
|
|
63
|
+
|
|
64
|
+
The built-in reader is mounted at `/reader` (configurable) and provides:
|
|
65
|
+
|
|
66
|
+
- Channel list with unread counts
|
|
67
|
+
- Timeline view with items
|
|
68
|
+
- Mark as read on scroll/click
|
|
69
|
+
- Like/reply/repost via Micropub
|
|
70
|
+
- Channel settings (filters)
|
|
71
|
+
- Compose modal
|
|
72
|
+
|
|
73
|
+
### WebSub callbacks
|
|
74
|
+
|
|
75
|
+
WebSub hub callbacks are handled at `/microsub/websub/:id`.
|
|
76
|
+
|
|
77
|
+
### Webmention receiving
|
|
78
|
+
|
|
79
|
+
Direct webmentions can be sent to `/microsub/webmention`.
|
|
80
|
+
|
|
81
|
+
## MongoDB Collections
|
|
82
|
+
|
|
83
|
+
This plugin creates the following collections:
|
|
84
|
+
|
|
85
|
+
- `microsub_channels` - User's feed channels
|
|
86
|
+
- `microsub_feeds` - Subscribed feeds
|
|
87
|
+
- `microsub_items` - Timeline entries
|
|
88
|
+
- `microsub_notifications` - Webmention notifications
|
|
89
|
+
- `microsub_muted` - Muted URLs
|
|
90
|
+
- `microsub_blocked` - Blocked URLs
|
|
91
|
+
|
|
92
|
+
## Dependencies
|
|
93
|
+
|
|
94
|
+
- **feedparser** - RSS/Atom parsing
|
|
95
|
+
- **microformats-parser** - h-feed parsing
|
|
96
|
+
- **ioredis** - Redis client (optional, for caching/pub-sub)
|
|
97
|
+
- **sanitize-html** - XSS prevention
|
|
98
|
+
|
|
99
|
+
## External Clients
|
|
100
|
+
|
|
101
|
+
This endpoint is compatible with:
|
|
102
|
+
|
|
103
|
+
- [Monocle](https://monocle.p3k.io/) - Web-based reader
|
|
104
|
+
- [Together](https://together.tpxl.io/) - Web-based reader
|
|
105
|
+
- [IndiePass](https://indiepass.app/) - Mobile/desktop app (archived)
|
|
106
|
+
|
|
107
|
+
## References
|
|
108
|
+
|
|
109
|
+
- [Microsub Specification](https://indieweb.org/Microsub-spec)
|
|
110
|
+
- [Ekster](https://github.com/pstuifzand/ekster) - Reference implementation in Go
|
|
111
|
+
- [Aperture](https://github.com/aaronpk/Aperture) - Popular Microsub server in PHP
|
package/index.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import express from "express";
|
|
4
|
+
|
|
5
|
+
import { microsubController } from "./lib/controllers/microsub.js";
|
|
6
|
+
import { readerController } from "./lib/controllers/reader.js";
|
|
7
|
+
import { startScheduler, stopScheduler } from "./lib/polling/scheduler.js";
|
|
8
|
+
import { webmentionReceiver } from "./lib/webmention/receiver.js";
|
|
9
|
+
import { websubHandler } from "./lib/websub/handler.js";
|
|
10
|
+
|
|
11
|
+
const defaults = {
|
|
12
|
+
mountPath: "/microsub",
|
|
13
|
+
};
|
|
14
|
+
const router = express.Router();
|
|
15
|
+
const readerRouter = express.Router();
|
|
16
|
+
|
|
17
|
+
export default class MicrosubEndpoint {
|
|
18
|
+
name = "Microsub endpoint";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {object} options - Plugin options
|
|
22
|
+
* @param {string} [options.mountPath] - Path to mount Microsub endpoint
|
|
23
|
+
*/
|
|
24
|
+
constructor(options = {}) {
|
|
25
|
+
this.options = { ...defaults, ...options };
|
|
26
|
+
this.mountPath = this.options.mountPath;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Navigation items for Indiekit admin
|
|
31
|
+
* @returns {object} Navigation item configuration
|
|
32
|
+
*/
|
|
33
|
+
get navigationItems() {
|
|
34
|
+
return {
|
|
35
|
+
href: path.join(this.options.mountPath, "reader"),
|
|
36
|
+
text: "microsub.reader.title",
|
|
37
|
+
requiresDatabase: true,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Shortcut items for quick actions
|
|
43
|
+
* @returns {object} Shortcut item configuration
|
|
44
|
+
*/
|
|
45
|
+
get shortcutItems() {
|
|
46
|
+
return {
|
|
47
|
+
url: path.join(this.options.mountPath, "reader", "channels"),
|
|
48
|
+
name: "microsub.channels.title",
|
|
49
|
+
iconName: "feed",
|
|
50
|
+
requiresDatabase: true,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Microsub API and reader UI routes (authenticated)
|
|
56
|
+
* @returns {import("express").Router} Express router
|
|
57
|
+
*/
|
|
58
|
+
get routes() {
|
|
59
|
+
// Main Microsub endpoint - dispatches based on action parameter
|
|
60
|
+
router.get("/", microsubController.get);
|
|
61
|
+
router.post("/", microsubController.post);
|
|
62
|
+
|
|
63
|
+
// WebSub callback endpoint
|
|
64
|
+
router.get("/websub/:id", websubHandler.verify);
|
|
65
|
+
router.post("/websub/:id", websubHandler.receive);
|
|
66
|
+
|
|
67
|
+
// Webmention receiving endpoint
|
|
68
|
+
router.post("/webmention", webmentionReceiver.receive);
|
|
69
|
+
|
|
70
|
+
// Reader UI routes (mounted as sub-router for correct baseUrl)
|
|
71
|
+
readerRouter.get("/", readerController.index);
|
|
72
|
+
readerRouter.get("/channels", readerController.channels);
|
|
73
|
+
readerRouter.get("/channels/new", readerController.newChannel);
|
|
74
|
+
readerRouter.post("/channels/new", readerController.createChannel);
|
|
75
|
+
readerRouter.get("/channels/:uid", readerController.channel);
|
|
76
|
+
readerRouter.get("/channels/:uid/settings", readerController.settings);
|
|
77
|
+
readerRouter.post(
|
|
78
|
+
"/channels/:uid/settings",
|
|
79
|
+
readerController.updateSettings,
|
|
80
|
+
);
|
|
81
|
+
readerRouter.get("/item/:id", readerController.item);
|
|
82
|
+
readerRouter.get("/compose", readerController.compose);
|
|
83
|
+
readerRouter.post("/compose", readerController.submitCompose);
|
|
84
|
+
router.use("/reader", readerRouter);
|
|
85
|
+
|
|
86
|
+
return router;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Public routes (no authentication required)
|
|
91
|
+
* @returns {import("express").Router} Express router
|
|
92
|
+
*/
|
|
93
|
+
get routesPublic() {
|
|
94
|
+
const publicRouter = express.Router();
|
|
95
|
+
|
|
96
|
+
// WebSub verification must be public for hubs to verify
|
|
97
|
+
publicRouter.get("/websub/:id", websubHandler.verify);
|
|
98
|
+
publicRouter.post("/websub/:id", websubHandler.receive);
|
|
99
|
+
|
|
100
|
+
// Webmention endpoint must be public
|
|
101
|
+
publicRouter.post("/webmention", webmentionReceiver.receive);
|
|
102
|
+
|
|
103
|
+
return publicRouter;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Initialize plugin
|
|
108
|
+
* @param {object} indiekit - Indiekit instance
|
|
109
|
+
*/
|
|
110
|
+
init(indiekit) {
|
|
111
|
+
// Register MongoDB collections
|
|
112
|
+
indiekit.addCollection("microsub_channels");
|
|
113
|
+
indiekit.addCollection("microsub_feeds");
|
|
114
|
+
indiekit.addCollection("microsub_items");
|
|
115
|
+
indiekit.addCollection("microsub_notifications");
|
|
116
|
+
indiekit.addCollection("microsub_muted");
|
|
117
|
+
indiekit.addCollection("microsub_blocked");
|
|
118
|
+
|
|
119
|
+
// Register endpoint
|
|
120
|
+
indiekit.addEndpoint(this);
|
|
121
|
+
|
|
122
|
+
// Set microsub endpoint URL in config
|
|
123
|
+
if (!indiekit.config.application.microsubEndpoint) {
|
|
124
|
+
indiekit.config.application.microsubEndpoint = this.mountPath;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Start feed polling scheduler when server starts
|
|
128
|
+
// This will be called after the server is ready
|
|
129
|
+
if (indiekit.database) {
|
|
130
|
+
startScheduler(indiekit);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Cleanup on shutdown
|
|
136
|
+
*/
|
|
137
|
+
destroy() {
|
|
138
|
+
stopScheduler();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redis caching utilities
|
|
3
|
+
* @module cache/redis
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Get Redis client from application
|
|
8
|
+
* @param {object} application - Indiekit application
|
|
9
|
+
* @returns {object|undefined} Redis client or undefined
|
|
10
|
+
*/
|
|
11
|
+
export function getRedisClient(application) {
|
|
12
|
+
// Check if Redis is configured
|
|
13
|
+
if (application.redis) {
|
|
14
|
+
return application.redis;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Check for Redis URL in config
|
|
18
|
+
if (application.config?.application?.redisUrl) {
|
|
19
|
+
// Lazily create Redis connection
|
|
20
|
+
// This will be implemented when Redis support is added to Indiekit core
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Get value from cache
|
|
27
|
+
* @param {object} redis - Redis client
|
|
28
|
+
* @param {string} key - Cache key
|
|
29
|
+
* @returns {Promise<object|undefined>} Cached value or undefined
|
|
30
|
+
*/
|
|
31
|
+
export async function getCache(redis, key) {
|
|
32
|
+
if (!redis) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const value = await redis.get(key);
|
|
38
|
+
if (value) {
|
|
39
|
+
return JSON.parse(value);
|
|
40
|
+
}
|
|
41
|
+
} catch {
|
|
42
|
+
// Ignore cache errors
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Set value in cache
|
|
48
|
+
* @param {object} redis - Redis client
|
|
49
|
+
* @param {string} key - Cache key
|
|
50
|
+
* @param {object} value - Value to cache
|
|
51
|
+
* @param {number} [ttl] - Time to live in seconds
|
|
52
|
+
* @returns {Promise<void>}
|
|
53
|
+
*/
|
|
54
|
+
export async function setCache(redis, key, value, ttl = 300) {
|
|
55
|
+
if (!redis) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const serialized = JSON.stringify(value);
|
|
61
|
+
await (ttl
|
|
62
|
+
? redis.set(key, serialized, "EX", ttl)
|
|
63
|
+
: redis.set(key, serialized));
|
|
64
|
+
} catch {
|
|
65
|
+
// Ignore cache errors
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Delete value from cache
|
|
71
|
+
* @param {object} redis - Redis client
|
|
72
|
+
* @param {string} key - Cache key
|
|
73
|
+
* @returns {Promise<void>}
|
|
74
|
+
*/
|
|
75
|
+
export async function deleteCache(redis, key) {
|
|
76
|
+
if (!redis) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
await redis.del(key);
|
|
82
|
+
} catch {
|
|
83
|
+
// Ignore cache errors
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Publish event to channel
|
|
89
|
+
* @param {object} redis - Redis client
|
|
90
|
+
* @param {string} channel - Channel name
|
|
91
|
+
* @param {object} data - Event data
|
|
92
|
+
* @returns {Promise<void>}
|
|
93
|
+
*/
|
|
94
|
+
export async function publishEvent(redis, channel, data) {
|
|
95
|
+
if (!redis) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
await redis.publish(channel, JSON.stringify(data));
|
|
101
|
+
} catch {
|
|
102
|
+
// Ignore pub/sub errors
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Subscribe to channel
|
|
108
|
+
* @param {object} redis - Redis client (must be separate connection for pub/sub)
|
|
109
|
+
* @param {string} channel - Channel name
|
|
110
|
+
* @param {(data: object) => void} callback - Callback function for messages
|
|
111
|
+
* @returns {Promise<void>}
|
|
112
|
+
*/
|
|
113
|
+
export async function subscribeToChannel(redis, channel, callback) {
|
|
114
|
+
if (!redis) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
await redis.subscribe(channel);
|
|
120
|
+
redis.on("message", (receivedChannel, message) => {
|
|
121
|
+
if (receivedChannel === channel) {
|
|
122
|
+
try {
|
|
123
|
+
const data = JSON.parse(message);
|
|
124
|
+
callback(data);
|
|
125
|
+
} catch {
|
|
126
|
+
callback(message);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
} catch {
|
|
131
|
+
// Ignore subscription errors
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Block controller
|
|
3
|
+
* @module controllers/block
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { deleteItemsByAuthorUrl } from "../storage/items.js";
|
|
7
|
+
import { validateUrl } from "../utils/validation.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Get blocked collection
|
|
11
|
+
* @param {object} application - Indiekit application
|
|
12
|
+
* @returns {object} MongoDB collection
|
|
13
|
+
*/
|
|
14
|
+
function getCollection(application) {
|
|
15
|
+
return application.collections.get("microsub_blocked");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* List blocked URLs
|
|
20
|
+
* GET ?action=block
|
|
21
|
+
* @param {object} request - Express request
|
|
22
|
+
* @param {object} response - Express response
|
|
23
|
+
*/
|
|
24
|
+
export async function list(request, response) {
|
|
25
|
+
const { application } = request.app.locals;
|
|
26
|
+
const userId = request.session?.userId;
|
|
27
|
+
|
|
28
|
+
const collection = getCollection(application);
|
|
29
|
+
const blocked = await collection.find({ userId }).toArray();
|
|
30
|
+
const items = blocked.map((b) => ({ url: b.url }));
|
|
31
|
+
|
|
32
|
+
response.json({ items });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Block a URL
|
|
37
|
+
* POST ?action=block
|
|
38
|
+
* @param {object} request - Express request
|
|
39
|
+
* @param {object} response - Express response
|
|
40
|
+
*/
|
|
41
|
+
export async function block(request, response) {
|
|
42
|
+
const { application } = request.app.locals;
|
|
43
|
+
const userId = request.session?.userId;
|
|
44
|
+
const { url } = request.body;
|
|
45
|
+
|
|
46
|
+
validateUrl(url);
|
|
47
|
+
|
|
48
|
+
const collection = getCollection(application);
|
|
49
|
+
|
|
50
|
+
// Check if already blocked
|
|
51
|
+
const existing = await collection.findOne({ userId, url });
|
|
52
|
+
if (!existing) {
|
|
53
|
+
await collection.insertOne({
|
|
54
|
+
userId,
|
|
55
|
+
url,
|
|
56
|
+
createdAt: new Date(),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Remove past items from blocked URL
|
|
61
|
+
await deleteItemsByAuthorUrl(application, userId, url);
|
|
62
|
+
|
|
63
|
+
response.json({ result: "ok" });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Unblock a URL
|
|
68
|
+
* POST ?action=unblock
|
|
69
|
+
* @param {object} request - Express request
|
|
70
|
+
* @param {object} response - Express response
|
|
71
|
+
*/
|
|
72
|
+
export async function unblock(request, response) {
|
|
73
|
+
const { application } = request.app.locals;
|
|
74
|
+
const userId = request.session?.userId;
|
|
75
|
+
const { url } = request.body;
|
|
76
|
+
|
|
77
|
+
validateUrl(url);
|
|
78
|
+
|
|
79
|
+
const collection = getCollection(application);
|
|
80
|
+
await collection.deleteOne({ userId, url });
|
|
81
|
+
|
|
82
|
+
response.json({ result: "ok" });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const blockController = { list, block, unblock };
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Channel management controller
|
|
3
|
+
* @module controllers/channels
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { IndiekitError } from "@indiekit/error";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
getChannels,
|
|
10
|
+
getChannel,
|
|
11
|
+
createChannel,
|
|
12
|
+
updateChannel,
|
|
13
|
+
deleteChannel,
|
|
14
|
+
reorderChannels,
|
|
15
|
+
} from "../storage/channels.js";
|
|
16
|
+
import {
|
|
17
|
+
validateChannel,
|
|
18
|
+
validateChannelName,
|
|
19
|
+
parseArrayParameter as parseArrayParametereter,
|
|
20
|
+
} from "../utils/validation.js";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* List all channels
|
|
24
|
+
* GET ?action=channels
|
|
25
|
+
* @param {object} request - Express request
|
|
26
|
+
* @param {object} response - Express response
|
|
27
|
+
*/
|
|
28
|
+
export async function list(request, response) {
|
|
29
|
+
const { application } = request.app.locals;
|
|
30
|
+
const userId = request.session?.userId;
|
|
31
|
+
|
|
32
|
+
const channels = await getChannels(application, userId);
|
|
33
|
+
|
|
34
|
+
response.json({ channels });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Handle channel actions (create, update, delete, order)
|
|
39
|
+
* POST ?action=channels
|
|
40
|
+
* @param {object} request - Express request
|
|
41
|
+
* @param {object} response - Express response
|
|
42
|
+
*/
|
|
43
|
+
export async function action(request, response) {
|
|
44
|
+
const { application } = request.app.locals;
|
|
45
|
+
const userId = request.session?.userId;
|
|
46
|
+
const { method, name, uid } = request.body;
|
|
47
|
+
|
|
48
|
+
// Delete channel
|
|
49
|
+
if (method === "delete") {
|
|
50
|
+
validateChannel(uid);
|
|
51
|
+
|
|
52
|
+
const deleted = await deleteChannel(application, uid, userId);
|
|
53
|
+
if (!deleted) {
|
|
54
|
+
throw new IndiekitError("Channel not found or cannot be deleted", {
|
|
55
|
+
status: 404,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return response.json({ deleted: uid });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Reorder channels
|
|
63
|
+
if (method === "order") {
|
|
64
|
+
const channelUids = parseArrayParametereter(request.body, "channels");
|
|
65
|
+
if (channelUids.length === 0) {
|
|
66
|
+
throw new IndiekitError("Missing channels[] parameter", {
|
|
67
|
+
status: 400,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
await reorderChannels(application, channelUids, userId);
|
|
72
|
+
|
|
73
|
+
const channels = await getChannels(application, userId);
|
|
74
|
+
return response.json({ channels });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Update existing channel
|
|
78
|
+
if (uid) {
|
|
79
|
+
validateChannel(uid);
|
|
80
|
+
|
|
81
|
+
if (name) {
|
|
82
|
+
validateChannelName(name);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const channel = await updateChannel(application, uid, { name }, userId);
|
|
86
|
+
if (!channel) {
|
|
87
|
+
throw new IndiekitError("Channel not found", {
|
|
88
|
+
status: 404,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return response.json({
|
|
93
|
+
uid: channel.uid,
|
|
94
|
+
name: channel.name,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Create new channel
|
|
99
|
+
validateChannelName(name);
|
|
100
|
+
|
|
101
|
+
const channel = await createChannel(application, { name, userId });
|
|
102
|
+
|
|
103
|
+
response.status(201).json({
|
|
104
|
+
uid: channel.uid,
|
|
105
|
+
name: channel.name,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Get a single channel by UID
|
|
111
|
+
* @param {object} request - Express request
|
|
112
|
+
* @param {object} response - Express response
|
|
113
|
+
*/
|
|
114
|
+
export async function get(request, response) {
|
|
115
|
+
const { application } = request.app.locals;
|
|
116
|
+
const userId = request.session?.userId;
|
|
117
|
+
const { uid } = request.params;
|
|
118
|
+
|
|
119
|
+
validateChannel(uid);
|
|
120
|
+
|
|
121
|
+
const channel = await getChannel(application, uid, userId);
|
|
122
|
+
if (!channel) {
|
|
123
|
+
throw new IndiekitError("Channel not found", {
|
|
124
|
+
status: 404,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
response.json({
|
|
129
|
+
uid: channel.uid,
|
|
130
|
+
name: channel.name,
|
|
131
|
+
settings: channel.settings,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export const channelsController = { list, action, get };
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-Sent Events controller
|
|
3
|
+
* @module controllers/events
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
addClient,
|
|
8
|
+
removeClient,
|
|
9
|
+
sendEvent,
|
|
10
|
+
subscribeClient,
|
|
11
|
+
} from "../realtime/broker.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* SSE stream endpoint
|
|
15
|
+
* GET ?action=events
|
|
16
|
+
* @param {object} request - Express request
|
|
17
|
+
* @param {object} response - Express response
|
|
18
|
+
*/
|
|
19
|
+
export async function stream(request, response) {
|
|
20
|
+
const { application } = request.app.locals;
|
|
21
|
+
const userId = request.session?.userId;
|
|
22
|
+
|
|
23
|
+
// Set SSE headers
|
|
24
|
+
response.setHeader("Content-Type", "text/event-stream");
|
|
25
|
+
response.setHeader("Cache-Control", "no-cache");
|
|
26
|
+
response.setHeader("Connection", "keep-alive");
|
|
27
|
+
response.setHeader("X-Accel-Buffering", "no"); // Disable nginx buffering
|
|
28
|
+
|
|
29
|
+
// Flush headers immediately
|
|
30
|
+
response.flushHeaders();
|
|
31
|
+
|
|
32
|
+
// Add client to broker (handles ping internally)
|
|
33
|
+
const client = addClient(response, userId, application);
|
|
34
|
+
|
|
35
|
+
// Subscribe to channels from query parameter
|
|
36
|
+
const { channels } = request.query;
|
|
37
|
+
if (channels) {
|
|
38
|
+
const channelList = Array.isArray(channels) ? channels : [channels];
|
|
39
|
+
for (const channelId of channelList) {
|
|
40
|
+
subscribeClient(response, channelId);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Send initial event
|
|
45
|
+
sendEvent(response, "started", {
|
|
46
|
+
version: "1.0.0",
|
|
47
|
+
channels: [...client.channels],
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Handle client disconnect
|
|
51
|
+
request.on("close", () => {
|
|
52
|
+
removeClient(response);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const eventsController = { stream };
|