+7
-2
readme.md
+7
-2
readme.md
···
9
9
- no account necessary for over-18 content
10
10
11
11
i host a version for myself and a few friends. reach out to
12
-
me if you would like an invite.
12
+
me if you would like an invite.
13
13
14
14
### features
15
15
···
87
87
or with just [bun](https://bun.sh/):
88
88
89
89
```bash
90
-
bun run src/index.js
90
+
bun run src/index.js
91
91
```
92
92
93
93
### usage
···
98
98
username at the top-right to view the dashboard and to
99
99
invite other users to your instance. copy the link and send
100
100
it to your friends!
101
+
102
+
### environment variables
103
+
104
+
- `LURKER_PORT`: port to listen on, defaults to `3000`.
105
+
- `LURKER_THEME`: name of CSS theme file. The file must be present in `src/public`.
101
106
102
107
### technical
103
108
+63
-15
src/geddit.js
+63
-15
src/geddit.js
···
10
10
include_over_18: true,
11
11
type: "sr,link,user",
12
12
};
13
+
this.headers = {
14
+
"User-Agent":
15
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
16
+
};
13
17
}
14
18
15
19
async getSubmissions(sort = "hot", subreddit = null, options = {}) {
···
24
28
`${
25
29
this.host + subredditStr
26
30
}/${sort}.json?${new URLSearchParams(Object.assign(params, options))}`,
31
+
{ headers: this.headers },
27
32
)
28
33
.then((res) => res.json())
29
34
.then((json) => json.data)
···
37
42
async getDomainHot(domain, options = this.parameters) {
38
43
return await fetch(
39
44
`${this.host}/domain/${domain}/hot.json?${new URLSearchParams(options)}`,
45
+
{ headers: this.headers },
40
46
)
41
47
.then((res) => res.json())
42
48
.then((json) => json.data)
···
50
56
async getDomainBest(domain, options = this.parameters) {
51
57
return await fetch(
52
58
`${this.host}/domain/${domain}/best.json?${new URLSearchParams(options)}`,
59
+
{ headers: this.headers },
53
60
)
54
61
.then((res) => res.json())
55
62
.then((json) => json.data)
···
63
70
async getDomainTop(domain, options = this.parameters) {
64
71
return await fetch(
65
72
`${this.host}/domain/${domain}/top.json?${new URLSearchParams(options)}`,
73
+
{ headers: this.headers },
66
74
)
67
75
.then((res) => res.json())
68
76
.then((json) => json.data)
···
70
78
after: data.after,
71
79
posts: data.children,
72
80
}))
73
-
.catch((err) => null);
81
+
.catch((_) => null);
74
82
}
75
83
76
84
async getDomainNew(domain, options = this.parameters) {
77
85
return await fetch(
78
86
`${this.host}/domain/${domain}/new.json?${new URLSearchParams(options)}`,
87
+
{ headers: this.headers },
79
88
)
80
89
.then((res) => res.json())
81
90
.then((json) => json.data)
···
89
98
async getDomainRising(domain, options = this.parameters) {
90
99
return await fetch(
91
100
`${this.host}/domain/${domain}/rising.json?${new URLSearchParams(options)}`,
101
+
{ headers: this.headers },
92
102
)
93
103
.then((res) => res.json())
94
104
.then((json) => json.data)
···
102
112
async getDomainControversial(domain, options = this.parameters) {
103
113
return await fetch(
104
114
`${this.host}/domain/${domain}/controversial.json?${new URLSearchParams(options)}`,
115
+
{ headers: this.headers },
105
116
)
106
117
.then((res) => res.json())
107
118
.then((json) => json.data)
···
113
124
}
114
125
115
126
async getSubreddit(subreddit) {
116
-
return await fetch(`${this.host}/r/${subreddit}/about.json`)
127
+
return await fetch(`${this.host}/r/${subreddit}/about.json`, {
128
+
headers: this.headers,
129
+
})
117
130
.then((res) => res.json())
118
131
.then((json) => json.data)
119
132
.catch((err) => null);
120
133
}
121
134
122
135
async getSubredditRules(subreddit) {
123
-
return await fetch(`${this.host}/r/${subreddit}/about/rules.json`)
136
+
return await fetch(`${this.host}/r/${subreddit}/about/rules.json`, {
137
+
headers: this.headers,
138
+
})
124
139
.then((res) => res.json())
125
140
.then((json) => json.data)
126
141
.catch((err) => null);
127
142
}
128
143
129
144
async getSubredditModerators(subreddit) {
130
-
return await fetch(`${this.host}/r/${subreddit}/about/moderators.json`)
145
+
return await fetch(`${this.host}/r/${subreddit}/about/moderators.json`, {
146
+
headers: this.headers,
147
+
})
131
148
.then((res) => res.json())
132
149
.then((json) => json.data)
133
-
.then({
134
-
data: {
135
-
users: data.children,
136
-
},
137
-
})
150
+
.then((data) => ({
151
+
users: data.children,
152
+
}))
138
153
.catch((err) => null);
139
154
}
140
155
141
156
async getSubredditWikiPages(subreddit) {
142
-
return await fetch(`${this.host}/r/${subreddit}/wiki/pages.json`)
157
+
return await fetch(`${this.host}/r/${subreddit}/wiki/pages.json`, {
158
+
headers: this.headers,
159
+
})
143
160
.then((res) => res.json())
144
161
.then((json) => json.data)
145
162
.catch((err) => null);
146
163
}
147
164
148
165
async getSubredditWikiPage(subreddit, page) {
149
-
return await fetch(`${this.host}/r/${subreddit}/wiki/${page}.json`)
166
+
return await fetch(`${this.host}/r/${subreddit}/wiki/${page}.json`, {
167
+
headers: this.headers,
168
+
})
150
169
.then((res) => res.json())
151
170
.then((json) => json.data)
152
171
.catch((err) => null);
153
172
}
154
173
155
174
async getSubredditWikiPageRevisions(subreddit, page) {
156
-
return await fetch(`${this.host}/r/${subreddit}/wiki/revisions${page}.json`)
175
+
return await fetch(
176
+
`${this.host}/r/${subreddit}/wiki/revisions${page}.json`,
177
+
{ headers: this.headers },
178
+
)
157
179
.then((res) => res.json())
158
180
.then((json) => json.data.children)
159
181
.catch((err) => null);
···
162
184
async getPopularSubreddits(options = this.parameters) {
163
185
return await fetch(
164
186
`${this.host}/subreddits/popular.json?${new URLSearchParams(options)}`,
187
+
{ headers: this.headers },
165
188
)
166
189
.then((res) => res.json())
167
190
.then((json) => json.data)
···
175
198
async getNewSubreddits(options = this.parameters) {
176
199
return await fetch(
177
200
`${this.host}/subreddits/new.json?${new URLSearchParams(options)}`,
201
+
{ headers: this.headers },
178
202
)
179
203
.then((res) => res.json())
180
204
.then((json) => json.data)
···
188
212
async getPremiumSubreddits(options = this.parameters) {
189
213
return await fetch(
190
214
`${this.host}/subreddits/premium.json?${new URLSearchParams(options)}`,
215
+
{ headers: this.headers },
191
216
)
192
217
.then((res) => res.json())
193
218
.then((json) => json.data)
···
201
226
async getDefaultSubreddits(options = this.parameters) {
202
227
return await fetch(
203
228
`${this.host}/subreddits/default.json?${new URLSearchParams(options)}`,
229
+
{ headers: this.headers },
204
230
)
205
231
.then((res) => res.json())
206
232
.then((json) => json.data)
···
214
240
async getPopularUsers(options = this.parameters) {
215
241
return await fetch(
216
242
`${this.host}/users/popular.json?${new URLSearchParams(options)}`,
243
+
{ headers: this.headers },
217
244
)
218
245
.then((res) => res.json())
219
246
.then((json) => json.data)
···
227
254
async getNewUsers(options = this.parameters) {
228
255
return await fetch(
229
256
`${this.host}/users/new.json?${new URLSearchParams(options)}`,
257
+
{ headers: this.headers },
230
258
)
231
259
.then((res) => res.json())
232
260
.then((json) => json.data)
···
243
271
244
272
return await fetch(
245
273
`${this.host}/search.json?${new URLSearchParams(options)}`,
274
+
{ headers: this.headers },
246
275
)
247
276
.then((res) => res.json())
248
277
.then((json) => json.data)
···
263
292
264
293
return await fetch(
265
294
`${this.host}/subreddits/search.json?${new URLSearchParams(Object.assign(params, options))}`,
295
+
{ headers: this.headers },
266
296
)
267
297
.then((res) => res.json())
268
298
.then((json) => json.data)
···
283
313
284
314
return await fetch(
285
315
`${this.host}/users/search.json?${new URLSearchParams(Object.assign(params, options))}`,
316
+
{ headers: this.headers },
286
317
)
287
318
.then((res) => res.json())
288
319
.then((json) => json.data)
···
307
338
`${
308
339
this.host + subredditStr
309
340
}/search.json?${new URLSearchParams(Object.assign(params, options))}`,
341
+
{ headers: this.headers },
310
342
)
311
343
.then((res) => res.json())
312
344
.then((json) =>
···
324
356
}
325
357
326
358
async getSubmission(id) {
327
-
return await fetch(`${this.host}/by_id/${id}.json`)
359
+
return await fetch(`${this.host}/by_id/${id}.json`, {
360
+
headers: this.headers,
361
+
})
328
362
.then((res) => res.json())
329
363
.then((json) => json.data.children[0].data)
330
364
.catch((err) => null);
···
333
367
async getSubmissionComments(id, options = this.parameters) {
334
368
return await fetch(
335
369
`${this.host}/comments/${id}.json?${new URLSearchParams(options)}`,
370
+
{ headers: this.headers },
336
371
)
337
372
.then((res) => res.json())
338
373
.then((json) => ({
···
345
380
async getSingleCommentThread(parent_id, child_id, options = this.parameters) {
346
381
return await fetch(
347
382
`${this.host}/comments/${parent_id}/comment/${child_id}.json?${new URLSearchParams(options)}`,
383
+
{ headers: this.headers },
348
384
)
349
385
.then((res) => res.json())
350
386
.then((json) => ({
···
357
393
async getSubredditComments(subreddit, options = this.parameters) {
358
394
return await fetch(
359
395
`${this.host}/r/${subreddit}/comments.json?${new URLSearchParams(options)}`,
396
+
{ headers: this.headers },
360
397
)
361
398
.then((res) => res.json())
362
399
.then((json) => json.data.children)
···
364
401
}
365
402
366
403
async getUser(username) {
367
-
return await fetch(`${this.host}/user/${username}/about.json`)
404
+
return await fetch(`${this.host}/user/${username}/about.json`, {
405
+
headers: this.headers,
406
+
})
368
407
.then((res) => res.json())
369
408
.then((json) => json.data)
370
409
.catch((err) => null);
···
373
412
async getUserOverview(username, options = this.parameters) {
374
413
return await fetch(
375
414
`${this.host}/user/${username}/overview.json?${new URLSearchParams(options)}`,
415
+
{ headers: this.headers },
376
416
)
377
417
.then((res) => res.json())
378
418
.then((json) => json.data)
···
386
426
async getUserComments(username, options = this.parameters) {
387
427
return await fetch(
388
428
`${this.host}/user/${username}/comments.json?${new URLSearchParams(options)}`,
429
+
{ headers: this.headers },
389
430
)
390
431
.then((res) => res.json())
391
432
.then((json) => json.data)
···
399
440
async getUserSubmissions(username, options = this.parameters) {
400
441
return await fetch(
401
442
`${this.host}/user/${username}/submitted.json?${new URLSearchParams(options)}`,
443
+
{ headers: this.headers },
402
444
)
403
445
.then((res) => res.json())
404
446
.then((json) => json.data)
···
410
452
}
411
453
412
454
async getLiveThread(id) {
413
-
return await fetch(`${this.host}/live/${id}/about.json`)
455
+
return await fetch(`${this.host}/live/${id}/about.json`, {
456
+
headers: this.headers,
457
+
})
414
458
.then((res) => res.json())
415
459
.then((json) => json.data)
416
460
.catch((err) => null);
···
419
463
async getLiveThreadUpdates(id, options = this.parameters) {
420
464
return await fetch(
421
465
`${this.host}/live/${id}.json?${new URLSearchParams(options)}`,
466
+
{ headers: this.headers },
422
467
)
423
468
.then((res) => res.json())
424
469
.then((json) => json.data.children)
···
428
473
async getLiveThreadContributors(id, options = this.parameters) {
429
474
return await fetch(
430
475
`${this.host}/live/${id}/contributors.json?${new URLSearchParams(options)}`,
476
+
{ headers: this.headers },
431
477
)
432
478
.then((res) => res.json())
433
479
.then((json) => json.data.children)
···
437
483
async getLiveThreadDiscussions(id, options = this.parameters) {
438
484
return await fetch(
439
485
`${this.host}/live/${id}/discussions.json?${new URLSearchParams(options)}`,
486
+
{ headers: this.headers },
440
487
)
441
488
.then((res) => res.json())
442
489
.then((json) => json.data.children)
···
446
493
async getLiveThreadsNow(options = this.parameters) {
447
494
return await fetch(
448
495
`${this.host}/live/happening_now.json?${new URLSearchParams(options)}`,
496
+
{ headers: this.headers },
449
497
)
450
498
.then((res) => res.json())
451
499
.then((json) => json.data.children)
+2
-1
src/mixins/head.pug
+2
-1
src/mixins/head.pug
···
4
4
meta(charset='UTF-8')
5
5
title #{`${title} ยท lurker `}
6
6
link(rel="stylesheet", href="/styles.css")
7
+
if theme
8
+
link(rel="stylesheet", href=`/${theme}.css`)
7
9
link(rel="preconnect" href="https://rsms.me/")
8
10
link(rel="stylesheet" href="https://rsms.me/inter/inter.css")
9
11
script(src="https://cdn.dashjs.org/latest/dash.all.min.js")
10
-
+1
-1
src/mixins/header.pug
+1
-1
src/mixins/header.pug
+4
-4
src/mixins/post.pug
+4
-4
src/mixins/post.pug
···
46
46
- var url = postThumbnail(p)
47
47
img(src=url onclick=onclick)
48
48
else if isPostVideo(p)
49
-
- var decodedVideos = decodePostVideoUrls(p)
50
-
video(data-dashjs-player="" playsinline="" autoplay="" muted="" onclick=`toggleDetails('${p.id}')` src=decodedVideos[3] poster=decodedVideos[4] width="100px" height="100px")
49
+
- var url = p.secure_media.reddit_video.scrubber_media_url
50
+
video(src=url data-dashjs-player width='100px' height='100px' onclick=`toggleDetails('${p.id}')`)
51
51
else if isPostLink(p)
52
52
a(href=p.url)
53
53
| โ
···
71
71
a(href=`/media/${p.url}`)
72
72
img(src=p.url loading="lazy")
73
73
else if isPostVideo(p)
74
-
- var decodedVideos = decodePostVideoUrls(p)
75
-
video(data-dashjs-player="" playsinline="" controls="" muted="" preload="metadata" src=decodedVideos[1] poster=decodedVideos[4])
74
+
- var url = p.secure_media.reddit_video.dash_url
75
+
video(src=url controls data-dashjs-player loading="lazy").post-media
76
76
else if isPostLink(p)
77
77
a(href=p.url)
78
78
| #{p.domain} โ
+36
src/public/theme.css
+36
src/public/theme.css
···
1
+
/*
2
+
Uncomment and modify the values in this file to change the theme of the app.
3
+
4
+
:root {
5
+
--bg-color: white;
6
+
--bg-color-muted: #eee;
7
+
--text-color: black;
8
+
--text-color-muted: #999;
9
+
--blockquote-color: green;
10
+
--sticky-color: #dcfeda;
11
+
--gilded: darkorange;
12
+
--link-color: #29bc9b;
13
+
--link-visited-color: #999;
14
+
--accent: var(--link-color);
15
+
--error-text-color: red;
16
+
--border-radius-card: 0.5vmin;
17
+
--border-radius-media: 0.5vmin;
18
+
--border-radius-preview: 0.3vmin;
19
+
}
20
+
21
+
@media (prefers-color-scheme: dark) {
22
+
:root {
23
+
--bg-color: black;
24
+
--bg-color-muted: #333;
25
+
--text-color: white;
26
+
--text-color-muted: #999;
27
+
--blockquote-color: lightgreen;
28
+
--sticky-color: #014413;
29
+
--gilded: gold;
30
+
--link-color: #79ffe1;
31
+
--link-visited-color: #999;
32
+
--accent: var(--link-color);
33
+
--error-text-color: lightcoral;
34
+
}
35
+
}
36
+
*/
+42
-12
src/routes/index.js
+42
-12
src/routes/index.js
···
6
6
const { db } = require("../db");
7
7
const { authenticateToken, authenticateAdmin } = require("../auth");
8
8
const { validateInviteToken } = require("../invite");
9
-
const url = require("url");
10
9
11
10
const router = express.Router();
12
11
const G = new geddit.Geddit();
13
12
13
+
const commonRenderOptions = {
14
+
theme: process.env.LURKER_THEME,
15
+
};
16
+
14
17
// GET /
15
18
router.get("/", authenticateToken, async (req, res) => {
16
19
const subs = db
17
20
.query("SELECT * FROM subscriptions WHERE user_id = $id")
18
21
.all({ id: req.user.id });
19
22
20
-
const qs = req.query ? ('?' + new URLSearchParams(req.query).toString()) : '';
23
+
const qs = req.query ? "?" + new URLSearchParams(req.query).toString() : "";
21
24
22
25
if (subs.length === 0) {
23
26
res.redirect(`/r/all${qs}`);
···
53
56
54
57
const [posts, about] = await Promise.all([postsReq, aboutReq]);
55
58
56
-
if (query.view == 'card' && posts && posts.posts) {
59
+
if (query.view == "card" && posts && posts.posts) {
57
60
posts.posts.forEach(unescape_selftext);
58
61
}
59
62
···
66
69
user: req.user,
67
70
isSubbed,
68
71
currentUrl: req.url,
72
+
...commonRenderOptions,
69
73
});
70
74
});
71
75
···
82
86
user: req.user,
83
87
from: req.query.from,
84
88
query: req.query,
89
+
...commonRenderOptions,
85
90
});
86
91
});
87
92
···
103
108
comments,
104
109
parent_id,
105
110
user: req.user,
111
+
...commonRenderOptions,
106
112
});
107
113
},
108
114
);
···
115
121
)
116
122
.all({ id: req.user.id });
117
123
118
-
res.render("subs", { subs, user: req.user, query: req.query });
124
+
res.render("subs", {
125
+
subs,
126
+
user: req.user,
127
+
query: req.query,
128
+
...commonRenderOptions,
129
+
});
119
130
});
120
131
121
132
// GET /search
122
133
router.get("/search", authenticateToken, async (req, res) => {
123
-
res.render("search", { user: req.user, query: req.query });
134
+
res.render("search", {
135
+
user: req.user,
136
+
query: req.query,
137
+
...commonRenderOptions,
138
+
});
124
139
});
125
140
126
141
// GET /sub-search
127
142
router.get("/sub-search", authenticateToken, async (req, res) => {
128
143
if (!req.query || !req.query.q) {
129
-
res.render("sub-search", { user: req.user });
144
+
res.render("sub-search", { user: req.user, ...commonRenderOptions });
130
145
} else {
131
146
const { items, after } = await G.searchSubreddits(req.query.q);
132
147
const subs = db
···
145
160
user: req.user,
146
161
original_query: req.query.q,
147
162
query: req.query,
163
+
...commonRenderOptions,
148
164
});
149
165
}
150
166
});
···
152
168
// GET /post-search
153
169
router.get("/post-search", authenticateToken, async (req, res) => {
154
170
if (!req.query || !req.query.q) {
155
-
res.render("post-search", { user: req.user });
171
+
res.render("post-search", { user: req.user, ...commonRenderOptions });
156
172
} else {
157
173
const { items, after } = await G.searchSubmissions(req.query.q);
158
174
const message =
···
160
176
? "no results found"
161
177
: `showing ${items.length} results`;
162
178
163
-
if (req.query.view == 'card' && items) {
179
+
if (req.query.view == "card" && items) {
164
180
items.forEach(unescape_selftext);
165
181
}
166
-
182
+
167
183
res.render("post-search", {
168
184
items,
169
185
after,
···
172
188
original_query: req.query.q,
173
189
currentUrl: req.url,
174
190
query: req.query,
191
+
...commonRenderOptions,
175
192
});
176
193
}
177
194
});
···
194
211
usedAt: Date.parse(inv.usedAt),
195
212
}));
196
213
}
197
-
res.render("dashboard", { invites, isAdmin, user: req.user, query: req.query });
214
+
res.render("dashboard", {
215
+
invites,
216
+
isAdmin,
217
+
user: req.user,
218
+
query: req.query,
219
+
...commonRenderOptions,
220
+
});
198
221
});
199
222
200
223
router.get("/create-invite", authenticateAdmin, async (req, res) => {
···
233
256
const kind = ["jpg", "jpeg", "png", "gif", "webp"].includes(ext)
234
257
? "img"
235
258
: "video";
236
-
res.render("media", { kind, url });
259
+
res.render("media", { kind, url, ...commonRenderOptions });
237
260
});
238
261
239
262
router.get("/register", validateInviteToken, async (req, res) => {
240
-
res.render("register", { isDisabled: false, token: req.query.token });
263
+
res.render("register", {
264
+
isDisabled: false,
265
+
token: req.query.token,
266
+
...commonRenderOptions,
267
+
});
241
268
});
242
269
243
270
router.post("/register", validateInviteToken, async (req, res) => {
···
253
280
if (user) {
254
281
return res.render("register", {
255
282
message: `user by the name "${username}" exists, choose a different username`,
283
+
...commonRenderOptions,
256
284
});
257
285
}
258
286
259
287
if (password !== confirm_password) {
260
288
return res.render("register", {
261
289
message: "passwords do not match, try again",
290
+
...commonRenderOptions,
262
291
});
263
292
}
264
293
···
294
323
} catch (err) {
295
324
return res.render("register", {
296
325
message: "error registering user, try again later",
326
+
...commonRenderOptions,
297
327
});
298
328
}
299
329
});
+1
-11
src/views/media.pug
+1
-11
src/views/media.pug
···
2
2
doctype html
3
3
html
4
4
+head("home")
5
-
script(type='text/javascript').
6
-
function toggleZoom(event) {
7
-
const percentX = event.offsetX / event.target.width;
8
-
const percentY = event.offsetY / event.target.height;
9
-
Array.from(document.getElementsByClassName('media-maximized')).forEach(element => element.classList.toggle('zoom'));
10
-
const moveClientX = (event.target.width * percentX) + event.target.offsetLeft - (event.view.visualViewport.width / 2)
11
-
const moveClientY = (event.target.height * percentY) + event.target.offsetTop - (event.view.visualViewport.height / 2);
12
-
event.target.parentElement.scrollTo(moveClientX, moveClientY);
13
-
}
14
-
15
5
body.media-maximized
16
6
div.media-maximized.container
17
7
if kind == 'img'
18
-
img(src=url onclick=`toggleZoom(event)`).media-maximized
8
+
img(src=url).media-maximized
19
9
else
20
10
video(src=url controls).media-maximized