selfhostable, read-only reddit client

Compare changes

Choose any two refs to compare.

+14 -7
.github/workflows/publish-docker.yml
··· 13 13 - name: checkout repository 14 14 uses: actions/checkout@v4 15 15 16 - - name: build docker image 17 - run: docker build -t lurker:latest . 18 - 19 16 - name: log in to github container registry 20 17 uses: docker/login-action@v3 21 18 with: ··· 23 20 username: ${{ github.actor }} 24 21 password: ${{ secrets.GITHUB_TOKEN }} 25 22 26 - - name: publish docker image 27 - run: | 28 - docker tag lurker:latest ghcr.io/${{ github.repository_owner }}/lurker:latest 29 - docker push ghcr.io/${{ github.repository_owner }}/lurker:latest 23 + - name: Set up QEMU 24 + uses: docker/setup-qemu-action@v3 25 + 26 + - name: Set up Docker Buildx 27 + uses: docker/setup-buildx-action@v3 28 + 29 + - name: Build and push 30 + id: build 31 + uses: docker/build-push-action@v6 32 + with: 33 + context: . 34 + platforms: linux/amd64,linux/arm64 35 + push: true 36 + tags: ghcr.io/${{ github.repository_owner }}/lurker:latest
+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
··· 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
··· 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 -
+5 -6
src/mixins/header.pug
··· 1 1 mixin header(user) 2 2 - var viewQuery = 'view=' + (query && query.view ? query.view : 'compact') 3 - - var sortQuery = 'sort=' + (query ? (query.sort ? query.sort + (query.t ? '&t=' + query.t : '') : 'hot') : 'hot') 4 3 div.header 5 4 div.header-item 6 - a(href=`/?${sortQuery}&${viewQuery}`) home 5 + a(href=`/?${viewQuery}`) home 7 6 div.header-item 8 - a(href=`/r/all?${sortQuery}&${viewQuery}`) all 7 + a(href=`/r/all?${viewQuery}`) all 9 8 div.header-item 10 - a(href=`/search?${sortQuery}&${viewQuery}`) search 9 + a(href=`/search?${viewQuery}`) search 11 10 div.header-item 12 - a(href=`/subs?${sortQuery}&${viewQuery}`) subs 11 + a(href=`/subs?${viewQuery}`) subs 13 12 if user 14 13 div.header-item 15 - a(href=`/dashboard?${sortQuery}&${viewQuery}`) #{user.username} 14 + a(href=`/dashboard?${viewQuery}`) #{user.username} 16 15 |  17 16 a(href='/logout') (logout) 18 17 else
+57 -80
src/mixins/post.pug
··· 6 6 - var sortQuery = query && query.sort ? query.sort + (query.t ? '&t=' + query.t : '') : 'hot' 7 7 article(class=`post`) 8 8 div.post-container(class=`${query.view} ${p.stickied?"sticky":""}`) 9 - div.post-text(class=`${query.view}`) 10 - div.title-container(class=`${query.view}`) 11 - a(class=`${query.view}`, href=`/comments/${p.id}?from=${from}&sort=${sortQuery}&view=${viewQuery}`) 12 - != p.title 13 - span.domain (#{p.domain}) 14 - div.info-container 15 - p 16 - | #{fmtnum(p.ups)} ↑ 17 - if p.gilded > 0 9 + div.post-info 10 + div.post-text(class=`${query.view}`) 11 + div.title-container(class=`${query.view}`) 12 + a(class=`${query.view}`, href=`/comments/${p.id}?from=${from}&sort=${sortQuery}&view=${viewQuery}`) 13 + != p.title 14 + span.domain (#{p.domain}) 15 + div.info-container 16 + p 17 + | #{fmtnum(p.ups)} ↑ 18 + if p.gilded > 0 19 + |  ·  20 + span.gilded 21 + | #{p.gilded} ☆ 22 + span.post-author 23 + |  ·  24 + | u/#{p.author} 25 + |  ·  26 + | #{timeDifference(Date.now(), p.created * 1000)} 18 27 |  ·  19 - span.gilded 20 - | #{p.gilded} ☆ 21 - span.post-author 28 + a(href=`/r/${p.subreddit}?view=${viewQuery}`) r/#{p.subreddit} 22 29 |  ·  23 - | u/#{p.author} 24 - |  ·  25 - | #{timeDifference(Date.now(), p.created * 1000)} 26 - |  ·  27 - a(href=`/r/${p.subreddit}?sort=${sortQuery}&view=${viewQuery}`) r/#{p.subreddit} 28 - |  ·  29 - a(href=`/comments/${p.id}?from=${from}&sort=${sortQuery}&view=${viewQuery}`) #{fmtnum (p.num_comments)} ↩ 30 - if (query.view == "card" && !isPostGallery(p) && !isPostImage(p) && !isPostVideo(p) && p.selftext_html) 31 - div.self-text-overflow(class='card') 32 - if query.view == "card" && (p.spoiler || p.over_18) 33 - div.spoiler(id=`spoiler_${p.id}`, onclick=`javascript:document.getElementById('spoiler_${p.id}').style.display = 'none';`) 34 - h2 35 - != p.over_18 ? 'nsfw' : 'spoiler' 36 - div.self-text(class='card') 37 - != convertInlineImageLinks(p.selftext_html) 38 - div.media-preview(class=`${query.view}`) 39 - - var onclick = query.view != "card" ? `toggleDetails('${p.id}')` : `` 40 - if query.view == "card" && (p.spoiler || p.over_18) && (isPostGallery(p) || isPostImage(p) || isPostVideo(p)) 41 - div.spoiler(id=`spoiler_${p.id}`, onclick=`javascript:document.getElementById('spoiler_${p.id}').style.display = 'none';`) 42 - h2 43 - != p.over_18 ? 'nsfw' : 'spoiler' 44 - if isPostGallery(p) 45 - - var item = postGalleryItems(p)[0] 46 - if query.view == "card" 47 - div.gallery(class=`${query.view}`) 48 - each item in postGalleryItems(p) 49 - div.gallery-item(class=`${query.view}`) 50 - div.gallery-item-idx(class=`${query.view}`) 51 - | #{`${item.idx}/${item.total}`} 52 - a(href=`/media/${item.url}`) 53 - img(src=item.url loading="lazy") 54 - else 55 - img(src=item.url onclick=onclick) 56 - else if isPostImage(p) 57 - - var url = query.view == "card" ? p.url : postThumbnail(p) 58 - #{query.view == "card" ? "a href=/media/" + url : span} 59 - img(src=url onclick=onclick) 60 - else if isPostVideo(p) 61 - - var decodedVideos = decodePostVideoUrls(p) 62 - if query.view == "card" 63 - video(controls="" muted="" data-dashjs-player="" preload="metadata" poster=decodedVideos[4]) 64 - // HLS 65 - source(src=decodedVideos[0]) 66 - // Dash 67 - source(src=decodedVideos[1]) 68 - // Fallback 69 - source(src=decodedVideos[2]) 70 - else 71 - video(autoplay="" muted="" data-dashjs-player="" onclick=`toggleDetails('${p.id}')` width="100px" height="100px") 72 - // Scrubber 73 - source(src=decodedVideos[3]) 74 - else if isPostLink(p) 75 - a(href=p.url) 76 - if (query.view == 'card') 77 - | #{p.domain} 78 - | ↗ 30 + a(href=`/comments/${p.id}?from=${from}`) #{fmtnum (p.num_comments)} ↩ 31 + if (query.view == "card" && !isPostMedia(p) && p.selftext_html) 32 + div.self-text-overflow.card 33 + if p.spoiler || p.over_18 34 + div.spoiler(id=`spoiler_${p.id}`, onclick=`javascript:document.getElementById('spoiler_${p.id}').style.display = 'none';`) 35 + h2 36 + != p.over_18 ? 'nsfw' : 'spoiler' 37 + div.self-text.card 38 + != convertInlineImageLinks(p.selftext_html) 39 + if query.view != "card" 40 + div.media-preview 41 + - var onclick = `toggleDetails('${p.id}')` 42 + if isPostGallery(p) 43 + - var item = (p.over_18 ? `/nsfw.svg` : p.spoiler ? `/spoiler.svg` : postGalleryItems(p)[0].url) 44 + img(src=item onclick=onclick) 45 + else if isPostImage(p) 46 + - var url = postThumbnail(p) 47 + img(src=url onclick=onclick) 48 + else if isPostVideo(p) 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 + else if isPostLink(p) 52 + a(href=p.url) 53 + | ↗ 79 54 80 - if query.view == "compact" && (isPostGallery(p) || isPostImage(p) || isPostVideo(p)) 81 - details(id=`${p.id}`) 55 + details(id=`${p.id}` open=(query.view == "card" && (isPostMedia(p) || isPostLink(p))) class=`${query.view}`) 82 56 summary.expand-post expand media 83 57 div.image-viewer 58 + if query.view == "card" && (p.spoiler || p.over_18) && isPostMedia(p) 59 + div.spoiler(id=`spoiler_${p.id}`, onclick=`javascript:document.getElementById('spoiler_${p.id}').style.display = 'none';`) 60 + h2 61 + != p.over_18 ? 'nsfw' : 'spoiler' 84 62 if isPostGallery(p) 85 63 div.gallery 86 64 each item in postGalleryItems(p) 87 65 div.gallery-item 88 - div.gallery-item-idx 89 - | #{`${item.idx}/${item.total}`} 90 66 a(href=`/media/${item.url}`) 91 67 img(src=item.url loading="lazy") 68 + div.gallery-item-idx 69 + | #{`${item.idx}/${item.total}`} 92 70 else if isPostImage(p) 93 71 a(href=`/media/${p.url}`) 94 - img(src=p.url loading="lazy").post-media 72 + img(src=p.url loading="lazy") 95 73 else if isPostVideo(p) 96 - video(controls="" muted="" data-dashjs-player="" preload="metadata" playsinline="" poster=decodedVideos[4] objectfit="contain" loading="lazy").post-media 97 - //HLS 98 - source(src=decodedVideos[0]) 99 - // Dash 100 - source(src=decodedVideos[1]) 101 - // Fallback 102 - source(src=decodedVideos[2]) 74 + - var url = p.secure_media.reddit_video.dash_url 75 + video(src=url controls data-dashjs-player loading="lazy").post-media 76 + else if isPostLink(p) 77 + a(href=p.url) 78 + | #{p.domain} ↗ 79 + if (query.view == "compact") 103 80 button(onclick=`toggleDetails('${p.id}')`) 104 81 | close
+6 -2
src/mixins/postUtils.pug
··· 1 + - 2 + function isPostMedia(p) { 3 + return isPostImage(p) || isPostGallery(p) || isPostVideo(p); 4 + } 1 5 - 2 6 function isPostGallery(p) { 3 7 return (p.is_gallery && p.is_gallery == true); ··· 56 60 // Find all anchors that href to preview.redd.it, i.redd.it, i.imgur.com 57 61 // and contain just a link to the same href 58 62 const expression = /<a href="(http[s]?:\/\/(?:preview\.redd\.it|i\.redd\.it|i\.imgur\.com).*?)">\1?<\/a>/g; 59 - const matches = html.matchAll(expression); 63 + const matches = Array.from(html.matchAll(expression)); 60 64 var result = html; 61 65 matches.forEach((match) => { 62 66 // Replace each occurrence with an actual img tag ··· 81 85 var poster_url = p.preview && p.preview.images ? p.preview.images[0].source.url.replace(expression, '&') : ''; 82 86 83 87 return [hls_url, dash_url, fallback_url, scrubber_url, poster_url]; 84 - } 88 + }
+82 -72
src/public/styles.css
··· 11 11 --link-visited-color: #999; 12 12 --accent: var(--link-color); 13 13 --error-text-color: red; 14 + --border-radius-card: 0.5vmin; 15 + --border-radius-media: 0.5vmin; 16 + --border-radius-preview: 0.3vmin; 14 17 15 18 font-family: Inter, sans-serif; 16 19 font-feature-settings: 'ss01' 1, 'kern' 1, 'liga' 1, 'cv05' 1, 'dlig' 1, 'ss01' 1, 'ss07' 1, 'ss08' 1; ··· 47 50 color: var(--text-color); 48 51 } 49 52 50 - body:has(details.card[open]) { 51 - overflow: hidden; 52 - } 53 - 54 53 body.media-maximized { 55 54 /* Fix for Safari User Agent stylesheet */ 56 55 margin: 0; ··· 82 81 .info-container a, 83 82 .comment-info-container a, 84 83 .sort-opts a, 84 + .view-opts a, 85 85 .more a 86 86 { 87 87 text-decoration: none; ··· 116 116 align-items: center; 117 117 } 118 118 119 - .sorting { 119 + .sorting, 120 + .viewing { 120 121 margin-top: 20px; 121 122 } 122 123 123 - .sort-opts { 124 + .sort-opts, 125 + .view-opts { 124 126 display: grid; 125 127 margin: 10px; 126 128 } 127 129 128 - .sort-opts { 130 + .sort-opts, 131 + .view-opts { 129 132 grid-template-columns: repeat(2, 1fr); 130 133 grid-template-rows: repeat(5, 1fr); 131 134 grid-auto-flow: column; 132 135 } 133 136 137 + .view-opts { 138 + grid-template-rows: repeat(2, 1fr); 139 + } 140 + 134 141 .footer { 135 142 display: flex; 136 143 flex-direction: row; ··· 161 168 162 169 .post-container.card { 163 170 border: 1px solid var(--bg-color-muted); 164 - border-radius: 8px; 171 + border-radius: var(--border-radius-card); 165 172 display: block; 166 173 } 167 174 168 175 .post-text.card { 169 176 padding: 0.9rem; 170 - padding-top: 0.3rem; 177 + padding-top: 0.5rem; 178 + padding-bottom: 0.5rem; 179 + overflow-wrap: break-word; 180 + max-width: 95%; 171 181 } 172 182 173 183 .self-text-overflow.card { ··· 178 188 overflow: hidden; 179 189 overflow-wrap: break-word; 180 190 display: block; 191 + max-width: 98%; 181 192 } 182 193 183 194 .self-text.card { ··· 190 201 text-overflow: ellipsis; 191 202 } 192 203 193 - .media-preview.card { 204 + .image-viewer { 194 205 position: relative; 195 - padding: 0.3rem; 196 - padding-bottom: 0.3rem; 197 206 } 198 207 199 - .media-preview.card > img { 208 + .image-viewer > img { 200 209 cursor: pointer; 201 210 } 202 211 203 - .gallery.card { 204 - align-items: center; 205 - } 206 - 207 - .gallery-item.card > a > img { 208 - max-width: 95vw; 209 - max-height: 95vh; 210 - width: auto; 211 - height: auto; 212 - } 213 - 214 212 .spoiler { 215 213 background-color: rbga(var(--bg-color-muted), 0.2); 216 214 /* Safari on iOS <= 17 */ 217 215 -webkit-backdrop-filter: blur(3rem); 218 216 backdrop-filter: blur(3rem); 219 - border-radius: 4px; 217 + border-radius: var(--border-radius-preview); 220 218 221 219 position: absolute; 222 220 top: 0; ··· 231 229 align-items: center; 232 230 233 231 cursor: pointer; 232 + 233 + z-index: 10; 234 234 } 235 235 236 - .gallery-item-idx.card, 236 + .gallery-item-idx, 237 237 .spoiler > h2 { 238 238 text-shadow: 0.1rem 0.1rem 1rem var(--bg-color-muted); 239 239 } ··· 280 280 object-fit: cover; 281 281 width: 4rem; 282 282 height: 4rem; 283 + border-radius: var(--border-radius-preview); 283 284 } 284 285 285 - .media-preview.card img, 286 - .media-preview.card video { 287 - border-radius: 6px; 286 + .image-viewer img, 287 + .image-viewer video { 288 + border-radius: var(--border-radius-media); 288 289 289 - max-height: 40vh; 290 - max-width: 100%; 290 + max-height: 50vh; 291 + max-width: 95%; 291 292 292 293 display: block; 293 - width: initial; 294 - height: initial; 294 + width: unset; 295 + height: unset; 295 296 margin-left: auto; 296 297 margin-right: auto; 297 - margin-bottom: 1rem; 298 + margin-bottom: 0.5rem; 298 299 299 300 object-fit: fill; 300 301 } 301 302 302 - .media-preview.card a { 303 - font-size: 1.5rem; 304 - margin: 1rem; 305 - padding: initial; 303 + .image-viewer.main-content img, 304 + .image-viewer.main-content video { 305 + max-height: 70vh; 306 306 } 307 307 308 - .media-preview a { 309 - font-size: 2rem; 310 - text-decoration: none; 311 - padding: 1rem; 308 + .image-viewer a:has(img) { 309 + font-size: 0rem; 310 + padding: unset; 311 + margin: unset; 312 312 } 313 313 314 314 .media-maximized { ··· 349 349 } 350 350 351 351 @media (min-width: 768px) { 352 + :root { 353 + --border-radius-card: 0.5vmin; 354 + --border-radius-media: 0.5vmin; 355 + --border-radius-preview: 0.3vmin; 356 + } 352 357 .post, .comments-container, .hero, .header, .footer { 353 358 flex: 1 1 90%; 354 359 width: 90%; ··· 359 364 width: 5rem; 360 365 height: 5rem; 361 366 } 362 - .media-preview.card img, 363 - .media-preview.card video 367 + .image-viewer img, 368 + .image-viewer video 364 369 { 365 370 max-height: 50vh; 366 371 } 367 - .media-preview.card a { 368 - font-size: 1rem; 369 - margin: 0.7rem; 370 - padding: initial; 372 + .post-text.card { 373 + max-width: 100%; 371 374 } 372 - .self-text.card { 373 - -webkit-line-clamp: 4; 374 - line-clamp: 4; 375 + .self-text-overflow.card { 376 + max-width: 100%; 375 377 } 376 378 .post-author { 377 379 display: inline ··· 382 384 form { 383 385 width: 40%; 384 386 } 385 - .sort-opts { 387 + .sort-opts, 388 + .view-opts { 386 389 grid-template-columns: repeat(9, 1fr); 387 390 grid-template-rows: repeat(1, 1fr); 388 391 grid-auto-flow: row; ··· 390 393 } 391 394 392 395 @media (min-width: 1080px) { 396 + :root { 397 + --border-radius-card: 0.5vmin; 398 + --border-radius-media: 0.5vmin; 399 + --border-radius-preview: 0.3vmin; 400 + } 393 401 .post, .comments-container, .hero, .header, .footer { 394 402 flex: 1 1 60%; 395 403 width: 60%; ··· 400 408 width: 5rem; 401 409 height: 5rem; 402 410 } 403 - .media-preview.card img, 404 - .media-preview.card video 411 + .image-viewer img, 412 + .image-viewer video 405 413 { 406 - max-height: 30vh; 414 + max-height: 45vh; 407 415 } 408 416 .media-preview a { 409 417 font-size: 2rem; 410 418 padding: 2rem; 411 419 } 412 - .media-preview.card a { 413 - font-size: 1rem; 414 - margin: 0.5rem; 415 - padding: initial; 416 - } 417 420 .self-text.card { 418 - -webkit-line-clamp: 6; 419 - line-clamp: 6; 421 + -webkit-line-clamp: 4; 422 + line-clamp: 4; 420 423 } 421 424 .post-author { 422 425 display: inline ··· 427 430 form { 428 431 width: 20%; 429 432 } 430 - .sort-opts { 433 + .sort-opts, 434 + .view-opts { 431 435 grid-template-columns: repeat(9, 1fr); 432 436 grid-template-rows: repeat(1, 1fr); 433 437 grid-auto-flow: row; ··· 439 443 flex: 1 1 40%; 440 444 width: 40%; 441 445 } 442 - .media-preview.card img, 443 - .media-preview.card video 446 + .image-viewer img, 447 + .image-viewer video 444 448 { 445 - max-height: 20vh; 449 + max-height: 30vh; 446 450 } 447 - .sort-opts { 451 + .sort-opts, 452 + .view-opts { 448 453 grid-template-columns: repeat(9, 1fr); 449 454 grid-template-rows: repeat(1, 1fr); 450 455 grid-auto-flow: row; ··· 468 473 margin-top: 10px; 469 474 } 470 475 471 - .post-container { 476 + .post-info { 472 477 display: flex; 473 478 flex-direction: row; 474 479 align-items: center; ··· 527 532 528 533 .header a, 529 534 .sort-opts a, 535 + .view-opts a, 530 536 .sub-title a { 531 537 color: var(--text-color); 532 538 } ··· 621 627 overflow-x: auto; 622 628 position: relative; 623 629 padding: 5px; 630 + align-items: center; 631 + scroll-snap-type: both mandatory; 624 632 } 625 633 626 634 .gallery-item { 627 635 flex: 0 0 auto; 628 636 margin-right: 10px; 637 + max-width: 100%; 638 + width: 100%; 639 + scroll-snap-align: center; 629 640 } 630 641 631 - .gallery img { 632 - width: auto; 633 - max-height: 500px; 642 + .gallery-item-idx { 643 + text-align: center; 634 644 } 635 645 636 646 .post-title { ··· 791 801 792 802 .sticky { 793 803 background-color: var(--sticky-color); 794 - border-radius: 2px; 804 + border-radius: var(--border-radius-card); 795 805 border: 4px solid var(--sticky-color); 796 806 } 797 807
+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
··· 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 });
+6 -1
src/utils.pug
··· 1 - - var fmtnum = (n)=>n>=1000?(n/1000).toFixed(1)+'k':n; 1 + - 2 + function fmtnum(n) { 3 + return n >= 1e6 ? (n / 1e6).toFixed(1) + 'mil' : 4 + n >= 1e3 ? (n / 1e3).toFixed(1) + 'k' : 5 + n; 6 + } 2 7 - var fmttxt = (n,t)=>`${t}${n==1?'':'s'}` 3 8 - var stripPrefix = (s, p) => s.startsWith(p) ? s.slice(p.length) : s; 4 9 -
+18 -17
src/views/comments.pug
··· 47 47 h2.post-title 48 48 != post.title 49 49 50 - if isPostGallery(post) 51 - div.gallery 52 - each item in postGalleryItems(post) 53 - div.gallery-item 54 - div.gallery-item-idx 55 - | #{`${item.idx}/${item.total}`} 56 - a(href=`/media/${item.url}`) 57 - img(src=item.url loading="lazy") 58 - else if isPostImage(post) 59 - a(href=`/media/${post.url}`) 60 - img(src=post.url).post-media 61 - else if isPostVideo(post) 62 - - var url = post.secure_media.reddit_video.dash_url 63 - video(controls data-dashjs-player src=`${url}`).post-media 64 - else if isPostLink(post) 65 - a(href=post.url) 66 - | #{post.url} 50 + div.image-viewer.main-content 51 + if isPostGallery(post) 52 + div.gallery 53 + each item in postGalleryItems(post) 54 + div.gallery-item 55 + a(href=`/media/${item.url}`) 56 + img(src=item.url loading="lazy") 57 + div.gallery-item-idx 58 + | #{`${item.idx}/${item.total}`} 59 + else if isPostImage(post) 60 + a(href=`/media/${post.url}`) 61 + img(src=post.url).post-media 62 + else if isPostVideo(post) 63 + - var url = post.secure_media.reddit_video.dash_url 64 + video(controls data-dashjs-player src=`${url}`).post-media 65 + else if isPostLink(post) 66 + a(href=post.url) 67 + | #{post.url} 67 68 68 69 if post.selftext_html 69 70 div.self-text
+9 -3
src/views/index.pug
··· 33 33 | consider donating to&nbsp; 34 34 a(href="https://donate.stripe.com/dR62bTaZH1295Da4gg") oppiliappan 35 35 |, author of lurker 36 + if about && !isMulti 37 + div.info-container 38 + p 39 + | #{fmtnum(about.accounts_active)} active 40 + | &nbsp;·&nbsp; 41 + | #{fmtnum(about.subscribers)} subscribers 36 42 hr 37 43 details.sort-details 38 44 summary.sorting sorting by #{query.sort + (query.t?' '+query.t:'')} ··· 55 61 a(href=`/r/${subreddit}?sort=top&t=year&view=${viewQuery}`) top year 56 62 div 57 63 a(href=`/r/${subreddit}?sort=top&t=all&view=${viewQuery}`) top all 58 - details.sort-details 59 - summary.sorting viewing as #{viewQuery} 60 - div.sort-opts 64 + details.view-details 65 + summary.viewing viewing as #{viewQuery} 66 + div.view-opts 61 67 div 62 68 a(href=`/r/${subreddit}?sort=${sortQuery}&view=compact`) compact 63 69 div
+1 -6
src/views/media.pug
··· 2 2 doctype html 3 3 html 4 4 +head("home") 5 - script(type='text/javascript'). 6 - function toggleZoom() { 7 - Array.from(document.getElementsByClassName('media-maximized')).forEach(element => element.classList.toggle('zoom')); 8 - } 9 - 10 5 body.media-maximized 11 6 div.media-maximized.container 12 7 if kind == 'img' 13 - img(src=url onclick=`toggleZoom()`).media-maximized 8 + img(src=url).media-maximized 14 9 else 15 10 video(src=url controls).media-maximized