Merge branch 'master' into organize-readme
This commit is contained in:
commit
da3e57b98b
34 changed files with 1171 additions and 696 deletions
38
.github/workflows/docker-arm.yml
vendored
38
.github/workflows/docker-arm.yml
vendored
|
@ -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
|
41
.github/workflows/docker-armv7.yml
vendored
41
.github/workflows/docker-armv7.yml
vendored
|
@ -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
|
|
@ -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
78
.github/workflows/main-rust.yml
vendored
Normal 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
62
.github/workflows/pull-request.yml
vendored
Normal 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
|
22
.github/workflows/rust-tests.yml
vendored
22
.github/workflows/rust-tests.yml
vendored
|
@ -1,22 +0,0 @@
|
|||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "master" ]
|
||||
pull_request:
|
||||
branches: [ "master" ]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Build
|
||||
run: cargo build --verbose
|
||||
- name: Run tests
|
||||
run: cargo test --verbose
|
59
.github/workflows/rust.yml
vendored
59
.github/workflows/rust.yml
vendored
|
@ -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
5
.gitignore
vendored
|
@ -1 +1,4 @@
|
|||
/target
|
||||
/target
|
||||
|
||||
# Idea Files
|
||||
.idea/
|
2
.replit
2
.replit
|
@ -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"
|
||||
|
|
732
Cargo.lock
generated
732
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
12
Cargo.toml
12
Cargo.toml
|
@ -8,15 +8,15 @@ authors = ["spikecodes <19519553+spikecodes@users.noreply.github.com>"]
|
|||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
askama = { version = "0.11.1", default-features = false }
|
||||
cached = "0.42.0"
|
||||
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.16.2"
|
||||
cookie = "0.17.0"
|
||||
futures-lite = "1.12.0"
|
||||
hyper = { version = "0.14.23", features = ["full"] }
|
||||
hyper-rustls = "0.23.2"
|
||||
hyper-rustls = "0.24.0"
|
||||
percent-encoding = "2.2.0"
|
||||
route-recognizer = "0.3.1"
|
||||
serde_json = "1.0.91"
|
||||
|
@ -26,13 +26,13 @@ url = "2.3.1"
|
|||
rust-embed = { version = "6.4.2", features = ["include-exclude"] }
|
||||
libflate = "1.2.0"
|
||||
brotli = { version = "3.3.4", features = ["std"] }
|
||||
toml = "0.5.10"
|
||||
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]
|
||||
|
|
|
@ -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
|
||||
|
|
54
README.md
54
README.md
|
@ -2,11 +2,15 @@
|
|||
|
||||
> An alternative private front-end to Reddit
|
||||
|
||||
# ⚠️ Why do I get TOO MANY REQUESTS errors? ⚠️
|
||||
## 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)
|
||||
## One of the project maintainers is working towards keeping this project alive to some extent: https://github.com/libreddit/libreddit/issues/836
|
||||
|
||||
![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.
|
||||
|
@ -189,7 +193,7 @@ If you're on Linux and none of these methods work for you, you can grab a Linux
|
|||
## 6) Replit/Heroku/Glitch
|
||||
|
||||
> **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.
|
||||
> 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)
|
||||
|
@ -209,31 +213,33 @@ libreddit
|
|||
|
||||
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|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. |
|
||||
| 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` |
|
||||
| `COMMENT_SORT` | `["confidence", "top", "new", "controversial", "old"]` | `confidence` |
|
||||
| `POST_SORT` | `["hot", "new", "top", "rising", "controversial"]` | `hot` |
|
||||
| `SHOW_NSFW` | `["on", "off"]` | `off` |
|
||||
| `BLUR_NSFW` | `["on", "off"]` | `off` |
|
||||
| `USE_HLS` | `["on", "off"]` | `off` |
|
||||
| `HIDE_HLS_NOTIFICATION` | `["on", "off"]` | `off` |
|
||||
| `HIDE_AWARDS` | `["on", "off"]` | `off`
|
||||
| `AUTOPLAY_VIDEOS` | `["on", "off"]` | `off` |
|
||||
| `SUBSCRIPTIONS` | `+`-delimited list of subreddits (`sub1+sub2+sub3+...`) | _(none)_ |
|
||||
| `DISABLE_VISIT_REDDIT_CONFIRMATION` | `["on", "off"]` | `off` |
|
||||
| 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` |
|
||||
| `COMMENT_SORT` | `["confidence", "top", "new", "controversial", "old"]` | `confidence` |
|
||||
| `POST_SORT` | `["hot", "new", "top", "rising", "controversial"]` | `hot` |
|
||||
| `SHOW_NSFW` | `["on", "off"]` | `off` |
|
||||
| `BLUR_NSFW` | `["on", "off"]` | `off` |
|
||||
| `USE_HLS` | `["on", "off"]` | `off` |
|
||||
| `HIDE_AWARDS` | `["on", "off"]` | `off` |
|
||||
| `HIDE_HLS_NOTIFICATION` | `["on", "off"]` | `off` |
|
||||
| `AUTOPLAY_VIDEOS` | `["on", "off"]` | `off` |
|
||||
| `SUBSCRIPTIONS` | `+`-delimited list of subreddits (`sub1+sub2+sub3+...`) | _(none)_ |
|
||||
| `DISABLE_VISIT_REDDIT_CONFIRMATION` | `["on", "off"]` | `off` |
|
||||
|
||||
You can also configure Libreddit with a configuration file. An example `libreddit.toml` can be found below:
|
||||
|
||||
|
|
6
app.json
6
app.json
|
@ -50,11 +50,17 @@
|
|||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
4
build.rs
4
build.rs
|
@ -1,6 +1,4 @@
|
|||
use std::{
|
||||
process::{Command, ExitStatus, Output},
|
||||
};
|
||||
use std::process::{Command, ExitStatus, Output};
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
use std::os::unix::process::ExitStatusExt;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -9,12 +9,16 @@ use std::{env::var, fs::read_to_string};
|
|||
// 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)]
|
||||
#[derive(Default, Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct Config {
|
||||
#[serde(rename = "LIBREDDIT_SFW_ONLY")]
|
||||
pub(crate) sfw_only: Option<String>,
|
||||
|
@ -60,6 +64,12 @@ pub struct Config {
|
|||
|
||||
#[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 {
|
||||
|
@ -74,6 +84,7 @@ impl Config {
|
|||
// 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"),
|
||||
|
@ -90,6 +101,8 @@ impl Config {
|
|||
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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -111,6 +124,8 @@ fn get_setting_from_config(name: &str, config: &Config) -> Option<String> {
|
|||
"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,
|
||||
}
|
||||
}
|
||||
|
@ -123,6 +138,13 @@ pub(crate) fn get_setting(name: &str) -> Option<String> {
|
|||
#[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() {
|
||||
|
|
|
@ -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, nsfw_landing, parse_post, setting, 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};
|
||||
|
@ -67,11 +67,12 @@ pub async fn item(req: Request<Body>) -> Result<Response<Body>, String> {
|
|||
Ok(response) => {
|
||||
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 && (setting(&req, "show_nsfw") != "on" || crate::utils::sfw_only()) {
|
||||
return Ok(nsfw_landing(req).await.unwrap_or_default());
|
||||
// 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);
|
||||
|
@ -195,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,
|
||||
url: req_url,
|
||||
num_posts_filtered,
|
||||
all_posts_filtered,
|
||||
})
|
||||
|
|
|
@ -122,6 +122,8 @@ impl InstanceInfo {
|
|||
["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"]),
|
||||
);
|
||||
|
@ -155,6 +157,7 @@ impl InstanceInfo {
|
|||
Deploy timestamp: {}\n
|
||||
Compile mode: {}\n
|
||||
SFW only: {:?}\n
|
||||
Pushshift frontend: {:?}\n
|
||||
Config:\n
|
||||
Banner: {:?}\n
|
||||
Hide awards: {:?}\n
|
||||
|
@ -175,6 +178,7 @@ impl InstanceInfo {
|
|||
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,
|
||||
|
|
18
src/main.rs
18
src/main.rs
|
@ -186,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());
|
||||
|
|
239
src/post.rs
239
src/post.rs
|
@ -1,5 +1,6 @@
|
|||
// CRATES
|
||||
use crate::client::json;
|
||||
use crate::config::get_setting;
|
||||
use crate::server::RequestExt;
|
||||
use crate::subreddit::{can_access_quarantine, quarantine};
|
||||
use crate::utils::{
|
||||
|
@ -8,6 +9,8 @@ use crate::utils::{
|
|||
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(|| {
|
||||
|
@ -56,24 +64,34 @@ pub async fn item(req: Request<Body>) -> Result<Response<Body>, String> {
|
|||
// Parse the JSON into Post and Comment structs
|
||||
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 && (setting(&req, "show_nsfw") != "on" || crate::utils::sfw_only()) {
|
||||
return Ok(nsfw_landing(req).await.unwrap_or_default());
|
||||
if post.nsfw && crate::utils::should_be_nsfw_gated(&req, &req_url) {
|
||||
return Ok(nsfw_landing(req, req_url).await.unwrap_or_default());
|
||||
}
|
||||
|
||||
let comments = parse_comments(&response[1], &post.permalink, &post.author.name, highlighted_comment, &get_filters(&req), &req);
|
||||
let url = req.uri().to_string();
|
||||
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),
|
||||
single_thread,
|
||||
url,
|
||||
url: req_url,
|
||||
comment_query: query,
|
||||
})
|
||||
}
|
||||
// If the Reddit API returns an error, exit and send error page to user
|
||||
|
@ -89,6 +107,7 @@ 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>, 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);
|
||||
|
@ -97,96 +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);
|
||||
|
||||
// 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();
|
||||
|
||||
// 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, 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,
|
||||
more_count,
|
||||
prefs: Preferences::new(req),
|
||||
}
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -97,10 +97,11 @@ 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 && (setting(&req, "show_nsfw") != "on" || crate::utils::sfw_only()) {
|
||||
return Ok(nsfw_landing(req).await.unwrap_or_default());
|
||||
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());
|
||||
|
|
|
@ -50,11 +50,12 @@ pub async fn profile(req: Request<Body>) -> Result<Response<Body>, String> {
|
|||
// 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 && (setting(&req, "show_nsfw") != "on" || crate::utils::sfw_only()) {
|
||||
return Ok(nsfw_landing(req).await.unwrap_or_default());
|
||||
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);
|
||||
|
|
217
src/utils.rs
217
src/utils.rs
|
@ -1,3 +1,4 @@
|
|||
use crate::config::get_setting;
|
||||
//
|
||||
// CRATES
|
||||
//
|
||||
|
@ -5,6 +6,7 @@ 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;
|
||||
|
@ -96,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,
|
||||
|
@ -233,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,
|
||||
|
@ -342,6 +400,7 @@ 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),
|
||||
|
@ -538,21 +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"),
|
||||
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"),
|
||||
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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -600,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 {
|
||||
|
@ -630,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,
|
||||
|
@ -717,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" {
|
||||
|
@ -725,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(),
|
||||
})
|
||||
};
|
||||
|
||||
|
@ -757,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(®EX_URL_WWW, "/", 1),
|
||||
"old.reddit.com" => capture(®EX_URL_OLD, "/", 1),
|
||||
"np.reddit.com" => capture(®EX_URL_NP, "/", 1),
|
||||
"reddit.com" => capture(®EX_URL_PLAIN, "/", 1),
|
||||
"v.redd.it" => chain!(capture(®EX_URL_VIDEOS, "/vid/", 2), capture(®EX_URL_VIDEOS_HLS, "/hls/", 2)),
|
||||
"i.redd.it" => capture(®EX_URL_IMAGES, "/img/", 1),
|
||||
"a.thumbs.redditmedia.com" => capture(®EX_URL_THUMBS_A, "/thumb/a/", 1),
|
||||
"b.thumbs.redditmedia.com" => capture(®EX_URL_THUMBS_B, "/thumb/b/", 1),
|
||||
"emoji.redditmedia.com" => capture(®EX_URL_EMOJI, "/emoji/", 2),
|
||||
"preview.redd.it" => capture(®EX_URL_PREVIEW, "/preview/pre/", 1),
|
||||
"external-preview.redd.it" => capture(®EX_URL_EXTERNAL_PREVIEW, "/preview/external-pre/", 1),
|
||||
"styles.redditmedia.com" => capture(®EX_URL_STYLES, "/style/", 1),
|
||||
"www.redditstatic.com" => capture(®EX_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.
|
||||
|
@ -815,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
|
||||
|
@ -893,11 +982,21 @@ pub fn sfw_only() -> bool {
|
|||
}
|
||||
}
|
||||
|
||||
// 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>) -> Result<Response<Body>, String> {
|
||||
pub async fn nsfw_landing(req: Request<Body>, req_url: String) -> Result<Response<Body>, String> {
|
||||
let res_type: ResourceType;
|
||||
let url = req.uri().to_string();
|
||||
|
||||
// Determine from the request URL if the resource is a subreddit, a user
|
||||
// page, or a post.
|
||||
|
@ -916,7 +1015,7 @@ pub async fn nsfw_landing(req: Request<Body>) -> Result<Response<Body>, String>
|
|||
res,
|
||||
res_type,
|
||||
prefs: Preferences::new(&req),
|
||||
url,
|
||||
url: req_url,
|
||||
}
|
||||
.render()
|
||||
.unwrap_or_default();
|
||||
|
|
|
@ -293,7 +293,7 @@ body > footer {
|
|||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100vw;
|
||||
width: 100%;
|
||||
background: var(--post);
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
|
@ -329,6 +329,7 @@ button {
|
|||
background: none;
|
||||
border: none;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
hr {
|
||||
|
@ -379,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;
|
||||
|
@ -540,6 +545,7 @@ select, #search, #sort_options, #listing_options, #inside, #searchbox > *, #sort
|
|||
select {
|
||||
background: var(--outside);
|
||||
transition: 0.2s background;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
select, #search {
|
||||
|
@ -552,6 +558,10 @@ select, #search {
|
|||
border-radius: 5px 0px 0px 5px;
|
||||
}
|
||||
|
||||
.commentQuery {
|
||||
background: var(--post);
|
||||
}
|
||||
|
||||
#searchbox {
|
||||
grid-area: searchbox;
|
||||
display: flex;
|
||||
|
@ -629,6 +639,15 @@ 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;
|
||||
|
@ -752,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));
|
||||
|
@ -952,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);;
|
||||
|
@ -1550,6 +1608,7 @@ td, th {
|
|||
#user, #sidebar { margin: 20px 0; }
|
||||
#logo, #links { margin-bottom: 5px; }
|
||||
#searchbox { width: calc(100vw - 35px); }
|
||||
|
||||
}
|
||||
|
||||
@media screen and (max-width: 480px) {
|
||||
|
@ -1563,6 +1622,7 @@ td, th {
|
|||
"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));
|
||||
|
@ -1572,6 +1632,10 @@ td, th {
|
|||
margin: 5px 0px 20px 15px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.post_poll {
|
||||
padding: 5px 15px 10px 12px;
|
||||
}
|
||||
|
||||
.compact .post_score { padding: 0; }
|
||||
|
||||
|
@ -1623,4 +1687,9 @@ td, th {
|
|||
.popup-inner {
|
||||
max-width: 80%;
|
||||
}
|
||||
}
|
||||
|
||||
#commentQueryForms {
|
||||
display: initial;
|
||||
justify-content: initial;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -19,10 +19,12 @@
|
|||
{% 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 %}.
|
||||
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 %}
|
||||
|
||||
|
|
|
@ -43,18 +43,32 @@
|
|||
{% call utils::post(post) %}
|
||||
|
||||
<!-- SORT FORM -->
|
||||
<div id="commentQueryForms">
|
||||
<form id="sort">
|
||||
<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">
|
||||
<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" />
|
||||
→
|
||||
</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" />
|
||||
→
|
||||
</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 }}" | <a id="allCommentsLink" href="{{ url_without_query }}">All comments</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- COMMENTS -->
|
||||
{% for c in comments -%}
|
||||
|
|
|
@ -29,7 +29,7 @@
|
|||
→
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
</form>
|
||||
|
||||
{% if !is_filtered %}
|
||||
{% if subreddits.len() > 0 || params.typed == "sr_user" %}
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
|
@ -272,6 +275,9 @@
|
|||
<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>
|
||||
|
@ -299,3 +305,34 @@
|
|||
</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 %}
|
||||
|
|
Loading…
Reference in a new issue