Compare commits

...

121 Commits

Author SHA1 Message Date
Spike O'Carroll 64d7b30eca
Direct visitors to Redlib 2024-05-02 16:22:30 -07:00
dependabot[bot] b69fae2383
Bump mio from 0.8.8 to 0.8.11 (#875)
Bumps [mio](https://github.com/tokio-rs/mio) from 0.8.8 to 0.8.11.
- [Release notes](https://github.com/tokio-rs/mio/releases)
- [Changelog](https://github.com/tokio-rs/mio/blob/master/CHANGELOG.md)
- [Commits](https://github.com/tokio-rs/mio/compare/v0.8.8...v0.8.11)

---
updated-dependencies:
- dependency-name: mio
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-05 14:37:19 -08:00
Matthew Esposito 9d948abadc
Merge pull request #831 from bennettmsherman/header-filters
Remove Reddit's 'Nel' and 'Report-To' (network error logging) response headers
2023-11-29 09:25:16 -05:00
Spike 2815dc5209
Correct the shutdown announcement 2023-07-14 12:05:57 -07:00
Spike 00697c6ae4
Add shut down announcement 2023-07-14 11:57:25 -07:00
Ben Sherman 7a14975fb8 Remove 'Nel' and 'Report-To' response headers 2023-07-08 19:20:58 -07:00
Matthew Esposito ea696687be
Merge pull request #821 from fawni/feat/hide-subreddit-panel 2023-06-09 19:01:57 -04:00
fawn 13394b4a5e
Add ability to hide subreddit panel (closes #801) 2023-06-07 13:51:27 +03:00
Matthew Esposito ba89b76332
Merge pull request #814 from Tokarak/deps-update 2023-06-04 18:14:27 -04:00
Matthew Esposito 96e9e0ea9f
Update .replit to download from nightly build artifacts (#815) 2023-06-03 23:36:39 +00:00
Matthew Esposito c1dd1a091e
Update release binary paths 2023-06-03 16:30:58 -04:00
Matthew Esposito 05ae39f743
Update RUSTFLAGS 2023-06-03 16:15:24 -04:00
Matthew Esposito 221260c282
Remove MUSL, build statically via flags 2023-06-03 16:12:48 -04:00
Tokarak f3c835bee7 Proof-read README.md 2023-06-03 20:02:02 +01:00
Tokarak f9fd54aa3c Specify newer dependencies + cargo update 2023-06-03 19:41:32 +01:00
Matthew Esposito 510d967777
Add MUSL target 2023-06-03 14:33:27 -04:00
Matthew Esposito 0bcebff6f2
Fix YAML formatting 2023-06-03 14:24:19 -04:00
Matthew Esposito 0c74305617
Add MUSL builds to GH Actions and fix Release event trigger (#810) 2023-06-03 18:19:20 +00:00
Nazar 97f0f69059
Rebase #811 (#812)
Co-authored-by: Matthew Esposito <matt@matthew.science>
2023-06-03 17:32:46 +00:00
Matthew Esposito b5fc4bef28
Fix github-actions versioning 2023-05-31 19:50:38 -04:00
Mathew Davies 81a6e6458c
ci: cleanup github actions (#803) 2023-05-31 23:47:58 +00:00
Matthew Esposito 193a6effbf
Merge pull request #792 from beucismis/master 2023-05-31 18:42:39 -04:00
Matthew Esposito 09551fca29
Merge pull request #806 from gmnsii/comment-searchbar-color 2023-05-31 18:40:25 -04:00
gmnsii 38ee0d9428 make comment search bar color change based on theme 2023-05-31 19:41:13 +02:00
Matthew Esposito ca7ad9f812
Merge pull request #796 from StuffNoOneCaresAbout/lazy-init-regex 2023-05-01 10:09:59 -04:00
Matthew Esposito 98e2833881
Merge pull request #790 from StuffNoOneCaresAbout/allow-disabling-indexing 2023-05-01 10:08:20 -04:00
Kavin 4d5c52b83b
Rename variables to more descriptive names. 2023-05-01 05:00:49 +01:00
Kavin 6c47ea921b
performance: compile regex only once 2023-05-01 04:22:10 +01:00
beucismis 6c0e5cfe93 Add cursor:pointer for button and select 2023-04-29 21:16:02 +03:00
Kavin 0c591149d5
Add option to disable all indexing. 2023-04-26 12:52:12 +01:00
Kavin 8b4b2dd268
Ignore idea files. 2023-04-26 12:52:00 +01:00
Matthew Esposito ac58bb532a
Merge pull request #787 from libreddit/clippy_refactor 2023-04-19 13:08:44 -04:00
Matthew Esposito af8fe176ea
Fix clippy warnings 2023-04-19 10:37:47 -04:00
Matthew Esposito bfa9c084bb
Merge pull request #786 from libreddit/update_deps 2023-04-19 10:32:46 -04:00
Matthew Esposito 3c892d3cfd
Update Cargo.lock - h2 moderate 2023-04-19 10:27:50 -04:00
Matthew Esposito 4a1b448abb
Merge pull request #776 from iTzBoboCz/polls 2023-04-17 18:12:02 -04:00
Matthew Esposito 991677cd1e
Add variable for now_utc, format 2023-04-17 18:00:41 -04:00
Matthew Esposito 3b8a13d050
Merge pull request #773 from libreddit/fmt_clippy 2023-04-15 11:01:19 -04:00
Matthew Esposito 0e90ebc1a1
Merge pull request #769 from gmnsii/bypass-gate 2023-04-15 11:00:20 -04:00
Matthew Esposito af89d4c88f
Merge pull request #778 from Akanksh12/comments-to-contrib-files 2023-04-15 10:59:28 -04:00
Matthew Esposito 5f87875b8e
Merge branch 'master' into bypass-gate 2023-04-15 10:56:28 -04:00
Matthew Esposito aaf05de1a8
Merge pull request #771 from gmnsii/comment-search 2023-04-15 10:55:10 -04:00
Akanksh Chitimalla 17f7f6a9d1 changed default port to 12345 2023-04-08 21:17:19 +05:30
Ondřej Pešek ec226e0cab fix(polls): apply clippy suggestions 2023-04-08 10:41:12 +02:00
Matthew Esposito 2b8931c032
Merge pull request #770 from invakid404/patch-1
fix(style): fit footer width to body size
2023-04-07 12:05:41 -04:00
Matthew Esposito 62771bf4a3
Merge pull request #751 from master-hax/optimize-docker
optimize arm dockerfile
2023-04-07 12:02:03 -04:00
Akanksh Chitimalla 22e3e0eb91 added comments to libreddit.service and .conf 2023-04-06 10:06:37 +05:30
Ondřej Pešek 94a781c82c fix(polls): minor improvements 2023-04-01 14:31:39 +02:00
Ondřej Pešek 75af984154 fix(polls): apply suggestions and fix id parsing 2023-04-01 14:26:04 +02:00
Ondřej Pešek 8bed342a6d fix: print time suffix only for relative dates 2023-04-01 13:21:15 +02:00
gmnsii de5d8d5f86 Requested code style changes 2023-03-26 11:52:02 -07:00
Matthew Esposito f465394f93
Address fmt + clippy 2023-03-25 16:32:42 -04:00
gmnsii 1e418619f1 Feat: search for comments within posts
Add the ability to search for specific comments within posts.
Known issues:
  - Just like on reddit, this does not work with comment sorting. The
    sorting order is ignored during the search and changing the sorting
    order after the search does not change anything. I do not think we
    can fix this before reddit does, since in my understanding we rely
    on them for the sorting. However we could implement a default
    sorting method ourselves by taking the vector of comments returned
    from the search and sorting it manually.
  - The UI could be improved on mobile. On screens with a max width
    inferior to 480 pixels, the comment search bar is displayed below
    the comment sorting form. It would be great if we could make the
    search bar have the same width as the whole comment sorting form
    but I do not have the willpower to write any more css.
2023-03-24 17:41:26 -07:00
gmnsii 8be69f6fe5 Checks if the link contains the parameter instead of ends with it
To know if the gate should be bypassed, we check if the link contains
the pasameter instead of checking if the link ends with it. This is
impostant, for example if we were to implement searching for comments
within a post. If we wanted to search for comments within a post that we
have bypassed the gate to view: the link will look like
https://libreddit-instance/r/somesub/comments/post-id/post-title&bypass_nsfw_landing/?q=some-query&type=comment
2023-03-23 12:36:04 -07:00
gmnsii a0726c5903 Change the bypass message and format code
The bypass message now indicates that the bypass is only temporary.
2023-03-23 11:09:33 -07:00
Ondřej Pešek c1c867a5ff feat: add polls 2023-03-23 13:21:09 +01:00
Ondřej Pešek 5dc3279ac3 fix: make time work with future dates 2023-03-23 13:18:48 +01:00
Tsvetomir Bonev dead990ba0
fix(style): fit footer width to body size 2023-03-23 13:49:40 +02:00
gmnsii e046144bf3 Allow bypassing nsfw gate for posts
On instances that are not sfw-only, the nsfw gate for posts can now be
bypassed.
2023-03-22 23:18:35 -07:00
kuanhulio e25622dac2
harden docker-compose.yml (#760)
`user: nobody`: the least privileged account.
`read_only: true`: this container doesn't write anything to the filesystem, this removes a vector.
`security_opt`: disallows the container to grab more privileges.
`cap_drop`: this container doesn't need any capabilities, drop them.
`networks`: put `libreddit` into its own network so it cannot see other containers by default.
2023-03-17 10:17:01 -06:00
Daniel Valentine 6bcc4aa368
Update version string in Cargo.lock. 2023-03-17 09:36:52 -06:00
Vivek 6d652fc38c
optimize arm dockerfile 2023-03-12 23:36:25 -07:00
Daniel Valentine f62f7bf200
v0.30.1 2023-03-10 21:34:42 -07:00
Daniel Valentine aece392a86
Pad bottom of body to prevent footer collision (fixes #747) 2023-03-10 21:33:45 -07:00
xatier aeeb066e47
Update README.md (#748)
* Remove duplicated config

Was accidentally introduced in  412ce8f1f3
2023-03-10 21:04:05 -07:00
Daniel Valentine 51cdf574f7
v0.30.0 2023-03-08 22:15:31 -07:00
Spike af6722c053
Move unimportant links to footer (#728) 2023-03-08 22:14:43 -07:00
Matthew Esposito 412ce8f1f3
Fix default subscriptions (#732)
Co-authored-by: Daniel Valentine <daniel@vielle.ws>
2023-03-08 21:53:23 -07:00
o69mar dfa57c890d
fix build error on windows (#741) 2023-03-08 21:32:41 -07:00
mikupls 01f9907aaf
show the count of 'more replies'. (#740)
Co-authored-by: Daniel Valentine <daniel@vielle.ws>
Co-authored-by: Matthew Esposito <matt@matthew.science>
2023-03-08 21:30:41 -07:00
mikupls bf19ff513f
add support for gifs in galleries. (#744) 2023-03-08 21:04:26 -07:00
mikupls ffc9ca2e98
use the documented LIBREDDIT_DEFAULT_DISABLE_VISIT_REDDIT_CONFIRMATION config option. (#737) 2023-03-04 13:04:40 -07:00
Daniel Valentine cef9266648
Restructure section on Libreddit user privacy. 2023-02-26 03:35:36 -07:00
Daniel Valentine d3b4f4e379
Update tempfile to v3.4.0. 2023-02-26 03:11:17 -07:00
Daniel Valentine b90b41c009
v0.29.4 2023-02-26 03:01:35 -07:00
pin 0eccb9bcf2
Add NetBSD install (#720) 2023-02-26 01:13:56 -07:00
domve eb07a2ce7c
Make gated subreddits accessible by treating them as quarantined (#722)
* Fix gated communities being unviewable by treating them as quarantined

* Show restriction reason in quarantine template

* Add `gated` checks for other requests
2023-02-26 00:40:32 -07:00
wsy2220 0b39d4f059
Mark search query as safe on Prev/Next button (#731)
Fixes: #677 again. Complement to #686.
2023-02-26 00:35:05 -07:00
wsy2220 58fa213be8
Reuse hyper client. (#727)
Making a new connection on every request is very slow and wasteful, espectially on slower network.

Fix this by reuse a hyper client which shares a connection pool.

I'm able to lower /r/popular loading time from 5s to 1.5s on my machine.
2023-02-26 00:33:55 -07:00
Spike 5e03d701e4
Revert "Move unimportant links to footer"
This reverts commit e3df3a9470.
2023-02-19 18:03:55 +00:00
Spike e3df3a9470
Move unimportant links to footer 2023-02-19 18:00:56 +00:00
Daniel Valentine 35504eda14
v0.29.3 -- fix layout bugs on mobile
Addresses the following layout bugs in mobile view:

* improper rendering of award images on posts
* upvote ratio no longer appearing on bottom-right corner of post as
  before
* Reddit warning pop-up background cut off at bottom of page

Fixes #713.
2023-02-14 20:19:19 -07:00
Daniel Valentine a05cfe60fe
v0.29.2 2023-02-12 03:36:48 -07:00
Daniel Valentine 2774d15298
Fix bug causing user/sub title to appear off-center. 2023-02-12 01:02:25 -07:00
Spike f544daf8c0
Replace snoo with r/ icon 2023-02-09 21:40:51 -08:00
Daniel Valentine 089315f9bb
v0.29.1 (fixes #713) 2023-02-09 22:25:42 -07:00
Daniel Valentine 1f7e14dd4e
v0.29.0 2023-02-08 00:33:57 -07:00
Daniel Valentine 37f71c48d1
Reduce size of instance info button in footer. 2023-02-08 00:33:31 -07:00
potatoesAreGod fa68bf561b
added leaving reddit dialog (#643) 2023-02-08 00:24:06 -07:00
spikecodes a4eecb251e
Fix listing_options hidden overflow 2023-02-04 00:02:32 -08:00
Daniel Valentine 9bf6194b09
v0.28.1
Remove font-weight associated with instance info button, which made the
icon look ghastly in Chrome.
2023-01-31 00:14:23 -07:00
Daniel Valentine f405f509c4
v0.28.0 2023-01-30 02:07:32 -07:00
Matthew Esposito 8be5fdee2d
Implement instance info endpoint (JSON, YAML, TXT) (#685)
Co-authored-by: Daniel Valentine <daniel@vielle.ws>
Co-authored-by: spikecodes <19519553+spikecodes@users.noreply.github.com>
2023-01-30 02:02:43 -07:00
spikecodes 7efa26e811
Fix #699 2023-01-21 00:35:49 -08:00
Spike 755fff0818
Use Markdown Highlights in README 2023-01-19 18:28:24 -08:00
Spenser Black 53e1e302d5
Register Dockerfile.* as Dockerfiles for Linguist (#694)
This allows GitHub Linguist to generate slightly more accurate language
stats for this repository, and also enable syntax highlighting in the
GitHub web UI. Due to caching, it may take a few days for this change to
have a visible effect on github.com.
2023-01-16 21:57:55 -07:00
Matthew Esposito 3d0287f04f
Add comment count in post (#659)
* Add comment count in post

* Restyle comment count
2023-01-16 12:05:53 -08:00
spikecodes 7cb132af01
Update packages 2023-01-16 11:09:57 -08:00
Daniel Valentine 63b0b936aa
Update CREDITS file. 2023-01-12 02:19:09 -07:00
Daniel Valentine 412122d7d9
v0.27.1 2023-01-12 01:57:03 -07:00
potatoesAreGod eb9ef9f6d9
added leaving reddit dialog (#643) 2023-01-12 01:46:56 -07:00
Matthew Esposito 27091db53b
Create rust-tests.yml (#690)
This will run tests on every push and PR to master.
2023-01-12 01:43:08 -07:00
Spenser Black 2a54043afc
Simplify listener definition (#681)
This simplifies the logic to build the listener by using more clap
features instead of manually accessing the PORT environment variable.
This also removes unnecessary `unwrap_or` calls that set defaults that
are already set by clap.
2023-01-12 01:41:59 -07:00
dependabot[bot] e238a7b168
Bump tokio from 1.23.0 to 1.23.1 (#691)
Bumps [tokio](https://github.com/tokio-rs/tokio) from 1.23.0 to 1.23.1.
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.23.0...tokio-1.23.1)

---
updated-dependencies:
- dependency-name: tokio
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-12 01:39:23 -07:00
Matthew E 1e554acd20
Merge pull request #687 from jojosch/fix-cfg-test
Fix tests
2023-01-04 16:03:48 -05:00
Johannes Schleifenbaum dff91da877
config: fix SFW test 2023-01-04 11:12:19 +01:00
Matthew E f6bb53e388
Mark search query as safe in askama template (#686) 2023-01-03 20:55:17 -08:00
Matthew E 709292339a
Merge pull request #674 from spenserblack/codespace 2023-01-03 20:12:36 -05:00
Matthew E 799e5b882b
Merge pull request #667 from erdnaxe/scrollbar_theme 2023-01-03 19:34:40 -05:00
Daniel Valentine 0ff92cbfe3
v0.27.0 2023-01-03 11:21:27 -07:00
Daniel Valentine e9891236cd
Remove unnecessary SFW-only disclosure in settings in SFW-only mode. 2023-01-03 11:20:55 -07:00
Matthew E e2c48c3438
Add hide_awards config option (fixes #442) 2023-01-03 11:16:22 -07:00
Daniel Valentine 9a7b3b29f5
Merge remote-tracking branch 'origin/master' into hide_awards 2023-01-03 11:12:27 -07:00
Daniel Valentine 10add895fb
Merge pull request #683 from Tokarak/master 2023-01-03 09:28:13 -07:00
Tokarak 050eaedf15 Remove unused dep "async-recursion"
Found using cargo-udeps. Checked.
2023-01-03 14:56:17 +00:00
Matthew E 5b06a3fc64
Add config system to read from file (#664)
Co-authored-by: Daniel Valentine <daniel@vielle.ws>
2023-01-03 02:55:22 -07:00
Daniel Valentine 4817f51bc0
v0.26.0 2023-01-03 02:40:44 -07:00
Daniel Valentine c83a4e0cc8
Landing page for NSFW content, SFW-only mode (#656)
Co-authored-by: Matt <matt@matthew.science>
Co-authored-by: Spike <19519553+spikecodes@users.noreply.github.com>
2023-01-03 02:39:45 -07:00
Matthew Esposito 6a785baa2c Add hide_awards config 2023-01-01 21:39:38 -05:00
Spenser Black b1182e7cf5
Create devcontainer.json 2023-01-01 14:52:33 -05:00
Alexandre Iooss 1fa9f27619 Theme browser scrollbar
Hint current color-scheme to the browser. This enables chromium-based
browsers to change the scrollbar color according to the current theme.
2022-12-26 22:57:04 +01:00
45 changed files with 2400 additions and 790 deletions

View File

@ -0,0 +1,14 @@
{
"name": "Rust",
"image": "mcr.microsoft.com/devcontainers/rust:0-1-bullseye",
"features": {
"ghcr.io/devcontainers/features/docker-in-docker:2": {}
},
"portsAttributes": {
"8080": {
"label": "libreddit",
"onAutoForward": "notify"
}
},
"postCreateCommand": "cargo build"
}

1
.gitattributes vendored Normal file
View File

@ -0,0 +1 @@
Dockerfile.* linguist-language=Dockerfile

View File

@ -1,38 +0,0 @@
name: Docker ARM Build
on:
push:
paths-ignore:
- "**.md"
branches:
- master
jobs:
build-docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
with:
platforms: all
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1
with:
version: latest
- name: Login to DockerHub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v2
with:
context: .
file: ./Dockerfile.arm
platforms: linux/arm64
push: true
tags: libreddit/libreddit:arm
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@ -1,41 +0,0 @@
name: Docker ARM V7 Build
on:
push:
paths-ignore:
- "**.md"
branches:
- master
jobs:
build-docker:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Set up QEMU
id: qemu
uses: docker/setup-qemu-action@v1
with:
platforms: all
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1
with:
version: latest
- name: Login to DockerHub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push
id: build_push
uses: docker/build-push-action@v2
with:
context: .
file: ./Dockerfile.armv7
platforms: linux/arm/v7
push: true
tags: libreddit/libreddit:armv7
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@ -1,44 +1,58 @@
name: Docker amd64 Build
name: Docker Build
on:
push:
paths-ignore:
- "**.md"
branches:
- master
- 'main'
- 'master'
jobs:
build-docker:
runs-on: ubuntu-latest
strategy:
matrix:
config:
- { platform: 'linux/amd64', tag: 'latest', dockerfile: 'Dockerfile' }
- { platform: 'linux/arm64', tag: 'latest-arm', dockerfile: 'Dockerfile.arm' }
- { platform: 'linux/arm/v7', tag: 'latest-armv7', dockerfile: 'Dockerfile.armv7' }
steps:
- uses: actions/checkout@v2
- name: Checkout sources
uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
uses: docker/setup-qemu-action@v2
with:
platforms: all
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1
uses: docker/setup-buildx-action@v2
with:
version: latest
- name: Login to DockerHub
uses: docker/login-action@v1
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Docker Hub Description
uses: peter-evans/dockerhub-description@v3
if: matrix.config.platform == 'linux/amd64'
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
repository: libreddit/libreddit
- name: Build and push
uses: docker/build-push-action@v2
uses: docker/build-push-action@v4
with:
context: .
file: ./Dockerfile
platforms: linux/amd64
file: ./${{ matrix.config.dockerfile }}
platforms: ${{ matrix.config.platform }}
push: true
tags: libreddit/libreddit:latest
tags: libreddit/libreddit:${{ matrix.config.tag }}
cache-from: type=gha
cache-to: type=gha,mode=max

78
.github/workflows/main-rust.yml vendored Normal file
View File

@ -0,0 +1,78 @@
name: Rust Build & Publish
on:
push:
paths-ignore:
- "**.md"
branches:
- 'main'
- 'master'
release:
types: [published]
env:
CARGO_TERM_COLOR: always
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout sources
uses: actions/checkout@v3
- name: Cache Packages
uses: Swatinem/rust-cache@v2
- name: Install stable toolchain
uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
# Building actions
- name: Build
run: RUSTFLAGS='-C target-feature=+crt-static' cargo build --release --target x86_64-unknown-linux-gnu
- name: Calculate SHA512 checksum
run: sha512sum target/x86_64-unknown-linux-gnu/release/libreddit > libreddit.sha512
- name: Calculate SHA256 checksum
run: sha256sum target/x86_64-unknown-linux-gnu/release/libreddit > libreddit.sha256
- uses: actions/upload-artifact@v3
name: Upload a Build Artifact
with:
name: libreddit
path: |
target/x86_64-unknown-linux-gnu/release/libreddit
libreddit.sha512
libreddit.sha256
- name: Versions
id: version
run: echo "VERSION=$(cargo metadata --format-version 1 --no-deps | jq .packages[0].version -r | sed 's/^/v/')" >> "$GITHUB_OUTPUT"
# Publishing actions
- name: Publish to crates.io
if: github.event_name == 'release'
run: cargo publish --no-verify --token ${{ secrets.CARGO_REGISTRY_TOKEN }}
- name: Release
uses: softprops/action-gh-release@v1
if: github.base_ref != 'master' && github.event_name == 'release'
with:
tag_name: ${{ steps.version.outputs.VERSION }}
name: ${{ steps.version.outputs.VERSION }} - ${{ github.event.head_commit.message }}
draft: true
files: |
target/x86_64-unknown-linux-gnu/release/libreddit
libreddit.sha512
libreddit.sha256
body: |
- ${{ github.event.head_commit.message }} ${{ github.sha }}
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}

62
.github/workflows/pull-request.yml vendored Normal file
View File

@ -0,0 +1,62 @@
name: Pull Request
on:
push:
branches:
- 'main'
- 'master'
pull_request:
branches:
- 'main'
- 'master'
jobs:
test:
name: cargo test
runs-on: ubuntu-latest
steps:
- name: Checkout sources
uses: actions/checkout@v3
- name: Install stable toolchain
uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
- name: Run cargo test
run: cargo test
format:
name: cargo fmt --all -- --check
runs-on: ubuntu-latest
steps:
- name: Checkout sources
uses: actions/checkout@v3
- name: Install stable toolchain with rustfmt component
uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
components: rustfmt
- name: Run cargo fmt
run: cargo fmt --all -- --check
clippy:
name: cargo clippy -- -D warnings
runs-on: ubuntu-latest
steps:
- name: Checkout sources
uses: actions/checkout@v3
- name: Install stable toolchain with clippy component
uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
components: clippy
- name: Run cargo clippy
run: cargo clippy -- -D warnings

View File

@ -1,59 +0,0 @@
name: Rust
on:
push:
paths-ignore:
- "**.md"
branches:
- master
env:
CARGO_TERM_COLOR: always
jobs:
build:
runs-on: ubuntu-18.04
steps:
- uses: actions/checkout@v2
- name: Cache Packages
uses: Swatinem/rust-cache@v1.0.1
- name: Build
run: cargo build --release
- name: Publish to crates.io
continue-on-error: true
run: cargo publish --no-verify --token ${{ secrets.CARGO_REGISTRY_TOKEN }}
- uses: actions/upload-artifact@v2.2.1
name: Upload a Build Artifact
with:
name: libreddit
path: target/release/libreddit
- name: Versions
id: version
run: |
echo "::set-output name=version::$(cargo metadata --format-version 1 --no-deps | jq .packages[0].version -r | sed 's/^/v/')"
echo "::set-output name=tag::$(git describe --tags)"
- name: Calculate SHA512 checksum
run: sha512sum target/release/libreddit > libreddit.sha512
- name: Release
uses: softprops/action-gh-release@v1
if: github.base_ref != 'master'
with:
tag_name: ${{ steps.version.outputs.version }}
name: ${{ steps.version.outputs.version }} - ${{ github.event.head_commit.message }}
draft: true
files: |
target/release/libreddit
libreddit.sha512
body: |
- ${{ github.event.head_commit.message }} ${{ github.sha }}
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}

5
.gitignore vendored
View File

@ -1 +1,4 @@
/target
/target
# Idea Files
.idea/

View File

@ -1,2 +1,2 @@
run = "while :; do set -ex; curl -o./libreddit -fsSL -- https://github.com/libreddit/libreddit/releases/latest/download/libreddit ; chmod +x libreddit; set +e; ./libreddit -H 63115200; sleep 1; done"
run = "while :; do set -ex; nix-env -iA nixpkgs.unzip; curl -o./libreddit.zip -fsSL -- https://nightly.link/libreddit/libreddit/workflows/main-rust/master/libreddit.zip; unzip -n libreddit.zip; mv target/x86_64-unknown-linux-gnu/release/libreddit .; chmod +x libreddit; set +e; ./libreddit -H 63115200; sleep 1; done"
language = "bash"

14
CREDITS
View File

@ -3,6 +3,7 @@
accountForIssues <52367365+accountForIssues@users.noreply.github.com>
Adrian Lebioda <adrianlebioda@gmail.com>
alefvanoon <53198048+alefvanoon@users.noreply.github.com>
Alexandre Iooss <erdnaxe@crans.org>
alyaeanyx <alexandra.hollmeier@mailbox.org>
AndreVuillemot160 <84594011+AndreVuillemot160@users.noreply.github.com>
Andrew Kaufman <57281817+andrew-kaufman@users.noreply.github.com>
@ -18,18 +19,23 @@ dacousb <53299044+dacousb@users.noreply.github.com>
Daniel Valentine <Daniel-Valentine@users.noreply.github.com>
Daniel Valentine <daniel@vielle.ws>
dbrennand <52419383+dbrennand@users.noreply.github.com>
dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Diego Magdaleno <38844659+DiegoMagdaleno@users.noreply.github.com>
domve <domve@posteo.net>
Dyras <jevwmguf@duck.com>
Edward <101938856+EdwardLangdon@users.noreply.github.com>
elliot <75391956+ellieeet123@users.noreply.github.com>
erdnaxe <erdnaxe@users.noreply.github.com>
Esmail EL BoB <github.defilable@simplelogin.co>
FireMasterK <20838718+FireMasterK@users.noreply.github.com>
George Roubos <cowkingdom@hotmail.com>
git-bruh <e817509a-8ee9-4332-b0ad-3a6bdf9ab63f@aleeas.com>
gmnsii <95436780+gmnsii@users.noreply.github.com>
guaddy <67671414+guaddy@users.noreply.github.com>
Harsh Mishra <erbeusgriffincasper@gmail.com>
igna <igna@intent.cool>
imabritishcow <bcow@protonmail.com>
Johannes Schleifenbaum <johannes@js-webcoding.de>
Josiah <70736638+fres7h@users.noreply.github.com>
JPyke3 <pyke.jacob1@gmail.com>
Kavin <20838718+FireMasterK@users.noreply.github.com>
@ -44,6 +50,7 @@ Macic <46872282+Macic-Dev@users.noreply.github.com>
Mario A <10923513+Midblyte@users.noreply.github.com>
Matthew Crossman <matt@crossman.page>
Matthew E <matt@matthew.science>
Matthew Esposito <matt@matthew.science>
Mennaruuk <52135169+Mennaruuk@users.noreply.github.com>
mikupls <93015331+mikupls@users.noreply.github.com>
Nainar <nainar.mb@gmail.com>
@ -52,9 +59,12 @@ Nicholas Christopher <nchristopher@tuta.io>
Nick Lowery <ClockVapor@users.noreply.github.com>
Nico <github@dr460nf1r3.org>
NKIPSC <15067635+NKIPSC@users.noreply.github.com>
o69mar <119129086+o69mar@users.noreply.github.com>
obeho <71698631+obeho@users.noreply.github.com>
obscurity <z@x4.pm>
Om G <34579088+OxyMagnesium@users.noreply.github.com>
pin <90570748+0323pin@users.noreply.github.com>
potatoesAreGod <118043038+potatoesAreGod@users.noreply.github.com>
RiversideRocks <59586759+RiversideRocks@users.noreply.github.com>
robin <8597693+robrobinbin@users.noreply.github.com>
Robin <8597693+robrobinbin@users.noreply.github.com>
@ -62,11 +72,13 @@ robrobinbin <>
robrobinbin <8597693+robrobinbin@users.noreply.github.com>
robrobinbin <robindepril@gmail.com>
Ruben Elshof <15641671+rubenelshof@users.noreply.github.com>
Rupert Angermeier <rangermeier@users.noreply.github.com>
Scoder12 <34356756+Scoder12@users.noreply.github.com>
Slayer <51095261+GhostSlayer@users.noreply.github.com>
Soheb <somoso@users.noreply.github.com>
somini <somini@users.noreply.github.com>
somoso <github@soheb.anonaddy.com>
Spenser Black <spenserblack01@gmail.com>
Spike <19519553+spikecodes@users.noreply.github.com>
spikecodes <19519553+spikecodes@users.noreply.github.com>
sybenx <syb@duck.com>
@ -74,9 +86,11 @@ TheCultLeader666 <65368815+TheCultLeader666@users.noreply.github.com>
TheFrenchGhosty <47571719+TheFrenchGhosty@users.noreply.github.com>
The TwilightBlood <hwengerstickel@protonmail.com>
tirz <36501933+tirz@users.noreply.github.com>
Tokarak <63452145+Tokarak@users.noreply.github.com>
Tsvetomir Bonev <invakid404@riseup.net>
Vladislav Nepogodin <nepogodin.vlad@gmail.com>
Walkx <walkxnl@gmail.com>
Wichai <1482605+Chengings@users.noreply.github.com>
wsy2220 <wsy@dogben.com>
xatier <xatierlike@gmail.com>
Zach <72994911+zachjmurphy@users.noreply.github.com>

946
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -3,36 +3,39 @@ name = "libreddit"
description = " Alternative private front-end to Reddit"
license = "AGPL-3.0"
repository = "https://github.com/spikecodes/libreddit"
version = "0.25.3"
version = "0.30.1"
authors = ["spikecodes <19519553+spikecodes@users.noreply.github.com>"]
edition = "2021"
[dependencies]
askama = { version = "0.11.1", default-features = false }
async-recursion = "1.0.0"
cached = "0.40.0"
clap = { version = "4.0.24", default-features = false, features = ["std"] }
regex = "1.7.0"
serde = { version = "1.0.147", features = ["derive"] }
cookie = "0.16.1"
askama = { version = "0.12.0", default-features = false }
cached = "0.43.0"
clap = { version = "4.1.1", default-features = false, features = ["std", "env"] }
regex = "1.7.1"
serde = { version = "1.0.152", features = ["derive"] }
cookie = "0.17.0"
futures-lite = "1.12.0"
hyper = { version = "0.14.23", features = ["full"] }
hyper-rustls = "0.23.0"
hyper-rustls = "0.24.0"
percent-encoding = "2.2.0"
route-recognizer = "0.3.1"
serde_json = "1.0.87"
tokio = { version = "1.21.2", features = ["full"] }
time = "0.3.17"
serde_json = "1.0.91"
tokio = { version = "1.24.2", features = ["full"] }
time = { version = "0.3.17", features = ["local-offset"] }
url = "2.3.1"
rust-embed = { version = "6.4.2", features = ["include-exclude"] }
libflate = "1.2.0"
brotli = { version = "3.3.4", features = ["std"] }
once_cell = "1.16.0"
toml = "0.7.4"
once_cell = "1.17.0"
serde_yaml = "0.9.16"
build_html = "2.2.0"
[dev-dependencies]
lipsum = "0.8.2"
lipsum = "0.9.0"
sealed_test = "1.0.0"
[profile.release]
codegen-units = 1
lto = true
strip = true
strip = "symbols"

View File

@ -7,6 +7,10 @@ RUN apk add --no-cache g++ git
WORKDIR /usr/src/libreddit
# cache dependencies in their own layer
COPY Cargo.lock Cargo.toml .
RUN mkdir src && echo "fn main() {}" > src/main.rs && cargo install --config net.git-fetch-with-cli=true --path . && rm -rf ./src
COPY . .
# net.git-fetch-with-cli is specified in order to prevent a potential OOM kill

109
README.md
View File

@ -2,11 +2,15 @@
> An alternative private front-end to Reddit
# ➡️ Discontinued. Use [Redlib](https://github.com/redlib-org/redlib) instead.
## As of July 12th, 2023, Libreddit is currently not operational as Reddit's API changes, that were designed to kill third-party apps and content scrapers who don't pay [large fees](https://www.theverge.com/2023/5/31/23743993/reddit-apollo-client-api-cost), went into effect. [Read the full announcement here.](https://github.com/libreddit/libreddit/issues/840)
![screenshot](https://i.ibb.co/QYbqTQt/libreddit-rust.png)
---
**10 second pitch:** Libreddit is a portmanteau of "libre" (meaning freedom) and "Reddit". It is a private front-end like [Invidious](https://github.com/iv-org/invidious) but for Reddit. Browse the coldest takes of [r/unpopularopinion](https://libreddit.spike.codes/r/unpopularopinion) without being [tracked](#reddit).
**10-second pitch:** Libreddit is a portmanteau of "libre" (meaning freedom) and "Reddit". It is a private front-end like [Invidious](https://github.com/iv-org/invidious) but for Reddit. Browse the coldest takes of [r/unpopularopinion](https://libreddit.spike.codes/r/unpopularopinion) without being [tracked](#reddit).
- 🚀 Fast: written in Rust for blazing-fast speeds and memory safety
- ☁️ Light: no JavaScript, no ads, no tracking, no bloat
@ -31,7 +35,7 @@ I appreciate any donations! Your support allows me to continue developing Libred
🔗 **Want to automatically redirect Reddit links to Libreddit? Use [LibRedirect](https://github.com/libredirect/libredirect) or [Privacy Redirect](https://github.com/SimonBrazell/privacy-redirect)!**
[Follow this link](https://github.com/libreddit/libreddit-instances/blob/master/instances.md) for an up-to-date table of instances in markdown format. This list is also available as [a machine-readable JSON](https://github.com/libreddit/libreddit-instances/blob/master/instances.json).
[Follow this link](https://github.com/libreddit/libreddit-instances/blob/master/instances.md) for an up-to-date table of instances in Markdown format. This list is also available as [a machine-readable JSON](https://github.com/libreddit/libreddit-instances/blob/master/instances.json).
Both files are part of the [libreddit-instances](https://github.com/libreddit/libreddit-instances) repository. To contribute your [self-hosted instance](#deployment) to the list, see the [libreddit-instances README](https://github.com/libreddit/libreddit-instances/blob/master/README.md).
@ -55,7 +59,7 @@ Libreddit currently implements most of Reddit's (signed-out) functionalities but
## How does it compare to Teddit?
Teddit is another awesome open source project designed to provide an alternative frontend to Reddit. There is no connection between the two and you're welcome to use whichever one you favor. Competition fosters innovation and Teddit's release has motivated me to build Libreddit into an even more polished product.
Teddit is another awesome open source project designed to provide an alternative frontend to Reddit. There is no connection between the two, and you're welcome to use whichever one you favor. Competition fosters innovation and Teddit's release has motivated me to build Libreddit into an even more polished product.
If you are looking to compare, the biggest differences I have noticed are:
- Libreddit is themed around Reddit's redesign whereas Teddit appears to stick much closer to Reddit's old design. This may suit some users better as design is always subjective.
@ -114,13 +118,21 @@ Results from Google PageSpeed Insights ([Libreddit Report](https://pagespeed.web
For transparency, I hope to describe all the ways Libreddit handles user privacy.
**Logging:** In production (when running the binary, hosting with docker, or using the official instances), Libreddit logs nothing. When debugging (running from source without `--release`), Libreddit logs post IDs fetched to aid with troubleshooting.
#### Server
**DNS:** Both official domains (`libredd.it` and `libreddit.spike.codes`) use Cloudflare as the DNS resolver. Though, the sites are not proxied through Cloudflare meaning Cloudflare doesn't have access to user traffic.
* **Logging:** In production (when running the binary, hosting with docker, or using the official instances), Libreddit logs nothing. When debugging (running from source without `--release`), Libreddit logs post IDs fetched to aid with troubleshooting.
**Cookies:** Libreddit uses optional cookies to store any configured settings in [the settings menu](https://libreddit.spike.codes/settings). These are not cross-site cookies and the cookies hold no personal data.
* **Cookies:** Libreddit uses optional cookies to store any configured settings in [the settings menu](https://libreddit.spike.codes/settings). These are not cross-site cookies and the cookies hold no personal data.
**Hosting:** The official instances are hosted on [Replit](https://replit.com/) which monitors usage to prevent abuse. I can understand if this invalidates certain users' threat models and therefore, self-hosting, using unofficial instances, and browsing through Tor are welcomed.
#### Official instance (libreddit.spike.codes)
The official instance is hosted at https://libreddit.spike.codes.
* **Server:** The official instance runs a production binary, and thus logs nothing.
* **DNS:** The domain for the official instance uses Cloudflare as the DNS resolver. However, this site is not proxied through Cloudflare, and thus Cloudflare doesn't have access to user traffic.
* **Hosting:** The official instance is hosted on [Replit](https://replit.com/), which monitors usage to prevent abuse. I can understand if this invalidates certain users' threat models, and therefore, self-hosting, using unofficial instances, and browsing through Tor are welcomed.
---
@ -159,14 +171,29 @@ For ArchLinux users, Libreddit is available from the AUR as [`libreddit-git`](ht
```
yay -S libreddit-git
```
## 4) NetBSD/pkgsrc
## 4) GitHub Releases
For NetBSD users, Libreddit is available from the official repositories.
```
pkgin install libreddit
```
Or, if you prefer to build from source
```
cd /usr/pkgsrc/libreddit
make install
```
## 5) GitHub Releases
If you're on Linux and none of these methods work for you, you can grab a Linux binary from [the newest release](https://github.com/libreddit/libreddit/releases/latest).
## 5) Replit/Heroku/Glitch
## 6) Replit/Heroku/Glitch
**Note:** These are free hosting options but they are *not* private and will monitor server usage to prevent abuse. If you need a free and easy setup, this method may work best for you.
> **Warning**
> These are free hosting options, but they are *not* private and will monitor server usage to prevent abuse. If you need a free and easy setup, this method may work best for you.
<a href="https://repl.it/github/libreddit/libreddit"><img src="https://repl.it/badge/github/libreddit/libreddit" alt="Run on Repl.it" height="32" /></a>
[![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy?template=https://github.com/libreddit/libreddit)
@ -182,23 +209,44 @@ Once installed, deploy Libreddit to `0.0.0.0:8080` by running:
libreddit
```
## Change Default Settings
## Instance settings
Assign a default value for each setting by passing environment variables to Libreddit in the format `LIBREDDIT_DEFAULT_{X}`. Replace `{X}` with the setting name (see list below) in capital letters.
Assign a default value for each instance-specific setting by passing environment variables to Libreddit in the format `LIBREDDIT_{X}`. Replace `{X}` with the setting name (see list below) in capital letters.
| Name | Possible values | Default value |
|-------------------------|-----------------------------------------------------------------------------------------------------|---------------|
| `THEME` | `["system", "light", "dark", "black", "dracula", "nord", "laserwave", "violet", "gold", "rosebox", "gruvboxdark", "gruvboxlight"]` | `system` |
| `FRONT_PAGE` | `["default", "popular", "all"]` | `default` |
| `LAYOUT` | `["card", "clean", "compact"]` | `card` |
| `WIDE` | `["on", "off"]` | `off` |
| `POST_SORT` | `["hot", "new", "top", "rising", "controversial"]` | `hot` |
| `COMMENT_SORT` | `["confidence", "top", "new", "controversial", "old"]` | `confidence` |
| `SHOW_NSFW` | `["on", "off"]` | `off` |
| `BLUR_NSFW` | `["on", "off"]` | `off` |
| `USE_HLS` | `["on", "off"]` | `off` |
| `HIDE_HLS_NOTIFICATION` | `["on", "off"]` | `off` |
| `AUTOPLAY_VIDEOS` | `["on", "off"]` | `off` |
| Name | Possible values | Default value | Description |
|---------------------------|-----------------|------------------|-----------------------------------------------------------------------------------------------------------|
| `SFW_ONLY` | `["on", "off"]` | `off` | Enables SFW-only mode for the instance, i.e. all NSFW content is filtered. |
| `BANNER` | String | (empty) | Allows the server to set a banner to be displayed. Currently this is displayed on the instance info page. |
| `ROBOTS_DISABLE_INDEXING` | `["on", "off"]` | `off` | Disables indexing of the instance by search engines. |
| `PUSHSHIFT_FRONTEND` | String | `www.unddit.com` | Allows the server to set the Pushshift frontend to be used with "removed" links. |
## Default User Settings
Assign a default value for each user-modifiable setting by passing environment variables to Libreddit in the format `LIBREDDIT_DEFAULT_{Y}`. Replace `{Y}` with the setting name (see list below) in capital letters.
| Name | Possible values | Default value |
|-------------------------------------|------------------------------------------------------------------------------------------------------------------------------------|---------------|
| `THEME` | `["system", "light", "dark", "black", "dracula", "nord", "laserwave", "violet", "gold", "rosebox", "gruvboxdark", "gruvboxlight"]` | `system` |
| `FRONT_PAGE` | `["default", "popular", "all"]` | `default` |
| `LAYOUT` | `["card", "clean", "compact"]` | `card` |
| `WIDE` | `["on", "off"]` | `off` |
| `POST_SORT` | `["hot", "new", "top", "rising", "controversial"]` | `hot` |
| `COMMENT_SORT` | `["confidence", "top", "new", "controversial", "old"]` | `confidence` |
| `SHOW_NSFW` | `["on", "off"]` | `off` |
| `BLUR_NSFW` | `["on", "off"]` | `off` |
| `USE_HLS` | `["on", "off"]` | `off` |
| `HIDE_HLS_NOTIFICATION` | `["on", "off"]` | `off` |
| `AUTOPLAY_VIDEOS` | `["on", "off"]` | `off` |
| `SUBSCRIPTIONS` | `+`-delimited list of subreddits (`sub1+sub2+sub3+...`) | _(none)_ |
| `HIDE_AWARDS` | `["on", "off"]` | `off` |
| `DISABLE_VISIT_REDDIT_CONFIRMATION` | `["on", "off"]` | `off` |
You can also configure Libreddit with a configuration file. An example `libreddit.toml` can be found below:
```toml
LIBREDDIT_DEFAULT_WIDE = "on"
LIBREDDIT_DEFAULT_USE_HLS = "on"
```
### Examples
@ -212,11 +260,12 @@ LIBREDDIT_DEFAULT_WIDE=on LIBREDDIT_DEFAULT_THEME=dark libreddit -r
## Proxying using NGINX
**NOTE** If you're [proxying Libreddit through an NGINX Reverse Proxy](https://github.com/libreddit/libreddit/issues/122#issuecomment-782226853), add
```nginx
proxy_http_version 1.1;
```
to your NGINX configuration file above your `proxy_pass` line.
> **Note**
> If you're [proxying Libreddit through an NGINX Reverse Proxy](https://github.com/libreddit/libreddit/issues/122#issuecomment-782226853), add
> ```nginx
> proxy_http_version 1.1;
> ```
> to your NGINX configuration file above your `proxy_pass` line.
## systemd

View File

@ -40,6 +40,27 @@
},
"LIBREDDIT_HIDE_HLS_NOTIFICATION": {
"required": false
},
"LIBREDDIT_SFW_ONLY": {
"required": false
},
"LIBREDDIT_DEFAULT_HIDE_AWARDS": {
"required": false
},
"LIBREDDIT_BANNER": {
"required": false
},
"LIBREDDIT_ROBOTS_DISABLE_INDEXING": {
"required": false
},
"LIBREDDIT_DEFAULT_SUBSCRIPTIONS": {
"required": false
},
"LIBREDDIT_DEFAULT_DISABLE_VISIT_REDDIT_CONFIRMATION": {
"required": false
},
"LIBREDDIT_PUSHSHIFT_FRONTEND": {
"required": false
}
}
}

24
build.rs Normal file
View File

@ -0,0 +1,24 @@
use std::process::{Command, ExitStatus, Output};
#[cfg(not(target_os = "windows"))]
use std::os::unix::process::ExitStatusExt;
#[cfg(target_os = "windows")]
use std::os::windows::process::ExitStatusExt;
fn main() {
let output = String::from_utf8(
Command::new("git")
.args(["rev-parse", "HEAD"])
.output()
.unwrap_or(Output {
stdout: vec![],
stderr: vec![],
status: ExitStatus::from_raw(0),
})
.stdout,
)
.unwrap_or_default();
let git_hash = if output == String::default() { "dev".into() } else { output };
println!("cargo:rustc-env=GIT_HASH={git_hash}");
}

View File

@ -1,2 +1,16 @@
ADDRESS=0.0.0.0
PORT=12345
#LIBREDDIT_DEFAULT_THEME=default
#LIBREDDIT_DEFAULT_FRONT_PAGE=default
#LIBREDDIT_DEFAULT_LAYOUT=card
#LIBREDDIT_DEFAULT_WIDE=off
#LIBREDDIT_DEFAULT_POST_SORT=hot
#LIBREDDIT_DEFAULT_COMMENT_SORT=confidence
#LIBREDDIT_DEFAULT_SHOW_NSFW=off
#LIBREDDIT_DEFAULT_BLUR_NSFW=off
#LIBREDDIT_DEFAULT_USE_HLS=off
#LIBREDDIT_DEFAULT_HIDE_HLS_NOTIFICATION=off
#LIBREDDIT_DEFAULT_AUTOPLAY_VIDEOS=off
#LIBREDDIT_DEFAULT_SUBSCRIPTIONS=off (sub1+sub2+sub3)
#LIBREDDIT_DEFAULT_HIDE_AWARDS=off
#LIBREDDIT_DEFAULT_DISABLE_VISIT_REDDIT_CONFIRMATION=off

View File

@ -5,8 +5,8 @@ After=network.service
[Service]
DynamicUser=yes
# Default Values
Environment=ADDRESS=0.0.0.0
Environment=PORT=8080
#Environment=ADDRESS=0.0.0.0
#Environment=PORT=8080
# Optional Override
EnvironmentFile=-/etc/libreddit.conf
ExecStart=/usr/bin/libreddit -a ${ADDRESS} -p ${PORT}

View File

@ -7,7 +7,18 @@ services:
container_name: "libreddit"
ports:
- 8080:8080
user: nobody
read_only: true
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
networks:
- libreddit
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "--tries=1", "http://localhost:8080/settings"]
interval: 5m
timeout: 3s
networks:
libreddit:

View File

@ -1,7 +1,10 @@
use cached::proc_macro::cached;
use futures_lite::{future::Boxed, FutureExt};
use hyper::{body, body::Buf, client, header, Body, Method, Request, Response, Uri};
use hyper::client::HttpConnector;
use hyper::{body, body::Buf, client, header, Body, Client, Method, Request, Response, Uri};
use hyper_rustls::HttpsConnector;
use libflate::gzip;
use once_cell::sync::Lazy;
use percent_encoding::{percent_encode, CONTROLS};
use serde_json::Value;
use std::{io, result::Result};
@ -11,6 +14,11 @@ use crate::server::RequestExt;
const REDDIT_URL_BASE: &str = "https://www.reddit.com";
static CLIENT: Lazy<Client<HttpsConnector<HttpConnector>>> = Lazy::new(|| {
let https = hyper_rustls::HttpsConnectorBuilder::new().with_native_roots().https_only().enable_http1().build();
client::Client::builder().build(https)
});
/// Gets the canonical path for a resource on Reddit. This is accomplished by
/// making a `HEAD` request to Reddit at the path given in `path`.
///
@ -66,11 +74,8 @@ async fn stream(url: &str, req: &Request<Body>) -> Result<Response<Body>, String
// First parameter is target URL (mandatory).
let uri = url.parse::<Uri>().map_err(|_| "Couldn't parse URL".to_string())?;
// Prepare the HTTPS connector.
let https = hyper_rustls::HttpsConnectorBuilder::new().with_native_roots().https_only().enable_http1().build();
// Build the hyper client from the HTTPS connector.
let client: client::Client<_, hyper::Body> = client::Client::builder().build(https);
let client: client::Client<_, hyper::Body> = CLIENT.clone();
let mut builder = Request::get(uri);
@ -99,6 +104,8 @@ async fn stream(url: &str, req: &Request<Body>) -> Result<Response<Body>, String
rm("x-cdn-server-region");
rm("x-reddit-cdn");
rm("x-reddit-video-features");
rm("Nel");
rm("Report-To");
res
})
@ -123,11 +130,8 @@ fn request(method: &'static Method, path: String, redirect: bool, quarantine: bo
// Build Reddit URL from path.
let url = format!("{}{}", REDDIT_URL_BASE, path);
// Prepare the HTTPS connector.
let https = hyper_rustls::HttpsConnectorBuilder::new().with_native_roots().https_or_http().enable_http1().build();
// Construct the hyper client from the HTTPS connector.
let client: client::Client<_, hyper::Body> = client::Client::builder().build(https);
let client: client::Client<_, hyper::Body> = CLIENT.clone();
// Build request to Reddit. When making a GET, request gzip compression.
// (Reddit doesn't do brotli yet.)
@ -140,7 +144,14 @@ fn request(method: &'static Method, path: String, redirect: bool, quarantine: bo
.header("Accept-Encoding", if method == Method::GET { "gzip" } else { "identity" })
.header("Accept-Language", "en-US,en;q=0.5")
.header("Connection", "keep-alive")
.header("Cookie", if quarantine { "_options=%7B%22pref_quarantine_optin%22%3A%20true%7D" } else { "" })
.header(
"Cookie",
if quarantine {
"_options=%7B%22pref_quarantine_optin%22%3A%20true%2C%20%22pref_gated_sr_optin%22%3A%20true%7D"
} else {
""
},
)
.body(Body::empty());
async move {

181
src/config.rs Normal file
View File

@ -0,0 +1,181 @@
use once_cell::sync::Lazy;
use serde::{Deserialize, Serialize};
use std::{env::var, fs::read_to_string};
// Waiting for https://github.com/rust-lang/rust/issues/74465 to land, so we
// can reduce reliance on once_cell.
//
// This is the local static that is initialized at runtime (technically at
// first request) and contains the instance settings.
pub(crate) static CONFIG: Lazy<Config> = Lazy::new(Config::load);
// This serves as the frontend for the Pushshift API - on removed comments, this URL will
// be the base of a link, to display removed content (on another site).
pub(crate) const DEFAULT_PUSHSHIFT_FRONTEND: &str = "www.unddit.com";
/// Stores the configuration parsed from the environment variables and the
/// config file. `Config::Default()` contains None for each setting.
/// When adding more config settings, add it to `Config::load`,
/// `get_setting_from_config`, both below, as well as
/// instance_info::InstanceInfo.to_string(), README.md and app.json.
#[derive(Default, Serialize, Deserialize, Clone, Debug)]
pub struct Config {
#[serde(rename = "LIBREDDIT_SFW_ONLY")]
pub(crate) sfw_only: Option<String>,
#[serde(rename = "LIBREDDIT_DEFAULT_THEME")]
pub(crate) default_theme: Option<String>,
#[serde(rename = "LIBREDDIT_DEFAULT_FRONT_PAGE")]
pub(crate) default_front_page: Option<String>,
#[serde(rename = "LIBREDDIT_DEFAULT_LAYOUT")]
pub(crate) default_layout: Option<String>,
#[serde(rename = "LIBREDDIT_DEFAULT_WIDE")]
pub(crate) default_wide: Option<String>,
#[serde(rename = "LIBREDDIT_DEFAULT_COMMENT_SORT")]
pub(crate) default_comment_sort: Option<String>,
#[serde(rename = "LIBREDDIT_DEFAULT_POST_SORT")]
pub(crate) default_post_sort: Option<String>,
#[serde(rename = "LIBREDDIT_DEFAULT_SHOW_NSFW")]
pub(crate) default_show_nsfw: Option<String>,
#[serde(rename = "LIBREDDIT_DEFAULT_BLUR_NSFW")]
pub(crate) default_blur_nsfw: Option<String>,
#[serde(rename = "LIBREDDIT_DEFAULT_USE_HLS")]
pub(crate) default_use_hls: Option<String>,
#[serde(rename = "LIBREDDIT_DEFAULT_HIDE_HLS_NOTIFICATION")]
pub(crate) default_hide_hls_notification: Option<String>,
#[serde(rename = "LIBREDDIT_DEFAULT_HIDE_AWARDS")]
pub(crate) default_hide_awards: Option<String>,
#[serde(rename = "LIBREDDIT_DEFAULT_SUBSCRIPTIONS")]
pub(crate) default_subscriptions: Option<String>,
#[serde(rename = "LIBREDDIT_DEFAULT_DISABLE_VISIT_REDDIT_CONFIRMATION")]
pub(crate) default_disable_visit_reddit_confirmation: Option<String>,
#[serde(rename = "LIBREDDIT_BANNER")]
pub(crate) banner: Option<String>,
#[serde(rename = "LIBREDDIT_ROBOTS_DISABLE_INDEXING")]
pub(crate) robots_disable_indexing: Option<String>,
#[serde(rename = "LIBREDDIT_PUSHSHIFT_FRONTEND")]
pub(crate) pushshift: Option<String>,
}
impl Config {
/// Load the configuration from the environment variables and the config file.
/// In the case that there are no environment variables set and there is no
/// config file, this function returns a Config that contains all None values.
pub fn load() -> Self {
// Read from libreddit.toml config file. If for any reason, it fails, the
// default `Config` is used (all None values)
let config: Config = toml::from_str(&read_to_string("libreddit.toml").unwrap_or_default()).unwrap_or_default();
// This function defines the order of preference - first check for
// environment variables with "LIBREDDIT", then check the config, then if
// both are `None`, return a `None` via the `map_or_else` function
let parse = |key: &str| -> Option<String> { var(key).ok().map_or_else(|| get_setting_from_config(key, &config), Some) };
Self {
sfw_only: parse("LIBREDDIT_SFW_ONLY"),
default_theme: parse("LIBREDDIT_DEFAULT_THEME"),
default_front_page: parse("LIBREDDIT_DEFAULT_FRONT_PAGE"),
default_layout: parse("LIBREDDIT_DEFAULT_LAYOUT"),
default_post_sort: parse("LIBREDDIT_DEFAULT_POST_SORT"),
default_wide: parse("LIBREDDIT_DEFAULT_WIDE"),
default_comment_sort: parse("LIBREDDIT_DEFAULT_COMMENT_SORT"),
default_show_nsfw: parse("LIBREDDIT_DEFAULT_SHOW_NSFW"),
default_blur_nsfw: parse("LIBREDDIT_DEFAULT_BLUR_NSFW"),
default_use_hls: parse("LIBREDDIT_DEFAULT_USE_HLS"),
default_hide_hls_notification: parse("LIBREDDIT_DEFAULT_HIDE_HLS"),
default_hide_awards: parse("LIBREDDIT_DEFAULT_HIDE_AWARDS"),
default_subscriptions: parse("LIBREDDIT_DEFAULT_SUBSCRIPTIONS"),
default_disable_visit_reddit_confirmation: parse("LIBREDDIT_DEFAULT_DISABLE_VISIT_REDDIT_CONFIRMATION"),
banner: parse("LIBREDDIT_BANNER"),
robots_disable_indexing: parse("LIBREDDIT_ROBOTS_DISABLE_INDEXING"),
pushshift: parse("LIBREDDIT_PUSHSHIFT_FRONTEND"),
}
}
}
fn get_setting_from_config(name: &str, config: &Config) -> Option<String> {
match name {
"LIBREDDIT_SFW_ONLY" => config.sfw_only.clone(),
"LIBREDDIT_DEFAULT_THEME" => config.default_theme.clone(),
"LIBREDDIT_DEFAULT_FRONT_PAGE" => config.default_front_page.clone(),
"LIBREDDIT_DEFAULT_LAYOUT" => config.default_layout.clone(),
"LIBREDDIT_DEFAULT_COMMENT_SORT" => config.default_comment_sort.clone(),
"LIBREDDIT_DEFAULT_POST_SORT" => config.default_post_sort.clone(),
"LIBREDDIT_DEFAULT_SHOW_NSFW" => config.default_show_nsfw.clone(),
"LIBREDDIT_DEFAULT_BLUR_NSFW" => config.default_blur_nsfw.clone(),
"LIBREDDIT_DEFAULT_USE_HLS" => config.default_use_hls.clone(),
"LIBREDDIT_DEFAULT_HIDE_HLS_NOTIFICATION" => config.default_hide_hls_notification.clone(),
"LIBREDDIT_DEFAULT_WIDE" => config.default_wide.clone(),
"LIBREDDIT_DEFAULT_HIDE_AWARDS" => config.default_hide_awards.clone(),
"LIBREDDIT_DEFAULT_SUBSCRIPTIONS" => config.default_subscriptions.clone(),
"LIBREDDIT_DEFAULT_DISABLE_VISIT_REDDIT_CONFIRMATION" => config.default_disable_visit_reddit_confirmation.clone(),
"LIBREDDIT_BANNER" => config.banner.clone(),
"LIBREDDIT_ROBOTS_DISABLE_INDEXING" => config.robots_disable_indexing.clone(),
"LIBREDDIT_PUSHSHIFT_FRONTEND" => config.pushshift.clone(),
_ => None,
}
}
/// Retrieves setting from environment variable or config file.
pub(crate) fn get_setting(name: &str) -> Option<String> {
get_setting_from_config(name, &CONFIG)
}
#[cfg(test)]
use {sealed_test::prelude::*, std::fs::write};
#[test]
fn test_deserialize() {
// Must handle empty input
let result = toml::from_str::<Config>("");
assert!(result.is_ok(), "Error: {}", result.unwrap_err());
}
#[test]
#[sealed_test(env = [("LIBREDDIT_SFW_ONLY", "on")])]
fn test_env_var() {
assert!(crate::utils::sfw_only())
}
#[test]
#[sealed_test]
fn test_config() {
let config_to_write = r#"LIBREDDIT_DEFAULT_COMMENT_SORT = "best""#;
write("libreddit.toml", config_to_write).unwrap();
assert_eq!(get_setting("LIBREDDIT_DEFAULT_COMMENT_SORT"), Some("best".into()));
}
#[test]
#[sealed_test(env = [("LIBREDDIT_DEFAULT_COMMENT_SORT", "top")])]
fn test_env_config_precedence() {
let config_to_write = r#"LIBREDDIT_DEFAULT_COMMENT_SORT = "best""#;
write("libreddit.toml", config_to_write).unwrap();
assert_eq!(get_setting("LIBREDDIT_DEFAULT_COMMENT_SORT"), Some("top".into()))
}
#[test]
#[sealed_test(env = [("LIBREDDIT_DEFAULT_COMMENT_SORT", "top")])]
fn test_alt_env_config_precedence() {
let config_to_write = r#"LIBREDDIT_DEFAULT_COMMENT_SORT = "best""#;
write("libreddit.toml", config_to_write).unwrap();
assert_eq!(get_setting("LIBREDDIT_DEFAULT_COMMENT_SORT"), Some("top".into()))
}
#[test]
#[sealed_test(env = [("LIBREDDIT_DEFAULT_SUBSCRIPTIONS", "news+bestof")])]
fn test_default_subscriptions() {
assert_eq!(get_setting("LIBREDDIT_DEFAULT_SUBSCRIPTIONS"), Some("news+bestof".into()));
}

View File

@ -3,7 +3,7 @@
use crate::client::json;
use crate::server::RequestExt;
use crate::subreddit::{can_access_quarantine, quarantine};
use crate::utils::{error, filter_posts, get_filters, parse_post, template, Post, Preferences};
use crate::utils::{error, filter_posts, get_filters, nsfw_landing, parse_post, template, Post, Preferences};
use askama::Template;
use hyper::{Body, Request, Response};
@ -65,8 +65,17 @@ pub async fn item(req: Request<Body>) -> Result<Response<Body>, String> {
match json(path, quarantined).await {
// Process response JSON.
Ok(response) => {
let filters = get_filters(&req);
let post = parse_post(&response[0]["data"]["children"][0]).await;
let req_url = req.uri().to_string();
// Return landing page if this post if this Reddit deems this post
// NSFW, but we have also disabled the display of NSFW content
// or if the instance is SFW-only
if post.nsfw && crate::utils::should_be_nsfw_gated(&req, &req_url) {
return Ok(nsfw_landing(req, req_url).await.unwrap_or_default());
}
let filters = get_filters(&req);
let (duplicates, num_posts_filtered, all_posts_filtered) = parse_duplicates(&response[1], &filters).await;
// These are the values for the "before=", "after=", and "sort="
@ -187,14 +196,13 @@ pub async fn item(req: Request<Body>) -> Result<Response<Body>, String> {
after = response[1]["data"]["after"].as_str().unwrap_or_default().to_string();
}
}
let url = req.uri().to_string();
template(DuplicatesTemplate {
params: DuplicatesParams { before, after, sort },
post,
duplicates,
prefs: Preferences::new(req),
url,
prefs: Preferences::new(&req),
url: req_url,
num_posts_filtered,
all_posts_filtered,
})
@ -202,9 +210,9 @@ pub async fn item(req: Request<Body>) -> Result<Response<Body>, String> {
// Process error.
Err(msg) => {
if msg == "quarantined" {
if msg == "quarantined" || msg == "gated" {
let sub = req.param("sub").unwrap_or_default();
quarantine(req, sub)
quarantine(req, sub, msg)
} else {
error(req, msg).await
}

212
src/instance_info.rs Normal file
View File

@ -0,0 +1,212 @@
use crate::{
config::{Config, CONFIG},
server::RequestExt,
utils::{ErrorTemplate, Preferences},
};
use askama::Template;
use build_html::{Container, Html, HtmlContainer, Table};
use hyper::{http::Error, Body, Request, Response};
use once_cell::sync::Lazy;
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
// This is the local static that is intialized at runtime (technically at
// the first request to the info endpoint) and contains the data
// retrieved from the info endpoint.
pub(crate) static INSTANCE_INFO: Lazy<InstanceInfo> = Lazy::new(InstanceInfo::new);
/// Handles instance info endpoint
pub async fn instance_info(req: Request<Body>) -> Result<Response<Body>, String> {
// This will retrieve the extension given, or create a new string - which will
// simply become the last option, an HTML page.
let extension = req.param("extension").unwrap_or(String::new());
let response = match extension.as_str() {
"yaml" | "yml" => info_yaml(),
"txt" => info_txt(),
"json" => info_json(),
"html" | "" => info_html(req),
_ => {
let error = ErrorTemplate {
msg: "Error: Invalid info extension".into(),
prefs: Preferences::new(&req),
url: req.uri().to_string(),
}
.render()
.unwrap();
Response::builder().status(404).header("content-type", "text/html; charset=utf-8").body(error.into())
}
};
response.map_err(|err| format!("{err}"))
}
fn info_json() -> Result<Response<Body>, Error> {
if let Ok(body) = serde_json::to_string(&*INSTANCE_INFO) {
Response::builder().status(200).header("content-type", "application/json").body(body.into())
} else {
Response::builder()
.status(500)
.header("content-type", "text/plain")
.body(Body::from("Error serializing JSON"))
}
}
fn info_yaml() -> Result<Response<Body>, Error> {
if let Ok(body) = serde_yaml::to_string(&*INSTANCE_INFO) {
// We can use `application/yaml` as media type, though there is no guarantee
// that browsers will honor it. But we'll do it anyway. See:
// https://github.com/ietf-wg-httpapi/mediatypes/blob/main/draft-ietf-httpapi-yaml-mediatypes.md#media-type-applicationyaml-application-yaml
Response::builder().status(200).header("content-type", "application/yaml").body(body.into())
} else {
Response::builder()
.status(500)
.header("content-type", "text/plain")
.body(Body::from("Error serializing YAML."))
}
}
fn info_txt() -> Result<Response<Body>, Error> {
Response::builder()
.status(200)
.header("content-type", "text/plain")
.body(Body::from(INSTANCE_INFO.to_string(StringType::Raw)))
}
fn info_html(req: Request<Body>) -> Result<Response<Body>, Error> {
let message = MessageTemplate {
title: String::from("Instance information"),
body: INSTANCE_INFO.to_string(StringType::Html),
prefs: Preferences::new(&req),
url: req.uri().to_string(),
}
.render()
.unwrap();
Response::builder().status(200).header("content-type", "text/html; charset=utf8").body(Body::from(message))
}
#[derive(Serialize, Deserialize, Default)]
pub(crate) struct InstanceInfo {
crate_version: String,
git_commit: String,
deploy_date: String,
compile_mode: String,
deploy_unix_ts: i64,
config: Config,
}
impl InstanceInfo {
pub fn new() -> Self {
Self {
crate_version: env!("CARGO_PKG_VERSION").to_string(),
git_commit: env!("GIT_HASH").to_string(),
deploy_date: OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc()).to_string(),
#[cfg(debug_assertions)]
compile_mode: "Debug".into(),
#[cfg(not(debug_assertions))]
compile_mode: "Release".into(),
deploy_unix_ts: OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc()).unix_timestamp(),
config: CONFIG.clone(),
}
}
fn to_table(&self) -> String {
let mut container = Container::default();
let convert = |o: &Option<String>| -> String { o.clone().unwrap_or("<span class=\"unset\"><i>Unset</i></span>".to_owned()) };
if let Some(banner) = &self.config.banner {
container.add_header(3, "Instance banner");
container.add_raw("<br />");
container.add_paragraph(banner);
container.add_raw("<br />");
}
container.add_table(
Table::from([
["Crate version", &self.crate_version],
["Git commit", &self.git_commit],
["Deploy date", &self.deploy_date],
["Deploy timestamp", &self.deploy_unix_ts.to_string()],
["Compile mode", &self.compile_mode],
["SFW only", &convert(&self.config.sfw_only)],
["Pushshift frontend", &convert(&self.config.pushshift)],
//TODO: fallback to crate::config::DEFAULT_PUSHSHIFT_FRONTEND
])
.with_header_row(["Settings"]),
);
container.add_raw("<br />");
container.add_table(
Table::from([
["Hide awards", &convert(&self.config.default_hide_awards)],
["Theme", &convert(&self.config.default_theme)],
["Front page", &convert(&self.config.default_front_page)],
["Layout", &convert(&self.config.default_layout)],
["Wide", &convert(&self.config.default_wide)],
["Comment sort", &convert(&self.config.default_comment_sort)],
["Post sort", &convert(&self.config.default_post_sort)],
["Show NSFW", &convert(&self.config.default_show_nsfw)],
["Blur NSFW", &convert(&self.config.default_blur_nsfw)],
["Use HLS", &convert(&self.config.default_use_hls)],
["Hide HLS notification", &convert(&self.config.default_hide_hls_notification)],
["Subscriptions", &convert(&self.config.default_subscriptions)],
])
.with_header_row(["Default preferences"]),
);
container.to_html_string().replace("<th>", "<th colspan=\"2\">")
}
fn to_string(&self, string_type: StringType) -> String {
match string_type {
StringType::Raw => {
format!(
"Crate version: {}\n
Git commit: {}\n
Deploy date: {}\n
Deploy timestamp: {}\n
Compile mode: {}\n
SFW only: {:?}\n
Pushshift frontend: {:?}\n
Config:\n
Banner: {:?}\n
Hide awards: {:?}\n
Default theme: {:?}\n
Default front page: {:?}\n
Default layout: {:?}\n
Default wide: {:?}\n
Default comment sort: {:?}\n
Default post sort: {:?}\n
Default show NSFW: {:?}\n
Default blur NSFW: {:?}\n
Default use HLS: {:?}\n
Default hide HLS notification: {:?}\n
Default subscriptions: {:?}\n",
self.crate_version,
self.git_commit,
self.deploy_date,
self.deploy_unix_ts,
self.compile_mode,
self.config.sfw_only,
self.config.pushshift,
self.config.banner,
self.config.default_hide_awards,
self.config.default_theme,
self.config.default_front_page,
self.config.default_layout,
self.config.default_wide,
self.config.default_comment_sort,
self.config.default_post_sort,
self.config.default_show_nsfw,
self.config.default_blur_nsfw,
self.config.default_use_hls,
self.config.default_hide_hls_notification,
self.config.default_subscriptions,
)
}
StringType::Html => self.to_table(),
}
}
}
enum StringType {
Raw,
Html,
}
#[derive(Template)]
#[template(path = "message.html")]
struct MessageTemplate {
title: String,
body: String,
prefs: Preferences,
url: String,
}

View File

@ -3,7 +3,9 @@
#![allow(clippy::cmp_owned)]
// Reference local files
mod config;
mod duplicates;
mod instance_info;
mod post;
mod search;
mod settings;
@ -12,13 +14,14 @@ mod user;
mod utils;
// Import Crates
use clap::{Arg, Command};
use clap::{Arg, ArgAction, Command};
use futures_lite::FutureExt;
use hyper::{header::HeaderValue, Body, Request, Response};
mod client;
use client::{canonical_path, proxy};
use once_cell::sync::Lazy;
use server::RequestExt;
use utils::{error, redirect, ThemeAssets};
@ -129,8 +132,10 @@ async fn main() {
.short('p')
.long("port")
.value_name("PORT")
.env("PORT")
.help("Port to listen on")
.default_value("8080")
.action(ArgAction::Set)
.num_args(1),
)
.arg(
@ -144,17 +149,24 @@ async fn main() {
)
.get_matches();
let address = matches.get_one("address").map(|m: &String| m.as_str()).unwrap_or("0.0.0.0");
let port = std::env::var("PORT").unwrap_or_else(|_| matches.get_one("port").map(|m: &String| m.as_str()).unwrap_or("8080").to_string());
let address = matches.get_one::<String>("address").unwrap();
let port = matches.get_one::<String>("port").unwrap();
let hsts = matches.get_one("hsts").map(|m: &String| m.as_str());
let listener = [address, ":", &port].concat();
let listener = [address, ":", port].concat();
println!("Starting Libreddit...");
// Begin constructing a server
let mut app = server::Server::new();
// Force evaluation of statics. In instance_info case, we need to evaluate
// the timestamp so deploy date is accurate - in config case, we need to
// evaluate the configuration to avoid paying penalty at first request.
Lazy::force(&config::CONFIG);
Lazy::force(&instance_info::INSTANCE_INFO);
// Define default headers (added to all responses)
app.default_headers = headers! {
"Referrer-Policy" => "no-referrer",
@ -174,9 +186,21 @@ async fn main() {
app
.at("/manifest.json")
.get(|_| resource(include_str!("../static/manifest.json"), "application/json", false).boxed());
app
.at("/robots.txt")
.get(|_| resource("User-agent: *\nDisallow: /u/\nDisallow: /user/", "text/plain", true).boxed());
app.at("/robots.txt").get(|_| {
resource(
if match config::get_setting("LIBREDDIT_ROBOTS_DISABLE_INDEXING") {
Some(val) => val == "on",
None => false,
} {
"User-agent: *\nDisallow: /"
} else {
"User-agent: *\nDisallow: /u/\nDisallow: /user/"
},
"text/plain",
true,
)
.boxed()
});
app.at("/favicon.ico").get(|_| favicon().boxed());
app.at("/logo.png").get(|_| pwa_logo().boxed());
app.at("/Inter.var.woff2").get(|_| font().boxed());
@ -282,6 +306,10 @@ async fn main() {
// Handle about pages
app.at("/about").get(|req| error(req, "About pages aren't added yet".to_string()).boxed());
// Instance info page
app.at("/info").get(|r| instance_info::instance_info(r).boxed());
app.at("/info.:extension").get(|r| instance_info::instance_info(r).boxed());
app.at("/:id").get(|req: Request<Body>| {
Box::pin(async move {
match req.param("id").as_deref() {

View File

@ -1,13 +1,16 @@
// CRATES
use crate::client::json;
use crate::config::get_setting;
use crate::server::RequestExt;
use crate::subreddit::{can_access_quarantine, quarantine};
use crate::utils::{
error, format_num, get_filters, param, parse_post, rewrite_urls, setting, template, time, val, Author, Awards, Comment, Flair, FlairPart, Post, Preferences,
error, format_num, get_filters, nsfw_landing, param, parse_post, rewrite_urls, setting, template, time, val, Author, Awards, Comment, Flair, FlairPart, Post, Preferences,
};
use hyper::{Body, Request, Response};
use askama::Template;
use once_cell::sync::Lazy;
use regex::Regex;
use std::collections::HashSet;
// STRUCTS
@ -20,13 +23,18 @@ struct PostTemplate {
prefs: Preferences,
single_thread: bool,
url: String,
url_without_query: String,
comment_query: String,
}
static COMMENT_SEARCH_CAPTURE: Lazy<Regex> = Lazy::new(|| Regex::new(r#"\?q=(.*)&type=comment"#).unwrap());
pub async fn item(req: Request<Body>) -> Result<Response<Body>, String> {
// Build Reddit API path
let mut path: String = format!("{}.json?{}&raw_json=1", req.uri().path(), req.uri().query().unwrap_or_default());
let sub = req.param("sub").unwrap_or_default();
let quarantined = can_access_quarantine(&req, &sub);
let url = req.uri().to_string();
// Set sort to sort query parameter
let sort = param(&path, "sort").unwrap_or_else(|| {
@ -55,24 +63,42 @@ pub async fn item(req: Request<Body>) -> Result<Response<Body>, String> {
Ok(response) => {
// Parse the JSON into Post and Comment structs
let post = parse_post(&response[0]["data"]["children"][0]).await;
let comments = parse_comments(&response[1], &post.permalink, &post.author.name, highlighted_comment, &get_filters(&req));
let url = req.uri().to_string();
let req_url = req.uri().to_string();
// Return landing page if this post if this Reddit deems this post
// NSFW, but we have also disabled the display of NSFW content
// or if the instance is SFW-only.
if post.nsfw && crate::utils::should_be_nsfw_gated(&req, &req_url) {
return Ok(nsfw_landing(req, req_url).await.unwrap_or_default());
}
let query = match COMMENT_SEARCH_CAPTURE.captures(&url) {
Some(captures) => captures.get(1).unwrap().as_str().replace("%20", " ").replace('+', " "),
None => String::new(),
};
let comments = match query.as_str() {
"" => parse_comments(&response[1], &post.permalink, &post.author.name, highlighted_comment, &get_filters(&req), &req),
_ => query_comments(&response[1], &post.permalink, &post.author.name, highlighted_comment, &get_filters(&req), &query, &req),
};
// Use the Post and Comment structs to generate a website to show users
template(PostTemplate {
comments,
post,
url_without_query: url.clone().trim_end_matches(&format!("?q={query}&type=comment")).to_string(),
sort,
prefs: Preferences::new(req),
prefs: Preferences::new(&req),
single_thread,
url,
url: req_url,
comment_query: query,
})
}
// If the Reddit API returns an error, exit and send error page to user
Err(msg) => {
if msg == "quarantined" {
if msg == "quarantined" || msg == "gated" {
let sub = req.param("sub").unwrap_or_default();
quarantine(req, sub)
quarantine(req, sub, msg)
} else {
error(req, msg).await
}
@ -81,7 +107,8 @@ pub async fn item(req: Request<Body>) -> Result<Response<Body>, String> {
}
// COMMENTS
fn parse_comments(json: &serde_json::Value, post_link: &str, post_author: &str, highlighted_comment: &str, filters: &HashSet<String>) -> Vec<Comment> {
fn parse_comments(json: &serde_json::Value, post_link: &str, post_author: &str, highlighted_comment: &str, filters: &HashSet<String>, req: &Request<Body>) -> Vec<Comment> {
// Parse the comment JSON into a Vector of Comments
let comments = json["data"]["children"].as_array().map_or(Vec::new(), std::borrow::ToOwned::to_owned);
@ -89,87 +116,138 @@ fn parse_comments(json: &serde_json::Value, post_link: &str, post_author: &str,
comments
.into_iter()
.map(|comment| {
let kind = comment["kind"].as_str().unwrap_or_default().to_string();
let data = &comment["data"];
let unix_time = data["created_utc"].as_f64().unwrap_or_default();
let (rel_time, created) = time(unix_time);
let edited = data["edited"].as_f64().map_or((String::new(), String::new()), time);
let score = data["score"].as_i64().unwrap_or(0);
// If this comment contains replies, handle those too
let replies: Vec<Comment> = if data["replies"].is_object() {
parse_comments(&data["replies"], post_link, post_author, highlighted_comment, filters)
parse_comments(&data["replies"], post_link, post_author, highlighted_comment, filters, req)
} else {
Vec::new()
};
let awards: Awards = Awards::parse(&data["all_awardings"]);
let parent_kind_and_id = val(&comment, "parent_id");
let parent_info = parent_kind_and_id.split('_').collect::<Vec<&str>>();
let id = val(&comment, "id");
let highlighted = id == highlighted_comment;
let body = if (val(&comment, "author") == "[deleted]" && val(&comment, "body") == "[removed]") || val(&comment, "body") == "[ Removed by Reddit ]" {
format!(
"<div class=\"md\"><p>[removed] — <a href=\"https://www.unddit.com{}{}\">view removed comment</a></p></div>",
post_link, id
)
} else {
rewrite_urls(&val(&comment, "body_html"))
};
let author = Author {
name: val(&comment, "author"),
flair: Flair {
flair_parts: FlairPart::parse(
data["author_flair_type"].as_str().unwrap_or_default(),
data["author_flair_richtext"].as_array(),
data["author_flair_text"].as_str(),
),
text: val(&comment, "link_flair_text"),
background_color: val(&comment, "author_flair_background_color"),
foreground_color: val(&comment, "author_flair_text_color"),
},
distinguished: val(&comment, "distinguished"),
};
let is_filtered = filters.contains(&["u_", author.name.as_str()].concat());
// Many subreddits have a default comment posted about the sub's rules etc.
// Many libreddit users do not wish to see this kind of comment by default.
// Reddit does not tell us which users are "bots", so a good heuristic is to
// collapse stickied moderator comments.
let is_moderator_comment = data["distinguished"].as_str().unwrap_or_default() == "moderator";
let is_stickied = data["stickied"].as_bool().unwrap_or_default();
let collapsed = (is_moderator_comment && is_stickied) || is_filtered;
Comment {
id,
kind,
parent_id: parent_info[1].to_string(),
parent_kind: parent_info[0].to_string(),
post_link: post_link.to_string(),
post_author: post_author.to_string(),
body,
author,
score: if data["score_hidden"].as_bool().unwrap_or_default() {
("\u{2022}".to_string(), "Hidden".to_string())
} else {
format_num(score)
},
rel_time,
created,
edited,
replies,
highlighted,
awards,
collapsed,
is_filtered,
}
build_comment(&comment, data, replies, post_link, post_author, highlighted_comment, filters, req)
})
.collect()
}
fn query_comments(
json: &serde_json::Value,
post_link: &str,
post_author: &str,
highlighted_comment: &str,
filters: &HashSet<String>,
query: &str,
req: &Request<Body>,
) -> Vec<Comment> {
let comments = json["data"]["children"].as_array().map_or(Vec::new(), std::borrow::ToOwned::to_owned);
let mut results = Vec::new();
comments.into_iter().for_each(|comment| {
let data = &comment["data"];
// If this comment contains replies, handle those too
if data["replies"].is_object() {
results.append(&mut query_comments(&data["replies"], post_link, post_author, highlighted_comment, filters, query, req))
}
let c = build_comment(&comment, data, Vec::new(), post_link, post_author, highlighted_comment, filters, req);
if c.body.to_lowercase().contains(&query.to_lowercase()) {
results.push(c);
}
});
results
}
#[allow(clippy::too_many_arguments)]
fn build_comment(
comment: &serde_json::Value,
data: &serde_json::Value,
replies: Vec<Comment>,
post_link: &str,
post_author: &str,
highlighted_comment: &str,
filters: &HashSet<String>,
req: &Request<Body>,
) -> Comment {
let id = val(comment, "id");
let body = if (val(comment, "author") == "[deleted]" && val(comment, "body") == "[removed]") || val(comment, "body") == "[ Removed by Reddit ]" {
format!(
"<div class=\"md\"><p>[removed] — <a href=\"https://{}{}{}\">view removed comment</a></p></div>",
get_setting("LIBREDDIT_PUSHSHIFT_FRONTEND").unwrap_or(String::from(crate::config::DEFAULT_PUSHSHIFT_FRONTEND)),
post_link,
id
)
} else {
rewrite_urls(&val(comment, "body_html"))
};
let kind = comment["kind"].as_str().unwrap_or_default().to_string();
let unix_time = data["created_utc"].as_f64().unwrap_or_default();
let (rel_time, created) = time(unix_time);
let edited = data["edited"].as_f64().map_or((String::new(), String::new()), time);
let score = data["score"].as_i64().unwrap_or(0);
// The JSON API only provides comments up to some threshold.
// Further comments have to be loaded by subsequent requests.
// The "kind" value will be "more" and the "count"
// shows how many more (sub-)comments exist in the respective nesting level.
// Note that in certain (seemingly random) cases, the count is simply wrong.
let more_count = data["count"].as_i64().unwrap_or_default();
let awards: Awards = Awards::parse(&data["all_awardings"]);
let parent_kind_and_id = val(comment, "parent_id");
let parent_info = parent_kind_and_id.split('_').collect::<Vec<&str>>();
let highlighted = id == highlighted_comment;
let author = Author {
name: val(comment, "author"),
flair: Flair {
flair_parts: FlairPart::parse(
data["author_flair_type"].as_str().unwrap_or_default(),
data["author_flair_richtext"].as_array(),
data["author_flair_text"].as_str(),
),
text: val(comment, "link_flair_text"),
background_color: val(comment, "author_flair_background_color"),
foreground_color: val(comment, "author_flair_text_color"),
},
distinguished: val(comment, "distinguished"),
};
let is_filtered = filters.contains(&["u_", author.name.as_str()].concat());
// Many subreddits have a default comment posted about the sub's rules etc.
// Many libreddit users do not wish to see this kind of comment by default.
// Reddit does not tell us which users are "bots", so a good heuristic is to
// collapse stickied moderator comments.
let is_moderator_comment = data["distinguished"].as_str().unwrap_or_default() == "moderator";
let is_stickied = data["stickied"].as_bool().unwrap_or_default();
let collapsed = (is_moderator_comment && is_stickied) || is_filtered;
Comment {
id,
kind,
parent_id: parent_info[1].to_string(),
parent_kind: parent_info[0].to_string(),
post_link: post_link.to_string(),
post_author: post_author.to_string(),
body,
author,
score: if data["score_hidden"].as_bool().unwrap_or_default() {
("\u{2022}".to_string(), "Hidden".to_string())
} else {
format_num(score)
},
rel_time,
created,
edited,
replies,
highlighted,
awards,
collapsed,
is_filtered,
more_count,
prefs: Preferences::new(req),
}
}

View File

@ -1,5 +1,5 @@
// CRATES
use crate::utils::{catch_random, error, filter_posts, format_num, format_url, get_filters, param, redirect, setting, template, val, Post, Preferences};
use crate::utils::{self, catch_random, error, filter_posts, format_num, format_url, get_filters, param, redirect, setting, template, val, Post, Preferences};
use crate::{
client::json,
subreddit::{can_access_quarantine, quarantine},
@ -54,7 +54,12 @@ static REDDIT_URL_MATCH: Lazy<Regex> = Lazy::new(|| Regex::new(r"^https?://([^\.
// SERVICES
pub async fn find(req: Request<Body>) -> Result<Response<Body>, String> {
let nsfw_results = if setting(&req, "show_nsfw") == "on" { "&include_over_18=on" } else { "" };
// This ensures that during a search, no NSFW posts are fetched at all
let nsfw_results = if setting(&req, "show_nsfw") == "on" && !utils::sfw_only() {
"&include_over_18=on"
} else {
""
};
let path = format!("{}.json?{}{}&raw_json=1", req.uri().path(), req.uri().query().unwrap_or_default(), nsfw_results);
let mut query = param(&path, "q").unwrap_or_default();
query = REDDIT_URL_MATCH.replace(&query, "").to_string();
@ -105,7 +110,7 @@ pub async fn find(req: Request<Body>) -> Result<Response<Body>, String> {
restrict_sr: param(&path, "restrict_sr").unwrap_or_default(),
typed,
},
prefs: Preferences::new(req),
prefs: Preferences::new(&req),
url,
is_filtered: true,
all_posts_filtered: false,
@ -131,7 +136,7 @@ pub async fn find(req: Request<Body>) -> Result<Response<Body>, String> {
restrict_sr: param(&path, "restrict_sr").unwrap_or_default(),
typed,
},
prefs: Preferences::new(req),
prefs: Preferences::new(&req),
url,
is_filtered: false,
all_posts_filtered,
@ -140,9 +145,9 @@ pub async fn find(req: Request<Body>) -> Result<Response<Body>, String> {
})
}
Err(msg) => {
if msg == "quarantined" {
if msg == "quarantined" || msg == "gated" {
let sub = req.param("sub").unwrap_or_default();
quarantine(req, sub)
quarantine(req, sub, msg)
} else {
error(req, msg).await
}

View File

@ -253,7 +253,7 @@ impl Server {
.boxed()
}
// If there was a routing error
Err(e) => async move { new_boilerplate(def_headers, req_headers, 404, e.into()).await }.boxed(),
Err(e) => new_boilerplate(def_headers, req_headers, 404, e.into()).boxed(),
}
}))
}
@ -379,7 +379,7 @@ fn determine_compressor(accept_encoding: String) -> Option<CompressionType> {
// This loop reads the requested compressors and keeps track of whichever
// one has the highest priority per our heuristic.
for val in accept_encoding.to_string().split(',') {
for val in accept_encoding.split(',') {
let mut q: f64 = 1.0;
// The compressor and q-value (if the latter is defined)

View File

@ -19,7 +19,7 @@ struct SettingsTemplate {
// CONSTANTS
const PREFS: [&str; 11] = [
const PREFS: [&str; 13] = [
"theme",
"front_page",
"layout",
@ -31,6 +31,8 @@ const PREFS: [&str; 11] = [
"use_hls",
"hide_hls_notification",
"autoplay_videos",
"hide_awards",
"disable_visit_reddit_confirmation",
];
// FUNCTIONS
@ -39,7 +41,7 @@ const PREFS: [&str; 11] = [
pub async fn get(req: Request<Body>) -> Result<Response<Body>, String> {
let url = req.uri().to_string();
template(SettingsTemplate {
prefs: Preferences::new(req),
prefs: Preferences::new(&req),
url,
})
}

View File

@ -1,6 +1,6 @@
// CRATES
use crate::utils::{
catch_random, error, filter_posts, format_num, format_url, get_filters, param, redirect, rewrite_urls, setting, template, val, Post, Preferences, Subreddit,
catch_random, error, filter_posts, format_num, format_url, get_filters, nsfw_landing, param, redirect, rewrite_urls, setting, template, val, Post, Preferences, Subreddit,
};
use crate::{client::json, server::ResponseExt, RequestExt};
use askama::Template;
@ -97,6 +97,13 @@ pub async fn community(req: Request<Body>) -> Result<Response<Body>, String> {
}
};
let req_url = req.uri().to_string();
// Return landing page if this post if this is NSFW community but the user
// has disabled the display of NSFW content or if the instance is SFW-only.
if sub.nsfw && crate::utils::should_be_nsfw_gated(&req, &req_url) {
return Ok(nsfw_landing(req, req_url).await.unwrap_or_default());
}
let path = format!("/r/{}/{}.json?{}&raw_json=1", sub_name.clone(), sort, req.uri().query().unwrap_or_default());
let url = String::from(req.uri().path_and_query().map_or("", |val| val.as_str()));
let redirect_url = url[1..].replace('?', "%3F").replace('&', "%26").replace('+', "%2B");
@ -109,7 +116,7 @@ pub async fn community(req: Request<Body>) -> Result<Response<Body>, String> {
posts: Vec::new(),
sort: (sort, param(&path, "t").unwrap_or_default()),
ends: (param(&path, "after").unwrap_or_default(), "".to_string()),
prefs: Preferences::new(req),
prefs: Preferences::new(&req),
url,
redirect_url,
is_filtered: true,
@ -128,7 +135,7 @@ pub async fn community(req: Request<Body>) -> Result<Response<Body>, String> {
posts,
sort: (sort, param(&path, "t").unwrap_or_default()),
ends: (param(&path, "after").unwrap_or_default(), after),
prefs: Preferences::new(req),
prefs: Preferences::new(&req),
url,
redirect_url,
is_filtered: false,
@ -138,7 +145,7 @@ pub async fn community(req: Request<Body>) -> Result<Response<Body>, String> {
})
}
Err(msg) => match msg.as_str() {
"quarantined" => quarantine(req, sub_name),
"quarantined" | "gated" => quarantine(req, sub_name, msg),
"private" => error(req, format!("r/{} is a private community", sub_name)).await,
"banned" => error(req, format!("r/{} has been banned from Reddit", sub_name)).await,
_ => error(req, msg).await,
@ -147,13 +154,13 @@ pub async fn community(req: Request<Body>) -> Result<Response<Body>, String> {
}
}
pub fn quarantine(req: Request<Body>, sub: String) -> Result<Response<Body>, String> {
pub fn quarantine(req: Request<Body>, sub: String, restriction: String) -> Result<Response<Body>, String> {
let wall = WallTemplate {
title: format!("r/{} is quarantined", sub),
title: format!("r/{} is {}", sub, restriction),
msg: "Please click the button below to continue to this subreddit.".to_string(),
url: req.uri().to_string(),
sub,
prefs: Preferences::new(req),
prefs: Preferences::new(&req),
};
Ok(
@ -200,7 +207,7 @@ pub async fn subscriptions_filters(req: Request<Body>) -> Result<Response<Body>,
let query = req.uri().query().unwrap_or_default().to_string();
let preferences = Preferences::new(req);
let preferences = Preferences::new(&req);
let mut sub_list = preferences.subscriptions;
let mut filters = preferences.filters;
@ -313,12 +320,12 @@ pub async fn wiki(req: Request<Body>) -> Result<Response<Body>, String> {
sub,
wiki: rewrite_urls(response["data"]["content_html"].as_str().unwrap_or("<h3>Wiki not found</h3>")),
page,
prefs: Preferences::new(req),
prefs: Preferences::new(&req),
url,
}),
Err(msg) => {
if msg == "quarantined" {
quarantine(req, sub)
if msg == "quarantined" || msg == "gated" {
quarantine(req, sub, msg)
} else {
error(req, msg).await
}
@ -351,12 +358,12 @@ pub async fn sidebar(req: Request<Body>) -> Result<Response<Body>, String> {
// ),
sub,
page: "Sidebar".to_string(),
prefs: Preferences::new(req),
prefs: Preferences::new(&req),
url,
}),
Err(msg) => {
if msg == "quarantined" {
quarantine(req, sub)
if msg == "quarantined" || msg == "gated" {
quarantine(req, sub, msg)
} else {
error(req, msg).await
}
@ -424,5 +431,6 @@ async fn subreddit(sub: &str, quarantined: bool) -> Result<Subreddit, String> {
members: format_num(members),
active: format_num(active),
wiki: res["data"]["wiki_enabled"].as_bool().unwrap_or_default(),
nsfw: res["data"]["over18"].as_bool().unwrap_or_default(),
})
}

View File

@ -1,7 +1,7 @@
// CRATES
use crate::client::json;
use crate::server::RequestExt;
use crate::utils::{error, filter_posts, format_url, get_filters, param, setting, template, Post, Preferences, User};
use crate::utils::{error, filter_posts, format_url, get_filters, nsfw_landing, param, setting, template, Post, Preferences, User};
use askama::Template;
use hyper::{Body, Request, Response};
use time::{macros::format_description, OffsetDateTime};
@ -46,8 +46,18 @@ pub async fn profile(req: Request<Body>) -> Result<Response<Body>, String> {
// Retrieve other variables from Libreddit request
let sort = param(&path, "sort").unwrap_or_default();
let username = req.param("name").unwrap_or_default();
// Retrieve info from user about page.
let user = user(&username).await.unwrap_or_default();
let req_url = req.uri().to_string();
// Return landing page if this post if this Reddit deems this user NSFW,
// but we have also disabled the display of NSFW content or if the instance
// is SFW-only.
if user.nsfw && crate::utils::should_be_nsfw_gated(&req, &req_url) {
return Ok(nsfw_landing(req, req_url).await.unwrap_or_default());
}
let filters = get_filters(&req);
if filters.contains(&["u_", &username].concat()) {
template(UserTemplate {
@ -56,7 +66,7 @@ pub async fn profile(req: Request<Body>) -> Result<Response<Body>, String> {
sort: (sort, param(&path, "t").unwrap_or_default()),
ends: (param(&path, "after").unwrap_or_default(), "".to_string()),
listing,
prefs: Preferences::new(req),
prefs: Preferences::new(&req),
url,
redirect_url,
is_filtered: true,
@ -77,7 +87,7 @@ pub async fn profile(req: Request<Body>) -> Result<Response<Body>, String> {
sort: (sort, param(&path, "t").unwrap_or_default()),
ends: (param(&path, "after").unwrap_or_default(), after),
listing,
prefs: Preferences::new(req),
prefs: Preferences::new(&req),
url,
redirect_url,
is_filtered: false,
@ -115,6 +125,7 @@ async fn user(name: &str) -> Result<User, String> {
created: created.format(format_description!("[month repr:short] [day] '[year repr:last_two]")).unwrap_or_default(),
banner: about("banner_img"),
description: about("public_description"),
nsfw: res["data"]["subreddit"]["over_18"].as_bool().unwrap_or_default(),
}
})
}

View File

@ -1,3 +1,4 @@
use crate::config::get_setting;
//
// CRATES
//
@ -5,10 +6,12 @@ use crate::{client::json, server::RequestExt};
use askama::Template;
use cookie::Cookie;
use hyper::{Body, Request, Response};
use once_cell::sync::Lazy;
use regex::Regex;
use rust_embed::RustEmbed;
use serde_json::Value;
use std::collections::{HashMap, HashSet};
use std::env;
use std::str::FromStr;
use time::{macros::format_description, Duration, OffsetDateTime};
use url::Url;
@ -28,6 +31,16 @@ macro_rules! dbg_msg {
};
}
/// Identifies whether or not the page is a subreddit, a user page, or a post.
/// This is used by the NSFW landing template to determine the mesage to convey
/// to the user.
#[derive(PartialEq, Eq)]
pub enum ResourceType {
Subreddit,
User,
Post,
}
// Post flair with content, background color and foreground color
pub struct Flair {
pub flair_parts: Vec<FlairPart>,
@ -85,6 +98,61 @@ pub struct Author {
pub distinguished: String,
}
pub struct Poll {
pub poll_options: Vec<PollOption>,
pub voting_end_timestamp: (String, String),
pub total_vote_count: u64,
}
impl Poll {
pub fn parse(poll_data: &Value) -> Option<Self> {
poll_data.as_object()?;
let total_vote_count = poll_data["total_vote_count"].as_u64()?;
// voting_end_timestamp is in the format of milliseconds
let voting_end_timestamp = time(poll_data["voting_end_timestamp"].as_f64()? / 1000.0);
let poll_options = PollOption::parse(&poll_data["options"])?;
Some(Self {
poll_options,
total_vote_count,
voting_end_timestamp,
})
}
pub fn most_votes(&self) -> u64 {
self.poll_options.iter().filter_map(|o| o.vote_count).max().unwrap_or(0)
}
}
pub struct PollOption {
pub id: u64,
pub text: String,
pub vote_count: Option<u64>,
}
impl PollOption {
pub fn parse(options: &Value) -> Option<Vec<Self>> {
Some(
options
.as_array()?
.iter()
.filter_map(|option| {
// For each poll option
// we can't just use as_u64() because "id": String("...") and serde would parse it as None
let id = option["id"].as_str()?.parse::<u64>().ok()?;
let text = option["text"].as_str()?.to_owned();
let vote_count = option["vote_count"].as_u64();
// Construct PollOption items
Some(Self { id, text, vote_count })
})
.collect::<Vec<Self>>(),
)
}
}
// Post flags with nsfw and stickied
pub struct Flags {
pub nsfw: bool,
@ -193,10 +261,17 @@ impl GalleryMedia {
// For each image in gallery
let media_id = item["media_id"].as_str().unwrap_or_default();
let image = &metadata[media_id]["s"];
let image_type = &metadata[media_id]["m"];
let url = if image_type == "image/gif" {
image["gif"].as_str().unwrap_or_default()
} else {
image["u"].as_str().unwrap_or_default()
};
// Construct gallery items
Self {
url: format_url(image["u"].as_str().unwrap_or_default()),
url: format_url(url),
width: image["x"].as_i64().unwrap_or_default(),
height: image["y"].as_i64().unwrap_or_default(),
caption: item["caption"].as_str().unwrap_or_default().to_string(),
@ -215,6 +290,7 @@ pub struct Post {
pub body: String,
pub author: Author,
pub permalink: String,
pub poll: Option<Poll>,
pub score: (String, String),
pub upvote_ratio: i64,
pub post_type: String,
@ -229,6 +305,7 @@ pub struct Post {
pub comments: (String, String),
pub gallery: Vec<GalleryMedia>,
pub awards: Awards,
pub nsfw: bool,
}
impl Post {
@ -323,12 +400,14 @@ impl Post {
stickied: data["stickied"].as_bool().unwrap_or_default() || data["pinned"].as_bool().unwrap_or_default(),
},
permalink: val(post, "permalink"),
poll: Poll::parse(&data["poll_data"]),
rel_time,
created,
num_duplicates: post["data"]["num_duplicates"].as_u64().unwrap_or(0),
comments: format_num(data["num_comments"].as_i64().unwrap_or_default()),
gallery,
awards,
nsfw: post["data"]["over_18"].as_bool().unwrap_or_default(),
});
}
@ -357,6 +436,8 @@ pub struct Comment {
pub awards: Awards,
pub collapsed: bool,
pub is_filtered: bool,
pub more_count: i64,
pub prefs: Preferences,
}
#[derive(Default, Clone)]
@ -420,6 +501,27 @@ pub struct ErrorTemplate {
pub url: String,
}
/// Template for NSFW landing page. The landing page is displayed when a page's
/// content is wholly NSFW, but a user has not enabled the option to view NSFW
/// posts.
#[derive(Template)]
#[template(path = "nsfwlanding.html")]
pub struct NSFWLandingTemplate {
/// Identifier for the resource. This is either a subreddit name or a
/// username. (In the case of the latter, set is_user to true.)
pub res: String,
/// Identifies whether or not the resource is a subreddit, a user page,
/// or a post.
pub res_type: ResourceType,
/// User preferences.
pub prefs: Preferences,
/// Request URL.
pub url: String,
}
#[derive(Default)]
// User struct containing metadata about user
pub struct User {
@ -430,6 +532,7 @@ pub struct User {
pub created: String,
pub banner: String,
pub description: String,
pub nsfw: bool,
}
#[derive(Default)]
@ -444,6 +547,7 @@ pub struct Subreddit {
pub members: (String, String),
pub active: (String, String),
pub wiki: bool,
pub nsfw: bool,
}
// Parser for query params, used in sorting (eg. /r/rust/?sort=hot)
@ -468,10 +572,12 @@ pub struct Preferences {
pub hide_hls_notification: String,
pub use_hls: String,
pub autoplay_videos: String,
pub disable_visit_reddit_confirmation: String,
pub comment_sort: String,
pub post_sort: String,
pub subscriptions: Vec<String>,
pub filters: Vec<String>,
pub hide_awards: String,
}
#[derive(RustEmbed)]
@ -481,7 +587,7 @@ pub struct ThemeAssets;
impl Preferences {
// Build preferences from cookies
pub fn new(req: Request<Body>) -> Self {
pub fn new(req: &Request<Body>) -> Self {
// Read available theme names from embedded css files.
// Always make the default "system" theme available.
let mut themes = vec!["system".to_string()];
@ -491,19 +597,21 @@ impl Preferences {
}
Self {
available_themes: themes,
theme: setting(&req, "theme"),
front_page: setting(&req, "front_page"),
layout: setting(&req, "layout"),
wide: setting(&req, "wide"),
show_nsfw: setting(&req, "show_nsfw"),
blur_nsfw: setting(&req, "blur_nsfw"),
use_hls: setting(&req, "use_hls"),
hide_hls_notification: setting(&req, "hide_hls_notification"),
autoplay_videos: setting(&req, "autoplay_videos"),
comment_sort: setting(&req, "comment_sort"),
post_sort: setting(&req, "post_sort"),
subscriptions: setting(&req, "subscriptions").split('+').map(String::from).filter(|s| !s.is_empty()).collect(),
filters: setting(&req, "filters").split('+').map(String::from).filter(|s| !s.is_empty()).collect(),
theme: setting(req, "theme"),
front_page: setting(req, "front_page"),
layout: setting(req, "layout"),
wide: setting(req, "wide"),
show_nsfw: setting(req, "show_nsfw"),
blur_nsfw: setting(req, "blur_nsfw"),
use_hls: setting(req, "use_hls"),
hide_hls_notification: setting(req, "hide_hls_notification"),
autoplay_videos: setting(req, "autoplay_videos"),
disable_visit_reddit_confirmation: setting(req, "disable_visit_reddit_confirmation"),
comment_sort: setting(req, "comment_sort"),
post_sort: setting(req, "post_sort"),
subscriptions: setting(req, "subscriptions").split('+').map(String::from).filter(|s| !s.is_empty()).collect(),
filters: setting(req, "filters").split('+').map(String::from).filter(|s| !s.is_empty()).collect(),
hide_awards: setting(req, "hide_awards"),
}
}
}
@ -551,9 +659,12 @@ pub async fn parse_post(post: &serde_json::Value) -> Post {
let permalink = val(post, "permalink");
let poll = Poll::parse(&post["data"]["poll_data"]);
let body = if val(post, "removed_by_category") == "moderator" {
format!(
"<div class=\"md\"><p>[removed] — <a href=\"https://www.unddit.com{}\">view removed post</a></p></div>",
"<div class=\"md\"><p>[removed] — <a href=\"https://{}{}\">view removed post</a></p></div>",
get_setting("LIBREDDIT_PUSHSHIFT_FRONTEND").unwrap_or(String::from(crate::config::DEFAULT_PUSHSHIFT_FRONTEND)),
permalink
)
} else {
@ -581,6 +692,7 @@ pub async fn parse_post(post: &serde_json::Value) -> Post {
distinguished: val(post, "distinguished"),
},
permalink,
poll,
score: format_num(score),
upvote_ratio: ratio as i64,
post_type,
@ -617,6 +729,7 @@ pub async fn parse_post(post: &serde_json::Value) -> Post {
comments: format_num(post["data"]["num_comments"].as_i64().unwrap_or_default()),
gallery,
awards,
nsfw: post["data"]["over_18"].as_bool().unwrap_or_default(),
}
}
@ -643,8 +756,8 @@ pub fn setting(req: &Request<Body>, name: &str) -> String {
req
.cookie(name)
.unwrap_or_else(|| {
// If there is no cookie for this setting, try receiving a default from an environment variable
if let Ok(default) = std::env::var(format!("LIBREDDIT_DEFAULT_{}", name.to_uppercase())) {
// If there is no cookie for this setting, try receiving a default from the config
if let Some(default) = crate::config::get_setting(&format!("LIBREDDIT_DEFAULT_{}", name.to_uppercase())) {
Cookie::new(name, default)
} else {
Cookie::named(name)
@ -667,6 +780,21 @@ pub async fn catch_random(sub: &str, additional: &str) -> Result<Response<Body>,
}
}
static REGEX_URL_WWW: Lazy<Regex> = Lazy::new(|| Regex::new(r"https://www\.reddit\.com/(.*)").unwrap());
static REGEX_URL_OLD: Lazy<Regex> = Lazy::new(|| Regex::new(r"https://old\.reddit\.com/(.*)").unwrap());
static REGEX_URL_NP: Lazy<Regex> = Lazy::new(|| Regex::new(r"https://np\.reddit\.com/(.*)").unwrap());
static REGEX_URL_PLAIN: Lazy<Regex> = Lazy::new(|| Regex::new(r"https://reddit\.com/(.*)").unwrap());
static REGEX_URL_VIDEOS: Lazy<Regex> = Lazy::new(|| Regex::new(r"https://v\.redd\.it/(.*)/DASH_([0-9]{2,4}(\.mp4|$|\?source=fallback))").unwrap());
static REGEX_URL_VIDEOS_HLS: Lazy<Regex> = Lazy::new(|| Regex::new(r"https://v\.redd\.it/(.+)/(HLSPlaylist\.m3u8.*)$").unwrap());
static REGEX_URL_IMAGES: Lazy<Regex> = Lazy::new(|| Regex::new(r"https://i\.redd\.it/(.*)").unwrap());
static REGEX_URL_THUMBS_A: Lazy<Regex> = Lazy::new(|| Regex::new(r"https://a\.thumbs\.redditmedia\.com/(.*)").unwrap());
static REGEX_URL_THUMBS_B: Lazy<Regex> = Lazy::new(|| Regex::new(r"https://b\.thumbs\.redditmedia\.com/(.*)").unwrap());
static REGEX_URL_EMOJI: Lazy<Regex> = Lazy::new(|| Regex::new(r"https://emoji\.redditmedia\.com/(.*)/(.*)").unwrap());
static REGEX_URL_PREVIEW: Lazy<Regex> = Lazy::new(|| Regex::new(r"https://preview\.redd\.it/(.*)").unwrap());
static REGEX_URL_EXTERNAL_PREVIEW: Lazy<Regex> = Lazy::new(|| Regex::new(r"https://external\-preview\.redd\.it/(.*)").unwrap());
static REGEX_URL_STYLES: Lazy<Regex> = Lazy::new(|| Regex::new(r"https://styles\.redditmedia\.com/(.*)").unwrap());
static REGEX_URL_STATIC_MEDIA: Lazy<Regex> = Lazy::new(|| Regex::new(r"https://www\.redditstatic\.com/(.*)").unwrap());
// Direct urls to proxy if proxy is enabled
pub fn format_url(url: &str) -> String {
if url.is_empty() || url == "self" || url == "default" || url == "nsfw" || url == "spoiler" {
@ -675,13 +803,11 @@ pub fn format_url(url: &str) -> String {
Url::parse(url).map_or(url.to_string(), |parsed| {
let domain = parsed.domain().unwrap_or_default();
let capture = |regex: &str, format: &str, segments: i16| {
Regex::new(regex).map_or(String::new(), |re| {
re.captures(url).map_or(String::new(), |caps| match segments {
1 => [format, &caps[1]].join(""),
2 => [format, &caps[1], "/", &caps[2]].join(""),
_ => String::new(),
})
let capture = |regex: &Regex, format: &str, segments: i16| {
regex.captures(url).map_or(String::new(), |caps| match segments {
1 => [format, &caps[1]].join(""),
2 => [format, &caps[1], "/", &caps[2]].join(""),
_ => String::new(),
})
};
@ -707,44 +833,46 @@ pub fn format_url(url: &str) -> String {
}
match domain {
"www.reddit.com" => capture(r"https://www\.reddit\.com/(.*)", "/", 1),
"old.reddit.com" => capture(r"https://old\.reddit\.com/(.*)", "/", 1),
"np.reddit.com" => capture(r"https://np\.reddit\.com/(.*)", "/", 1),
"reddit.com" => capture(r"https://reddit\.com/(.*)", "/", 1),
"v.redd.it" => chain!(
capture(r"https://v\.redd\.it/(.*)/DASH_([0-9]{2,4}(\.mp4|$|\?source=fallback))", "/vid/", 2),
capture(r"https://v\.redd\.it/(.+)/(HLSPlaylist\.m3u8.*)$", "/hls/", 2)
),
"i.redd.it" => capture(r"https://i\.redd\.it/(.*)", "/img/", 1),
"a.thumbs.redditmedia.com" => capture(r"https://a\.thumbs\.redditmedia\.com/(.*)", "/thumb/a/", 1),
"b.thumbs.redditmedia.com" => capture(r"https://b\.thumbs\.redditmedia\.com/(.*)", "/thumb/b/", 1),
"emoji.redditmedia.com" => capture(r"https://emoji\.redditmedia\.com/(.*)/(.*)", "/emoji/", 2),
"preview.redd.it" => capture(r"https://preview\.redd\.it/(.*)", "/preview/pre/", 1),
"external-preview.redd.it" => capture(r"https://external\-preview\.redd\.it/(.*)", "/preview/external-pre/", 1),
"styles.redditmedia.com" => capture(r"https://styles\.redditmedia\.com/(.*)", "/style/", 1),
"www.redditstatic.com" => capture(r"https://www\.redditstatic\.com/(.*)", "/static/", 1),
"www.reddit.com" => capture(&REGEX_URL_WWW, "/", 1),
"old.reddit.com" => capture(&REGEX_URL_OLD, "/", 1),
"np.reddit.com" => capture(&REGEX_URL_NP, "/", 1),
"reddit.com" => capture(&REGEX_URL_PLAIN, "/", 1),
"v.redd.it" => chain!(capture(&REGEX_URL_VIDEOS, "/vid/", 2), capture(&REGEX_URL_VIDEOS_HLS, "/hls/", 2)),
"i.redd.it" => capture(&REGEX_URL_IMAGES, "/img/", 1),
"a.thumbs.redditmedia.com" => capture(&REGEX_URL_THUMBS_A, "/thumb/a/", 1),
"b.thumbs.redditmedia.com" => capture(&REGEX_URL_THUMBS_B, "/thumb/b/", 1),
"emoji.redditmedia.com" => capture(&REGEX_URL_EMOJI, "/emoji/", 2),
"preview.redd.it" => capture(&REGEX_URL_PREVIEW, "/preview/pre/", 1),
"external-preview.redd.it" => capture(&REGEX_URL_EXTERNAL_PREVIEW, "/preview/external-pre/", 1),
"styles.redditmedia.com" => capture(&REGEX_URL_STYLES, "/style/", 1),
"www.redditstatic.com" => capture(&REGEX_URL_STATIC_MEDIA, "/static/", 1),
_ => url.to_string(),
}
})
}
}
static REDDIT_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r#"href="(https|http|)://(www\.|old\.|np\.|amp\.|)(reddit\.com|redd\.it)/"#).unwrap());
static REDDIT_PREVIEW_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"https://external-preview\.redd\.it(.*)[^?]").unwrap());
// Rewrite Reddit links to Libreddit in body of text
pub fn rewrite_urls(input_text: &str) -> String {
let text1 = Regex::new(r#"href="(https|http|)://(www\.|old\.|np\.|amp\.|)(reddit\.com|redd\.it)/"#)
.map_or(String::new(), |re| re.replace_all(input_text, r#"href="/"#).to_string())
// Remove (html-encoded) "\" from URLs.
.replace("%5C", "")
.replace('\\', "");
let text1 =
// Rewrite Reddit links to Libreddit
REDDIT_REGEX.replace_all(input_text, r#"href="/"#)
.to_string()
// Remove (html-encoded) "\" from URLs.
.replace("%5C", "")
.replace('\\', "");
// Rewrite external media previews to Libreddit
Regex::new(r"https://external-preview\.redd\.it(.*)[^?]").map_or(String::new(), |re| {
if re.is_match(&text1) {
re.replace_all(&text1, format_url(re.find(&text1).map(|x| x.as_str()).unwrap_or_default())).to_string()
} else {
text1
}
})
if REDDIT_PREVIEW_REGEX.is_match(&text1) {
REDDIT_PREVIEW_REGEX
.replace_all(&text1, format_url(REDDIT_PREVIEW_REGEX.find(&text1).map(|x| x.as_str()).unwrap_or_default()))
.to_string()
} else {
text1
}
}
// Format vote count to a string that will be displayed.
@ -765,20 +893,31 @@ pub fn format_num(num: i64) -> (String, String) {
// Parse a relative and absolute time from a UNIX timestamp
pub fn time(created: f64) -> (String, String) {
let time = OffsetDateTime::from_unix_timestamp(created.round() as i64).unwrap_or(OffsetDateTime::UNIX_EPOCH);
let time_delta = OffsetDateTime::now_utc() - time;
let now = OffsetDateTime::now_utc();
let min = time.min(now);
let max = time.max(now);
let time_delta = max - min;
// If the time difference is more than a month, show full date
let rel_time = if time_delta > Duration::days(30) {
let mut rel_time = if time_delta > Duration::days(30) {
time.format(format_description!("[month repr:short] [day] '[year repr:last_two]")).unwrap_or_default()
// Otherwise, show relative date/time
} else if time_delta.whole_days() > 0 {
format!("{}d ago", time_delta.whole_days())
format!("{}d", time_delta.whole_days())
} else if time_delta.whole_hours() > 0 {
format!("{}h ago", time_delta.whole_hours())
format!("{}h", time_delta.whole_hours())
} else {
format!("{}m ago", time_delta.whole_minutes())
format!("{}m", time_delta.whole_minutes())
};
if time_delta <= Duration::days(30) {
if now < time {
rel_time += " left";
} else {
rel_time += " ago";
}
}
(
rel_time,
time
@ -820,7 +959,7 @@ pub async fn error(req: Request<Body>, msg: impl ToString) -> Result<Response<Bo
let url = req.uri().to_string();
let body = ErrorTemplate {
msg: msg.to_string(),
prefs: Preferences::new(req),
prefs: Preferences::new(&req),
url,
}
.render()
@ -829,6 +968,61 @@ pub async fn error(req: Request<Body>, msg: impl ToString) -> Result<Response<Bo
Ok(Response::builder().status(404).header("content-type", "text/html").body(body.into()).unwrap_or_default())
}
/// Returns true if the config/env variable `LIBREDDIT_SFW_ONLY` carries the
/// value `on`.
///
/// If this variable is set as such, the instance will operate in SFW-only
/// mode; all NSFW content will be filtered. Attempts to access NSFW
/// subreddits or posts or userpages for users Reddit has deemed NSFW will
/// be denied.
pub fn sfw_only() -> bool {
match crate::config::get_setting("LIBREDDIT_SFW_ONLY") {
Some(val) => val == "on",
None => false,
}
}
// Determines if a request shoud redirect to a nsfw landing gate.
pub fn should_be_nsfw_gated(req: &Request<Body>, req_url: &str) -> bool {
let sfw_instance = sfw_only();
let gate_nsfw = (setting(req, "show_nsfw") != "on") || sfw_instance;
// Nsfw landing gate should not be bypassed on a sfw only instance,
let bypass_gate = !sfw_instance && req_url.contains("&bypass_nsfw_landing");
gate_nsfw && !bypass_gate
}
/// Renders the landing page for NSFW content when the user has not enabled
/// "show NSFW posts" in settings.
pub async fn nsfw_landing(req: Request<Body>, req_url: String) -> Result<Response<Body>, String> {
let res_type: ResourceType;
// Determine from the request URL if the resource is a subreddit, a user
// page, or a post.
let res: String = if !req.param("name").unwrap_or_default().is_empty() {
res_type = ResourceType::User;
req.param("name").unwrap_or_default()
} else if !req.param("id").unwrap_or_default().is_empty() {
res_type = ResourceType::Post;
req.param("id").unwrap_or_default()
} else {
res_type = ResourceType::Subreddit;
req.param("sub").unwrap_or_default()
};
let body = NSFWLandingTemplate {
res,
res_type,
prefs: Preferences::new(&req),
url: req_url,
}
.render()
.unwrap_or_default();
Ok(Response::builder().status(403).header("content-type", "text/html").body(body.into()).unwrap_or_default())
}
#[cfg(test)]
mod tests {
use super::{format_num, format_url, rewrite_urls};

View File

@ -4,6 +4,30 @@
:root {
--nsfw: #ff5c5d;
--admin: #ea0027;
/* Reddit redirect warning constants */
--popup-red: #ea0027;
--popup-black: #111;
--popup-text: #fff;
--popup-background-1: #0f0f0f;
--popup-background-2: #220f0f;
--popup-reddit-url: var(--popup-red);
--popup-background: repeating-linear-gradient(
-45deg,
var(--popup-background-1),
var(--popup-background-1) 50px,
var(--popup-background-2) 50px,
var(--popup-background-2) 100px
);
--popup-toreddit-background: var(--popup-black);
--popup-toreddit-text: var(--popup-red);
--popup-goback-background: var(--popup-red);
--popup-goback-text: #222;
--popup-border: 1px solid var(--popup-red);
--footer-height: 30px;
}
@font-face {
@ -26,6 +50,10 @@
--highlighted: #333;
--visited: #aaa;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
--popup: #b80a27;
/* Hint color theme to browser for scrollbar */
color-scheme: dark;
}
/* Browser-defined light theme */
@ -42,6 +70,9 @@
--highlighted: white;
--visited: #555;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
/* Hint color theme to browser for scrollbar */
color-scheme: light;
}
}
@ -70,6 +101,9 @@ body {
background: var(--background);
font-size: 15px;
padding-top: 60px;
padding-bottom: var(--footer-height);
min-height: calc(100vh - 60px);
position: relative;
}
nav {
@ -113,12 +147,6 @@ nav #links svg {
display: none;
}
nav #version {
opacity: 50%;
vertical-align: -2px;
margin-right: 10px;
}
nav #libreddit {
vertical-align: -2px;
}
@ -128,10 +156,109 @@ nav #libreddit {
margin-left: 10px;
}
#reddit_link {
.popup {
display: flex;
align-items: center;
justify-content: center;
overflow: clip;
opacity: 0;
position: fixed;
width: 100vw;
height: 100vh;
bottom: 0;
right: 0;
visibility: hidden;
transition: all 0.1s ease-in-out;
z-index: 2;
}
/* fallback for firefox esr */
.popup {
background-color: #000000fd;
}
/* all other browsers */
@supports ((-webkit-backdrop-filter: none) or (backdrop-filter: none)) {
.popup {
-webkit-backdrop-filter: blur(.25rem) brightness(15%);
backdrop-filter: blur(.25rem) brightness(15%);
}
}
.popup-inner {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
max-width: 600px;
max-height: 500px;
width: fit-content;
height: fit-content;
padding: 1rem;
background: var(--popup-background);
border: var(--popup-border);
border-radius: 5px;
transition: all 0.1s ease-in-out;
}
.popup-inner svg {
display: unset !important;
width: 35%;
stroke: none;
margin: 1rem;
}
.popup-inner h1 {
color: var(--popup-text);
margin: 1.5rem 1.5rem 1rem;
}
.popup-inner p {
color: var(--popup-text);
}
.popup-inner a {
border-radius: 5px;
padding: 2%;
width: 80%;
margin: 0.5rem;
cursor: pointer;
transition: all 0.1s ease-in-out;
}
#goback {
background: var(--popup-goback-background);
color: var(--popup-goback-text);
}
#goback:not(.selected):hover {
opacity: 0.8;
}
#toreddit {
background: var(--popup-toreddit-background);
color: var(--popup-toreddit-text);
border: 1px solid var(--popup-red);
}
#toreddit:not(.selected):hover {
background: var(--popup-toreddit-text);
color: var(--popup-toreddit-background);
}
.popup:target {
visibility: visible;
opacity: 1;
}
#reddit_url {
width: 80%;
color: var(--popup-reddit-url);
font-weight: 600;
line-break: anywhere;
margin-top: 1rem;
}
#code {
margin-left: 10px;
}
@ -142,6 +269,7 @@ main {
max-width: 1000px;
padding: 10px 20px;
margin: 0 auto;
padding-bottom: 4em;
}
.wide main {
@ -160,20 +288,48 @@ main {
overflow: inherit;
}
footer {
/* Body footer. */
body > footer {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
background: var(--post);
position: absolute;
bottom: 0;
}
.footer-button {
align-items: center;
border-radius: .25rem;
box-sizing: border-box;
color: var(--text);
cursor: pointer;
display: inline-flex;
padding-left: 1em;
opacity: 0.8;
}
/* / Body footer. */
/* Footer in content block. */
main > * > footer {
display: flex;
justify-content: center;
margin-top: 20px;
}
footer > a {
main > * > footer > a {
margin-right: 5px;
}
/* / Footer in content block. */
button {
background: none;
border: none;
font-weight: bold;
cursor: pointer;
}
hr {
@ -224,13 +380,17 @@ aside {
border-radius: 5px;
overflow: hidden;
}
#subreddit, #sidebar { min-width: 350px; }
#user *, #subreddit * { text-align: center; }
#user, #sub_meta, #sidebar_contents { padding: 20px; }
#sidebar, #sidebar_contents { margin-top: 10px; }
#sidebar_label { padding: 10px; }
#sidebar_label, #subreddit_label {
padding: 10px;
text-align: left;
}
#user_icon, #sub_icon {
width: 100px;
@ -242,7 +402,6 @@ aside {
}
#user_title, #sub_title {
margin: 0 20px;
font-size: 20px;
font-weight: bold;
}
@ -386,6 +545,7 @@ select, #search, #sort_options, #listing_options, #inside, #searchbox > *, #sort
select {
background: var(--outside);
transition: 0.2s background;
cursor: pointer;
}
select, #search {
@ -398,6 +558,10 @@ select, #search {
border-radius: 5px 0px 0px 5px;
}
.commentQuery {
background: var(--post);
}
#searchbox {
grid-area: searchbox;
display: flex;
@ -475,26 +639,31 @@ button.submit:hover > svg { stroke: var(--accent); }
background: transparent;
}
#commentQueryForms {
display: flex;
justify-content: space-between;
}
#allCommentsLink {
color: var(--green);
}
#sort, #search_sort {
display: flex;
align-items: center;
margin-bottom: 20px;
}
#listing_options {
overflow-x: auto;
}
#sort_options, #listing_options, footer > a {
#sort_options, #listing_options, main > * > footer > a {
border-radius: 5px;
align-items: center;
box-shadow: var(--shadow);
background: var(--outside);
display: flex;
overflow: hidden;
overflow-y: hidden;
}
#sort_options > a, #listing_options > a, footer > a {
#sort_options > a, #listing_options > a, main > * > footer > a {
color: var(--text);
padding: 10px 20px;
text-align: center;
@ -602,6 +771,7 @@ a.search_subreddit:hover {
"post_score post_title post_thumbnail" 1fr
"post_score post_media post_thumbnail" auto
"post_score post_body post_thumbnail" auto
"post_score post_poll post_thumbnail" auto
"post_score post_notification post_thumbnail" auto
"post_score post_footer post_thumbnail" auto
/ minmax(40px, auto) minmax(0, 1fr) fit-content(min(20%, 152px));
@ -802,6 +972,44 @@ a.search_subreddit:hover {
overflow-wrap: anywhere;
}
.post_poll {
grid-area: post_poll;
padding: 5px 15px 5px 12px;
}
.poll_option {
position: relative;
margin-right: 15px;
margin-top: 14px;
z-index: 0;
display: flex;
align-items: center;
}
.poll_chart {
padding: 14px 0;
background-color: var(--accent);
opacity: 0.2;
border-radius: 5px;
z-index: -1;
position: absolute;
}
.poll_option span {
margin-left: 8px;
color: var(--text);
}
.poll_option span:nth-of-type(1) {
min-width: 10%;
font-weight: bold;
}
.most_voted {
opacity: 0.45;
width: 100%;
}
/* Used only for text post preview */
.post_preview {
-webkit-mask-image: linear-gradient(180deg,#000 60%,transparent);;
@ -824,6 +1032,17 @@ a.search_subreddit:hover {
font-weight: bold;
}
#comment_count {
font-weight: 500;
opacity: 0.9;
}
#comment_count > #sorted_by {
font-weight: normal;
opacity: 0.7;
margin-right: 7px;
}
#post_links {
display: flex;
list-style: none;
@ -1202,6 +1421,10 @@ input[type="submit"] {
width: 250px;
background: var(--highlighted) !important;
}
/* Info page */
.unset {
color: lightslategrey;
}
/* Markdown */
@ -1315,10 +1538,38 @@ td, th {
color: var(--accent);
}
/* NSFW Landing Page */
#nsfw_landing {
display: inline-block;
text-align: center;
width: 100%;
}
#nsfw_landing h1 {
display: inline-block;
margin-bottom: 20px;
text-align: center;
width: 100%;
}
#nsfw_landing p {
display: inline-block;
text-align: center;
width: 100%;
}
#nsfw_landing a {
color: var(--accent);
}
/* Mobile */
@media screen and (max-width: 800px) {
body { padding-top: 120px }
body {
padding-top: 120px;
padding-bottom: var(--footer-height);
}
main {
flex-direction: column-reverse;
@ -1357,17 +1608,21 @@ td, th {
#user, #sidebar { margin: 20px 0; }
#logo, #links { margin-bottom: 5px; }
#searchbox { width: calc(100vw - 35px); }
}
@media screen and (max-width: 480px) {
body { padding-top: 100px; }
#version { display: none; }
body {
padding-top: 100px;
padding-bottom: var(--footer-height);
}
.post {
grid-template: "post_header post_header post_thumbnail" auto
"post_title post_title post_thumbnail" 1fr
"post_media post_media post_thumbnail" auto
"post_body post_body post_thumbnail" auto
"post_poll post_poll post_thumbnail" auto
"post_notification post_notification post_thumbnail" auto
"post_score post_footer post_thumbnail" auto
/ auto 1fr fit-content(min(20%, 152px));
@ -1377,6 +1632,10 @@ td, th {
margin: 5px 0px 20px 15px;
padding: 0;
}
.post_poll {
padding: 5px 15px 10px 12px;
}
.compact .post_score { padding: 0; }
@ -1420,4 +1679,17 @@ td, th {
#post_links > li.desktop_item { display: none }
#post_links > li.mobile_item { display: auto }
.post_footer > p > span#upvoted { display: none }
.popup {
width: auto;
}
.popup-inner {
max-width: 80%;
}
#commentQueryForms {
display: initial;
justify-content: initial;
}
}

View File

@ -11,3 +11,8 @@
--highlighted: #fbf1c7;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
}
html:has(> .gruvboxlight) {
/* Hint color theme to browser for scrollbar */
color-scheme: light;
}

View File

@ -11,4 +11,9 @@
--highlighted: white;
--visited: #555;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
html:has(> .light) {
/* Hint color theme to browser for scrollbar */
color-scheme: light;
}

View File

@ -1,3 +1,5 @@
{% import "utils.html" as utils %}
<!DOCTYPE html>
<html lang="en">
<head>
@ -30,17 +32,20 @@
<nav>
<div id="logo">
<a id="libreddit" href="/"><span id="lib">lib</span><span id="reddit">reddit.</span></a>
<span id="version">v{{ env!("CARGO_PKG_VERSION") }}</span>
{% block subscriptions %}{% endblock %}
</div>
{% block search %}{% endblock %}
<div id="links">
<a id="reddit_link" href="https://www.reddit.com{{ url }}" rel="nofollow">
<a id="reddit_link" {% if prefs.disable_visit_reddit_confirmation != "on" %}href="#popup"{% else %}href="https://www.reddit.com{{ url }}" rel="nofollow"{% endif %}>
<span>reddit</span>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M23 12.0737C23 10.7308 21.9222 9.64226 20.5926 9.64226C19.9435 9.64226 19.3557 9.90274 18.923 10.3244C17.2772 9.12492 15.0099 8.35046 12.4849 8.26135L13.5814 3.05002L17.1643 3.8195C17.2081 4.73947 17.9539 5.47368 18.8757 5.47368C19.8254 5.47368 20.5951 4.69626 20.5951 3.73684C20.5951 2.77769 19.8254 2 18.8758 2C18.2001 2 17.6214 2.39712 17.3404 2.96952L13.3393 2.11066C13.2279 2.08679 13.1116 2.10858 13.016 2.17125C12.9204 2.23393 12.8533 2.33235 12.8295 2.44491L11.6051 8.25987C9.04278 8.33175 6.73904 9.10729 5.07224 10.3201C4.63988 9.90099 4.05398 9.64226 3.40757 9.64226C2.0781 9.64226 1 10.7308 1 12.0737C1 13.0618 1.58457 13.9105 2.4225 14.2909C2.38466 14.5342 2.36545 14.78 2.36505 15.0263C2.36505 18.7673 6.67626 21.8 11.9945 21.8C17.3131 21.8 21.6243 18.7673 21.6243 15.0263C21.6243 14.7794 21.6043 14.5359 21.5678 14.2957C22.4109 13.9175 23 13.0657 23 12.0737Z"/>
<path d="M22 2L12 22"/>
<path d="M2 6.70587C3.33333 8.07884 3.33333 11.5971 3.33333 11.5971M3.33333 19.647V11.5971M3.33333 11.5971C3.33333 11.5971 5.125 7.47817 8 7.47817C10.875 7.47817 12 8.85114 12 8.85114"/>
</svg>
</a>
{% if prefs.disable_visit_reddit_confirmation != "on" %}
{% call utils::visit_reddit_confirmation(url) %}
{% endif %}
<a id="settings_link" href="/settings">
<span>settings</span>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@ -48,13 +53,6 @@
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
</a>
<a id="code" href="https://github.com/libreddit/libreddit" target="_blank" rel="noopener noreferrer">
<span>code</span>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<title>code</title>
<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>
</svg>
</a>
</div>
</nav>
@ -65,5 +63,18 @@
{% endblock %}
</main>
{% endblock %}
<!-- FOOTER -->
{% block footer %}
<footer>
<p id="version">v{{ env!("CARGO_PKG_VERSION") }}</p>
<div class="footer-button">
<a href="/info" title="View instance information">ⓘ View instance info</a>
</div>
<div class="footer-button">
<a href="https://github.com/libreddit/libreddit" title="View code on GitHub">&lt;&gt; Code</a>
</div>
</footer>
{% endblock %}
</body>
</html>

View File

@ -1,7 +1,7 @@
{% import "utils.html" as utils %}
{% if kind == "more" && parent_kind == "t1" %}
<a class="deeper_replies" href="{{ post_link }}{{ parent_id }}">&rarr; More replies</a>
<a class="deeper_replies" href="{{ post_link }}{{ parent_id }}">&rarr; More replies ({{ more_count }})</a>
{% else if kind == "t1" %}
<div id="{{ id }}" class="comment">
<div class="comment_left">
@ -20,7 +20,7 @@
{% endif %}
<a href="{{ post_link }}{{ id }}/?context=3" class="created" title="{{ created }}">{{ rel_time }}</a>
{% if edited.0 != "".to_string() %}<span class="edited" title="{{ edited.1 }}">edited {{ edited.0 }}</span>{% endif %}
{% if !awards.is_empty() %}
{% if !awards.is_empty() && prefs.hide_awards != "on" %}
<span class="dot">&bull;</span>
{% for award in awards.clone() %}
<span class="award" title="{{ award.name }}">
@ -35,7 +35,7 @@
<div class="comment_body {% if highlighted %}highlighted{% endif %}">{{ body|safe }}</div>
{% endif %}
<blockquote class="replies">{% for c in replies -%}{{ c.render().unwrap()|safe }}{%- endfor %}
</blockquote>
</bockquote>
</details>
</div>
{% endif %}

View File

@ -65,7 +65,7 @@
<a class="post_author {{ post.author.distinguished }}" href="/u/{{ post.author.name }}">u/{{ post.author.name }}</a>
<span class="dot">&bull;</span>
<span class="created" title="{{ post.created }}">{{ post.rel_time }}</span>
{% if !post.awards.is_empty() %}
{% if !post.awards.is_empty() && prefs.hide_awards != "on" %}
{% for award in post.awards.clone() %}
<span class="award" title="{{ award.name }}">
<img alt="{{ award.name }}" src="{{ award.icon_url }}" width="16" height="16"/>
@ -104,4 +104,4 @@
</footer>
{% endif %}
</div>
{% endblock %}
{% endblock %}

10
templates/message.html Normal file
View File

@ -0,0 +1,10 @@
{% extends "base.html" %}
{% block title %}{{ title }}{% endblock %}
{% block sortstyle %}{% endblock %}
{% block content %}
<div id="message">
<h1>{{ title }}</h1>
<br>
{{ body|safe }}
</div>
{% endblock %}

View File

@ -0,0 +1,30 @@
{% extends "base.html" %}
{% block title %}NSFW content gated{% endblock %}
{% block sortstyle %}{% endblock %}
{% block content %}
<div id="nsfw_landing">
<h1>
&#128561;
{% if res_type == crate::utils::ResourceType::Subreddit %}
r/{{ res }} is a NSFW community!
{% else if res_type == crate::utils::ResourceType::User %}
u/{{ res }}'s content is NSFW!
{% else if res_type == crate::utils::ResourceType::Post %}
This post is NSFW!
{% endif %}
</h1>
<br />
<p>
{% if crate::utils::sfw_only() %}
This instance of Libreddit is SFW-only.</p>
{% else %}
Enable "Show NSFW posts" in <a href="/settings">settings</a> to view this {% if res_type == crate::utils::ResourceType::Subreddit %}subreddit{% else if res_type == crate::utils::ResourceType::User %}user's posts or comments{% else if res_type == crate::utils::ResourceType::Post %}post{% endif %}. <br>
{% if res_type == crate::utils::ResourceType::Post %} You can also temporarily bypass this gate and view the post by clicking on this <a href="{{url}}&bypass_nsfw_landing">link</a>.{% endif %}
{% endif %}
</p>
</div>
{% endblock %}
{% block footer %}
{% endblock %}

View File

@ -43,17 +43,32 @@
{% call utils::post(post) %}
<!-- SORT FORM -->
<div id="commentQueryForms">
<form id="sort">
<select name="sort" title="Sort comments by">
<p id="comment_count">{{post.comments.0}} {% if post.comments.0 == "1" %}comment{% else %}comments{% endif %} <span id="sorted_by">sorted by </span></p>
<select name="sort" title="Sort comments by" id="commentSortSelect">
{% call utils::options(sort, ["confidence", "top", "new", "controversial", "old"], "confidence") %}
</select><button id="sort_submit" class="submit">
<svg width="15" viewBox="0 0 110 100" fill="none" stroke-width="10" stroke-linecap="round">
<path d="M20 50 H100" />
<path d="M75 15 L100 50 L75 85" />
&rarr;
</svg>
</button>
</form>
</select>
<button id="sort_submit" class="submit">
<svg width="15" viewBox="0 0 110 100" fill="none" stroke-width="10" stroke-linecap="round">
<path d="M20 50 H100" />
<path d="M75 15 L100 50 L75 85" />
&rarr;
</svg>
</button>
</form>
<!-- SEARCH FORM -->
<form id="sort">
<input id="search" class="commentQuery" type="search" name="q" value="{{ comment_query }}" placeholder="Search comments">
<input type="hidden" name="type" value="comment">
</form>
</div>
<div>
{% if comment_query != "" %}
Comments containing "{{ comment_query }}"&nbsp;|&nbsp;<a id="allCommentsLink" href="{{ url_without_query }}">All comments</a>
{% endif %}
</div>
<!-- COMMENTS -->
{% for c in comments -%}

View File

@ -10,7 +10,7 @@
{% block content %}
<div id="column_one">
<form id="search_sort">
<input id="search" type="text" name="q" placeholder="Search" value="{{ params.q }}" title="Search libreddit">
<input id="search" type="text" name="q" placeholder="Search" value="{{ params.q|safe }}" title="Search libreddit">
{% if sub != "" %}
<div id="inside">
<input type="checkbox" name="restrict_sr" id="restrict_sr" {% if params.restrict_sr != "" %}checked{% endif %}>
@ -29,7 +29,7 @@
&rarr;
</svg>
</button>
</form>
</form>
{% if !is_filtered %}
{% if subreddits.len() > 0 || params.typed == "sr_user" %}
@ -99,13 +99,13 @@
{% if params.typed != "sr_user" %}
<footer>
{% if params.before != "" %}
<a href="?q={{ params.q }}&restrict_sr={{ params.restrict_sr }}
<a href="?q={{ params.q|safe }}&restrict_sr={{ params.restrict_sr }}
&sort={{ params.sort }}&t={{ params.t }}
&before={{ params.before }}" accesskey="P">PREV</a>
{% endif %}
{% if params.after != "" %}
<a href="?q={{ params.q }}&restrict_sr={{ params.restrict_sr }}
<a href="?q={{ params.q|safe }}&restrict_sr={{ params.restrict_sr }}
&sort={{ params.sort }}&t={{ params.t }}
&after={{ params.after }}" accesskey="N">NEXT</a>
{% endif %}

View File

@ -54,6 +54,7 @@
{% call utils::options(prefs.comment_sort, ["confidence", "top", "new", "controversial", "old"], "confidence") %}
</select>
</div>
{% if !crate::utils::sfw_only() %}
<div class="prefs-group">
<label for="show_nsfw">Show NSFW posts:</label>
<input type="hidden" value="off" name="show_nsfw">
@ -64,6 +65,7 @@
<input type="hidden" value="off" name="blur_nsfw">
<input type="checkbox" name="blur_nsfw" id="blur_nsfw" {% if prefs.blur_nsfw == "on" %}checked{% endif %}>
</div>
{% endif %}
<div class="prefs-group">
<label for="autoplay_videos">Autoplay videos</label>
<input type="hidden" value="off" name="autoplay_videos">
@ -83,6 +85,16 @@
<input type="hidden" value="off" name="hide_hls_notification">
<input type="checkbox" name="hide_hls_notification" id="hide_hls_notification" {% if prefs.hide_hls_notification == "on" %}checked{% endif %}>
</div>
<div class="prefs-group">
<label for="hide_awards">Hide awards</label>
<input type="hidden" value="off" name="hide_awards">
<input type="checkbox" name="hide_awards" id="hide_awards" {% if prefs.hide_awards == "on" %}checked{% endif %}>
</div>
<div class="prefs-group">
<label for="disable_visit_reddit_confirmation">Do not confirm before visiting content on Reddit</label>
<input type="hidden" value="off" name="disable_visit_reddit_confirmation">
<input type="checkbox" name="disable_visit_reddit_confirmation" {% if prefs.disable_visit_reddit_confirmation == "on" %}checked{% endif %}>
</div>
</fieldset>
<input id="save" type="submit" value="Save">
</div>
@ -120,7 +132,7 @@
<div id="settings_note">
<p><b>Note:</b> settings and subscriptions are saved in browser cookies. Clearing your cookies will reset them.</p><br>
<p>You can restore your current settings and subscriptions after clearing your cookies using <a href="/settings/restore/?theme={{ prefs.theme }}&front_page={{ prefs.front_page }}&layout={{ prefs.layout }}&wide={{ prefs.wide }}&post_sort={{ prefs.post_sort }}&comment_sort={{ prefs.comment_sort }}&show_nsfw={{ prefs.show_nsfw }}&blur_nsfw={{ prefs.blur_nsfw }}&use_hls={{ prefs.use_hls }}&hide_hls_notification={{ prefs.hide_hls_notification }}&subscriptions={{ prefs.subscriptions.join("%2B") }}&filters={{ prefs.filters.join("%2B") }}">this link</a>.</p>
<p>You can restore your current settings and subscriptions after clearing your cookies using <a href="/settings/restore/?theme={{ prefs.theme }}&front_page={{ prefs.front_page }}&layout={{ prefs.layout }}&wide={{ prefs.wide }}&post_sort={{ prefs.post_sort }}&comment_sort={{ prefs.comment_sort }}&show_nsfw={{ prefs.show_nsfw }}&blur_nsfw={{ prefs.blur_nsfw }}&use_hls={{ prefs.use_hls }}&hide_hls_notification={{ prefs.hide_hls_notification }}&hide_awards={{ prefs.hide_awards }}&disable_visit_reddit_confirmation={{ prefs.disable_visit_reddit_confirmation }}&subscriptions={{ prefs.subscriptions.join("%2B") }}&autoplay_videos={{ prefs.autoplay_videos }}&filters={{ prefs.filters.join("%2B") }}">this link</a>.</p>
</div>
</div>

View File

@ -88,7 +88,8 @@
<center>(Content from r/{{ sub.name }} has been filtered)</center>
{% endif %}
{% if !sub.name.is_empty() && sub.name != "all" && sub.name != "popular" && !sub.name.contains("+") %}
<div class="panel" id="subreddit">
<details class="panel" id="subreddit" open>
<summary id="subreddit_label">Subreddit</summary>
{% if sub.wiki %}
<div id="top">
<div>Posts</div>
@ -131,7 +132,7 @@
</div>
</div>
</div>
</div>
</details>
<details class="panel" id="sidebar">
<summary id="sidebar_label">Sidebar</summary>
<div id="sidebar_contents">

View File

@ -73,7 +73,7 @@
{% endif %}
<span class="dot">&bull;</span>
<span class="created" title="{{ post.created }}">{{ post.rel_time }}</span>
{% if !post.awards.is_empty() %}
{% if !post.awards.is_empty() && prefs.hide_awards != "on" %}
<span class="dot">&bull;</span>
<span class="awards">
{% for award in post.awards.clone() %}
@ -148,6 +148,9 @@
<!-- POST BODY -->
<div class="post_body">{{ post.body|safe }}</div>
<div class="post_score" title="{{ post.score.1 }}">{{ post.score.0 }}<span class="label"> Upvotes</span></div>
{% call poll(post) %}
<div class="post_footer">
<ul id="post_links">
<li class="desktop_item"><a href="{{ post.permalink }}">permalink</a></li>
@ -156,14 +159,32 @@
<li class="desktop_item"><a href="/r/{{ post.community }}/duplicates/{{ post.id }}">duplicates</a></li>
<li class="mobile_item"><a href="/r/{{ post.community }}/duplicates/{{ post.id }}">dupes</a></li>
{% endif %}
<li class="desktop_item"><a href="https://reddit.com{{ post.permalink }}" rel="nofollow">reddit</a></li>
<li class="mobile_item"><a href="https://reddit.com{{ post.permalink }}" rel="nofollow">reddit</a></li>
{% call external_reddit_link(post.permalink) %}
</ul>
<p>{{ post.upvote_ratio }}%<span id="upvoted"> Upvoted</span></p>
</div>
</div>
{%- endmacro %}
{% macro external_reddit_link(permalink) %}
{% for dev_type in ["desktop", "mobile"] %}
<li class="{{ dev_type }}_item">
<a
{% if prefs.disable_visit_reddit_confirmation != "on" %}
href="#popup"
{% else %}
href="https://reddit.com{{ permalink }}"
rel="nofollow"
{% endif %}
>reddit</a>
{% if prefs.disable_visit_reddit_confirmation != "on" %}
{% call visit_reddit_confirmation(permalink) %}
{% endif %}
</li>
{% endfor %}
{% endmacro %}
{% macro post_in_list(post) -%}
<div class="post {% if post.flags.stickied %}stickied{% endif %}" id="{{ post.id }}">
<p class="post_header">
@ -178,7 +199,7 @@
<a class="post_author {{ post.author.distinguished }}" href="/u/{{ post.author.name }}">u/{{ post.author.name }}</a>
<span class="dot">&bull;</span>
<span class="created" title="{{ post.created }}">{{ post.rel_time }}</span>
{% if !post.awards.is_empty() %}
{% if !post.awards.is_empty() && prefs.hide_awards != "on" %}
{% for award in post.awards.clone() %}
<span class="award" title="{{ award.name }}">
<img alt="{{ award.name }}" src="{{ award.icon_url }}" width="16" height="16"/>
@ -254,8 +275,64 @@
<div class="post_body post_preview">
{{ post.body|safe }}
</div>
{% call poll(post) %}
<div class="post_footer">
<a href="{{ post.permalink }}" class="post_comments" title="{{ post.comments.1 }} {% if post.comments.1 == "1" %}comment{% else %}comments{% endif %}">{{ post.comments.0 }} {% if post.comments.1 == "1" %}comment{% else %}comments{% endif %}</a>
</div>
</div>
{%- endmacro %}
{% macro visit_reddit_confirmation(url) -%}
<div class="popup" id="popup">
<div class="popup-inner">
<h1>You are about to leave Libreddit</h1>
<p>Do you want to continue?</p>
<p id="reddit_url">https://www.reddit.com{{ url }}</p>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 639.24 563">
<defs>
<style>.cls-1{fill:#000000;}.cls-2{fill:#f8aa00;}</style>
</defs>
<path class="cls-2" d="M322.03,0c1.95,2.5,4.88,.9,7.33,1.65,10.5,3.21,17.65,10.39,22.83,19.35,93.64,162.06,186.98,324.29,280.25,486.56,15.73,20.19,2.49,51.27-22.92,54.37-1.21,.19-2.72-.54-3.49,1.08H239.03c-70.33-2.43-141.6,.79-212.08-1.74-17.49-4.92-23.16-15.88-26.91-32.26l-.04-1.97C88.74,354.76,194.49,188.2,289.92,18.43c6.2-10.66,15.03-16.94,27.61-17.36,.95-.03,2.05,.18,2.51-1.07h2Zm-2.43,545c94.95-.02,189.9,.04,284.85-.02,11.84-.73,20.75-13.19,16.68-23.55C523.83,355.97,430.74,187.62,332.05,23.07c-7.93-9.02-22.2-6.58-27.23,3.22C230.28,156.11,155.21,285.64,80.41,415.31c-19.88,34.41-39.31,69.07-59.78,103.14-2.43,4.05-4.24,8.8-1.68,14.18,3.92,8.24,9.59,12.37,18.82,12.37,93.95,0,187.9,0,281.85,0Z"/>
<path class="cls-1" d="M319.61,545c-93.95,0-187.9,0-281.85,0-9.22,0-14.89-4.13-18.82-12.37-2.56-5.38-.75-10.13,1.68-14.18,20.47-34.07,39.9-68.73,59.78-103.14C155.21,285.64,230.28,156.11,304.82,26.29c5.03-9.8,19.3-12.24,27.23-3.22,98.7,164.55,191.79,332.9,289.1,498.35,4.06,10.36-4.85,22.82-16.68,23.55-94.94,.06-189.9,0-284.85,.02Zm.44-462.31C238.88,223.22,158.17,362.95,77.28,503h485.54c-80.94-140.13-161.61-279.79-242.77-420.31Z"/>
<path class="cls-2" d="M320.05,82.69c81.16,140.52,161.83,280.18,242.77,420.31H77.28C158.17,362.95,238.88,223.22,320.05,82.69Zm36.05,118.99c-.14-46.75-68.32-52.32-74.66-4.76,.73,51.49,9.2,102.97,12.63,154.49,1.18,13.14,10.53,21.81,23.32,22.76,13.12,.97,23.89-9.13,24.96-21.58,4.44-49.99,9.4-101.22,13.76-150.91Zm-36.56,271.4c48.8,.76,49.24-74.7-.31-75.47-53.45,3-46.02,78.12,.31,75.47Z"/>
<path class="cls-1" d="M356.1,201.67c-4.36,49.69-9.31,100.91-13.76,150.91-1.07,12.45-11.84,22.56-24.96,21.58-12.79-.95-22.14-9.63-23.31-22.76-3.43-51.52-11.9-103-12.63-154.49,6.33-47.53,74.51-42.03,74.66,4.76Z"/>
<path class="cls-1" d="M319.54,473.08c-46.34,2.64-53.75-72.47-.31-75.47,49.56,.78,49.1,76.24,.31,75.47Z"/>
</svg>
<a id="goback" href="#">No, go back!</a>
<a id="toreddit" href="https://www.reddit.com{{ url }}" rel="nofollow">Yes, take me to Reddit</a>
</div>
</div>
{%- endmacro %}
{% macro poll(post) -%}
{% match post.poll %}
{% when Some with (poll) %}
{% let widest = poll.most_votes() %}
<div class="post_poll">
<span>{{ poll.total_vote_count }} votes,</span>
<span title="{{ poll.voting_end_timestamp.1 }}">{{ poll.voting_end_timestamp.0 }}</span>
{% for option in poll.poll_options %}
<div class="poll_option">
{# Posts without vote_count (all open polls) will show up without votes.
This is an issue with Reddit API, it doesn't work on Old Reddit either. #}
{% match option.vote_count %}
{% when Some with (vote_count) %}
{% if vote_count.eq(widest) || widest == 0 %}
<div class="poll_chart most_voted"></div>
{% else %}
<div class="poll_chart" style="width: {{ (vote_count * 100) / widest }}%"></div>
{% endif %}
<span>{{ vote_count }}</span>
{% when None %}
<div class="poll_chart most_voted"></div>
<span></span>
{% endmatch %}
<span>{{ option.text }}</span>
</div>
{% endfor %}
</div>
{% when None %}
{% endmatch %}
{%- endmacro %}