# Conflicts:

#	README.md
#	app.json
#	src/config.rs
#	src/instance_info.rs
#	src/post.rs
This commit is contained in:
Tokarak 2023-06-03 17:34:01 +01:00
parent e855c31f38
commit 2a6149cc39
36 changed files with 1327 additions and 597 deletions

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

View File

@ -1,33 +1,44 @@
name: Rust name: Rust Build & Publish
on: on:
push: push:
paths-ignore: paths-ignore:
- "**.md" - "**.md"
branches: branches:
- master - 'main'
- 'master'
release:
types: [published]
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
jobs: jobs:
build: build:
runs-on: ubuntu-18.04 runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - name: Checkout sources
uses: actions/checkout@v3
- name: Cache Packages - name: Cache Packages
uses: Swatinem/rust-cache@v1.0.1 uses: Swatinem/rust-cache@v2
- name: Install stable toolchain
uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
- name: Build - name: Build
run: cargo build --release run: cargo build --release
- name: Publish to crates.io - name: Publish to crates.io
continue-on-error: true if: github.event_name == 'release'
run: cargo publish --no-verify --token ${{ secrets.CARGO_REGISTRY_TOKEN }} run: cargo publish --no-verify --token ${{ secrets.CARGO_REGISTRY_TOKEN }}
- uses: actions/upload-artifact@v2.2.1 - uses: actions/upload-artifact@v3
name: Upload a Build Artifact name: Upload a Build Artifact
with: with:
name: libreddit name: libreddit
@ -35,23 +46,25 @@ jobs:
- name: Versions - name: Versions
id: version id: version
run: | run: echo "VERSION=$(cargo metadata --format-version 1 --no-deps | jq .packages[0].version -r | sed 's/^/v/')" >> "$GITHUB_OUTPUT"
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 - name: Calculate SHA512 checksum
run: sha512sum target/release/libreddit > libreddit.sha512 run: sha512sum target/release/libreddit > libreddit.sha512
- name: Calculate SHA256 checksum
run: sha256sum target/release/libreddit > libreddit.sha256
- name: Release - name: Release
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@v1
if: github.base_ref != 'master' if: github.base_ref != 'master'
with: with:
tag_name: ${{ steps.version.outputs.version }} tag_name: ${{ steps.version.outputs.VERSION }}
name: ${{ steps.version.outputs.version }} - ${{ github.event.head_commit.message }} name: ${{ steps.version.outputs.VERSION }} - ${{ github.event.head_commit.message }}
draft: true draft: true
files: | files: |
target/release/libreddit target/release/libreddit
libreddit.sha512 libreddit.sha512
libreddit.sha256
body: | body: |
- ${{ github.event.head_commit.message }} ${{ github.sha }} - ${{ github.event.head_commit.message }} ${{ github.sha }}
generate_release_notes: true generate_release_notes: true

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,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

5
.gitignore vendored
View File

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

View File

@ -21,6 +21,7 @@ Daniel Valentine <daniel@vielle.ws>
dbrennand <52419383+dbrennand@users.noreply.github.com> dbrennand <52419383+dbrennand@users.noreply.github.com>
dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Diego Magdaleno <38844659+DiegoMagdaleno@users.noreply.github.com> Diego Magdaleno <38844659+DiegoMagdaleno@users.noreply.github.com>
domve <domve@posteo.net>
Dyras <jevwmguf@duck.com> Dyras <jevwmguf@duck.com>
Edward <101938856+EdwardLangdon@users.noreply.github.com> Edward <101938856+EdwardLangdon@users.noreply.github.com>
elliot <75391956+ellieeet123@users.noreply.github.com> elliot <75391956+ellieeet123@users.noreply.github.com>
@ -58,9 +59,12 @@ Nicholas Christopher <nchristopher@tuta.io>
Nick Lowery <ClockVapor@users.noreply.github.com> Nick Lowery <ClockVapor@users.noreply.github.com>
Nico <github@dr460nf1r3.org> Nico <github@dr460nf1r3.org>
NKIPSC <15067635+NKIPSC@users.noreply.github.com> NKIPSC <15067635+NKIPSC@users.noreply.github.com>
o69mar <119129086+o69mar@users.noreply.github.com>
obeho <71698631+obeho@users.noreply.github.com> obeho <71698631+obeho@users.noreply.github.com>
obscurity <z@x4.pm> obscurity <z@x4.pm>
Om G <34579088+OxyMagnesium@users.noreply.github.com> 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> RiversideRocks <59586759+RiversideRocks@users.noreply.github.com>
robin <8597693+robrobinbin@users.noreply.github.com> robin <8597693+robrobinbin@users.noreply.github.com>
Robin <8597693+robrobinbin@users.noreply.github.com> Robin <8597693+robrobinbin@users.noreply.github.com>
@ -87,5 +91,6 @@ Tsvetomir Bonev <invakid404@riseup.net>
Vladislav Nepogodin <nepogodin.vlad@gmail.com> Vladislav Nepogodin <nepogodin.vlad@gmail.com>
Walkx <walkxnl@gmail.com> Walkx <walkxnl@gmail.com>
Wichai <1482605+Chengings@users.noreply.github.com> Wichai <1482605+Chengings@users.noreply.github.com>
wsy2220 <wsy@dogben.com>
xatier <xatierlike@gmail.com> xatier <xatierlike@gmail.com>
Zach <72994911+zachjmurphy@users.noreply.github.com> Zach <72994911+zachjmurphy@users.noreply.github.com>

609
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,7 @@ name = "libreddit"
description = " Alternative private front-end to Reddit" description = " Alternative private front-end to Reddit"
license = "AGPL-3.0" license = "AGPL-3.0"
repository = "https://github.com/spikecodes/libreddit" repository = "https://github.com/spikecodes/libreddit"
version = "0.28.0" version = "0.30.1"
authors = ["spikecodes <19519553+spikecodes@users.noreply.github.com>"] authors = ["spikecodes <19519553+spikecodes@users.noreply.github.com>"]
edition = "2021" edition = "2021"

View File

@ -7,6 +7,10 @@ RUN apk add --no-cache g++ git
WORKDIR /usr/src/libreddit 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 . . COPY . .
# net.git-fetch-with-cli is specified in order to prevent a potential OOM kill # net.git-fetch-with-cli is specified in order to prevent a potential OOM kill

View File

@ -114,13 +114,21 @@ Results from Google PageSpeed Insights ([Libreddit Report](https://pagespeed.web
For transparency, I hope to describe all the ways Libreddit handles user privacy. 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,12 +167,26 @@ For ArchLinux users, Libreddit is available from the AUR as [`libreddit-git`](ht
``` ```
yay -S libreddit-git 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). 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
> **Warning** > **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.
@ -191,6 +213,7 @@ Assign a default value for each instance-specific setting by passing environment
|-|-|-|-| |-|-|-|-|
| `SFW_ONLY` | `["on", "off"]` | `off` | Enables SFW-only mode for the instance, i.e. all NSFW content is filtered. | | `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. | | `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. | `PUSHSHIFT_FRONTEND` | String | `www.unddit.com` | Allows the server to set the Pushshift frontend to be used with "removed" links.
## Default User Settings ## Default User Settings
@ -210,8 +233,9 @@ Assign a default value for each user-modifiable setting by passing environment v
| `USE_HLS` | `["on", "off"]` | `off` | | `USE_HLS` | `["on", "off"]` | `off` |
| `HIDE_HLS_NOTIFICATION` | `["on", "off"]` | `off` | | `HIDE_HLS_NOTIFICATION` | `["on", "off"]` | `off` |
| `AUTOPLAY_VIDEOS` | `["on", "off"]` | `off` | | `AUTOPLAY_VIDEOS` | `["on", "off"]` | `off` |
| `HIDE_AWARDS` | `["on", "off"]` | `off` | | `SUBSCRIPTIONS` | `+`-delimited list of subreddits (`sub1+sub2+sub3+...`) | _(none)_ |
| `SUBSCRIPTIONS` | Array of subreddit names (`["sub1", "sub2"]`) | `[]` | | `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: You can also configure Libreddit with a configuration file. An example `libreddit.toml` can be found below:

View File

@ -50,9 +50,15 @@
"LIBREDDIT_BANNER": { "LIBREDDIT_BANNER": {
"required": false "required": false
}, },
"LIBREDDIT_ROBOTS_DISABLE_INDEXING": {
"required": false
},
"LIBREDDIT_DEFAULT_SUBSCRIPTIONS": { "LIBREDDIT_DEFAULT_SUBSCRIPTIONS": {
"required": false "required": false
}, },
"LIBREDDIT_DEFAULT_DISABLE_VISIT_REDDIT_CONFIRMATION": {
"required": false
},
"LIBREDDIT_PUSHSHIFT_FRONTEND": { "LIBREDDIT_PUSHSHIFT_FRONTEND": {
"required": false "required": false
} }

View File

@ -1,7 +1,11 @@
use std::{ use std::process::{Command, ExitStatus, Output};
os::unix::process::ExitStatusExt,
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() { fn main() {
let output = String::from_utf8( let output = String::from_utf8(
Command::new("git") Command::new("git")

View File

@ -1,2 +1,16 @@
ADDRESS=0.0.0.0 ADDRESS=0.0.0.0
PORT=12345 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] [Service]
DynamicUser=yes DynamicUser=yes
# Default Values # Default Values
Environment=ADDRESS=0.0.0.0 #Environment=ADDRESS=0.0.0.0
Environment=PORT=8080 #Environment=PORT=8080
# Optional Override # Optional Override
EnvironmentFile=-/etc/libreddit.conf EnvironmentFile=-/etc/libreddit.conf
ExecStart=/usr/bin/libreddit -a ${ADDRESS} -p ${PORT} ExecStart=/usr/bin/libreddit -a ${ADDRESS} -p ${PORT}

View File

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

View File

@ -1,7 +1,10 @@
use cached::proc_macro::cached; use cached::proc_macro::cached;
use futures_lite::{future::Boxed, FutureExt}; 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 libflate::gzip;
use once_cell::sync::Lazy;
use percent_encoding::{percent_encode, CONTROLS}; use percent_encoding::{percent_encode, CONTROLS};
use serde_json::Value; use serde_json::Value;
use std::{io, result::Result}; use std::{io, result::Result};
@ -11,6 +14,11 @@ use crate::server::RequestExt;
const REDDIT_URL_BASE: &str = "https://www.reddit.com"; 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 /// 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`. /// 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). // First parameter is target URL (mandatory).
let uri = url.parse::<Uri>().map_err(|_| "Couldn't parse URL".to_string())?; 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. // 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); let mut builder = Request::get(uri);
@ -123,11 +128,8 @@ fn request(method: &'static Method, path: String, redirect: bool, quarantine: bo
// Build Reddit URL from path. // Build Reddit URL from path.
let url = format!("{}{}", REDDIT_URL_BASE, 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. // 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. // Build request to Reddit. When making a GET, request gzip compression.
// (Reddit doesn't do brotli yet.) // (Reddit doesn't do brotli yet.)
@ -140,7 +142,14 @@ fn request(method: &'static Method, path: String, redirect: bool, quarantine: bo
.header("Accept-Encoding", if method == Method::GET { "gzip" } else { "identity" }) .header("Accept-Encoding", if method == Method::GET { "gzip" } else { "identity" })
.header("Accept-Language", "en-US,en;q=0.5") .header("Accept-Language", "en-US,en;q=0.5")
.header("Connection", "keep-alive") .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()); .body(Body::empty());
async move { async move {

View File

@ -59,9 +59,15 @@ pub struct Config {
#[serde(rename = "LIBREDDIT_DEFAULT_SUBSCRIPTIONS")] #[serde(rename = "LIBREDDIT_DEFAULT_SUBSCRIPTIONS")]
pub(crate) default_subscriptions: Option<String>, 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")] #[serde(rename = "LIBREDDIT_BANNER")]
pub(crate) banner: Option<String>, pub(crate) banner: Option<String>,
#[serde(rename = "LIBREDDIT_ROBOTS_DISABLE_INDEXING")]
pub(crate) robots_disable_indexing: Option<String>,
#[serde(rename = "LIBREDDIT_PUSHSHIFT_FRONTEND")] #[serde(rename = "LIBREDDIT_PUSHSHIFT_FRONTEND")]
pub(crate) pushshift: Option<String>, pub(crate) pushshift: Option<String>,
} }
@ -93,7 +99,9 @@ impl Config {
default_hide_hls_notification: parse("LIBREDDIT_DEFAULT_HIDE_HLS"), default_hide_hls_notification: parse("LIBREDDIT_DEFAULT_HIDE_HLS"),
default_hide_awards: parse("LIBREDDIT_DEFAULT_HIDE_AWARDS"), default_hide_awards: parse("LIBREDDIT_DEFAULT_HIDE_AWARDS"),
default_subscriptions: parse("LIBREDDIT_DEFAULT_SUBSCRIPTIONS"), default_subscriptions: parse("LIBREDDIT_DEFAULT_SUBSCRIPTIONS"),
default_disable_visit_reddit_confirmation: parse("LIBREDDIT_DEFAULT_DISABLE_VISIT_REDDIT_CONFIRMATION"),
banner: parse("LIBREDDIT_BANNER"), banner: parse("LIBREDDIT_BANNER"),
robots_disable_indexing: parse("LIBREDDIT_ROBOTS_DISABLE_INDEXING"),
pushshift: parse("LIBREDDIT_PUSHSHIFT_FRONTEND"), pushshift: parse("LIBREDDIT_PUSHSHIFT_FRONTEND"),
} }
} }
@ -114,7 +122,9 @@ fn get_setting_from_config(name: &str, config: &Config) -> Option<String> {
"LIBREDDIT_DEFAULT_WIDE" => config.default_wide.clone(), "LIBREDDIT_DEFAULT_WIDE" => config.default_wide.clone(),
"LIBREDDIT_DEFAULT_HIDE_AWARDS" => config.default_hide_awards.clone(), "LIBREDDIT_DEFAULT_HIDE_AWARDS" => config.default_hide_awards.clone(),
"LIBREDDIT_DEFAULT_SUBSCRIPTIONS" => config.default_subscriptions.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_BANNER" => config.banner.clone(),
"LIBREDDIT_ROBOTS_DISABLE_INDEXING" => config.robots_disable_indexing.clone(),
"LIBREDDIT_PUSHSHIFT_FRONTEND" => config.pushshift.clone(), "LIBREDDIT_PUSHSHIFT_FRONTEND" => config.pushshift.clone(),
_ => None, _ => None,
} }
@ -160,3 +170,8 @@ fn test_alt_env_config_precedence() {
write("libreddit.toml", config_to_write).unwrap(); write("libreddit.toml", config_to_write).unwrap();
assert_eq!(get_setting("LIBREDDIT_DEFAULT_COMMENT_SORT"), Some("top".into())) 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::client::json;
use crate::server::RequestExt; use crate::server::RequestExt;
use crate::subreddit::{can_access_quarantine, quarantine}; 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 askama::Template;
use hyper::{Body, Request, Response}; use hyper::{Body, Request, Response};
@ -67,11 +67,12 @@ pub async fn item(req: Request<Body>) -> Result<Response<Body>, String> {
Ok(response) => { Ok(response) => {
let post = parse_post(&response[0]["data"]["children"][0]).await; 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 // Return landing page if this post if this Reddit deems this post
// NSFW, but we have also disabled the display of NSFW content // NSFW, but we have also disabled the display of NSFW content
// or if the instance is SFW-only. // or if the instance is SFW-only
if post.nsfw && (setting(&req, "show_nsfw") != "on" || crate::utils::sfw_only()) { if post.nsfw && crate::utils::should_be_nsfw_gated(&req, &req_url) {
return Ok(nsfw_landing(req).await.unwrap_or_default()); return Ok(nsfw_landing(req, req_url).await.unwrap_or_default());
} }
let filters = get_filters(&req); 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(); after = response[1]["data"]["after"].as_str().unwrap_or_default().to_string();
} }
} }
let url = req.uri().to_string();
template(DuplicatesTemplate { template(DuplicatesTemplate {
params: DuplicatesParams { before, after, sort }, params: DuplicatesParams { before, after, sort },
post, post,
duplicates, duplicates,
prefs: Preferences::new(&req), prefs: Preferences::new(&req),
url, url: req_url,
num_posts_filtered, num_posts_filtered,
all_posts_filtered, all_posts_filtered,
}) })
@ -210,9 +210,9 @@ pub async fn item(req: Request<Body>) -> Result<Response<Body>, String> {
// Process error. // Process error.
Err(msg) => { Err(msg) => {
if msg == "quarantined" { if msg == "quarantined" || msg == "gated" {
let sub = req.param("sub").unwrap_or_default(); let sub = req.param("sub").unwrap_or_default();
quarantine(req, sub) quarantine(req, sub, msg)
} else { } else {
error(req, msg).await error(req, msg).await
} }

View File

@ -186,9 +186,21 @@ async fn main() {
app app
.at("/manifest.json") .at("/manifest.json")
.get(|_| resource(include_str!("../static/manifest.json"), "application/json", false).boxed()); .get(|_| resource(include_str!("../static/manifest.json"), "application/json", false).boxed());
app app.at("/robots.txt").get(|_| {
.at("/robots.txt") resource(
.get(|_| resource("User-agent: *\nDisallow: /u/\nDisallow: /user/", "text/plain", true).boxed()); 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("/favicon.ico").get(|_| favicon().boxed());
app.at("/logo.png").get(|_| pwa_logo().boxed()); app.at("/logo.png").get(|_| pwa_logo().boxed());
app.at("/Inter.var.woff2").get(|_| font().boxed()); app.at("/Inter.var.woff2").get(|_| font().boxed());

View File

@ -9,6 +9,8 @@ use crate::utils::{
use hyper::{Body, Request, Response}; use hyper::{Body, Request, Response};
use askama::Template; use askama::Template;
use once_cell::sync::Lazy;
use regex::Regex;
use std::collections::HashSet; use std::collections::HashSet;
// STRUCTS // STRUCTS
@ -21,13 +23,18 @@ struct PostTemplate {
prefs: Preferences, prefs: Preferences,
single_thread: bool, single_thread: bool,
url: String, 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> { pub async fn item(req: Request<Body>) -> Result<Response<Body>, String> {
// Build Reddit API path // Build Reddit API path
let mut path: String = format!("{}.json?{}&raw_json=1", req.uri().path(), req.uri().query().unwrap_or_default()); 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 sub = req.param("sub").unwrap_or_default();
let quarantined = can_access_quarantine(&req, &sub); let quarantined = can_access_quarantine(&req, &sub);
let url = req.uri().to_string();
// Set sort to sort query parameter // Set sort to sort query parameter
let sort = param(&path, "sort").unwrap_or_else(|| { let sort = param(&path, "sort").unwrap_or_else(|| {
@ -57,31 +64,41 @@ pub async fn item(req: Request<Body>) -> Result<Response<Body>, String> {
// Parse the JSON into Post and Comment structs // Parse the JSON into Post and Comment structs
let post = parse_post(&response[0]["data"]["children"][0]).await; 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 // Return landing page if this post if this Reddit deems this post
// NSFW, but we have also disabled the display of NSFW content // NSFW, but we have also disabled the display of NSFW content
// or if the instance is SFW-only. // or if the instance is SFW-only.
if post.nsfw && (setting(&req, "show_nsfw") != "on" || crate::utils::sfw_only()) { if post.nsfw && crate::utils::should_be_nsfw_gated(&req, &req_url) {
return Ok(nsfw_landing(req).await.unwrap_or_default()); 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 query = match COMMENT_SEARCH_CAPTURE.captures(&url) {
let url = req.uri().to_string(); 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 // Use the Post and Comment structs to generate a website to show users
template(PostTemplate { template(PostTemplate {
comments, comments,
post, post,
url_without_query: url.clone().trim_end_matches(&format!("?q={query}&type=comment")).to_string(),
sort, sort,
prefs: Preferences::new(&req), prefs: Preferences::new(&req),
single_thread, single_thread,
url, url: req_url,
comment_query: query,
}) })
} }
// If the Reddit API returns an error, exit and send error page to user // If the Reddit API returns an error, exit and send error page to user
Err(msg) => { Err(msg) => {
if msg == "quarantined" { if msg == "quarantined" || msg == "gated" {
let sub = req.param("sub").unwrap_or_default(); let sub = req.param("sub").unwrap_or_default();
quarantine(req, sub) quarantine(req, sub, msg)
} else { } else {
error(req, msg).await error(req, msg).await
} }
@ -90,6 +107,7 @@ pub async fn item(req: Request<Body>) -> Result<Response<Body>, String> {
} }
// COMMENTS // 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> { 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 // 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); let comments = json["data"]["children"].as_array().map_or(Vec::new(), std::borrow::ToOwned::to_owned);
@ -98,90 +116,138 @@ fn parse_comments(json: &serde_json::Value, post_link: &str, post_author: &str,
comments comments
.into_iter() .into_iter()
.map(|comment| { .map(|comment| {
let kind = comment["kind"].as_str().unwrap_or_default().to_string();
let data = &comment["data"]; 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() { let replies: Vec<Comment> = if data["replies"].is_object() {
parse_comments(&data["replies"], post_link, post_author, highlighted_comment, filters, req) parse_comments(&data["replies"], post_link, post_author, highlighted_comment, filters, req)
} else { } else {
Vec::new() Vec::new()
}; };
build_comment(&comment, data, replies, post_link, post_author, highlighted_comment, filters, req)
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://{}{}{}\">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 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,
prefs: Preferences::new(req),
}
}) })
.collect() .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

@ -145,9 +145,9 @@ pub async fn find(req: Request<Body>) -> Result<Response<Body>, String> {
}) })
} }
Err(msg) => { Err(msg) => {
if msg == "quarantined" { if msg == "quarantined" || msg == "gated" {
let sub = req.param("sub").unwrap_or_default(); let sub = req.param("sub").unwrap_or_default();
quarantine(req, sub) quarantine(req, sub, msg)
} else { } else {
error(req, msg).await error(req, msg).await
} }

View File

@ -253,7 +253,7 @@ impl Server {
.boxed() .boxed()
} }
// If there was a routing error // 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 // This loop reads the requested compressors and keeps track of whichever
// one has the highest priority per our heuristic. // 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; let mut q: f64 = 1.0;
// The compressor and q-value (if the latter is defined) // The compressor and q-value (if the latter is defined)

View File

@ -19,7 +19,7 @@ struct SettingsTemplate {
// CONSTANTS // CONSTANTS
const PREFS: [&str; 12] = [ const PREFS: [&str; 13] = [
"theme", "theme",
"front_page", "front_page",
"layout", "layout",
@ -32,6 +32,7 @@ const PREFS: [&str; 12] = [
"hide_hls_notification", "hide_hls_notification",
"autoplay_videos", "autoplay_videos",
"hide_awards", "hide_awards",
"disable_visit_reddit_confirmation",
]; ];
// FUNCTIONS // FUNCTIONS

View File

@ -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 // 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. // 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()) { if sub.nsfw && crate::utils::should_be_nsfw_gated(&req, &req_url) {
return Ok(nsfw_landing(req).await.unwrap_or_default()); 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 path = format!("/r/{}/{}.json?{}&raw_json=1", sub_name.clone(), sort, req.uri().query().unwrap_or_default());
@ -144,7 +145,7 @@ pub async fn community(req: Request<Body>) -> Result<Response<Body>, String> {
}) })
} }
Err(msg) => match msg.as_str() { 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, "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, "banned" => error(req, format!("r/{} has been banned from Reddit", sub_name)).await,
_ => error(req, msg).await, _ => error(req, msg).await,
@ -153,9 +154,9 @@ 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 { 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(), msg: "Please click the button below to continue to this subreddit.".to_string(),
url: req.uri().to_string(), url: req.uri().to_string(),
sub, sub,
@ -323,8 +324,8 @@ pub async fn wiki(req: Request<Body>) -> Result<Response<Body>, String> {
url, url,
}), }),
Err(msg) => { Err(msg) => {
if msg == "quarantined" { if msg == "quarantined" || msg == "gated" {
quarantine(req, sub) quarantine(req, sub, msg)
} else { } else {
error(req, msg).await error(req, msg).await
} }
@ -361,8 +362,8 @@ pub async fn sidebar(req: Request<Body>) -> Result<Response<Body>, String> {
url, url,
}), }),
Err(msg) => { Err(msg) => {
if msg == "quarantined" { if msg == "quarantined" || msg == "gated" {
quarantine(req, sub) quarantine(req, sub, msg)
} else { } else {
error(req, msg).await error(req, msg).await
} }

View File

@ -50,11 +50,12 @@ pub async fn profile(req: Request<Body>) -> Result<Response<Body>, String> {
// Retrieve info from user about page. // Retrieve info from user about page.
let user = user(&username).await.unwrap_or_default(); 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, // 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 // but we have also disabled the display of NSFW content or if the instance
// is SFW-only. // is SFW-only.
if user.nsfw && (setting(&req, "show_nsfw") != "on" || crate::utils::sfw_only()) { if user.nsfw && crate::utils::should_be_nsfw_gated(&req, &req_url) {
return Ok(nsfw_landing(req).await.unwrap_or_default()); return Ok(nsfw_landing(req, req_url).await.unwrap_or_default());
} }
let filters = get_filters(&req); let filters = get_filters(&req);

View File

@ -6,6 +6,7 @@ use crate::{client::json, server::RequestExt};
use askama::Template; use askama::Template;
use cookie::Cookie; use cookie::Cookie;
use hyper::{Body, Request, Response}; use hyper::{Body, Request, Response};
use once_cell::sync::Lazy;
use regex::Regex; use regex::Regex;
use rust_embed::RustEmbed; use rust_embed::RustEmbed;
use serde_json::Value; use serde_json::Value;
@ -97,6 +98,61 @@ pub struct Author {
pub distinguished: String, 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 // Post flags with nsfw and stickied
pub struct Flags { pub struct Flags {
pub nsfw: bool, pub nsfw: bool,
@ -205,10 +261,17 @@ impl GalleryMedia {
// For each image in gallery // For each image in gallery
let media_id = item["media_id"].as_str().unwrap_or_default(); let media_id = item["media_id"].as_str().unwrap_or_default();
let image = &metadata[media_id]["s"]; 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 // Construct gallery items
Self { Self {
url: format_url(image["u"].as_str().unwrap_or_default()), url: format_url(url),
width: image["x"].as_i64().unwrap_or_default(), width: image["x"].as_i64().unwrap_or_default(),
height: image["y"].as_i64().unwrap_or_default(), height: image["y"].as_i64().unwrap_or_default(),
caption: item["caption"].as_str().unwrap_or_default().to_string(), caption: item["caption"].as_str().unwrap_or_default().to_string(),
@ -227,6 +290,7 @@ pub struct Post {
pub body: String, pub body: String,
pub author: Author, pub author: Author,
pub permalink: String, pub permalink: String,
pub poll: Option<Poll>,
pub score: (String, String), pub score: (String, String),
pub upvote_ratio: i64, pub upvote_ratio: i64,
pub post_type: String, pub post_type: String,
@ -336,6 +400,7 @@ impl Post {
stickied: data["stickied"].as_bool().unwrap_or_default() || data["pinned"].as_bool().unwrap_or_default(), stickied: data["stickied"].as_bool().unwrap_or_default() || data["pinned"].as_bool().unwrap_or_default(),
}, },
permalink: val(post, "permalink"), permalink: val(post, "permalink"),
poll: Poll::parse(&data["poll_data"]),
rel_time, rel_time,
created, created,
num_duplicates: post["data"]["num_duplicates"].as_u64().unwrap_or(0), num_duplicates: post["data"]["num_duplicates"].as_u64().unwrap_or(0),
@ -371,6 +436,7 @@ pub struct Comment {
pub awards: Awards, pub awards: Awards,
pub collapsed: bool, pub collapsed: bool,
pub is_filtered: bool, pub is_filtered: bool,
pub more_count: i64,
pub prefs: Preferences, pub prefs: Preferences,
} }
@ -506,6 +572,7 @@ pub struct Preferences {
pub hide_hls_notification: String, pub hide_hls_notification: String,
pub use_hls: String, pub use_hls: String,
pub autoplay_videos: String, pub autoplay_videos: String,
pub disable_visit_reddit_confirmation: String,
pub comment_sort: String, pub comment_sort: String,
pub post_sort: String, pub post_sort: String,
pub subscriptions: Vec<String>, pub subscriptions: Vec<String>,
@ -530,20 +597,21 @@ impl Preferences {
} }
Self { Self {
available_themes: themes, available_themes: themes,
theme: setting(&req, "theme"), theme: setting(req, "theme"),
front_page: setting(&req, "front_page"), front_page: setting(req, "front_page"),
layout: setting(&req, "layout"), layout: setting(req, "layout"),
wide: setting(&req, "wide"), wide: setting(req, "wide"),
show_nsfw: setting(&req, "show_nsfw"), show_nsfw: setting(req, "show_nsfw"),
blur_nsfw: setting(&req, "blur_nsfw"), blur_nsfw: setting(req, "blur_nsfw"),
use_hls: setting(&req, "use_hls"), use_hls: setting(req, "use_hls"),
hide_hls_notification: setting(&req, "hide_hls_notification"), hide_hls_notification: setting(req, "hide_hls_notification"),
autoplay_videos: setting(&req, "autoplay_videos"), autoplay_videos: setting(req, "autoplay_videos"),
comment_sort: setting(&req, "comment_sort"), disable_visit_reddit_confirmation: setting(req, "disable_visit_reddit_confirmation"),
post_sort: setting(&req, "post_sort"), comment_sort: setting(req, "comment_sort"),
subscriptions: setting(&req, "subscriptions").split('+').map(String::from).filter(|s| !s.is_empty()).collect(), post_sort: setting(req, "post_sort"),
filters: setting(&req, "filters").split('+').map(String::from).filter(|s| !s.is_empty()).collect(), subscriptions: setting(req, "subscriptions").split('+').map(String::from).filter(|s| !s.is_empty()).collect(),
hide_awards: setting(&req, "hide_awards"), filters: setting(req, "filters").split('+').map(String::from).filter(|s| !s.is_empty()).collect(),
hide_awards: setting(req, "hide_awards"),
} }
} }
} }
@ -591,6 +659,8 @@ pub async fn parse_post(post: &serde_json::Value) -> Post {
let permalink = val(post, "permalink"); let permalink = val(post, "permalink");
let poll = Poll::parse(&post["data"]["poll_data"]);
let body = if val(post, "removed_by_category") == "moderator" { let body = if val(post, "removed_by_category") == "moderator" {
format!( format!(
"<div class=\"md\"><p>[removed] — <a href=\"https://{}{}\">view removed post</a></p></div>", "<div class=\"md\"><p>[removed] — <a href=\"https://{}{}\">view removed post</a></p></div>",
@ -622,6 +692,7 @@ pub async fn parse_post(post: &serde_json::Value) -> Post {
distinguished: val(post, "distinguished"), distinguished: val(post, "distinguished"),
}, },
permalink, permalink,
poll,
score: format_num(score), score: format_num(score),
upvote_ratio: ratio as i64, upvote_ratio: ratio as i64,
post_type, post_type,
@ -709,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 // Direct urls to proxy if proxy is enabled
pub fn format_url(url: &str) -> String { pub fn format_url(url: &str) -> String {
if url.is_empty() || url == "self" || url == "default" || url == "nsfw" || url == "spoiler" { if url.is_empty() || url == "self" || url == "default" || url == "nsfw" || url == "spoiler" {
@ -717,13 +803,11 @@ pub fn format_url(url: &str) -> String {
Url::parse(url).map_or(url.to_string(), |parsed| { Url::parse(url).map_or(url.to_string(), |parsed| {
let domain = parsed.domain().unwrap_or_default(); let domain = parsed.domain().unwrap_or_default();
let capture = |regex: &str, format: &str, segments: i16| { let capture = |regex: &Regex, format: &str, segments: i16| {
Regex::new(regex).map_or(String::new(), |re| { regex.captures(url).map_or(String::new(), |caps| match segments {
re.captures(url).map_or(String::new(), |caps| match segments { 1 => [format, &caps[1]].join(""),
1 => [format, &caps[1]].join(""), 2 => [format, &caps[1], "/", &caps[2]].join(""),
2 => [format, &caps[1], "/", &caps[2]].join(""), _ => String::new(),
_ => String::new(),
})
}) })
}; };
@ -749,44 +833,46 @@ pub fn format_url(url: &str) -> String {
} }
match domain { match domain {
"www.reddit.com" => capture(r"https://www\.reddit\.com/(.*)", "/", 1), "www.reddit.com" => capture(&REGEX_URL_WWW, "/", 1),
"old.reddit.com" => capture(r"https://old\.reddit\.com/(.*)", "/", 1), "old.reddit.com" => capture(&REGEX_URL_OLD, "/", 1),
"np.reddit.com" => capture(r"https://np\.reddit\.com/(.*)", "/", 1), "np.reddit.com" => capture(&REGEX_URL_NP, "/", 1),
"reddit.com" => capture(r"https://reddit\.com/(.*)", "/", 1), "reddit.com" => capture(&REGEX_URL_PLAIN, "/", 1),
"v.redd.it" => chain!( "v.redd.it" => chain!(capture(&REGEX_URL_VIDEOS, "/vid/", 2), capture(&REGEX_URL_VIDEOS_HLS, "/hls/", 2)),
capture(r"https://v\.redd\.it/(.*)/DASH_([0-9]{2,4}(\.mp4|$|\?source=fallback))", "/vid/", 2), "i.redd.it" => capture(&REGEX_URL_IMAGES, "/img/", 1),
capture(r"https://v\.redd\.it/(.+)/(HLSPlaylist\.m3u8.*)$", "/hls/", 2) "a.thumbs.redditmedia.com" => capture(&REGEX_URL_THUMBS_A, "/thumb/a/", 1),
), "b.thumbs.redditmedia.com" => capture(&REGEX_URL_THUMBS_B, "/thumb/b/", 1),
"i.redd.it" => capture(r"https://i\.redd\.it/(.*)", "/img/", 1), "emoji.redditmedia.com" => capture(&REGEX_URL_EMOJI, "/emoji/", 2),
"a.thumbs.redditmedia.com" => capture(r"https://a\.thumbs\.redditmedia\.com/(.*)", "/thumb/a/", 1), "preview.redd.it" => capture(&REGEX_URL_PREVIEW, "/preview/pre/", 1),
"b.thumbs.redditmedia.com" => capture(r"https://b\.thumbs\.redditmedia\.com/(.*)", "/thumb/b/", 1), "external-preview.redd.it" => capture(&REGEX_URL_EXTERNAL_PREVIEW, "/preview/external-pre/", 1),
"emoji.redditmedia.com" => capture(r"https://emoji\.redditmedia\.com/(.*)/(.*)", "/emoji/", 2), "styles.redditmedia.com" => capture(&REGEX_URL_STYLES, "/style/", 1),
"preview.redd.it" => capture(r"https://preview\.redd\.it/(.*)", "/preview/pre/", 1), "www.redditstatic.com" => capture(&REGEX_URL_STATIC_MEDIA, "/static/", 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),
_ => url.to_string(), _ => 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 // Rewrite Reddit links to Libreddit in body of text
pub fn rewrite_urls(input_text: &str) -> String { pub fn rewrite_urls(input_text: &str) -> String {
let text1 = Regex::new(r#"href="(https|http|)://(www\.|old\.|np\.|amp\.|)(reddit\.com|redd\.it)/"#) let text1 =
.map_or(String::new(), |re| re.replace_all(input_text, r#"href="/"#).to_string()) // Rewrite Reddit links to Libreddit
// Remove (html-encoded) "\" from URLs. REDDIT_REGEX.replace_all(input_text, r#"href="/"#)
.replace("%5C", "") .to_string()
.replace('\\', ""); // Remove (html-encoded) "\" from URLs.
.replace("%5C", "")
.replace('\\', "");
// Rewrite external media previews to Libreddit // Rewrite external media previews to Libreddit
Regex::new(r"https://external-preview\.redd\.it(.*)[^?]").map_or(String::new(), |re| { if REDDIT_PREVIEW_REGEX.is_match(&text1) {
if re.is_match(&text1) { REDDIT_PREVIEW_REGEX
re.replace_all(&text1, format_url(re.find(&text1).map(|x| x.as_str()).unwrap_or_default())).to_string() .replace_all(&text1, format_url(REDDIT_PREVIEW_REGEX.find(&text1).map(|x| x.as_str()).unwrap_or_default()))
} else { .to_string()
text1 } else {
} text1
}) }
} }
// Format vote count to a string that will be displayed. // Format vote count to a string that will be displayed.
@ -807,20 +893,31 @@ pub fn format_num(num: i64) -> (String, String) {
// Parse a relative and absolute time from a UNIX timestamp // Parse a relative and absolute time from a UNIX timestamp
pub fn time(created: f64) -> (String, String) { pub fn time(created: f64) -> (String, String) {
let time = OffsetDateTime::from_unix_timestamp(created.round() as i64).unwrap_or(OffsetDateTime::UNIX_EPOCH); 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 // 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() time.format(format_description!("[month repr:short] [day] '[year repr:last_two]")).unwrap_or_default()
// Otherwise, show relative date/time // Otherwise, show relative date/time
} else if time_delta.whole_days() > 0 { } 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 { } else if time_delta.whole_hours() > 0 {
format!("{}h ago", time_delta.whole_hours()) format!("{}h", time_delta.whole_hours())
} else { } 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, rel_time,
time time
@ -885,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 /// Renders the landing page for NSFW content when the user has not enabled
/// "show NSFW posts" in settings. /// "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 res_type: ResourceType;
let url = req.uri().to_string();
// Determine from the request URL if the resource is a subreddit, a user // Determine from the request URL if the resource is a subreddit, a user
// page, or a post. // page, or a post.
@ -908,7 +1015,7 @@ pub async fn nsfw_landing(req: Request<Body>) -> Result<Response<Body>, String>
res, res,
res_type, res_type,
prefs: Preferences::new(&req), prefs: Preferences::new(&req),
url, url: req_url,
} }
.render() .render()
.unwrap_or_default(); .unwrap_or_default();

View File

@ -4,6 +4,30 @@
:root { :root {
--nsfw: #ff5c5d; --nsfw: #ff5c5d;
--admin: #ea0027; --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 { @font-face {
@ -26,6 +50,7 @@
--highlighted: #333; --highlighted: #333;
--visited: #aaa; --visited: #aaa;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.5); --shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
--popup: #b80a27;
/* Hint color theme to browser for scrollbar */ /* Hint color theme to browser for scrollbar */
color-scheme: dark; color-scheme: dark;
@ -76,6 +101,9 @@ body {
background: var(--background); background: var(--background);
font-size: 15px; font-size: 15px;
padding-top: 60px; padding-top: 60px;
padding-bottom: var(--footer-height);
min-height: calc(100vh - 60px);
position: relative;
} }
nav { nav {
@ -119,12 +147,6 @@ nav #links svg {
display: none; display: none;
} }
nav #version {
opacity: 50%;
vertical-align: -2px;
margin-right: 10px;
}
nav #libreddit { nav #libreddit {
vertical-align: -2px; vertical-align: -2px;
} }
@ -134,10 +156,109 @@ nav #libreddit {
margin-left: 10px; 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; 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 { #code {
margin-left: 10px; margin-left: 10px;
} }
@ -148,6 +269,7 @@ main {
max-width: 1000px; max-width: 1000px;
padding: 10px 20px; padding: 10px 20px;
margin: 0 auto; margin: 0 auto;
padding-bottom: 4em;
} }
.wide main { .wide main {
@ -170,23 +292,22 @@ main {
body > footer { body > footer {
display: flex; display: flex;
justify-content: center; justify-content: center;
margin: 20px; align-items: center;
width: 100%;
background: var(--post);
position: absolute;
bottom: 0;
} }
.info-button { .footer-button {
align-items: center; align-items: center;
border-radius: .25rem; border-radius: .25rem;
box-sizing: border-box; box-sizing: border-box;
color: var(--text); color: var(--text);
cursor: pointer; cursor: pointer;
display: inline-flex; display: inline-flex;
font-size: 300%; padding-left: 1em;
font-weight: bold; opacity: 0.8;
padding: 0.5em;
}
.info-button > a:hover {
text-decoration: none;
} }
/* / Body footer. */ /* / Body footer. */
@ -208,6 +329,7 @@ button {
background: none; background: none;
border: none; border: none;
font-weight: bold; font-weight: bold;
cursor: pointer;
} }
hr { hr {
@ -276,7 +398,6 @@ aside {
} }
#user_title, #sub_title { #user_title, #sub_title {
margin: 0 20px;
font-size: 20px; font-size: 20px;
font-weight: bold; font-weight: bold;
} }
@ -420,6 +541,7 @@ select, #search, #sort_options, #listing_options, #inside, #searchbox > *, #sort
select { select {
background: var(--outside); background: var(--outside);
transition: 0.2s background; transition: 0.2s background;
cursor: pointer;
} }
select, #search { select, #search {
@ -432,6 +554,10 @@ select, #search {
border-radius: 5px 0px 0px 5px; border-radius: 5px 0px 0px 5px;
} }
.commentQuery {
background: var(--post);
}
#searchbox { #searchbox {
grid-area: searchbox; grid-area: searchbox;
display: flex; display: flex;
@ -509,23 +635,28 @@ button.submit:hover > svg { stroke: var(--accent); }
background: transparent; background: transparent;
} }
#commentQueryForms {
display: flex;
justify-content: space-between;
}
#allCommentsLink {
color: var(--green);
}
#sort, #search_sort { #sort, #search_sort {
display: flex; display: flex;
align-items: center; align-items: center;
margin-bottom: 20px; margin-bottom: 20px;
} }
#listing_options {
overflow-x: auto;
}
#sort_options, #listing_options, main > * > footer > a { #sort_options, #listing_options, main > * > footer > a {
border-radius: 5px; border-radius: 5px;
align-items: center; align-items: center;
box-shadow: var(--shadow); box-shadow: var(--shadow);
background: var(--outside); background: var(--outside);
display: flex; display: flex;
overflow: hidden; overflow-y: hidden;
} }
#sort_options > a, #listing_options > a, main > * > footer > a { #sort_options > a, #listing_options > a, main > * > footer > a {
@ -636,6 +767,7 @@ a.search_subreddit:hover {
"post_score post_title post_thumbnail" 1fr "post_score post_title post_thumbnail" 1fr
"post_score post_media post_thumbnail" auto "post_score post_media post_thumbnail" auto
"post_score post_body 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_notification post_thumbnail" auto
"post_score post_footer post_thumbnail" auto "post_score post_footer post_thumbnail" auto
/ minmax(40px, auto) minmax(0, 1fr) fit-content(min(20%, 152px)); / minmax(40px, auto) minmax(0, 1fr) fit-content(min(20%, 152px));
@ -836,6 +968,44 @@ a.search_subreddit:hover {
overflow-wrap: anywhere; 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 */ /* Used only for text post preview */
.post_preview { .post_preview {
-webkit-mask-image: linear-gradient(180deg,#000 60%,transparent);; -webkit-mask-image: linear-gradient(180deg,#000 60%,transparent);;
@ -1392,7 +1562,10 @@ td, th {
/* Mobile */ /* Mobile */
@media screen and (max-width: 800px) { @media screen and (max-width: 800px) {
body { padding-top: 120px } body {
padding-top: 120px;
padding-bottom: var(--footer-height);
}
main { main {
flex-direction: column-reverse; flex-direction: column-reverse;
@ -1431,17 +1604,21 @@ td, th {
#user, #sidebar { margin: 20px 0; } #user, #sidebar { margin: 20px 0; }
#logo, #links { margin-bottom: 5px; } #logo, #links { margin-bottom: 5px; }
#searchbox { width: calc(100vw - 35px); } #searchbox { width: calc(100vw - 35px); }
} }
@media screen and (max-width: 480px) { @media screen and (max-width: 480px) {
body { padding-top: 100px; } body {
#version { display: none; } padding-top: 100px;
padding-bottom: var(--footer-height);
}
.post { .post {
grid-template: "post_header post_header post_thumbnail" auto grid-template: "post_header post_header post_thumbnail" auto
"post_title post_title post_thumbnail" 1fr "post_title post_title post_thumbnail" 1fr
"post_media post_media post_thumbnail" auto "post_media post_media post_thumbnail" auto
"post_body post_body 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_notification post_notification post_thumbnail" auto
"post_score post_footer post_thumbnail" auto "post_score post_footer post_thumbnail" auto
/ auto 1fr fit-content(min(20%, 152px)); / auto 1fr fit-content(min(20%, 152px));
@ -1451,6 +1628,10 @@ td, th {
margin: 5px 0px 20px 15px; margin: 5px 0px 20px 15px;
padding: 0; padding: 0;
} }
.post_poll {
padding: 5px 15px 10px 12px;
}
.compact .post_score { padding: 0; } .compact .post_score { padding: 0; }
@ -1494,4 +1675,17 @@ td, th {
#post_links > li.desktop_item { display: none } #post_links > li.desktop_item { display: none }
#post_links > li.mobile_item { display: auto } #post_links > li.mobile_item { display: auto }
.post_footer > p > span#upvoted { display: none } .post_footer > p > span#upvoted { display: none }
.popup {
width: auto;
}
.popup-inner {
max-width: 80%;
}
#commentQueryForms {
display: initial;
justify-content: initial;
}
} }

View File

@ -1,3 +1,5 @@
{% import "utils.html" as utils %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
@ -30,17 +32,20 @@
<nav> <nav>
<div id="logo"> <div id="logo">
<a id="libreddit" href="/"><span id="lib">lib</span><span id="reddit">reddit.</span></a> <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 %} {% block subscriptions %}{% endblock %}
</div> </div>
{% block search %}{% endblock %} {% block search %}{% endblock %}
<div id="links"> <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> <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"> <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> </svg>
</a> </a>
{% if prefs.disable_visit_reddit_confirmation != "on" %}
{% call utils::visit_reddit_confirmation(url) %}
{% endif %}
<a id="settings_link" href="/settings"> <a id="settings_link" href="/settings">
<span>settings</span> <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"> <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"/> <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> </svg>
</a> </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> </div>
</nav> </nav>
@ -65,10 +63,16 @@
{% endblock %} {% endblock %}
</main> </main>
{% endblock %} {% endblock %}
<!-- FOOTER -->
{% block footer %} {% block footer %}
<footer> <footer>
<div class="info-button"> <p id="version">v{{ env!("CARGO_PKG_VERSION") }}</p>
<a href="/info" title="View instance information">&#x24D8;</a> <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> </div>
</footer> </footer>
{% endblock %} {% endblock %}

View File

@ -1,7 +1,7 @@
{% import "utils.html" as utils %} {% import "utils.html" as utils %}
{% if kind == "more" && parent_kind == "t1" %} {% 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" %} {% else if kind == "t1" %}
<div id="{{ id }}" class="comment"> <div id="{{ id }}" class="comment">
<div class="comment_left"> <div class="comment_left">
@ -35,7 +35,7 @@
<div class="comment_body {% if highlighted %}highlighted{% endif %}">{{ body|safe }}</div> <div class="comment_body {% if highlighted %}highlighted{% endif %}">{{ body|safe }}</div>
{% endif %} {% endif %}
<blockquote class="replies">{% for c in replies -%}{{ c.render().unwrap()|safe }}{%- endfor %} <blockquote class="replies">{% for c in replies -%}{{ c.render().unwrap()|safe }}{%- endfor %}
</blockquote> </bockquote>
</details> </details>
</div> </div>
{% endif %} {% endif %}

View File

@ -19,10 +19,12 @@
{% if crate::utils::sfw_only() %} {% if crate::utils::sfw_only() %}
This instance of Libreddit is SFW-only.</p> This instance of Libreddit is SFW-only.</p>
{% else %} {% 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 %} {% endif %}
</p> </p>
</div> </div>
{% endblock %} {% endblock %}
{% block footer %} {% block footer %}
{% endblock %} {% endblock %}

View File

@ -43,18 +43,32 @@
{% call utils::post(post) %} {% call utils::post(post) %}
<!-- SORT FORM --> <!-- SORT FORM -->
<div id="commentQueryForms">
<form id="sort"> <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> <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") %} {% call utils::options(sort, ["confidence", "top", "new", "controversial", "old"], "confidence") %}
</select><button id="sort_submit" class="submit"> </select>
<svg width="15" viewBox="0 0 110 100" fill="none" stroke-width="10" stroke-linecap="round"> <button id="sort_submit" class="submit">
<path d="M20 50 H100" /> <svg width="15" viewBox="0 0 110 100" fill="none" stroke-width="10" stroke-linecap="round">
<path d="M75 15 L100 50 L75 85" /> <path d="M20 50 H100" />
&rarr; <path d="M75 15 L100 50 L75 85" />
</svg> &rarr;
</button> </svg>
</form> </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 --> <!-- COMMENTS -->
{% for c in comments -%} {% for c in comments -%}

View File

@ -29,7 +29,7 @@
&rarr; &rarr;
</svg> </svg>
</button> </button>
</form> </form>
{% if !is_filtered %} {% if !is_filtered %}
{% if subreddits.len() > 0 || params.typed == "sr_user" %} {% if subreddits.len() > 0 || params.typed == "sr_user" %}
@ -99,13 +99,13 @@
{% if params.typed != "sr_user" %} {% if params.typed != "sr_user" %}
<footer> <footer>
{% if params.before != "" %} {% 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 }} &sort={{ params.sort }}&t={{ params.t }}
&before={{ params.before }}" accesskey="P">PREV</a> &before={{ params.before }}" accesskey="P">PREV</a>
{% endif %} {% endif %}
{% if params.after != "" %} {% 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 }} &sort={{ params.sort }}&t={{ params.t }}
&after={{ params.after }}" accesskey="N">NEXT</a> &after={{ params.after }}" accesskey="N">NEXT</a>
{% endif %} {% endif %}

View File

@ -90,6 +90,11 @@
<input type="hidden" value="off" name="hide_awards"> <input type="hidden" value="off" name="hide_awards">
<input type="checkbox" name="hide_awards" id="hide_awards" {% if prefs.hide_awards == "on" %}checked{% endif %}> <input type="checkbox" name="hide_awards" id="hide_awards" {% if prefs.hide_awards == "on" %}checked{% endif %}>
</div> </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> </fieldset>
<input id="save" type="submit" value="Save"> <input id="save" type="submit" value="Save">
</div> </div>
@ -127,7 +132,7 @@
<div id="settings_note"> <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><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 }}&hide_awards={{ prefs.hide_awards }}&subscriptions={{ prefs.subscriptions.join("%2B") }}&autoplay_videos={{ prefs.autoplay_videos }}&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>
</div> </div>

View File

@ -148,6 +148,9 @@
<!-- POST BODY --> <!-- POST BODY -->
<div class="post_body">{{ post.body|safe }}</div> <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> <div class="post_score" title="{{ post.score.1 }}">{{ post.score.0 }}<span class="label"> Upvotes</span></div>
{% call poll(post) %}
<div class="post_footer"> <div class="post_footer">
<ul id="post_links"> <ul id="post_links">
<li class="desktop_item"><a href="{{ post.permalink }}">permalink</a></li> <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="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> <li class="mobile_item"><a href="/r/{{ post.community }}/duplicates/{{ post.id }}">dupes</a></li>
{% endif %} {% endif %}
<li class="desktop_item"><a href="https://reddit.com{{ post.permalink }}" rel="nofollow">reddit</a></li> {% call external_reddit_link(post.permalink) %}
<li class="mobile_item"><a href="https://reddit.com{{ post.permalink }}" rel="nofollow">reddit</a></li>
</ul> </ul>
<p>{{ post.upvote_ratio }}%<span id="upvoted"> Upvoted</span></p> <p>{{ post.upvote_ratio }}%<span id="upvoted"> Upvoted</span></p>
</div> </div>
</div> </div>
{%- endmacro %} {%- 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) -%} {% macro post_in_list(post) -%}
<div class="post {% if post.flags.stickied %}stickied{% endif %}" id="{{ post.id }}"> <div class="post {% if post.flags.stickied %}stickied{% endif %}" id="{{ post.id }}">
<p class="post_header"> <p class="post_header">
@ -254,8 +275,64 @@
<div class="post_body post_preview"> <div class="post_body post_preview">
{{ post.body|safe }} {{ post.body|safe }}
</div> </div>
{% call poll(post) %}
<div class="post_footer"> <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> <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>
</div> </div>
{%- endmacro %} {%- 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 %}