Compare commits
128 commits
Author | SHA1 | Date | |
---|---|---|---|
|
dbdc4fc2a3 | ||
|
cbc3e49923 | ||
|
15147cea8e | ||
|
3d85df5044 | ||
|
c9e6ffd33c | ||
|
526c0d0797 | ||
|
357e7c2e09 | ||
|
d097495a41 | ||
|
f3ca7bb7d1 | ||
|
35688e4af7 | ||
|
efcf2fc24c | ||
|
9afe886c2c | ||
|
c9dbd7a3cc | ||
|
bb20190555 | ||
|
ebc682da2d | ||
|
2e95e1fc6e | ||
|
7d3160c149 | ||
|
5265ccb033 | ||
|
85329c96a7 | ||
|
a732f18143 | ||
|
7770c57856 | ||
|
c7f55c146a | ||
|
ef2cc01bf7 | ||
|
7930b19809 | ||
|
257871b56c | ||
|
bbe5f81914 | ||
|
51386671d3 | ||
|
68a0517115 | ||
|
2e0e1a1aaa | ||
|
23cda23d01 | ||
|
96ad7bf163 | ||
|
9e39a75e82 | ||
|
0703fa1036 | ||
|
cb659cc8a3 | ||
|
fd1c32f555 | ||
|
adf25cb15b | ||
|
9e47bc37c7 | ||
|
5c1e15c359 | ||
|
d7ec07cd0d | ||
|
e4fc22cf90 | ||
|
9f6b08cbb2 | ||
|
a4f511f67e | ||
|
7fe109df22 | ||
|
100a7b65a6 | ||
|
6be6f892a4 | ||
|
95ab6c5385 | ||
|
d3ba5f3efb | ||
|
cb9a2a3c39 | ||
|
6ecdedd2ed | ||
|
18efb8c714 | ||
|
0bc36d529c | ||
|
96ebfd2d3a | ||
|
3e1718bfc9 | ||
|
96e40e8887 | ||
|
f8a9ad363d | ||
|
f7240208f1 | ||
|
0714d58efe | ||
|
a96bebb099 | ||
|
6c64ebd56b | ||
|
62717ef6b2 | ||
|
a301afc383 | ||
|
6a18ea17ec | ||
|
f03bdcf472 | ||
|
2fd358f3ed | ||
|
5ef57812f8 | ||
|
d17d097b12 | ||
|
a96894c743 | ||
|
9aea9c90a2 | ||
|
efdf1848ac | ||
|
bc9530821d | ||
|
f3d2f0cc59 | ||
|
49ef59e000 | ||
|
3ff907d6c1 | ||
|
f4a457e529 | ||
|
7dda8d9bbb | ||
|
b99412b4a1 | ||
|
1838fdaea4 | ||
|
f71b0cd178 | ||
|
604db902e9 | ||
|
e57eaa0b78 | ||
|
31ad8c5f7b | ||
|
5aef97410c | ||
|
8d0ed4682e | ||
|
fe4fed0504 | ||
|
6e2e679a0e | ||
|
6b44c1abf2 | ||
|
a807002ddf | ||
|
403513ac4c | ||
|
72f7d9d08c | ||
|
e6273e2ed5 | ||
|
f1d4e6a417 | ||
|
e0d7837c02 | ||
|
2d6ac78acf | ||
|
1e54c639d3 | ||
|
d5f137ce47 | ||
|
245fd9d408 | ||
|
b54620b5aa | ||
|
69c7a69afd | ||
|
2991813c2d | ||
|
7156be6ad0 | ||
|
793047f63f | ||
|
3625fdfdbe | ||
|
28f85f2599 | ||
|
7be29f609c | ||
|
f18d135045 | ||
|
8ef45456d6 | ||
|
118708400a | ||
|
28e72c9058 | ||
|
0b15250cc8 | ||
|
408ebe6ef1 | ||
|
7a0ea1fbd3 | ||
|
db8b92ea55 | ||
|
c494fbec31 | ||
|
438e412be3 | ||
|
d0e081e6a0 | ||
|
041ecceeaf | ||
|
3677ca10e2 | ||
|
21fd34710c | ||
|
4205959ade | ||
|
27b56c1781 | ||
|
374238abc3 | ||
|
9bdb5c8966 | ||
|
410872d988 | ||
|
c890e809b7 | ||
|
4f21388643 | ||
|
8a917fcde3 | ||
|
67a890cab3 | ||
|
366bc17f97 |
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "Rust",
|
||||
"image": "mcr.microsoft.com/devcontainers/rust:0-1-bullseye",
|
||||
"image": "mcr.microsoft.com/devcontainers/rust:1.0.9-bookworm",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/docker-in-docker:2": {}
|
||||
},
|
||||
|
|
5
.github/FUNDING.yml
vendored
|
@ -1,2 +1,3 @@
|
|||
liberapay: spike
|
||||
custom: ['https://www.buymeacoffee.com/spikecodes']
|
||||
liberapay: sigaloid
|
||||
buy_me_a_coffee: sigaloid
|
||||
github: sigaloid
|
8
.github/ISSUE_TEMPLATE/bug_report.md
vendored
|
@ -7,6 +7,10 @@ assignees: ''
|
|||
|
||||
---
|
||||
|
||||
<!--
|
||||
BEFORE FILING A BUG REPORT: Ensure that you are running the latest git commit. Visit /info on your instance, and ensure the git commit listed is the same commit listed on the home page.
|
||||
-->
|
||||
|
||||
## Describe the bug
|
||||
<!--
|
||||
A clear and concise description of what the bug is.
|
||||
|
@ -31,3 +35,7 @@ Steps to reproduce the behavior:
|
|||
<!--
|
||||
Add any other context about the problem here.
|
||||
-->
|
||||
|
||||
|
||||
<!-- Mandatory -->
|
||||
- [ ] I checked that the instance that this was reported on is running the latest git commit, or I can reproduce it locally on the latest git commit
|
75
.github/workflows/main-docker.yml
vendored
|
@ -15,15 +15,13 @@ jobs:
|
|||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { platform: linux/amd64, target: x86_64-unknown-linux-musl}
|
||||
- { platform: linux/arm64, target: aarch64-unknown-linux-musl}
|
||||
- { platform: linux/arm/v7, target: armv7-unknown-linux-musleabihf}
|
||||
- { platform: linux/amd64, target: x86_64-unknown-linux-musl }
|
||||
- { platform: linux/arm64, target: aarch64-unknown-linux-musl }
|
||||
- { platform: linux/arm/v7, target: armv7-unknown-linux-musleabihf }
|
||||
steps:
|
||||
-
|
||||
name: Checkout
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
-
|
||||
name: Docker meta
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
|
@ -31,21 +29,17 @@ jobs:
|
|||
tags: |
|
||||
type=sha
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
-
|
||||
name: Set up QEMU
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
-
|
||||
name: Login to Quay.io Container Registry
|
||||
- name: Login to Quay.io Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: quay.io
|
||||
username: ${{ secrets.QUAY_USERNAME }}
|
||||
password: ${{ secrets.QUAY_ROBOT_TOKEN }}
|
||||
-
|
||||
name: Build and push
|
||||
- name: Build and push
|
||||
id: build
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
|
@ -55,17 +49,15 @@ jobs:
|
|||
outputs: type=image,name=${{ env.REGISTRY_IMAGE }},push-by-digest=true,name-canonical=true,push=true
|
||||
file: Dockerfile
|
||||
build-args: TARGET=${{ matrix.target }}
|
||||
-
|
||||
name: Export digest
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
-
|
||||
name: Upload digest
|
||||
uses: actions/upload-artifact@v3
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests
|
||||
name: digests-${{ matrix.target }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
@ -74,17 +66,16 @@ jobs:
|
|||
needs:
|
||||
- build
|
||||
steps:
|
||||
-
|
||||
name: Download digests
|
||||
uses: actions/download-artifact@v3
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4.1.7
|
||||
with:
|
||||
name: digests
|
||||
path: /tmp/digests
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
-
|
||||
name: Docker meta
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
|
@ -92,31 +83,27 @@ jobs:
|
|||
tags: |
|
||||
type=sha
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
-
|
||||
name: Login to Quay.io Container Registry
|
||||
- name: Login to Quay.io Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: quay.io
|
||||
username: ${{ secrets.QUAY_USERNAME }}
|
||||
password: ${{ secrets.QUAY_ROBOT_TOKEN }}
|
||||
-
|
||||
name: Create manifest list and push
|
||||
- name: Create manifest list and push
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *)
|
||||
|
||||
- name: Push README to Quay.io
|
||||
uses: christian-korneck/update-container-description-action@v1
|
||||
env:
|
||||
DOCKER_APIKEY: ${{ secrets.APIKEY__QUAY_IO }}
|
||||
with:
|
||||
destination_container_repo: quay.io/redlib/redlib
|
||||
provider: quay
|
||||
readme_file: 'README.md'
|
||||
# - name: Push README to Quay.io
|
||||
# uses: christian-korneck/update-container-description-action@v1
|
||||
# env:
|
||||
# DOCKER_APIKEY: ${{ secrets.APIKEY__QUAY_IO }}
|
||||
# with:
|
||||
# destination_container_repo: quay.io/redlib/redlib
|
||||
# provider: quay
|
||||
# readme_file: 'README.md'
|
||||
|
||||
-
|
||||
name: Inspect image
|
||||
- name: Inspect image
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.meta.outputs.version }}
|
||||
|
||||
|
|
2
.github/workflows/main-rust.yml
vendored
|
@ -56,7 +56,7 @@ jobs:
|
|||
- name: Calculate SHA256 checksum
|
||||
run: sha256sum target/x86_64-unknown-linux-musl/release/redlib > redlib.sha256
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
- uses: actions/upload-artifact@v4
|
||||
name: Upload a Build Artifact
|
||||
with:
|
||||
name: redlib
|
||||
|
|
1495
Cargo.lock
generated
30
Cargo.toml
|
@ -1,37 +1,38 @@
|
|||
[package]
|
||||
name = "redlib"
|
||||
description = " Alternative private front-end to Reddit"
|
||||
license = "AGPL-3.0"
|
||||
license = "AGPL-3.0-only"
|
||||
repository = "https://github.com/redlib-org/redlib"
|
||||
version = "0.35.1"
|
||||
version = "0.36.0"
|
||||
authors = [
|
||||
"Matthew Esposito <matt+cargo@matthew.science>",
|
||||
"spikecodes <19519553+spikecodes@users.noreply.github.com>",
|
||||
]
|
||||
edition = "2021"
|
||||
default-run = "redlib"
|
||||
|
||||
[dependencies]
|
||||
askama = { version = "0.12.1", default-features = false }
|
||||
cached = { version = "0.51.3", features = ["async"] }
|
||||
rinja = { version = "0.3.4", default-features = false }
|
||||
cached = { version = "0.54.0", features = ["async"] }
|
||||
clap = { version = "4.4.11", default-features = false, features = [
|
||||
"std",
|
||||
"env",
|
||||
"derive",
|
||||
] }
|
||||
regex = "1.10.2"
|
||||
serde = { version = "1.0.193", features = ["derive"] }
|
||||
cookie = "0.18.0"
|
||||
futures-lite = "2.2.0"
|
||||
hyper = { version = "0.14.28", features = ["full"] }
|
||||
hyper-rustls = "0.25.0"
|
||||
hyper = { version = "0.14.31", features = ["full"] }
|
||||
percent-encoding = "2.3.1"
|
||||
route-recognizer = "0.3.1"
|
||||
serde_json = "1.0.108"
|
||||
serde_json = "1.0.133"
|
||||
tokio = { version = "1.35.1", features = ["full"] }
|
||||
time = { version = "0.3.31", features = ["local-offset"] }
|
||||
url = "2.5.0"
|
||||
rust-embed = { version = "8.1.0", features = ["include-exclude"] }
|
||||
libflate = "2.0.0"
|
||||
brotli = { version = "6.0.0", features = ["std"] }
|
||||
brotli = { version = "7.0.0", features = ["std"] }
|
||||
toml = "0.8.8"
|
||||
once_cell = "1.19.0"
|
||||
serde_yaml = "0.9.29"
|
||||
|
@ -42,7 +43,20 @@ fastrand = "2.0.1"
|
|||
log = "0.4.20"
|
||||
pretty_env_logger = "0.5.0"
|
||||
dotenvy = "0.15.7"
|
||||
rss = "2.0.7"
|
||||
arc-swap = "1.7.1"
|
||||
serde_json_path = "0.7.1"
|
||||
async-recursion = "1.1.1"
|
||||
pulldown-cmark = { version = "0.12.0", features = ["simd", "html"], default-features = false }
|
||||
hyper-rustls = { version = "0.24.2", features = [ "http2" ] }
|
||||
tegen = "0.1.4"
|
||||
serde_urlencoded = "0.7.1"
|
||||
chrono = { version = "0.4.39", default-features = false, features = [ "std" ] }
|
||||
htmlescape = "0.3.1"
|
||||
bincode = "1.3.3"
|
||||
base2048 = "2.0.2"
|
||||
revision = "0.10.0"
|
||||
|
||||
|
||||
[dev-dependencies]
|
||||
lipsum = "0.9.0"
|
||||
|
|
|
@ -4,7 +4,7 @@ ARG TARGET
|
|||
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
RUN curl -L https://github.com/redlib-org/redlib/releases/latest/download/redlib-${TARGET}.tar.gz | \
|
||||
RUN curl -L "https://github.com/redlib-org/redlib/releases/latest/download/redlib-${TARGET}.tar.gz" | \
|
||||
tar xz -C /usr/local/bin/
|
||||
|
||||
RUN adduser --home /nonexistent --no-create-home --disabled-password redlib
|
||||
|
|
45
Dockerfile.alpine
Normal file
|
@ -0,0 +1,45 @@
|
|||
# supported versions here: https://hub.docker.com/_/rust
|
||||
ARG ALPINE_VERSION=3.20
|
||||
|
||||
########################
|
||||
## builder image
|
||||
########################
|
||||
FROM rust:alpine${ALPINE_VERSION} AS builder
|
||||
|
||||
RUN apk add --no-cache musl-dev
|
||||
|
||||
WORKDIR /redlib
|
||||
|
||||
# download (most) dependencies in their own layer
|
||||
COPY Cargo.lock Cargo.toml ./
|
||||
RUN mkdir src && echo "fn main() { panic!(\"why am i running?\") }" > src/main.rs
|
||||
RUN cargo build --release --locked --bin redlib
|
||||
RUN rm ./src/main.rs && rmdir ./src
|
||||
|
||||
# copy the source and build the redlib binary
|
||||
COPY . ./
|
||||
RUN cargo build --release --locked --bin redlib
|
||||
RUN echo "finished building redlib!"
|
||||
|
||||
########################
|
||||
## release image
|
||||
########################
|
||||
FROM alpine:${ALPINE_VERSION} AS release
|
||||
|
||||
# Import redlib binary from builder
|
||||
COPY --from=builder /redlib/target/release/redlib /usr/local/bin/redlib
|
||||
|
||||
# Add non-root user for running redlib
|
||||
RUN adduser --home /nonexistent --no-create-home --disabled-password redlib
|
||||
USER redlib
|
||||
|
||||
# Document that we intend to expose port 8080 to whoever runs the container
|
||||
EXPOSE 8080
|
||||
|
||||
# Run a healthcheck every minute to make sure redlib is functional
|
||||
HEALTHCHECK --interval=1m --timeout=3s CMD wget --spider --q http://localhost:8080/settings || exit 1
|
||||
|
||||
# Add container metadata
|
||||
LABEL org.opencontainers.image.authors="sigaloid"
|
||||
|
||||
CMD ["redlib"]
|
51
Dockerfile.ubuntu
Normal file
|
@ -0,0 +1,51 @@
|
|||
# supported versions here: https://hub.docker.com/_/rust
|
||||
ARG RUST_BUILDER_VERSION=slim-bookworm
|
||||
ARG UBUNTU_RELEASE_VERSION=noble
|
||||
|
||||
########################
|
||||
## builder image
|
||||
########################
|
||||
FROM rust:${RUST_BUILDER_VERSION} AS builder
|
||||
|
||||
WORKDIR /redlib
|
||||
|
||||
# download (most) dependencies in their own layer
|
||||
COPY Cargo.lock Cargo.toml ./
|
||||
RUN mkdir src && echo "fn main() { panic!(\"why am i running?\") }" > src/main.rs
|
||||
RUN cargo build --release --locked --bin redlib
|
||||
RUN rm ./src/main.rs && rmdir ./src
|
||||
|
||||
# copy the source and build the redlib binary
|
||||
COPY . ./
|
||||
RUN cargo build --release --locked --bin redlib
|
||||
RUN echo "finished building redlib!"
|
||||
|
||||
########################
|
||||
## release image
|
||||
########################
|
||||
FROM ubuntu:${UBUNTU_RELEASE_VERSION} AS release
|
||||
|
||||
# Install ca-certificates
|
||||
RUN apt-get update && apt-get install -y ca-certificates
|
||||
|
||||
# Import redlib binary from builder
|
||||
COPY --from=builder /redlib/target/release/redlib /usr/local/bin/redlib
|
||||
|
||||
# Add non-root user for running redlib
|
||||
RUN useradd \
|
||||
--no-create-home \
|
||||
--password "!" \
|
||||
--comment "user for running redlib" \
|
||||
redlib
|
||||
USER redlib
|
||||
|
||||
# Document that we intend to expose port 8080 to whoever runs the container
|
||||
EXPOSE 8080
|
||||
|
||||
# Run a healthcheck every minute to make sure redlib is functional
|
||||
HEALTHCHECK --interval=1m --timeout=3s CMD wget --spider --q http://localhost:8080/settings || exit 1
|
||||
|
||||
# Add container metadata
|
||||
LABEL org.opencontainers.image.authors="sigaloid"
|
||||
|
||||
CMD ["redlib"]
|
75
README.md
|
@ -2,11 +2,11 @@
|
|||
|
||||
> An alternative private front-end to Reddit, with its origins in [Libreddit](https://github.com/libreddit/libreddit).
|
||||
|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
**10-second pitch:** Redlib is a private front-end like [Invidious](https://github.com/iv-org/invidious) but for Reddit. Browse the coldest takes of [r/unpopularopinion](https://redlib.matthew.science/r/unpopularopinion) without being [tracked](#reddit).
|
||||
**10-second pitch:** Redlib is a private front-end like [Invidious](https://github.com/iv-org/invidious) but for Reddit. Browse the coldest takes of [r/unpopularopinion](https://farside.link/redlib/r/unpopularopinion) without being [tracked](#reddit).
|
||||
|
||||
- 🚀 Fast: written in Rust for blazing-fast speeds and memory safety
|
||||
- ☁️ Light: no JavaScript, no ads, no tracking, no bloat
|
||||
|
@ -30,11 +30,13 @@
|
|||
- [Reddit](#reddit)
|
||||
- [Redlib](#redlib-1)
|
||||
- [Server](#server)
|
||||
- [Official instance (redlib.matthew.science)](#official-instance-redlibmatthewscience)
|
||||
5. [Deployment](#deployment)
|
||||
- [Docker](#docker)
|
||||
- [Docker Compose](#docker-compose)
|
||||
- [Docker CLI](#docker-cli)
|
||||
- Podman
|
||||
- Quadlets
|
||||
|
||||
- [Binary](#binary)
|
||||
- [Running as a systemd service](#running-as-a-systemd-service)
|
||||
- [Building from source](#building-from-source)
|
||||
|
@ -72,7 +74,7 @@ Redlib currently implements most of Reddit's (signed-out) functionalities but st
|
|||
|
||||
- [Rust](https://www.rust-lang.org/) - Programming language
|
||||
- [Hyper](https://github.com/hyperium/hyper) - HTTP server and client
|
||||
- [Askama](https://github.com/djc/askama) - Templating engine
|
||||
- [Rinja](https://github.com/rinja-rs/rinja) - Templating engine
|
||||
- [Rustls](https://github.com/rustls/rustls) - TLS library
|
||||
|
||||
## How is it different from other Reddit front ends?
|
||||
|
@ -156,17 +158,7 @@ For transparency, I hope to describe all the ways Redlib handles user privacy.
|
|||
|
||||
- **Logging:** In production (when running the binary, hosting with docker, or using the official instances), Redlib logs nothing. When debugging (running from source without `--release`), Redlib logs post IDs fetched to aid with troubleshooting.
|
||||
|
||||
- **Cookies:** Redlib uses optional cookies to store any configured settings in [the settings menu](https://redlib.matthew.science/settings). These are not cross-site cookies and the cookies hold no personal data.
|
||||
|
||||
#### Official instance (redlib.matthew.science)
|
||||
|
||||
The official instance is hosted at https://redlib.matthew.science.
|
||||
|
||||
- **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.
|
||||
- **Cookies:** Redlib uses optional cookies to store any configured settings in the settings menu. These are not cross-site cookies and the cookies hold no personal data.
|
||||
|
||||
---
|
||||
|
||||
|
@ -180,7 +172,7 @@ For configuration options, see the [Configuration section](#Configuration).
|
|||
|
||||
[Docker](https://www.docker.com) lets you run containerized applications. Containers are loosely isolated environments that are lightweight and contain everything needed to run the application, so there's no need to rely on what's installed on the host.
|
||||
|
||||
Docker images for Redlib are available at [quay.io](https://quay.io/repository/redlib/redlib), with support for `amd64`, `arm64`, and `armv7` platforms.
|
||||
Container images for Redlib are available at [quay.io](https://quay.io/repository/redlib/redlib), with support for `amd64`, `arm64`, and `armv7` platforms.
|
||||
|
||||
### Docker Compose
|
||||
|
||||
|
@ -224,6 +216,37 @@ Stream logs from the Redlib container:
|
|||
```bash
|
||||
docker logs -f redlib
|
||||
```
|
||||
## Podman
|
||||
|
||||
[Podman](https://podman.io/) lets you run containerized applications in a rootless fashion. Containers are loosely isolated environments that are lightweight and contain everything needed to run the application, so there's no need to rely on what's installed on the host.
|
||||
|
||||
Container images for Redlib are available at [quay.io](https://quay.io/repository/redlib/redlib), with support for `amd64`, `arm64`, and `armv7` platforms.
|
||||
|
||||
### Quadlets
|
||||
|
||||
> [!IMPORTANT]
|
||||
> These instructions assume that you are on a systemd based distro with [podman](https://podman.io/). If not, follow these [instructions on podman's website](https://podman.io/docs/installation) for how to do so.
|
||||
> It also assumes you have used `loginctl enable-linger <username>` to enable the service to start for your user without logging in.
|
||||
|
||||
Copy the `redlib.container` and `.env.example` files to `.config/containers/systemd/` and modify any relevant values (for example, the ports Redlib should listen on, renaming the .env file and editing its values, etc.).
|
||||
|
||||
To start Redlib either reboot or follow the instructions below:
|
||||
|
||||
Notify systemd of the new files
|
||||
```bash
|
||||
systemctl --user daemon-reload
|
||||
```
|
||||
|
||||
Start the newly generated service file
|
||||
|
||||
```bash
|
||||
systemctl --user start redlib.service
|
||||
```
|
||||
|
||||
You can check the status of your container by using the following command:
|
||||
```bash
|
||||
systemctl --user status redlib.service
|
||||
```
|
||||
|
||||
## Binary
|
||||
|
||||
|
@ -370,6 +393,17 @@ REDLIB_DEFAULT_USE_HLS = "on"
|
|||
>
|
||||
> If using Docker Compose, no changes are needed as the `.env` file is already referenced in `compose.yaml` via the `env_file: .env` line.
|
||||
|
||||
## Command Line Flags
|
||||
|
||||
Redlib supports the following command line flags:
|
||||
|
||||
- `-4`, `--ipv4-only`: Listen on IPv4 only.
|
||||
- `-6`, `--ipv6-only`: Listen on IPv6 only.
|
||||
- `-r`, `--redirect-https`: Redirect all HTTP requests to HTTPS (no longer functional).
|
||||
- `-a`, `--address <ADDRESS>`: Sets address to listen on. Default is `[::]`.
|
||||
- `-p`, `--port <PORT>`: Port to listen on. Default is `8080`.
|
||||
- `-H`, `--hsts <EXPIRE_TIME>`: HSTS header to tell browsers that this site should only be accessed over HTTPS. Default is `604800`.
|
||||
|
||||
## Instance settings
|
||||
|
||||
Assign a default value for each instance-specific setting by passing environment variables to Redlib in the format `REDLIB_{X}`. Replace `{X}` with the setting name (see list below) in capital letters.
|
||||
|
@ -381,20 +415,21 @@ Assign a default value for each instance-specific setting by passing environment
|
|||
| `ROBOTS_DISABLE_INDEXING` | `["on", "off"]` | `off` | Disables indexing of the instance by search engines. |
|
||||
| `PUSHSHIFT_FRONTEND` | String | `undelete.pullpush.io` | Allows the server to set the Pushshift frontend to be used with "removed" links. |
|
||||
| `PORT` | Integer 0-65535 | `8080` | The **internal** port Redlib listens on. |
|
||||
|
||||
| `ENABLE_RSS` | `["on", "off"]` | `off` | Enables RSS feed generation. |
|
||||
| `FULL_URL` | String | (empty) | Allows for proper URLs (for now, only needed by RSS)
|
||||
## Default user settings
|
||||
|
||||
Assign a default value for each user-modifiable setting by passing environment variables to Redlib in the format `REDLIB_DEFAULT_{Y}`. Replace `{Y}` with the setting name (see list below) in capital letters.
|
||||
|
||||
| Name | Possible values | Default value |
|
||||
| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ------------- |
|
||||
| `THEME` | `["system", "light", "dark", "black", "dracula", "nord", "laserwave", "violet", "gold", "rosebox", "gruvboxdark", "gruvboxlight", "tokyoNight", "icebergDark"]` | `system` |
|
||||
| `THEME` | `["system", "light", "dark", "black", "dracula", "nord", "laserwave", "violet", "gold", "rosebox", "gruvboxdark", "gruvboxlight", "tokyoNight", "icebergDark", "doomone", "libredditBlack", "libredditDark", "libredditLight"]` | `system` |
|
||||
| `FRONT_PAGE` | `["default", "popular", "all"]` | `default` |
|
||||
| `LAYOUT` | `["card", "clean", "compact"]` | `card` |
|
||||
| `WIDE` | `["on", "off"]` | `off` |
|
||||
| `POST_SORT` | `["hot", "new", "top", "rising", "controversial"]` | `hot` |
|
||||
| `COMMENT_SORT` | `["confidence", "top", "new", "controversial", "old"]` | `confidence` |
|
||||
| `BLUR_SPOILER` | `["on", "off"]` | `off` |
|
||||
| `BLUR_SPOILER` | `["on", "off"]` | `off` |
|
||||
| `SHOW_NSFW` | `["on", "off"]` | `off` |
|
||||
| `BLUR_NSFW` | `["on", "off"]` | `off` |
|
||||
| `USE_HLS` | `["on", "off"]` | `off` |
|
||||
|
@ -404,4 +439,6 @@ Assign a default value for each user-modifiable setting by passing environment v
|
|||
| `HIDE_AWARDS` | `["on", "off"]` | `off` |
|
||||
| `DISABLE_VISIT_REDDIT_CONFIRMATION` | `["on", "off"]` | `off` |
|
||||
| `HIDE_SCORE` | `["on", "off"]` | `off` |
|
||||
| `HIDE_SIDEBAR_AND_SUMMARY` | `["on", "off"]` | `off` |
|
||||
| `FIXED_NAVBAR` | `["on", "off"]` | `on` |
|
||||
| `REMOVE_DEFAULT_FEEDS` | `["on", "off"]` | `off` |
|
13
app.json
|
@ -56,8 +56,8 @@
|
|||
"REDLIB_BANNER": {
|
||||
"required": false
|
||||
},
|
||||
"REDLIB_ROBOTS_DISABLE_INDEXING": {
|
||||
"required": false
|
||||
"REDLIB_ROBOTS_DISABLE_INDEXING": {
|
||||
"required": false
|
||||
},
|
||||
"REDLIB_DEFAULT_SUBSCRIPTIONS": {
|
||||
"required": false
|
||||
|
@ -70,6 +70,15 @@
|
|||
},
|
||||
"REDLIB_PUSHSHIFT_FRONTEND": {
|
||||
"required": false
|
||||
},
|
||||
"REDLIB_ENABLE_RSS": {
|
||||
"required": false
|
||||
},
|
||||
"REDLIB_FULL_URL": {
|
||||
"required": false
|
||||
},
|
||||
"REDLIB_DEFAULT_REMOVE_DEFAULT_FEEDS": {
|
||||
"required": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,6 +12,6 @@ PORT=12345
|
|||
#REDLIB_DEFAULT_USE_HLS=off
|
||||
#REDLIB_DEFAULT_HIDE_HLS_NOTIFICATION=off
|
||||
#REDLIB_DEFAULT_AUTOPLAY_VIDEOS=off
|
||||
#REDLIB_DEFAULT_SUBSCRIPTIONS=off (sub1+sub2+sub3)
|
||||
#REDLIB_DEFAULT_SUBSCRIPTIONS=(sub1+sub2+sub3)
|
||||
#REDLIB_DEFAULT_HIDE_AWARDS=off
|
||||
#REDLIB_DEFAULT_DISABLE_VISIT_REDDIT_CONFIRMATION=off
|
||||
|
|
|
@ -30,7 +30,8 @@ RestrictNamespaces=yes
|
|||
RestrictRealtime=yes
|
||||
RestrictSUIDSGID=yes
|
||||
SystemCallArchitectures=native
|
||||
SystemCallFilter=@system-service ~@privileged ~@resources
|
||||
SystemCallFilter=@system-service
|
||||
SystemCallFilter=~@privileged @resources
|
||||
UMask=0077
|
||||
|
||||
[Install]
|
||||
|
|
32
flake.lock
generated
|
@ -1,17 +1,12 @@
|
|||
{
|
||||
"nodes": {
|
||||
"crane": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1717025063,
|
||||
"narHash": "sha256-dIubLa56W9sNNz0e8jGxrX3CAkPXsq7snuFA/Ie6dn8=",
|
||||
"lastModified": 1731974733,
|
||||
"narHash": "sha256-enYSSZVVl15FI5p+0Y5/Ckf5DZAvXe6fBrHxyhA/njc=",
|
||||
"owner": "ipetkov",
|
||||
"repo": "crane",
|
||||
"rev": "480dff0be03dac0e51a8dfc26e882b0d123a450e",
|
||||
"rev": "3cb338ce81076ce5e461cf77f7824476addb0e1c",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
@ -25,11 +20,11 @@
|
|||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1710146030,
|
||||
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
@ -40,11 +35,11 @@
|
|||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1717112898,
|
||||
"narHash": "sha256-7R2ZvOnvd9h8fDd65p0JnB7wXfUvreox3xFdYWd1BnY=",
|
||||
"lastModified": 1731890469,
|
||||
"narHash": "sha256-D1FNZ70NmQEwNxpSSdTXCSklBH1z2isPR84J6DQrJGs=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "6132b0f6e344ce2fe34fc051b72fb46e34f668e0",
|
||||
"rev": "5083ec887760adfe12af64830a66807423a859a7",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
@ -64,19 +59,16 @@
|
|||
},
|
||||
"rust-overlay": {
|
||||
"inputs": {
|
||||
"flake-utils": [
|
||||
"flake-utils"
|
||||
],
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1717121863,
|
||||
"narHash": "sha256-/3sxIe7MZqF/jw1RTQCSmgTjwVod43mmrk84m50MJQ4=",
|
||||
"lastModified": 1732069891,
|
||||
"narHash": "sha256-moKx8AVJrViCSdA0e0nSsG8b1dAsObI4sRAtbqbvBY8=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "2a7b53172ed08f856b8382d7dcfd36a4e0cbd866",
|
||||
"rev": "8509a51241c407d583b1963d5079585a992506e8",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
10
flake.nix
|
@ -4,19 +4,13 @@
|
|||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
|
||||
|
||||
crane = {
|
||||
url = "github:ipetkov/crane";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
crane.url = "github:ipetkov/crane";
|
||||
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
|
||||
rust-overlay = {
|
||||
url = "github:oxalica/rust-overlay";
|
||||
inputs = {
|
||||
nixpkgs.follows = "nixpkgs";
|
||||
flake-utils.follows = "flake-utils";
|
||||
};
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
};
|
||||
|
||||
|
|
16
redlib.container
Normal file
|
@ -0,0 +1,16 @@
|
|||
[Install]
|
||||
WantedBy=default.target
|
||||
|
||||
[Container]
|
||||
AutoUpdate=registry
|
||||
ContainerName=redlib
|
||||
DropCapability=ALL
|
||||
EnvironmentFile=.env
|
||||
HealthCmd=["wget","--spider","-q","--tries=1","http://localhost:8080/settings"]
|
||||
HealthInterval=5m
|
||||
HealthTimeout=3s
|
||||
Image=quay.io/redlib/redlib:latest
|
||||
NoNewPrivileges=true
|
||||
PublishPort=8080:8080
|
||||
ReadOnly=true
|
||||
User=nobody
|
|
@ -24,7 +24,7 @@ echo "// Please do not edit manually" >> "$filename"
|
|||
echo "// Filled in with real app versions" >> "$filename"
|
||||
|
||||
# Open the array in the source file
|
||||
echo "pub static _IOS_APP_VERSION_LIST: &[&str; $ios_app_count] = &[" >> "$filename"
|
||||
echo "pub const _IOS_APP_VERSION_LIST: &[&str; $ios_app_count] = &[" >> "$filename"
|
||||
|
||||
num=0
|
||||
|
||||
|
@ -39,12 +39,12 @@ done
|
|||
echo "];" >> "$filename"
|
||||
|
||||
# Fetch Android app versions
|
||||
page_1=$(curl -s "https://apkcombo.com/reddit/com.reddit.frontpage/old-versions/" | rg "<a class=\"ver-item\" href=\"(/reddit/com\.reddit\.frontpage/download/phone-20\d{2}\.\d+\.\d+-apk)\" rel=\"nofollow\">" -r "https://apkcombo.com\$1" | sort | uniq)
|
||||
page_1=$(curl -s "https://apkcombo.com/reddit/com.reddit.frontpage/old-versions/" | rg "<a class=\"ver-item\" href=\"(/reddit/com\.reddit\.frontpage/download/phone-20\d{2}\.\d+\.\d+-apk)\" rel=\"nofollow\">" -r "https://apkcombo.com\$1" | sort | uniq | sed 's/ //g')
|
||||
# Append with pages
|
||||
page_2=$(curl -s "https://apkcombo.com/reddit/com.reddit.frontpage/old-versions?page=2" | rg "<a class=\"ver-item\" href=\"(/reddit/com\.reddit\.frontpage/download/phone-20\d{2}\.\d+\.\d+-apk)\" rel=\"nofollow\">" -r "https://apkcombo.com\$1" | sort | uniq)
|
||||
page_3=$(curl -s "https://apkcombo.com/reddit/com.reddit.frontpage/old-versions?page=3" | rg "<a class=\"ver-item\" href=\"(/reddit/com\.reddit\.frontpage/download/phone-20\d{2}\.\d+\.\d+-apk)\" rel=\"nofollow\">" -r "https://apkcombo.com\$1" | sort | uniq)
|
||||
page_4=$(curl -s "https://apkcombo.com/reddit/com.reddit.frontpage/old-versions?page=4" | rg "<a class=\"ver-item\" href=\"(/reddit/com\.reddit\.frontpage/download/phone-20\d{2}\.\d+\.\d+-apk)\" rel=\"nofollow\">" -r "https://apkcombo.com\$1" | sort | uniq)
|
||||
page_5=$(curl -s "https://apkcombo.com/reddit/com.reddit.frontpage/old-versions?page=5" | rg "<a class=\"ver-item\" href=\"(/reddit/com\.reddit\.frontpage/download/phone-20\d{2}\.\d+\.\d+-apk)\" rel=\"nofollow\">" -r "https://apkcombo.com\$1" | sort | uniq)
|
||||
page_2=$(curl -s "https://apkcombo.com/reddit/com.reddit.frontpage/old-versions?page=2" | rg "<a class=\"ver-item\" href=\"(/reddit/com\.reddit\.frontpage/download/phone-20\d{2}\.\d+\.\d+-apk)\" rel=\"nofollow\">" -r "https://apkcombo.com\$1" | sort | uniq | sed 's/ //g')
|
||||
page_3=$(curl -s "https://apkcombo.com/reddit/com.reddit.frontpage/old-versions?page=3" | rg "<a class=\"ver-item\" href=\"(/reddit/com\.reddit\.frontpage/download/phone-20\d{2}\.\d+\.\d+-apk)\" rel=\"nofollow\">" -r "https://apkcombo.com\$1" | sort | uniq | sed 's/ //g')
|
||||
page_4=$(curl -s "https://apkcombo.com/reddit/com.reddit.frontpage/old-versions?page=4" | rg "<a class=\"ver-item\" href=\"(/reddit/com\.reddit\.frontpage/download/phone-20\d{2}\.\d+\.\d+-apk)\" rel=\"nofollow\">" -r "https://apkcombo.com\$1" | sort | uniq | sed 's/ //g')
|
||||
page_5=$(curl -s "https://apkcombo.com/reddit/com.reddit.frontpage/old-versions?page=5" | rg "<a class=\"ver-item\" href=\"(/reddit/com\.reddit\.frontpage/download/phone-20\d{2}\.\d+\.\d+-apk)\" rel=\"nofollow\">" -r "https://apkcombo.com\$1" | sort | uniq | sed 's/ //g')
|
||||
|
||||
# Concatenate all pages
|
||||
versions="${page_1}"
|
||||
|
@ -63,7 +63,7 @@ android_count=$(echo "$versions" | wc -l)
|
|||
echo -e "Fetching \e[32m$android_count Android app versions...\e[0m"
|
||||
|
||||
# Append to the source file
|
||||
echo "pub static ANDROID_APP_VERSION_LIST: &[&str; $android_count] = &[" >> "$filename"
|
||||
echo "pub const ANDROID_APP_VERSION_LIST: &[&str; $android_count] = &[" >> "$filename"
|
||||
|
||||
num=0
|
||||
|
||||
|
@ -89,7 +89,7 @@ ios_count=$(echo "$table" | wc -l)
|
|||
echo -e "Fetching \e[34m$ios_count iOS versions...\e[0m"
|
||||
|
||||
# Append to the source file
|
||||
echo "pub static _IOS_OS_VERSION_LIST: &[&str; $ios_count] = &[" >> "$filename"
|
||||
echo "pub const _IOS_OS_VERSION_LIST: &[&str; $ios_count] = &[" >> "$filename"
|
||||
|
||||
num=0
|
||||
|
||||
|
|
262
src/client.rs
|
@ -4,7 +4,7 @@ use futures_lite::future::block_on;
|
|||
use futures_lite::{future::Boxed, FutureExt};
|
||||
use hyper::client::HttpConnector;
|
||||
use hyper::header::HeaderValue;
|
||||
use hyper::{body, body::Buf, client, header, Body, Client, Method, Request, Response, Uri};
|
||||
use hyper::{body, body::Buf, header, Body, Client, Method, Request, Response, Uri};
|
||||
use hyper_rustls::HttpsConnector;
|
||||
use libflate::gzip;
|
||||
use log::{error, trace, warn};
|
||||
|
@ -19,20 +19,21 @@ use std::{io, result::Result};
|
|||
use crate::dbg_msg;
|
||||
use crate::oauth::{force_refresh_token, token_daemon, Oauth};
|
||||
use crate::server::RequestExt;
|
||||
use crate::utils::format_url;
|
||||
use crate::utils::{format_url, Post};
|
||||
|
||||
const REDDIT_URL_BASE: &str = "https://oauth.reddit.com";
|
||||
const ALTERNATIVE_REDDIT_URL_BASE: &str = "https://www.reddit.com";
|
||||
const REDDIT_URL_BASE_HOST: &str = "oauth.reddit.com";
|
||||
|
||||
pub static CLIENT: Lazy<Client<HttpsConnector<HttpConnector>>> = Lazy::new(|| {
|
||||
let https = hyper_rustls::HttpsConnectorBuilder::new()
|
||||
.with_native_roots()
|
||||
.expect("No native root certificates found")
|
||||
.https_only()
|
||||
.enable_http1()
|
||||
.build();
|
||||
client::Client::builder().build(https)
|
||||
});
|
||||
const REDDIT_SHORT_URL_BASE: &str = "https://redd.it";
|
||||
const REDDIT_SHORT_URL_BASE_HOST: &str = "redd.it";
|
||||
|
||||
const ALTERNATIVE_REDDIT_URL_BASE: &str = "https://www.reddit.com";
|
||||
const ALTERNATIVE_REDDIT_URL_BASE_HOST: &str = "www.reddit.com";
|
||||
|
||||
pub static HTTPS_CONNECTOR: Lazy<HttpsConnector<HttpConnector>> =
|
||||
Lazy::new(|| hyper_rustls::HttpsConnectorBuilder::new().with_native_roots().https_only().enable_http2().build());
|
||||
|
||||
pub static CLIENT: Lazy<Client<HttpsConnector<HttpConnector>>> = Lazy::new(|| Client::builder().build::<_, Body>(HTTPS_CONNECTOR.clone()));
|
||||
|
||||
pub static OAUTH_CLIENT: Lazy<ArcSwap<Oauth>> = Lazy::new(|| {
|
||||
let client = block_on(Oauth::new());
|
||||
|
@ -44,6 +45,11 @@ pub static OAUTH_RATELIMIT_REMAINING: AtomicU16 = AtomicU16::new(99);
|
|||
|
||||
pub static OAUTH_IS_ROLLING_OVER: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
const URL_PAIRS: [(&str, &str); 2] = [
|
||||
(ALTERNATIVE_REDDIT_URL_BASE, ALTERNATIVE_REDDIT_URL_BASE_HOST),
|
||||
(REDDIT_SHORT_URL_BASE, REDDIT_SHORT_URL_BASE_HOST),
|
||||
];
|
||||
|
||||
/// 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`.
|
||||
///
|
||||
|
@ -57,13 +63,32 @@ pub static OAUTH_IS_ROLLING_OVER: AtomicBool = AtomicBool::new(false);
|
|||
/// `Location` header. An `Err(String)` is returned if Reddit responds with a
|
||||
/// 429, or if we were unable to decode the value in the `Location` header.
|
||||
#[cached(size = 1024, time = 600, result = true)]
|
||||
pub async fn canonical_path(path: String) -> Result<Option<String>, String> {
|
||||
let res = reddit_head(path.clone(), true).await?;
|
||||
#[async_recursion::async_recursion]
|
||||
pub async fn canonical_path(path: String, tries: i8) -> Result<Option<String>, String> {
|
||||
if tries == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// for each URL pair, try the HEAD request
|
||||
let res = {
|
||||
// for url base and host in URL_PAIRS, try reddit_short_head(path.clone(), true, url_base, url_base_host) and if it succeeds, set res. else, res = None
|
||||
let mut res = None;
|
||||
for (url_base, url_base_host) in URL_PAIRS {
|
||||
res = reddit_short_head(path.clone(), true, url_base, url_base_host).await.ok();
|
||||
if let Some(res) = &res {
|
||||
if !res.status().is_client_error() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
res
|
||||
};
|
||||
|
||||
let res = res.ok_or_else(|| "Unable to make HEAD request to Reddit.".to_string())?;
|
||||
let status = res.status().as_u16();
|
||||
let policy_error = res.headers().get(header::RETRY_AFTER).is_some();
|
||||
|
||||
match status {
|
||||
429 => Err("Too many requests.".to_string()),
|
||||
|
||||
// If Reddit responds with a 2xx, then the path is already canonical.
|
||||
200..=299 => Ok(Some(path)),
|
||||
|
||||
|
@ -73,6 +98,7 @@ pub async fn canonical_path(path: String) -> Result<Option<String>, String> {
|
|||
let Ok(original) = val.to_str() else {
|
||||
return Err("Unable to decode Location header.".to_string());
|
||||
};
|
||||
|
||||
// We need to strip the .json suffix from the original path.
|
||||
// In addition, we want to remove share parameters.
|
||||
// Cut it off here instead of letting it propagate all the way
|
||||
|
@ -85,7 +111,9 @@ pub async fn canonical_path(path: String) -> Result<Option<String>, String> {
|
|||
// also remove all Reddit domain parts with format_url.
|
||||
// Otherwise, it will literally redirect to Reddit.com.
|
||||
let uri = format_url(stripped_uri);
|
||||
Ok(Some(uri))
|
||||
|
||||
// Decrement tries and try again
|
||||
canonical_path(uri, tries - 1).await
|
||||
}
|
||||
None => Ok(None),
|
||||
},
|
||||
|
@ -94,6 +122,12 @@ pub async fn canonical_path(path: String) -> Result<Option<String>, String> {
|
|||
// as above), return a None.
|
||||
300..=399 => Ok(None),
|
||||
|
||||
// Rate limiting
|
||||
429 => Err("Too many requests.".to_string()),
|
||||
|
||||
// Special condition rate limiting - https://github.com/redlib-org/redlib/issues/229
|
||||
403 if policy_error => Err("Too many requests.".to_string()),
|
||||
|
||||
_ => Ok(
|
||||
res
|
||||
.headers()
|
||||
|
@ -120,7 +154,7 @@ async fn stream(url: &str, req: &Request<Body>) -> Result<Response<Body>, String
|
|||
let parsed_uri = url.parse::<Uri>().map_err(|_| "Couldn't parse URL".to_string())?;
|
||||
|
||||
// Build the hyper client from the HTTPS connector.
|
||||
let client: Client<_, Body> = CLIENT.clone();
|
||||
let client: &Lazy<Client<_, Body>> = &CLIENT;
|
||||
|
||||
let mut builder = Request::get(parsed_uri);
|
||||
|
||||
|
@ -160,58 +194,62 @@ async fn stream(url: &str, req: &Request<Body>) -> Result<Response<Body>, String
|
|||
/// Makes a GET request to Reddit at `path`. By default, this will honor HTTP
|
||||
/// 3xx codes Reddit returns and will automatically redirect.
|
||||
fn reddit_get(path: String, quarantine: bool) -> Boxed<Result<Response<Body>, String>> {
|
||||
request(&Method::GET, path, true, quarantine)
|
||||
request(&Method::GET, path, true, quarantine, REDDIT_URL_BASE, REDDIT_URL_BASE_HOST)
|
||||
}
|
||||
|
||||
/// Makes a HEAD request to Reddit at `path`. This will not follow redirects.
|
||||
fn reddit_head(path: String, quarantine: bool) -> Boxed<Result<Response<Body>, String>> {
|
||||
request(&Method::HEAD, path, false, quarantine)
|
||||
/// Makes a HEAD request to Reddit at `path, using the short URL base. This will not follow redirects.
|
||||
fn reddit_short_head(path: String, quarantine: bool, base_path: &'static str, host: &'static str) -> Boxed<Result<Response<Body>, String>> {
|
||||
request(&Method::HEAD, path, false, quarantine, base_path, host)
|
||||
}
|
||||
|
||||
// /// Makes a HEAD request to Reddit at `path`. This will not follow redirects.
|
||||
// fn reddit_head(path: String, quarantine: bool) -> Boxed<Result<Response<Body>, String>> {
|
||||
// request(&Method::HEAD, path, false, quarantine, false)
|
||||
// }
|
||||
// Unused - reddit_head is only ever called in the context of a short URL
|
||||
|
||||
/// Makes a request to Reddit. If `redirect` is `true`, `request_with_redirect`
|
||||
/// will recurse on the URL that Reddit provides in the Location HTTP header
|
||||
/// in its response.
|
||||
fn request(method: &'static Method, path: String, redirect: bool, quarantine: bool) -> Boxed<Result<Response<Body>, String>> {
|
||||
fn request(method: &'static Method, path: String, redirect: bool, quarantine: bool, base_path: &'static str, host: &'static str) -> Boxed<Result<Response<Body>, String>> {
|
||||
// Build Reddit URL from path.
|
||||
let url = format!("{REDDIT_URL_BASE}{path}");
|
||||
let url = format!("{base_path}{path}");
|
||||
|
||||
// Construct the hyper client from the HTTPS connector.
|
||||
let client: Client<_, Body> = CLIENT.clone();
|
||||
|
||||
let (token, vendor_id, device_id, user_agent, loid) = {
|
||||
let client = OAUTH_CLIENT.load_full();
|
||||
(
|
||||
client.token.clone(),
|
||||
client.headers_map.get("Client-Vendor-Id").cloned().unwrap_or_default(),
|
||||
client.headers_map.get("X-Reddit-Device-Id").cloned().unwrap_or_default(),
|
||||
client.headers_map.get("User-Agent").cloned().unwrap_or_default(),
|
||||
client.headers_map.get("x-reddit-loid").cloned().unwrap_or_default(),
|
||||
)
|
||||
};
|
||||
let client: &Lazy<Client<_, Body>> = &CLIENT;
|
||||
|
||||
// Build request to Reddit. When making a GET, request gzip compression.
|
||||
// (Reddit doesn't do brotli yet.)
|
||||
let builder = Request::builder()
|
||||
.method(method)
|
||||
.uri(&url)
|
||||
.header("User-Agent", user_agent)
|
||||
.header("Client-Vendor-Id", vendor_id)
|
||||
.header("X-Reddit-Device-Id", device_id)
|
||||
.header("x-reddit-loid", loid)
|
||||
.header("Host", "oauth.reddit.com")
|
||||
.header("Authorization", &format!("Bearer {token}"))
|
||||
.header("Accept-Encoding", if method == Method::GET { "gzip" } else { "identity" })
|
||||
.header("Accept-Language", "en-US,en;q=0.5")
|
||||
.header("Connection", "keep-alive")
|
||||
.header(
|
||||
"Cookie",
|
||||
let mut headers: Vec<(String, String)> = vec![
|
||||
("Host".into(), host.into()),
|
||||
("Accept-Encoding".into(), if method == Method::GET { "gzip".into() } else { "identity".into() }),
|
||||
(
|
||||
"Cookie".into(),
|
||||
if quarantine {
|
||||
"_options=%7B%22pref_quarantine_optin%22%3A%20true%2C%20%22pref_gated_sr_optin%22%3A%20true%7D"
|
||||
"_options=%7B%22pref_quarantine_optin%22%3A%20true%2C%20%22pref_gated_sr_optin%22%3A%20true%7D".into()
|
||||
} else {
|
||||
""
|
||||
"".into()
|
||||
},
|
||||
)
|
||||
.body(Body::empty());
|
||||
),
|
||||
];
|
||||
|
||||
{
|
||||
let client = OAUTH_CLIENT.load_full();
|
||||
for (key, value) in client.headers_map.clone() {
|
||||
headers.push((key, value));
|
||||
}
|
||||
}
|
||||
|
||||
// shuffle headers: https://github.com/redlib-org/redlib/issues/324
|
||||
fastrand::shuffle(&mut headers);
|
||||
|
||||
let mut builder = Request::builder().method(method).uri(&url);
|
||||
|
||||
for (key, value) in headers {
|
||||
builder = builder.header(key, value);
|
||||
}
|
||||
|
||||
let builder = builder.body(Body::empty());
|
||||
|
||||
async move {
|
||||
match builder {
|
||||
|
@ -224,7 +262,7 @@ fn request(method: &'static Method, path: String, redirect: bool, quarantine: bo
|
|||
return Ok(response);
|
||||
};
|
||||
let location_header = response.headers().get(header::LOCATION);
|
||||
if location_header == Some(&HeaderValue::from_static("https://www.reddit.com/")) {
|
||||
if location_header == Some(&HeaderValue::from_static(ALTERNATIVE_REDDIT_URL_BASE)) {
|
||||
return Err("Reddit response was invalid".to_string());
|
||||
}
|
||||
return request(
|
||||
|
@ -253,6 +291,8 @@ fn request(method: &'static Method, path: String, redirect: bool, quarantine: bo
|
|||
.to_string(),
|
||||
true,
|
||||
quarantine,
|
||||
base_path,
|
||||
host,
|
||||
)
|
||||
.await;
|
||||
};
|
||||
|
@ -348,6 +388,12 @@ pub async fn json(path: String, quarantine: bool) -> Result<Value, String> {
|
|||
"Ratelimit remaining: Header says {remaining}, we have {current_rate_limit}. Resets in {reset}. Rollover: {}. Ratelimit used: {used}",
|
||||
if is_rolling_over { "yes" } else { "no" },
|
||||
);
|
||||
|
||||
// If can parse remaining as a float, round to a u16 and save
|
||||
if let Ok(val) = remaining.parse::<f32>() {
|
||||
OAUTH_RATELIMIT_REMAINING.store(val.round() as u16, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
Some(reset)
|
||||
} else {
|
||||
None
|
||||
|
@ -374,6 +420,16 @@ pub async fn json(path: String, quarantine: bool) -> Result<Value, String> {
|
|||
match serde_json::from_reader(body.reader()) {
|
||||
Ok(value) => {
|
||||
let json: Value = value;
|
||||
|
||||
// If user is suspended
|
||||
if let Some(data) = json.get("data") {
|
||||
if let Some(is_suspended) = data.get("is_suspended").and_then(Value::as_bool) {
|
||||
if is_suspended {
|
||||
return Err("suspended".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If Reddit returned an error
|
||||
if json["error"].is_i64() {
|
||||
// OAuth token has expired; http status 401
|
||||
|
@ -382,6 +438,24 @@ pub async fn json(path: String, quarantine: bool) -> Result<Value, String> {
|
|||
let () = force_refresh_token().await;
|
||||
return Err("OAuth token has expired. Please refresh the page!".to_string());
|
||||
}
|
||||
|
||||
// Handle quarantined
|
||||
if json["reason"] == "quarantined" {
|
||||
return Err("quarantined".into());
|
||||
}
|
||||
// Handle gated
|
||||
if json["reason"] == "gated" {
|
||||
return Err("gated".into());
|
||||
}
|
||||
// Handle private subs
|
||||
if json["reason"] == "private" {
|
||||
return Err("private".into());
|
||||
}
|
||||
// Handle banned subs
|
||||
if json["reason"] == "banned" {
|
||||
return Err("banned".into());
|
||||
}
|
||||
|
||||
Err(format!("Reddit error {} \"{}\": {} | {path}", json["error"], json["reason"], json["message"]))
|
||||
} else {
|
||||
Ok(json)
|
||||
|
@ -404,8 +478,57 @@ pub async fn json(path: String, quarantine: bool) -> Result<Value, String> {
|
|||
}
|
||||
}
|
||||
|
||||
async fn self_check(sub: &str) -> Result<(), String> {
|
||||
let query = format!("/r/{sub}/hot.json?&raw_json=1");
|
||||
|
||||
match Post::fetch(&query, true).await {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn rate_limit_check() -> Result<(), String> {
|
||||
// First, check a subreddit.
|
||||
self_check("reddit").await?;
|
||||
// This will reduce the rate limit to 99. Assert this check.
|
||||
if OAUTH_RATELIMIT_REMAINING.load(Ordering::SeqCst) != 99 {
|
||||
return Err(format!("Rate limit check failed: expected 99, got {}", OAUTH_RATELIMIT_REMAINING.load(Ordering::SeqCst)));
|
||||
}
|
||||
// Now, we switch out the OAuth client.
|
||||
// This checks for the IP rate limit association.
|
||||
force_refresh_token().await;
|
||||
// Now, check a new sub to break cache.
|
||||
self_check("rust").await?;
|
||||
// Again, assert the rate limit check.
|
||||
if OAUTH_RATELIMIT_REMAINING.load(Ordering::SeqCst) != 99 {
|
||||
return Err(format!("Rate limit check failed: expected 99, got {}", OAUTH_RATELIMIT_REMAINING.load(Ordering::SeqCst)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
static POPULAR_URL: &str = "/r/popular/hot.json?&raw_json=1&geo_filter=GLOBAL";
|
||||
use {crate::config::get_setting, sealed_test::prelude::*};
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn test_rate_limit_check() {
|
||||
rate_limit_check().await.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[sealed_test(env = [("REDLIB_DEFAULT_SUBSCRIPTIONS", "rust")])]
|
||||
fn test_default_subscriptions() {
|
||||
tokio::runtime::Builder::new_multi_thread().enable_all().build().unwrap().block_on(async {
|
||||
let subscriptions = get_setting("REDLIB_DEFAULT_SUBSCRIPTIONS");
|
||||
assert!(subscriptions.is_some());
|
||||
|
||||
// check rate limit
|
||||
rate_limit_check().await.unwrap();
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
const POPULAR_URL: &str = "/r/popular/hot.json?&raw_json=1&geo_filter=GLOBAL";
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn test_localization_popular() {
|
||||
|
@ -417,13 +540,28 @@ async fn test_localization_popular() {
|
|||
async fn test_obfuscated_share_link() {
|
||||
let share_link = "/r/rust/s/kPgq8WNHRK".into();
|
||||
// Correct link without share parameters
|
||||
let canonical_link = "/r/rust/comments/18t5968/why_use_tuple_struct_over_standard_struct/kfbqlbc".into();
|
||||
assert_eq!(canonical_path(share_link).await, Ok(Some(canonical_link)));
|
||||
let canonical_link = "/r/rust/comments/18t5968/why_use_tuple_struct_over_standard_struct/kfbqlbc/".into();
|
||||
assert_eq!(canonical_path(share_link, 3).await, Ok(Some(canonical_link)));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn test_share_link_strip_json() {
|
||||
let link = "/17krzvz".into();
|
||||
let canonical_link = "/r/nfl/comments/17krzvz/rapoport_sources_former_no_2_overall_pick/".into();
|
||||
assert_eq!(canonical_path(link).await, Ok(Some(canonical_link)));
|
||||
async fn test_private_sub() {
|
||||
let link = json("/r/suicide/about.json?raw_json=1".into(), true).await;
|
||||
assert!(link.is_err());
|
||||
assert_eq!(link, Err("private".into()));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn test_banned_sub() {
|
||||
let link = json("/r/aaa/about.json?raw_json=1".into(), true).await;
|
||||
assert!(link.is_err());
|
||||
assert_eq!(link, Err("banned".into()));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn test_gated_sub() {
|
||||
// quarantine to false to specifically catch when we _don't_ catch it
|
||||
let link = json("/r/drugs/about.json?raw_json=1".into(), false).await;
|
||||
assert!(link.is_err());
|
||||
assert_eq!(link, Err("gated".into()));
|
||||
}
|
||||
|
|
|
@ -103,6 +103,15 @@ pub struct Config {
|
|||
#[serde(rename = "REDLIB_PUSHSHIFT_FRONTEND")]
|
||||
#[serde(alias = "LIBREDDIT_PUSHSHIFT_FRONTEND")]
|
||||
pub(crate) pushshift: Option<String>,
|
||||
|
||||
#[serde(rename = "REDLIB_ENABLE_RSS")]
|
||||
pub(crate) enable_rss: Option<String>,
|
||||
|
||||
#[serde(rename = "REDLIB_FULL_URL")]
|
||||
pub(crate) full_url: Option<String>,
|
||||
|
||||
#[serde(rename = "REDLIB_DEFAULT_REMOVE_DEFAULT_FEEDS")]
|
||||
pub(crate) default_remove_default_feeds: Option<String>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
|
@ -148,6 +157,9 @@ impl Config {
|
|||
banner: parse("REDLIB_BANNER"),
|
||||
robots_disable_indexing: parse("REDLIB_ROBOTS_DISABLE_INDEXING"),
|
||||
pushshift: parse("REDLIB_PUSHSHIFT_FRONTEND"),
|
||||
enable_rss: parse("REDLIB_ENABLE_RSS"),
|
||||
full_url: parse("REDLIB_FULL_URL"),
|
||||
default_remove_default_feeds: parse("REDLIB_DEFAULT_REMOVE_DEFAULT_FEEDS"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -175,6 +187,9 @@ fn get_setting_from_config(name: &str, config: &Config) -> Option<String> {
|
|||
"REDLIB_BANNER" => config.banner.clone(),
|
||||
"REDLIB_ROBOTS_DISABLE_INDEXING" => config.robots_disable_indexing.clone(),
|
||||
"REDLIB_PUSHSHIFT_FRONTEND" => config.pushshift.clone(),
|
||||
"REDLIB_ENABLE_RSS" => config.enable_rss.clone(),
|
||||
"REDLIB_FULL_URL" => config.full_url.clone(),
|
||||
"REDLIB_DEFAULT_REMOVE_DEFAULT_FEEDS" => config.default_remove_default_feeds.clone(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,8 +5,8 @@ use crate::server::RequestExt;
|
|||
use crate::subreddit::{can_access_quarantine, quarantine};
|
||||
use crate::utils::{error, filter_posts, get_filters, nsfw_landing, parse_post, template, Post, Preferences};
|
||||
|
||||
use askama::Template;
|
||||
use hyper::{Body, Request, Response};
|
||||
use rinja::Template;
|
||||
use serde_json::Value;
|
||||
use std::borrow::ToOwned;
|
||||
use std::collections::HashSet;
|
||||
|
|
|
@ -3,10 +3,10 @@ use crate::{
|
|||
server::RequestExt,
|
||||
utils::{ErrorTemplate, Preferences},
|
||||
};
|
||||
use askama::Template;
|
||||
use build_html::{Container, Html, HtmlContainer, Table};
|
||||
use hyper::{http::Error, Body, Request, Response};
|
||||
use once_cell::sync::Lazy;
|
||||
use rinja::Template;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use time::OffsetDateTime;
|
||||
|
||||
|
@ -85,7 +85,7 @@ fn info_html(req: &Request<Body>) -> Result<Response<Body>, Error> {
|
|||
pub struct InstanceInfo {
|
||||
package_name: String,
|
||||
crate_version: String,
|
||||
git_commit: String,
|
||||
pub git_commit: String,
|
||||
deploy_date: String,
|
||||
compile_mode: String,
|
||||
deploy_unix_ts: i64,
|
||||
|
@ -126,6 +126,9 @@ impl InstanceInfo {
|
|||
["Compile mode", &self.compile_mode],
|
||||
["SFW only", &convert(&self.config.sfw_only)],
|
||||
["Pushshift frontend", &convert(&self.config.pushshift)],
|
||||
["RSS enabled", &convert(&self.config.enable_rss)],
|
||||
["Full URL", &convert(&self.config.full_url)],
|
||||
["Remove default feeds", &convert(&self.config.default_remove_default_feeds)],
|
||||
//TODO: fallback to crate::config::DEFAULT_PUSHSHIFT_FRONTEND
|
||||
])
|
||||
.with_header_row(["Settings"]),
|
||||
|
@ -165,6 +168,9 @@ impl InstanceInfo {
|
|||
Compile mode: {}\n
|
||||
SFW only: {:?}\n
|
||||
Pushshift frontend: {:?}\n
|
||||
RSS enabled: {:?}\n
|
||||
Full URL: {:?}\n
|
||||
Remove default feeds: {:?}\n
|
||||
Config:\n
|
||||
Banner: {:?}\n
|
||||
Hide awards: {:?}\n
|
||||
|
@ -189,6 +195,9 @@ impl InstanceInfo {
|
|||
self.deploy_unix_ts,
|
||||
self.compile_mode,
|
||||
self.config.sfw_only,
|
||||
self.config.enable_rss,
|
||||
self.config.full_url,
|
||||
self.config.default_remove_default_feeds,
|
||||
self.config.pushshift,
|
||||
self.config.banner,
|
||||
self.config.default_hide_awards,
|
||||
|
|
13
src/lib.rs
Normal file
|
@ -0,0 +1,13 @@
|
|||
pub mod client;
|
||||
pub mod config;
|
||||
pub mod duplicates;
|
||||
pub mod instance_info;
|
||||
pub mod oauth;
|
||||
pub mod oauth_resources;
|
||||
pub mod post;
|
||||
pub mod search;
|
||||
pub mod server;
|
||||
pub mod settings;
|
||||
pub mod subreddit;
|
||||
pub mod user;
|
||||
pub mod utils;
|
128
src/main.rs
|
@ -2,35 +2,21 @@
|
|||
#![forbid(unsafe_code)]
|
||||
#![allow(clippy::cmp_owned)]
|
||||
|
||||
// Reference local files
|
||||
mod config;
|
||||
mod duplicates;
|
||||
mod instance_info;
|
||||
mod oauth;
|
||||
mod oauth_resources;
|
||||
mod post;
|
||||
mod search;
|
||||
mod settings;
|
||||
mod subreddit;
|
||||
mod user;
|
||||
mod utils;
|
||||
|
||||
// Import Crates
|
||||
use cached::proc_macro::cached;
|
||||
use clap::{Arg, ArgAction, Command};
|
||||
use std::str::FromStr;
|
||||
|
||||
use futures_lite::FutureExt;
|
||||
use hyper::Uri;
|
||||
use hyper::{header::HeaderValue, Body, Request, Response};
|
||||
|
||||
mod client;
|
||||
use client::{canonical_path, proxy};
|
||||
use log::info;
|
||||
use log::{info, warn};
|
||||
use once_cell::sync::Lazy;
|
||||
use server::RequestExt;
|
||||
use utils::{error, redirect, ThemeAssets};
|
||||
use redlib::client::{canonical_path, proxy, rate_limit_check, CLIENT};
|
||||
use redlib::server::{self, RequestExt};
|
||||
use redlib::utils::{error, redirect, ThemeAssets};
|
||||
use redlib::{config, duplicates, headers, instance_info, post, search, settings, subreddit, user};
|
||||
|
||||
use crate::client::OAUTH_CLIENT;
|
||||
|
||||
mod server;
|
||||
use redlib::client::OAUTH_CLIENT;
|
||||
|
||||
// Create Services
|
||||
|
||||
|
@ -78,6 +64,17 @@ async fn font() -> Result<Response<Body>, String> {
|
|||
)
|
||||
}
|
||||
|
||||
async fn opensearch() -> Result<Response<Body>, String> {
|
||||
Ok(
|
||||
Response::builder()
|
||||
.status(200)
|
||||
.header("content-type", "application/opensearchdescription+xml")
|
||||
.header("Cache-Control", "public, max-age=1209600, s-maxage=86400")
|
||||
.body(include_bytes!("../static/opensearch.xml").as_ref().into())
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
}
|
||||
|
||||
async fn resource(body: &str, content_type: &str, cache: bool) -> Result<Response<Body>, String> {
|
||||
let mut res = Response::builder()
|
||||
.status(200)
|
||||
|
@ -122,6 +119,8 @@ async fn main() {
|
|||
let matches = Command::new("Redlib")
|
||||
.version(env!("CARGO_PKG_VERSION"))
|
||||
.about("Private front-end for Reddit written in Rust ")
|
||||
.arg(Arg::new("ipv4-only").short('4').long("ipv4-only").help("Listen on IPv4 only").num_args(0))
|
||||
.arg(Arg::new("ipv6-only").short('6').long("ipv6-only").help("Listen on IPv6 only").num_args(0))
|
||||
.arg(
|
||||
Arg::new("redirect-https")
|
||||
.short('r')
|
||||
|
@ -160,11 +159,34 @@ async fn main() {
|
|||
)
|
||||
.get_matches();
|
||||
|
||||
match rate_limit_check().await {
|
||||
Ok(()) => {
|
||||
info!("[✅] Rate limit check passed");
|
||||
}
|
||||
Err(e) => {
|
||||
let mut message = format!("Rate limit check failed: {}", e);
|
||||
message += "\nThis may cause issues with the rate limit.";
|
||||
message += "\nPlease report this error with the above information.";
|
||||
message += "\nhttps://github.com/redlib-org/redlib/issues/new?assignees=sigaloid&labels=bug&title=%F0%9F%90%9B+Bug+Report%3A+Rate+limit+mismatch";
|
||||
warn!("{}", message);
|
||||
eprintln!("{}", message);
|
||||
}
|
||||
}
|
||||
|
||||
let address = matches.get_one::<String>("address").unwrap();
|
||||
let port = matches.get_one::<String>("port").unwrap();
|
||||
let hsts = matches.get_one("hsts").map(|m: &String| m.as_str());
|
||||
|
||||
let listener = [address, ":", port].concat();
|
||||
let ipv4_only = std::env::var("IPV4_ONLY").is_ok() || matches.get_flag("ipv4-only");
|
||||
let ipv6_only = std::env::var("IPV6_ONLY").is_ok() || matches.get_flag("ipv6-only");
|
||||
|
||||
let listener = if ipv4_only {
|
||||
format!("0.0.0.0:{}", port)
|
||||
} else if ipv6_only {
|
||||
format!("[::]:{}", port)
|
||||
} else {
|
||||
[address, ":", port].concat()
|
||||
};
|
||||
|
||||
println!("Starting Redlib...");
|
||||
|
||||
|
@ -223,6 +245,7 @@ async fn main() {
|
|||
app.at("/Inter.var.woff2").get(|_| font().boxed());
|
||||
app.at("/touch-icon-iphone.png").get(|_| iphone_logo().boxed());
|
||||
app.at("/apple-touch-icon.png").get(|_| iphone_logo().boxed());
|
||||
app.at("/opensearch.xml").get(|_| opensearch().boxed());
|
||||
app
|
||||
.at("/playHLSVideo.js")
|
||||
.get(|_| resource(include_str!("../static/playHLSVideo.js"), "text/javascript", false).boxed());
|
||||
|
@ -232,6 +255,13 @@ async fn main() {
|
|||
app
|
||||
.at("/highlighted.js")
|
||||
.get(|_| resource(include_str!("../static/highlighted.js"), "text/javascript", false).boxed());
|
||||
app
|
||||
.at("/check_update.js")
|
||||
.get(|_| resource(include_str!("../static/check_update.js"), "text/javascript", false).boxed());
|
||||
app.at("/copy.js").get(|_| resource(include_str!("../static/copy.js"), "text/javascript", false).boxed());
|
||||
|
||||
app.at("/commits.atom").get(|_| async move { proxy_commit_info().await }.boxed());
|
||||
app.at("/instances.json").get(|_| async move { proxy_instances().await }.boxed());
|
||||
|
||||
// Proxy media through Redlib
|
||||
app.at("/vid/:id/:size").get(|r| proxy(r, "https://v.redd.it/{id}/DASH_{size}").boxed());
|
||||
|
@ -239,6 +269,9 @@ async fn main() {
|
|||
app.at("/img/*path").get(|r| proxy(r, "https://i.redd.it/{path}").boxed());
|
||||
app.at("/thumb/:point/:id").get(|r| proxy(r, "https://{point}.thumbs.redditmedia.com/{id}").boxed());
|
||||
app.at("/emoji/:id/:name").get(|r| proxy(r, "https://emoji.redditmedia.com/{id}/{name}").boxed());
|
||||
app
|
||||
.at("/emote/:subreddit_id/:filename")
|
||||
.get(|r| proxy(r, "https://reddit-econ-prod-assets-permanent.s3.amazonaws.com/asset-manager/{subreddit_id}/{filename}").boxed());
|
||||
app
|
||||
.at("/preview/:loc/award_images/:fullname/:id")
|
||||
.get(|r| proxy(r, "https://{loc}view.redd.it/award_images/{fullname}/{id}").boxed());
|
||||
|
@ -254,6 +287,7 @@ async fn main() {
|
|||
app.at("/u/:name/comments/:id/:title/:comment_id").get(|r| post::item(r).boxed());
|
||||
|
||||
app.at("/user/[deleted]").get(|req| error(req, "User has deleted their account").boxed());
|
||||
app.at("/user/:name.rss").get(|r| user::rss(r).boxed());
|
||||
app.at("/user/:name").get(|r| user::profile(r).boxed());
|
||||
app.at("/user/:name/:listing").get(|r| user::profile(r).boxed());
|
||||
app.at("/user/:name/comments/:id").get(|r| post::item(r).boxed());
|
||||
|
@ -263,8 +297,12 @@ async fn main() {
|
|||
// Configure settings
|
||||
app.at("/settings").get(|r| settings::get(r).boxed()).post(|r| settings::set(r).boxed());
|
||||
app.at("/settings/restore").get(|r| settings::restore(r).boxed());
|
||||
app.at("/settings/encoded-restore").post(|r| settings::encoded_restore(r).boxed());
|
||||
app.at("/settings/update").get(|r| settings::update(r).boxed());
|
||||
|
||||
// RSS Subscriptions
|
||||
app.at("/r/:sub.rss").get(|r| subreddit::rss(r).boxed());
|
||||
|
||||
// Subreddit services
|
||||
app
|
||||
.at("/r/:sub")
|
||||
|
@ -337,7 +375,7 @@ async fn main() {
|
|||
let sub = req.param("sub").unwrap_or_default();
|
||||
match req.param("id").as_deref() {
|
||||
// Share link
|
||||
Some(id) if (8..12).contains(&id.len()) => match canonical_path(format!("/r/{sub}/s/{id}")).await {
|
||||
Some(id) if (8..12).contains(&id.len()) => match canonical_path(format!("/r/{sub}/s/{id}"), 3).await {
|
||||
Ok(Some(path)) => Ok(redirect(&path)),
|
||||
Ok(None) => error(req, "Post ID is invalid. It may point to a post on a community that has been banned.").await,
|
||||
Err(e) => error(req, &e).await,
|
||||
|
@ -356,7 +394,7 @@ async fn main() {
|
|||
Some("best" | "hot" | "new" | "top" | "rising" | "controversial") => subreddit::community(req).await,
|
||||
|
||||
// Short link for post
|
||||
Some(id) if (5..8).contains(&id.len()) => match canonical_path(format!("/{id}")).await {
|
||||
Some(id) if (5..8).contains(&id.len()) => match canonical_path(format!("/comments/{id}"), 3).await {
|
||||
Ok(path_opt) => match path_opt {
|
||||
Some(path) => Ok(redirect(&path)),
|
||||
None => error(req, "Post ID is invalid. It may point to a post on a community that has been banned.").await,
|
||||
|
@ -382,3 +420,41 @@ async fn main() {
|
|||
eprintln!("Server error: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn proxy_commit_info() -> Result<Response<Body>, String> {
|
||||
Ok(
|
||||
Response::builder()
|
||||
.status(200)
|
||||
.header("content-type", "application/atom+xml")
|
||||
.body(Body::from(fetch_commit_info().await))
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cached(time = 600)]
|
||||
async fn fetch_commit_info() -> String {
|
||||
let uri = Uri::from_str("https://github.com/redlib-org/redlib/commits/main.atom").expect("Invalid URI");
|
||||
|
||||
let resp: Body = CLIENT.get(uri).await.expect("Failed to request GitHub").into_body();
|
||||
|
||||
hyper::body::to_bytes(resp).await.expect("Failed to read body").iter().copied().map(|x| x as char).collect()
|
||||
}
|
||||
|
||||
pub async fn proxy_instances() -> Result<Response<Body>, String> {
|
||||
Ok(
|
||||
Response::builder()
|
||||
.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(fetch_instances().await))
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cached(time = 600)]
|
||||
async fn fetch_instances() -> String {
|
||||
let uri = Uri::from_str("https://raw.githubusercontent.com/redlib-org/redlib-instances/refs/heads/main/instances.json").expect("Invalid URI");
|
||||
|
||||
let resp: Body = CLIENT.get(uri).await.expect("Failed to request GitHub").into_body();
|
||||
|
||||
hyper::body::to_bytes(resp).await.expect("Failed to read body").iter().copied().map(|x| x as char).collect()
|
||||
}
|
||||
|
|
70
src/oauth.rs
|
@ -6,13 +6,14 @@ use crate::{
|
|||
};
|
||||
use base64::{engine::general_purpose, Engine as _};
|
||||
use hyper::{client, Body, Method, Request};
|
||||
use log::{info, trace};
|
||||
|
||||
use log::{error, info, trace};
|
||||
use serde_json::json;
|
||||
use tegen::tegen::TextGenerator;
|
||||
use tokio::time::{error::Elapsed, timeout};
|
||||
|
||||
static REDDIT_ANDROID_OAUTH_CLIENT_ID: &str = "ohXpoqrZYub1kg";
|
||||
const REDDIT_ANDROID_OAUTH_CLIENT_ID: &str = "ohXpoqrZYub1kg";
|
||||
|
||||
static AUTH_ENDPOINT: &str = "https://accounts.reddit.com";
|
||||
const AUTH_ENDPOINT: &str = "https://www.reddit.com";
|
||||
|
||||
// Spoofed client for Android devices
|
||||
#[derive(Debug, Clone, Default)]
|
||||
|
@ -25,11 +26,32 @@ pub struct Oauth {
|
|||
}
|
||||
|
||||
impl Oauth {
|
||||
/// Create a new OAuth client
|
||||
pub(crate) async fn new() -> Self {
|
||||
let mut oauth = Self::default();
|
||||
oauth.login().await;
|
||||
oauth
|
||||
// Call new_internal until it succeeds
|
||||
loop {
|
||||
let attempt = Self::new_with_timeout().await;
|
||||
match attempt {
|
||||
Ok(Some(oauth)) => {
|
||||
info!("[✅] Successfully created OAuth client");
|
||||
return oauth;
|
||||
}
|
||||
Ok(None) => {
|
||||
error!("Failed to create OAuth client. Retrying in 5 seconds...");
|
||||
}
|
||||
Err(duration) => {
|
||||
error!("Failed to create OAuth client in {duration:?}. Retrying in 5 seconds...");
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn new_with_timeout() -> Result<Option<Self>, Elapsed> {
|
||||
let mut oauth = Self::default();
|
||||
timeout(Duration::from_secs(5), oauth.login()).await.map(|result| result.map(|_| oauth))
|
||||
}
|
||||
|
||||
pub(crate) fn default() -> Self {
|
||||
// Generate a device to spoof
|
||||
let device = Device::new();
|
||||
|
@ -46,7 +68,7 @@ impl Oauth {
|
|||
}
|
||||
async fn login(&mut self) -> Option<()> {
|
||||
// Construct URL for OAuth token
|
||||
let url = format!("{AUTH_ENDPOINT}/api/access_token");
|
||||
let url = format!("{AUTH_ENDPOINT}/auth/v2/oauth/access-token/loid");
|
||||
let mut builder = Request::builder().method(Method::POST).uri(&url);
|
||||
|
||||
// Add headers from spoofed client
|
||||
|
@ -62,20 +84,27 @@ impl Oauth {
|
|||
|
||||
// Set JSON body. I couldn't tell you what this means. But that's what the client sends
|
||||
let json = json!({
|
||||
"scopes": ["*","email"]
|
||||
"scopes": ["*","email", "pii"]
|
||||
});
|
||||
let body = Body::from(json.to_string());
|
||||
|
||||
// Build request
|
||||
let request = builder.body(body).unwrap();
|
||||
|
||||
trace!("Sending token request...\n\n{request:?}");
|
||||
|
||||
// Send request
|
||||
let client: client::Client<_, Body> = CLIENT.clone();
|
||||
let client: &once_cell::sync::Lazy<client::Client<_, Body>> = &CLIENT;
|
||||
let resp = client.request(request).await.ok()?;
|
||||
|
||||
trace!("Received response with status {} and length {:?}", resp.status(), resp.headers().get("content-length"));
|
||||
trace!("OAuth headers: {:#?}", resp.headers());
|
||||
|
||||
// Parse headers - loid header _should_ be saved sent on subsequent token refreshes.
|
||||
// Technically it's not needed, but it's easy for Reddit API to check for this.
|
||||
// It's some kind of header that uniquely identifies the device.
|
||||
// Not worried about the privacy implications, since this is randomly changed
|
||||
// and really only as privacy-concerning as the OAuth token itself.
|
||||
if let Some(header) = resp.headers().get("x-reddit-loid") {
|
||||
self.headers_map.insert("x-reddit-loid".to_owned(), header.to_str().ok()?.to_string());
|
||||
}
|
||||
|
@ -85,10 +114,14 @@ impl Oauth {
|
|||
self.headers_map.insert("x-reddit-session".to_owned(), header.to_str().ok()?.to_string());
|
||||
}
|
||||
|
||||
trace!("Serializing response...");
|
||||
|
||||
// Serialize response
|
||||
let body_bytes = hyper::body::to_bytes(resp.into_body()).await.ok()?;
|
||||
let json: serde_json::Value = serde_json::from_slice(&body_bytes).ok()?;
|
||||
|
||||
trace!("Accessing relevant fields...");
|
||||
|
||||
// Save token and expiry
|
||||
self.token = json.get("access_token")?.as_str()?.to_string();
|
||||
self.expires_in = json.get("expires_in")?.as_u64()?;
|
||||
|
@ -153,11 +186,22 @@ impl Device {
|
|||
|
||||
let android_user_agent = format!("Reddit/{android_app_version}/Android {android_version}");
|
||||
|
||||
let qos = fastrand::u32(1000..=100_000);
|
||||
let qos: f32 = qos as f32 / 1000.0;
|
||||
let qos = format!("{:.3}", qos);
|
||||
|
||||
let codecs = TextGenerator::new().generate("available-codecs=video/avc, video/hevc{, video/x-vnd.on2.vp9|}");
|
||||
|
||||
// Android device headers
|
||||
let headers = HashMap::from([
|
||||
("Client-Vendor-Id".into(), uuid.clone()),
|
||||
("X-Reddit-Device-Id".into(), uuid.clone()),
|
||||
let headers: HashMap<String, String> = HashMap::from([
|
||||
("User-Agent".into(), android_user_agent),
|
||||
("x-reddit-retry".into(), "algo=no-retries".into()),
|
||||
("x-reddit-compression".into(), "1".into()),
|
||||
("x-reddit-qos".into(), qos),
|
||||
("x-reddit-media-codecs".into(), codecs),
|
||||
("Content-Type".into(), "application/json; charset=UTF-8".into()),
|
||||
("client-vendor-id".into(), uuid.clone()),
|
||||
("X-Reddit-Device-Id".into(), uuid.clone()),
|
||||
]);
|
||||
|
||||
info!("[🔄] Spoofing Android client with headers: {headers:?}, uuid: \"{uuid}\", and OAuth ID \"{REDDIT_ANDROID_OAUTH_CLIENT_ID}\"");
|
||||
|
|
|
@ -2,8 +2,38 @@
|
|||
// Rerun scripts/update_oauth_resources.sh to update this file
|
||||
// Please do not edit manually
|
||||
// Filled in with real app versions
|
||||
pub static _IOS_APP_VERSION_LIST: &[&str; 1] = &[""];
|
||||
pub static ANDROID_APP_VERSION_LIST: &[&str; 150] = &[
|
||||
pub const _IOS_APP_VERSION_LIST: &[&str; 1] = &[""];
|
||||
pub const ANDROID_APP_VERSION_LIST: &[&str; 150] = &[
|
||||
"Version 2024.22.1/Build 1652272",
|
||||
"Version 2024.23.1/Build 1665606",
|
||||
"Version 2024.24.1/Build 1682520",
|
||||
"Version 2024.25.0/Build 1693595",
|
||||
"Version 2024.25.2/Build 1700401",
|
||||
"Version 2024.25.3/Build 1703490",
|
||||
"Version 2024.26.0/Build 1710470",
|
||||
"Version 2024.26.1/Build 1717435",
|
||||
"Version 2024.28.0/Build 1737665",
|
||||
"Version 2024.28.1/Build 1741165",
|
||||
"Version 2024.30.0/Build 1770787",
|
||||
"Version 2024.31.0/Build 1786202",
|
||||
"Version 2024.32.0/Build 1809095",
|
||||
"Version 2024.32.1/Build 1813258",
|
||||
"Version 2024.33.0/Build 1819908",
|
||||
"Version 2024.34.0/Build 1837909",
|
||||
"Version 2024.35.0/Build 1861437",
|
||||
"Version 2024.36.0/Build 1875012",
|
||||
"Version 2024.37.0/Build 1888053",
|
||||
"Version 2024.38.0/Build 1902791",
|
||||
"Version 2024.39.0/Build 1916713",
|
||||
"Version 2024.40.0/Build 1928580",
|
||||
"Version 2024.41.0/Build 1941199",
|
||||
"Version 2024.41.1/Build 1947805",
|
||||
"Version 2024.42.0/Build 1952440",
|
||||
"Version 2024.43.0/Build 1972250",
|
||||
"Version 2024.44.0/Build 1988458",
|
||||
"Version 2024.45.0/Build 2001943",
|
||||
"Version 2024.46.0/Build 2012731",
|
||||
"Version 2024.47.0/Build 2029755",
|
||||
"Version 2023.48.0/Build 1319123",
|
||||
"Version 2023.49.0/Build 1321715",
|
||||
"Version 2023.49.1/Build 1322281",
|
||||
|
@ -31,9 +61,9 @@ pub static ANDROID_APP_VERSION_LIST: &[&str; 150] = &[
|
|||
"Version 2024.20.0/Build 1612800",
|
||||
"Version 2024.20.1/Build 1615586",
|
||||
"Version 2024.20.2/Build 1624969",
|
||||
"Version 2024.20.3/Build 1624970",
|
||||
"Version 2024.21.0/Build 1631686",
|
||||
"Version 2024.22.0/Build 1645257",
|
||||
"Version 2024.22.1/Build 1652272",
|
||||
"Version 2023.21.0/Build 956283",
|
||||
"Version 2023.22.0/Build 968223",
|
||||
"Version 2023.23.0/Build 983896",
|
||||
|
@ -124,35 +154,5 @@ pub static ANDROID_APP_VERSION_LIST: &[&str; 150] = &[
|
|||
"Version 2022.40.0/Build 624782",
|
||||
"Version 2022.41.0/Build 630468",
|
||||
"Version 2022.41.1/Build 634168",
|
||||
"Version 2021.39.1/Build 372418",
|
||||
"Version 2021.41.0/Build 376052",
|
||||
"Version 2021.42.0/Build 378193",
|
||||
"Version 2021.43.0/Build 382019",
|
||||
"Version 2021.44.0/Build 385129",
|
||||
"Version 2021.45.0/Build 387663",
|
||||
"Version 2021.46.0/Build 392043",
|
||||
"Version 2021.47.0/Build 394342",
|
||||
"Version 2022.10.0/Build 429896",
|
||||
"Version 2022.1.0/Build 402829",
|
||||
"Version 2022.11.0/Build 433004",
|
||||
"Version 2022.12.0/Build 436848",
|
||||
"Version 2022.13.0/Build 442084",
|
||||
"Version 2022.13.1/Build 444621",
|
||||
"Version 2022.14.1/Build 452742",
|
||||
"Version 2022.15.0/Build 455453",
|
||||
"Version 2022.16.0/Build 462377",
|
||||
"Version 2022.17.0/Build 468480",
|
||||
"Version 2022.18.0/Build 473740",
|
||||
"Version 2022.19.1/Build 482464",
|
||||
"Version 2022.2.0/Build 405543",
|
||||
"Version 2022.3.0/Build 408637",
|
||||
"Version 2022.4.0/Build 411368",
|
||||
"Version 2022.5.0/Build 414731",
|
||||
"Version 2022.6.0/Build 418391",
|
||||
"Version 2022.6.1/Build 419585",
|
||||
"Version 2022.6.2/Build 420562",
|
||||
"Version 2022.7.0/Build 420849",
|
||||
"Version 2022.8.0/Build 423906",
|
||||
"Version 2022.9.0/Build 426592",
|
||||
];
|
||||
pub static _IOS_OS_VERSION_LIST: &[&str; 1] = &[""];
|
||||
pub const _IOS_OS_VERSION_LIST: &[&str; 1] = &[""];
|
||||
|
|
16
src/post.rs
|
@ -1,17 +1,19 @@
|
|||
#![allow(clippy::cmp_owned)]
|
||||
|
||||
// CRATES
|
||||
use crate::client::json;
|
||||
use crate::config::get_setting;
|
||||
use crate::server::RequestExt;
|
||||
use crate::subreddit::{can_access_quarantine, quarantine};
|
||||
use crate::utils::{
|
||||
error, format_num, get_filters, nsfw_landing, param, parse_post, rewrite_urls, setting, template, time, val, Author, Awards, Comment, Flair, FlairPart, Post, Preferences,
|
||||
error, format_num, get_filters, nsfw_landing, param, parse_post, rewrite_emotes, setting, template, time, val, Author, Awards, Comment, Flair, FlairPart, Post, Preferences,
|
||||
};
|
||||
use hyper::{Body, Request, Response};
|
||||
|
||||
use askama::Template;
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
use std::collections::HashSet;
|
||||
use rinja::Template;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
// STRUCTS
|
||||
#[derive(Template)]
|
||||
|
@ -72,11 +74,15 @@ pub async fn item(req: Request<Body>) -> Result<Response<Body>, String> {
|
|||
return Ok(nsfw_landing(req, req_url).await.unwrap_or_default());
|
||||
}
|
||||
|
||||
let query = match COMMENT_SEARCH_CAPTURE.captures(&url) {
|
||||
let query_body = match COMMENT_SEARCH_CAPTURE.captures(&url) {
|
||||
Some(captures) => captures.get(1).unwrap().as_str().replace("%20", " ").replace('+', " "),
|
||||
None => String::new(),
|
||||
};
|
||||
|
||||
let query_string = format!("q={query_body}&type=comment");
|
||||
let form = url::form_urlencoded::parse(query_string.as_bytes()).collect::<HashMap<_, _>>();
|
||||
let query = form.get("q").unwrap().clone().to_string();
|
||||
|
||||
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),
|
||||
|
@ -174,7 +180,7 @@ fn build_comment(
|
|||
get_setting("REDLIB_PUSHSHIFT_FRONTEND").unwrap_or_else(|| String::from(crate::config::DEFAULT_PUSHSHIFT_FRONTEND)),
|
||||
)
|
||||
} else {
|
||||
rewrite_urls(&val(comment, "body_html"))
|
||||
rewrite_emotes(&data["media_metadata"], val(comment, "body_html"))
|
||||
};
|
||||
let kind = comment["kind"].as_str().unwrap_or_default().to_string();
|
||||
|
||||
|
|
|
@ -1,14 +1,16 @@
|
|||
#![allow(clippy::cmp_owned)]
|
||||
|
||||
// CRATES
|
||||
use crate::utils::{self, catch_random, error, filter_posts, format_num, format_url, get_filters, param, redirect, setting, template, val, Post, Preferences};
|
||||
use crate::{
|
||||
client::json,
|
||||
server::RequestExt,
|
||||
subreddit::{can_access_quarantine, quarantine},
|
||||
RequestExt,
|
||||
};
|
||||
use askama::Template;
|
||||
use hyper::{Body, Request, Response};
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
use rinja::Template;
|
||||
|
||||
// STRUCTS
|
||||
struct SearchParams {
|
||||
|
@ -60,7 +62,8 @@ pub async fn find(req: Request<Body>) -> Result<Response<Body>, String> {
|
|||
} else {
|
||||
""
|
||||
};
|
||||
let path = format!("{}.json?{}{}&raw_json=1", req.uri().path(), req.uri().query().unwrap_or_default(), nsfw_results);
|
||||
let uri_path = req.uri().path().replace("+", "%2B");
|
||||
let path = format!("{}.json?{}{}&raw_json=1", uri_path, req.uri().query().unwrap_or_default(), nsfw_results);
|
||||
let mut query = param(&path, "q").unwrap_or_default();
|
||||
query = REDDIT_URL_MATCH.replace(&query, "").to_string();
|
||||
|
||||
|
@ -68,10 +71,18 @@ pub async fn find(req: Request<Body>) -> Result<Response<Body>, String> {
|
|||
return Ok(redirect("/"));
|
||||
}
|
||||
|
||||
if query.starts_with("r/") {
|
||||
if query.starts_with("r/") || query.starts_with("user/") {
|
||||
return Ok(redirect(&format!("/{query}")));
|
||||
}
|
||||
|
||||
if query.starts_with("R/") {
|
||||
return Ok(redirect(&format!("/r{}", &query[1..])));
|
||||
}
|
||||
|
||||
if query.starts_with("u/") || query.starts_with("U/") {
|
||||
return Ok(redirect(&format!("/user{}", &query[1..])));
|
||||
}
|
||||
|
||||
let sub = req.param("sub").unwrap_or_default();
|
||||
let quarantined = can_access_quarantine(&req, &sub);
|
||||
// Handle random subreddits
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
#![allow(dead_code)]
|
||||
#![allow(clippy::cmp_owned)]
|
||||
|
||||
use brotli::enc::{BrotliCompress, BrotliEncoderParams};
|
||||
use cached::proc_macro::cached;
|
||||
|
@ -24,7 +25,7 @@ use std::{
|
|||
str::{from_utf8, Split},
|
||||
string::ToString,
|
||||
};
|
||||
use time::Duration;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::dbg_msg;
|
||||
|
||||
|
@ -169,10 +170,8 @@ impl ResponseExt for Response<Body> {
|
|||
}
|
||||
|
||||
fn remove_cookie(&mut self, name: String) {
|
||||
let mut cookie = Cookie::from(name);
|
||||
cookie.set_path("/");
|
||||
cookie.set_max_age(Duration::seconds(1));
|
||||
if let Ok(val) = header::HeaderValue::from_str(&cookie.to_string()) {
|
||||
let removal_cookie = Cookie::build(name).path("/").http_only(true).expires(OffsetDateTime::now_utc());
|
||||
if let Ok(val) = header::HeaderValue::from_str(&removal_cookie.to_string()) {
|
||||
self.headers_mut().append("Set-Cookie", val);
|
||||
}
|
||||
}
|
||||
|
@ -195,6 +194,12 @@ impl Route<'_> {
|
|||
}
|
||||
}
|
||||
|
||||
impl Default for Server {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Server {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
|
@ -233,8 +238,14 @@ impl Server {
|
|||
path.pop();
|
||||
}
|
||||
|
||||
// Replace HEAD with GET for routing
|
||||
let (method, is_head) = match req.method() {
|
||||
&Method::HEAD => (&Method::GET, true),
|
||||
method => (method, false),
|
||||
};
|
||||
|
||||
// Match the visited path with an added route
|
||||
match router.recognize(&format!("/{}{}", req.method().as_str(), path)) {
|
||||
match router.recognize(&format!("/{}{}", method.as_str(), path)) {
|
||||
// If a route was configured for this path
|
||||
Ok(found) => {
|
||||
let mut parammed = req;
|
||||
|
@ -246,17 +257,21 @@ impl Server {
|
|||
match func.await {
|
||||
Ok(mut res) => {
|
||||
res.headers_mut().extend(def_headers);
|
||||
let _ = compress_response(&req_headers, &mut res).await;
|
||||
if is_head {
|
||||
*res.body_mut() = Body::empty();
|
||||
} else {
|
||||
let _ = compress_response(&req_headers, &mut res).await;
|
||||
}
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
Err(msg) => new_boilerplate(def_headers, req_headers, 500, Body::from(msg)).await,
|
||||
Err(msg) => new_boilerplate(def_headers, req_headers, 500, if is_head { Body::empty() } else { Body::from(msg) }).await,
|
||||
}
|
||||
}
|
||||
.boxed()
|
||||
}
|
||||
// If there was a routing error
|
||||
Err(e) => new_boilerplate(def_headers, req_headers, 404, e.into()).boxed(),
|
||||
Err(e) => new_boilerplate(def_headers, req_headers, 404, if is_head { Body::empty() } else { e.into() }).boxed(),
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
@ -267,8 +282,19 @@ impl Server {
|
|||
|
||||
// Bind server to address specified above. Gracefully shut down if CTRL+C is pressed
|
||||
let server = HyperServer::bind(address).serve(make_svc).with_graceful_shutdown(async {
|
||||
#[cfg(windows)]
|
||||
// Wait for the CTRL+C signal
|
||||
tokio::signal::ctrl_c().await.expect("Failed to install CTRL+C signal handler");
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// Wait for CTRL+C or SIGTERM signals
|
||||
let mut signal_terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()).expect("Failed to install SIGTERM signal handler");
|
||||
tokio::select! {
|
||||
_ = tokio::signal::ctrl_c() => (),
|
||||
_ = signal_terminate.recv() => ()
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
server.boxed()
|
||||
|
@ -723,7 +749,7 @@ mod tests {
|
|||
|
||||
CompressionType::Brotli => Box::new(BrotliDecompressor::new(body_cursor, expected_lorem_ipsum.len())),
|
||||
|
||||
_ => panic!("no decompressor for {}", expected_encoding.to_string()),
|
||||
_ => panic!("no decompressor for {}", expected_encoding),
|
||||
};
|
||||
|
||||
let mut decompressed = Vec::<u8>::new();
|
||||
|
|
160
src/settings.rs
|
@ -1,13 +1,18 @@
|
|||
#![allow(clippy::cmp_owned)]
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
// CRATES
|
||||
use crate::server::ResponseExt;
|
||||
use crate::utils::{redirect, template, Preferences};
|
||||
use askama::Template;
|
||||
use crate::subreddit::join_until_size_limit;
|
||||
use crate::utils::{deflate_decompress, redirect, template, Preferences};
|
||||
use cookie::Cookie;
|
||||
use futures_lite::StreamExt;
|
||||
use hyper::{Body, Request, Response};
|
||||
use rinja::Template;
|
||||
use time::{Duration, OffsetDateTime};
|
||||
use tokio::time::timeout;
|
||||
use url::form_urlencoded;
|
||||
|
||||
// STRUCTS
|
||||
#[derive(Template)]
|
||||
|
@ -19,7 +24,7 @@ struct SettingsTemplate {
|
|||
|
||||
// CONSTANTS
|
||||
|
||||
const PREFS: [&str; 17] = [
|
||||
const PREFS: [&str; 19] = [
|
||||
"theme",
|
||||
"front_page",
|
||||
"layout",
|
||||
|
@ -37,6 +42,8 @@ const PREFS: [&str; 17] = [
|
|||
"hide_awards",
|
||||
"hide_score",
|
||||
"disable_visit_reddit_confirmation",
|
||||
"video_quality",
|
||||
"remove_default_feeds",
|
||||
];
|
||||
|
||||
// FUNCTIONS
|
||||
|
@ -116,7 +123,7 @@ fn set_cookies_method(req: Request<Body>, remove_cookies: bool) -> Response<Body
|
|||
|
||||
let mut response = redirect(&path);
|
||||
|
||||
for name in [PREFS.to_vec(), vec!["subscriptions", "filters"]].concat() {
|
||||
for name in PREFS {
|
||||
match form.get(name) {
|
||||
Some(value) => response.insert_cookie(
|
||||
Cookie::build((name.to_owned(), value.clone()))
|
||||
|
@ -133,6 +140,119 @@ fn set_cookies_method(req: Request<Body>, remove_cookies: bool) -> Response<Body
|
|||
};
|
||||
}
|
||||
|
||||
// Get subscriptions/filters to restore from query string
|
||||
let subscriptions = form.get("subscriptions");
|
||||
let filters = form.get("filters");
|
||||
|
||||
// We can't search through the cookies directly like in subreddit.rs, so instead we have to make a string out of the request's headers to search through
|
||||
let cookies_string = parts
|
||||
.headers
|
||||
.get("cookie")
|
||||
.map(|hv| hv.to_str().unwrap_or("").to_string()) // Return String
|
||||
.unwrap_or_else(String::new); // Return an empty string if None
|
||||
|
||||
// If there are subscriptions to restore set them and delete any old subscriptions cookies, otherwise delete them all
|
||||
if subscriptions.is_some() {
|
||||
let sub_list: Vec<String> = subscriptions.expect("Subscriptions").split('+').map(str::to_string).collect();
|
||||
|
||||
// Start at 0 to keep track of what number we need to start deleting old subscription cookies from
|
||||
let mut subscriptions_number_to_delete_from = 0;
|
||||
|
||||
// Starting at 0 so we handle the subscription cookie without a number first
|
||||
for (subscriptions_number, list) in join_until_size_limit(&sub_list).into_iter().enumerate() {
|
||||
let subscriptions_cookie = if subscriptions_number == 0 {
|
||||
"subscriptions".to_string()
|
||||
} else {
|
||||
format!("subscriptions{}", subscriptions_number)
|
||||
};
|
||||
|
||||
response.insert_cookie(
|
||||
Cookie::build((subscriptions_cookie, list))
|
||||
.path("/")
|
||||
.http_only(true)
|
||||
.expires(OffsetDateTime::now_utc() + Duration::weeks(52))
|
||||
.into(),
|
||||
);
|
||||
|
||||
subscriptions_number_to_delete_from += 1;
|
||||
}
|
||||
|
||||
// While subscriptionsNUMBER= is in the string of cookies add a response removing that cookie
|
||||
while cookies_string.contains(&format!("subscriptions{subscriptions_number_to_delete_from}=")) {
|
||||
// Remove that subscriptions cookie
|
||||
response.remove_cookie(format!("subscriptions{subscriptions_number_to_delete_from}"));
|
||||
|
||||
// Increment subscriptions cookie number
|
||||
subscriptions_number_to_delete_from += 1;
|
||||
}
|
||||
} else {
|
||||
// Remove unnumbered subscriptions cookie
|
||||
response.remove_cookie("subscriptions".to_string());
|
||||
|
||||
// Starts at one to deal with the first numbered subscription cookie and onwards
|
||||
let mut subscriptions_number_to_delete_from = 1;
|
||||
|
||||
// While subscriptionsNUMBER= is in the string of cookies add a response removing that cookie
|
||||
while cookies_string.contains(&format!("subscriptions{subscriptions_number_to_delete_from}=")) {
|
||||
// Remove that subscriptions cookie
|
||||
response.remove_cookie(format!("subscriptions{subscriptions_number_to_delete_from}"));
|
||||
|
||||
// Increment subscriptions cookie number
|
||||
subscriptions_number_to_delete_from += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// If there are filters to restore set them and delete any old filters cookies, otherwise delete them all
|
||||
if filters.is_some() {
|
||||
let filters_list: Vec<String> = filters.expect("Filters").split('+').map(str::to_string).collect();
|
||||
|
||||
// Start at 0 to keep track of what number we need to start deleting old subscription cookies from
|
||||
let mut filters_number_to_delete_from = 0;
|
||||
|
||||
// Starting at 0 so we handle the subscription cookie without a number first
|
||||
for (filters_number, list) in join_until_size_limit(&filters_list).into_iter().enumerate() {
|
||||
let filters_cookie = if filters_number == 0 {
|
||||
"filters".to_string()
|
||||
} else {
|
||||
format!("filters{}", filters_number)
|
||||
};
|
||||
|
||||
response.insert_cookie(
|
||||
Cookie::build((filters_cookie, list))
|
||||
.path("/")
|
||||
.http_only(true)
|
||||
.expires(OffsetDateTime::now_utc() + Duration::weeks(52))
|
||||
.into(),
|
||||
);
|
||||
|
||||
filters_number_to_delete_from += 1;
|
||||
}
|
||||
|
||||
// While filtersNUMBER= is in the string of cookies add a response removing that cookie
|
||||
while cookies_string.contains(&format!("filters{filters_number_to_delete_from}=")) {
|
||||
// Remove that filters cookie
|
||||
response.remove_cookie(format!("filters{filters_number_to_delete_from}"));
|
||||
|
||||
// Increment filters cookie number
|
||||
filters_number_to_delete_from += 1;
|
||||
}
|
||||
} else {
|
||||
// Remove unnumbered filters cookie
|
||||
response.remove_cookie("filters".to_string());
|
||||
|
||||
// Starts at one to deal with the first numbered subscription cookie and onwards
|
||||
let mut filters_number_to_delete_from = 1;
|
||||
|
||||
// While filtersNUMBER= is in the string of cookies add a response removing that cookie
|
||||
while cookies_string.contains(&format!("filters{filters_number_to_delete_from}=")) {
|
||||
// Remove that sfilters cookie
|
||||
response.remove_cookie(format!("filters{filters_number_to_delete_from}"));
|
||||
|
||||
// Increment filters cookie number
|
||||
filters_number_to_delete_from += 1;
|
||||
}
|
||||
}
|
||||
|
||||
response
|
||||
}
|
||||
|
||||
|
@ -144,3 +264,35 @@ pub async fn restore(req: Request<Body>) -> Result<Response<Body>, String> {
|
|||
pub async fn update(req: Request<Body>) -> Result<Response<Body>, String> {
|
||||
Ok(set_cookies_method(req, false))
|
||||
}
|
||||
|
||||
pub async fn encoded_restore(req: Request<Body>) -> Result<Response<Body>, String> {
|
||||
let body = hyper::body::to_bytes(req.into_body())
|
||||
.await
|
||||
.map_err(|e| format!("Failed to get bytes from request body: {}", e))?;
|
||||
|
||||
if body.len() > 1024 * 1024 {
|
||||
return Err("Request body too large".to_string());
|
||||
}
|
||||
|
||||
let encoded_prefs = form_urlencoded::parse(&body)
|
||||
.find(|(key, _)| key == "encoded_prefs")
|
||||
.map(|(_, value)| value)
|
||||
.ok_or_else(|| "encoded_prefs parameter not found in request body".to_string())?;
|
||||
|
||||
let bytes = base2048::decode(&encoded_prefs).ok_or_else(|| "Failed to decode base2048 encoded preferences".to_string())?;
|
||||
|
||||
let out = timeout(std::time::Duration::from_secs(1), async { deflate_decompress(bytes) })
|
||||
.await
|
||||
.map_err(|e| format!("Failed to decompress bytes: {}", e))??;
|
||||
|
||||
let mut prefs: Preferences = timeout(std::time::Duration::from_secs(1), async { bincode::deserialize(&out) })
|
||||
.await
|
||||
.map_err(|e| format!("Failed to deserialize preferences: {}", e))?
|
||||
.map_err(|e| format!("Failed to deserialize bytes into Preferences struct: {}", e))?;
|
||||
|
||||
prefs.available_themes = vec![];
|
||||
|
||||
let url = format!("/settings/restore/?{}", prefs.to_urlencoded()?);
|
||||
|
||||
Ok(redirect(&url))
|
||||
}
|
||||
|
|
234
src/subreddit.rs
|
@ -1,12 +1,18 @@
|
|||
#![allow(clippy::cmp_owned)]
|
||||
|
||||
use crate::{config, utils};
|
||||
// CRATES
|
||||
use crate::utils::{
|
||||
catch_random, error, filter_posts, format_num, format_url, get_filters, nsfw_landing, param, redirect, rewrite_urls, setting, template, val, Post, Preferences, Subreddit,
|
||||
catch_random, error, filter_posts, format_num, format_url, get_filters, info, nsfw_landing, param, redirect, rewrite_urls, setting, template, val, Post, Preferences,
|
||||
Subreddit,
|
||||
};
|
||||
use crate::{client::json, server::ResponseExt, RequestExt};
|
||||
use askama::Template;
|
||||
use crate::{client::json, server::RequestExt, server::ResponseExt};
|
||||
use cookie::Cookie;
|
||||
use htmlescape::decode_html;
|
||||
use hyper::{Body, Request, Response};
|
||||
use rinja::Template;
|
||||
|
||||
use chrono::DateTime;
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
use time::{Duration, OffsetDateTime};
|
||||
|
@ -61,6 +67,7 @@ pub async fn community(req: Request<Body>) -> Result<Response<Body>, String> {
|
|||
let query = req.uri().query().unwrap_or_default().to_string();
|
||||
let subscribed = setting(&req, "subscriptions");
|
||||
let front_page = setting(&req, "front_page");
|
||||
let remove_default_feeds = setting(&req, "remove_default_feeds") == "on";
|
||||
let post_sort = req.cookie("post_sort").map_or_else(|| "hot".to_string(), |c| c.value().to_string());
|
||||
let sort = req.param("sort").unwrap_or_else(|| req.param("id").unwrap_or(post_sort));
|
||||
|
||||
|
@ -73,6 +80,21 @@ pub async fn community(req: Request<Body>) -> Result<Response<Body>, String> {
|
|||
} else {
|
||||
front_page.clone()
|
||||
});
|
||||
|
||||
if (sub_name == "popular" || sub_name == "all") && remove_default_feeds {
|
||||
if subscribed.is_empty() {
|
||||
return info(req, "Subscribe to some subreddits! (Default feeds disabled in settings)").await;
|
||||
} else {
|
||||
// If there are subscribed subs, but we get here, then the problem is that front_page pref is set to something besides default.
|
||||
// Tell user to go to settings and change front page to default.
|
||||
return info(
|
||||
req,
|
||||
"You have subscribed to some subreddits, but your front page is not set to default. Visit settings and change front page to default.",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
let quarantined = can_access_quarantine(&req, &sub_name) || root;
|
||||
|
||||
// Handle random subreddits
|
||||
|
@ -145,6 +167,10 @@ pub async fn community(req: Request<Body>) -> Result<Response<Body>, String> {
|
|||
let (_, all_posts_filtered) = filter_posts(&mut posts, &filters);
|
||||
let no_posts = posts.is_empty();
|
||||
let all_posts_hidden_nsfw = !no_posts && (posts.iter().all(|p| p.flags.nsfw) && setting(&req, "show_nsfw") != "on");
|
||||
if sort == "new" {
|
||||
posts.sort_by(|a, b| b.created_ts.cmp(&a.created_ts));
|
||||
posts.sort_by(|a, b| b.flags.stickied.cmp(&a.flags.stickied));
|
||||
}
|
||||
Ok(template(&SubredditTemplate {
|
||||
sub,
|
||||
posts,
|
||||
|
@ -204,6 +230,41 @@ pub fn can_access_quarantine(req: &Request<Body>, sub: &str) -> bool {
|
|||
setting(req, &format!("allow_quaran_{}", sub.to_lowercase())).parse().unwrap_or_default()
|
||||
}
|
||||
|
||||
// Join items in chunks of 4000 bytes in length for cookies
|
||||
pub fn join_until_size_limit<T: std::fmt::Display>(vec: &[T]) -> Vec<std::string::String> {
|
||||
let mut result = Vec::new();
|
||||
let mut list = String::new();
|
||||
let mut current_size = 0;
|
||||
|
||||
for item in vec {
|
||||
// Size in bytes
|
||||
let item_size = item.to_string().len();
|
||||
// Use 4000 bytes to leave us some headroom because the name and options of the cookie count towards the 4096 byte cap
|
||||
if current_size + item_size > 4000 {
|
||||
// If last item add a seperator on the end of the list so it's interpreted properly in tanden with the next cookie
|
||||
list.push('+');
|
||||
|
||||
// Push current list to result vector
|
||||
result.push(list);
|
||||
|
||||
// Reset the list variable so we can continue with only new items
|
||||
list = String::new();
|
||||
}
|
||||
// Add separator if not the first item
|
||||
if !list.is_empty() {
|
||||
list.push('+');
|
||||
}
|
||||
// Add current item to list
|
||||
list.push_str(&item.to_string());
|
||||
current_size = list.len() + item_size;
|
||||
}
|
||||
// Make sure to push whatever the remaining subreddits are there into the result vector
|
||||
result.push(list);
|
||||
|
||||
// Return resulting vector
|
||||
result
|
||||
}
|
||||
|
||||
// Sub, filter, unfilter, or unsub by setting subscription cookie using response "Set-Cookie" header
|
||||
pub async fn subscriptions_filters(req: Request<Body>) -> Result<Response<Body>, String> {
|
||||
let sub = req.param("sub").unwrap_or_default();
|
||||
|
@ -296,28 +357,101 @@ pub async fn subscriptions_filters(req: Request<Body>) -> Result<Response<Body>,
|
|||
|
||||
let mut response = redirect(&path);
|
||||
|
||||
// Delete cookie if empty, else set
|
||||
// If sub_list is empty remove all subscriptions cookies, otherwise update them and remove old ones
|
||||
if sub_list.is_empty() {
|
||||
// Remove subscriptions cookie
|
||||
response.remove_cookie("subscriptions".to_string());
|
||||
|
||||
// Start with first numbered subscriptions cookie
|
||||
let mut subscriptions_number = 1;
|
||||
|
||||
// While whatever subscriptionsNUMBER cookie we're looking at has a value
|
||||
while req.cookie(&format!("subscriptions{}", subscriptions_number)).is_some() {
|
||||
// Remove that subscriptions cookie
|
||||
response.remove_cookie(format!("subscriptions{}", subscriptions_number));
|
||||
|
||||
// Increment subscriptions cookie number
|
||||
subscriptions_number += 1;
|
||||
}
|
||||
} else {
|
||||
response.insert_cookie(
|
||||
Cookie::build(("subscriptions", sub_list.join("+")))
|
||||
.path("/")
|
||||
.http_only(true)
|
||||
.expires(OffsetDateTime::now_utc() + Duration::weeks(52))
|
||||
.into(),
|
||||
);
|
||||
// Start at 0 to keep track of what number we need to start deleting old subscription cookies from
|
||||
let mut subscriptions_number_to_delete_from = 0;
|
||||
|
||||
// Starting at 0 so we handle the subscription cookie without a number first
|
||||
for (subscriptions_number, list) in join_until_size_limit(&sub_list).into_iter().enumerate() {
|
||||
let subscriptions_cookie = if subscriptions_number == 0 {
|
||||
"subscriptions".to_string()
|
||||
} else {
|
||||
format!("subscriptions{}", subscriptions_number)
|
||||
};
|
||||
|
||||
response.insert_cookie(
|
||||
Cookie::build((subscriptions_cookie, list))
|
||||
.path("/")
|
||||
.http_only(true)
|
||||
.expires(OffsetDateTime::now_utc() + Duration::weeks(52))
|
||||
.into(),
|
||||
);
|
||||
|
||||
subscriptions_number_to_delete_from += 1;
|
||||
}
|
||||
|
||||
// While whatever subscriptionsNUMBER cookie we're looking at has a value
|
||||
while req.cookie(&format!("subscriptions{}", subscriptions_number_to_delete_from)).is_some() {
|
||||
// Remove that subscriptions cookie
|
||||
response.remove_cookie(format!("subscriptions{}", subscriptions_number_to_delete_from));
|
||||
|
||||
// Increment subscriptions cookie number
|
||||
subscriptions_number_to_delete_from += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// If filters is empty remove all filters cookies, otherwise update them and remove old ones
|
||||
if filters.is_empty() {
|
||||
// Remove filters cookie
|
||||
response.remove_cookie("filters".to_string());
|
||||
|
||||
// Start with first numbered filters cookie
|
||||
let mut filters_number = 1;
|
||||
|
||||
// While whatever filtersNUMBER cookie we're looking at has a value
|
||||
while req.cookie(&format!("filters{}", filters_number)).is_some() {
|
||||
// Remove that filters cookie
|
||||
response.remove_cookie(format!("filters{}", filters_number));
|
||||
|
||||
// Increment filters cookie number
|
||||
filters_number += 1;
|
||||
}
|
||||
} else {
|
||||
response.insert_cookie(
|
||||
Cookie::build(("filters", filters.join("+")))
|
||||
.path("/")
|
||||
.http_only(true)
|
||||
.expires(OffsetDateTime::now_utc() + Duration::weeks(52))
|
||||
.into(),
|
||||
);
|
||||
// Start at 0 to keep track of what number we need to start deleting old filters cookies from
|
||||
let mut filters_number_to_delete_from = 0;
|
||||
|
||||
for (filters_number, list) in join_until_size_limit(&filters).into_iter().enumerate() {
|
||||
let filters_cookie = if filters_number == 0 {
|
||||
"filters".to_string()
|
||||
} else {
|
||||
format!("filters{}", filters_number)
|
||||
};
|
||||
|
||||
response.insert_cookie(
|
||||
Cookie::build((filters_cookie, list))
|
||||
.path("/")
|
||||
.http_only(true)
|
||||
.expires(OffsetDateTime::now_utc() + Duration::weeks(52))
|
||||
.into(),
|
||||
);
|
||||
|
||||
filters_number_to_delete_from += 1;
|
||||
}
|
||||
|
||||
// While whatever filtersNUMBER cookie we're looking at has a value
|
||||
while req.cookie(&format!("filters{}", filters_number_to_delete_from)).is_some() {
|
||||
// Remove that filters cookie
|
||||
response.remove_cookie(format!("filters{}", filters_number_to_delete_from));
|
||||
|
||||
// Increment filters cookie number
|
||||
filters_number_to_delete_from += 1;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
|
@ -455,8 +589,72 @@ async fn subreddit(sub: &str, quarantined: bool) -> Result<Subreddit, String> {
|
|||
})
|
||||
}
|
||||
|
||||
pub async fn rss(req: Request<Body>) -> Result<Response<Body>, String> {
|
||||
if config::get_setting("REDLIB_ENABLE_RSS").is_none() {
|
||||
return Ok(error(req, "RSS is disabled on this instance.").await.unwrap_or_default());
|
||||
}
|
||||
|
||||
use hyper::header::CONTENT_TYPE;
|
||||
use rss::{ChannelBuilder, Item};
|
||||
|
||||
// Get subreddit
|
||||
let sub = req.param("sub").unwrap_or_default();
|
||||
let post_sort = req.cookie("post_sort").map_or_else(|| "hot".to_string(), |c| c.value().to_string());
|
||||
let sort = req.param("sort").unwrap_or_else(|| req.param("id").unwrap_or(post_sort));
|
||||
|
||||
// Get path
|
||||
let path = format!("/r/{sub}/{sort}.json?{}", req.uri().query().unwrap_or_default());
|
||||
|
||||
// Get subreddit data
|
||||
let subreddit = subreddit(&sub, false).await?;
|
||||
|
||||
// Get posts
|
||||
let (posts, _) = Post::fetch(&path, false).await?;
|
||||
|
||||
// Build the RSS feed
|
||||
let channel = ChannelBuilder::default()
|
||||
.title(&subreddit.title)
|
||||
.description(&subreddit.description)
|
||||
.items(
|
||||
posts
|
||||
.into_iter()
|
||||
.map(|post| Item {
|
||||
title: Some(post.title.to_string()),
|
||||
link: Some(format_url(&utils::get_post_url(&post))),
|
||||
author: Some(post.author.name),
|
||||
content: Some(rewrite_urls(&decode_html(&post.body).unwrap())),
|
||||
pub_date: Some(DateTime::from_timestamp(post.created_ts as i64, 0).unwrap_or_default().to_rfc2822()),
|
||||
description: Some(format!(
|
||||
"<a href='{}{}'>Comments</a>",
|
||||
config::get_setting("REDLIB_FULL_URL").unwrap_or_default(),
|
||||
post.permalink
|
||||
)),
|
||||
..Default::default()
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.build();
|
||||
|
||||
// Serialize the feed to RSS
|
||||
let body = channel.to_string().into_bytes();
|
||||
|
||||
// Create the HTTP response
|
||||
let mut res = Response::new(Body::from(body));
|
||||
res.headers_mut().insert(CONTENT_TYPE, hyper::header::HeaderValue::from_static("application/rss+xml"));
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn test_fetching_subreddit() {
|
||||
let subreddit = subreddit("rust", false).await;
|
||||
assert!(subreddit.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn test_gated_and_quarantined() {
|
||||
let quarantined = subreddit("edgy", true).await;
|
||||
assert!(quarantined.is_ok());
|
||||
let gated = subreddit("drugs", true).await;
|
||||
assert!(gated.is_ok());
|
||||
}
|
||||
|
|
58
src/user.rs
|
@ -1,9 +1,14 @@
|
|||
#![allow(clippy::cmp_owned)]
|
||||
|
||||
// CRATES
|
||||
use crate::client::json;
|
||||
use crate::server::RequestExt;
|
||||
use crate::utils::{error, filter_posts, format_url, get_filters, nsfw_landing, param, setting, template, Post, Preferences, User};
|
||||
use askama::Template;
|
||||
use crate::{config, utils};
|
||||
use chrono::DateTime;
|
||||
use htmlescape::decode_html;
|
||||
use hyper::{Body, Request, Response};
|
||||
use rinja::Template;
|
||||
use time::{macros::format_description, OffsetDateTime};
|
||||
|
||||
// STRUCTS
|
||||
|
@ -129,6 +134,57 @@ async fn user(name: &str) -> Result<User, String> {
|
|||
})
|
||||
}
|
||||
|
||||
pub async fn rss(req: Request<Body>) -> Result<Response<Body>, String> {
|
||||
if config::get_setting("REDLIB_ENABLE_RSS").is_none() {
|
||||
return Ok(error(req, "RSS is disabled on this instance.").await.unwrap_or_default());
|
||||
}
|
||||
use crate::utils::rewrite_urls;
|
||||
use hyper::header::CONTENT_TYPE;
|
||||
use rss::{ChannelBuilder, Item};
|
||||
|
||||
// Get user
|
||||
let user_str = req.param("name").unwrap_or_default();
|
||||
|
||||
let listing = req.param("listing").unwrap_or_else(|| "overview".to_string());
|
||||
|
||||
// Get path
|
||||
let path = format!("/user/{user_str}/{listing}.json?{}&raw_json=1", req.uri().query().unwrap_or_default(),);
|
||||
|
||||
// Get user
|
||||
let user_obj = user(&user_str).await.unwrap_or_default();
|
||||
|
||||
// Get posts
|
||||
let (posts, _) = Post::fetch(&path, false).await?;
|
||||
|
||||
// Build the RSS feed
|
||||
let channel = ChannelBuilder::default()
|
||||
.title(user_str)
|
||||
.description(user_obj.description)
|
||||
.items(
|
||||
posts
|
||||
.into_iter()
|
||||
.map(|post| Item {
|
||||
title: Some(post.title.to_string()),
|
||||
link: Some(format_url(&utils::get_post_url(&post))),
|
||||
author: Some(post.author.name),
|
||||
pub_date: Some(DateTime::from_timestamp(post.created_ts as i64, 0).unwrap_or_default().to_rfc2822()),
|
||||
content: Some(rewrite_urls(&decode_html(&post.body).unwrap())),
|
||||
..Default::default()
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.build();
|
||||
|
||||
// Serialize the feed to RSS
|
||||
let body = channel.to_string().into_bytes();
|
||||
|
||||
// Create the HTTP response
|
||||
let mut res = Response::new(Body::from(body));
|
||||
res.headers_mut().insert(CONTENT_TYPE, hyper::header::HeaderValue::from_static("application/rss+xml"));
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn test_fetching_user() {
|
||||
let user = user("spez").await;
|
||||
|
|
599
src/utils.rs
|
@ -1,20 +1,28 @@
|
|||
#![allow(dead_code)]
|
||||
use crate::config::get_setting;
|
||||
#![allow(clippy::cmp_owned)]
|
||||
|
||||
use crate::config::{self, get_setting};
|
||||
//
|
||||
// CRATES
|
||||
//
|
||||
use crate::{client::json, server::RequestExt};
|
||||
use askama::Template;
|
||||
use cookie::Cookie;
|
||||
use hyper::{Body, Request, Response};
|
||||
use libflate::deflate::{Decoder, Encoder};
|
||||
use log::error;
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
use revision::revisioned;
|
||||
use rinja::Template;
|
||||
use rust_embed::RustEmbed;
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
use serde_json::Value;
|
||||
use serde_json_path::{JsonPath, JsonPathExt};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::env;
|
||||
use std::io::{Read, Write};
|
||||
use std::str::FromStr;
|
||||
use std::string::ToString;
|
||||
use time::{macros::format_description, Duration, OffsetDateTime};
|
||||
use url::Url;
|
||||
|
||||
|
@ -44,6 +52,7 @@ pub enum ResourceType {
|
|||
}
|
||||
|
||||
// Post flair with content, background color and foreground color
|
||||
#[derive(Serialize)]
|
||||
pub struct Flair {
|
||||
pub flair_parts: Vec<FlairPart>,
|
||||
pub text: String,
|
||||
|
@ -52,7 +61,7 @@ pub struct Flair {
|
|||
}
|
||||
|
||||
// Part of flair, either emoji or text
|
||||
#[derive(Clone)]
|
||||
#[derive(Clone, Serialize)]
|
||||
pub struct FlairPart {
|
||||
pub flair_part_type: String,
|
||||
pub value: String,
|
||||
|
@ -94,12 +103,14 @@ impl FlairPart {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Author {
|
||||
pub name: String,
|
||||
pub flair: Flair,
|
||||
pub distinguished: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Poll {
|
||||
pub poll_options: Vec<PollOption>,
|
||||
pub voting_end_timestamp: (String, String),
|
||||
|
@ -127,6 +138,7 @@ impl Poll {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct PollOption {
|
||||
pub id: u64,
|
||||
pub text: String,
|
||||
|
@ -156,19 +168,21 @@ impl PollOption {
|
|||
}
|
||||
|
||||
// Post flags with nsfw and stickied
|
||||
#[derive(Serialize)]
|
||||
pub struct Flags {
|
||||
pub spoiler: bool,
|
||||
pub nsfw: bool,
|
||||
pub stickied: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Media {
|
||||
pub url: String,
|
||||
pub alt_url: String,
|
||||
pub width: i64,
|
||||
pub height: i64,
|
||||
pub poster: String,
|
||||
pub download_name: String,
|
||||
}
|
||||
|
||||
impl Media {
|
||||
|
@ -222,6 +236,14 @@ impl Media {
|
|||
// If this post contains a gallery of images
|
||||
gallery = GalleryMedia::parse(&data["gallery_data"]["items"], &data["media_metadata"]);
|
||||
|
||||
("gallery", &data["url"], None)
|
||||
} else if data["crosspost_parent_list"][0]["is_gallery"].as_bool().unwrap_or_default() {
|
||||
// If this post contains a gallery of images
|
||||
gallery = GalleryMedia::parse(
|
||||
&data["crosspost_parent_list"][0]["gallery_data"]["items"],
|
||||
&data["crosspost_parent_list"][0]["media_metadata"],
|
||||
);
|
||||
|
||||
("gallery", &data["url"], None)
|
||||
} else if data["is_reddit_media_domain"].as_bool().unwrap_or_default() && data["domain"] == "i.redd.it" {
|
||||
// If this post contains a reddit media (image) URL.
|
||||
|
@ -235,6 +257,15 @@ impl Media {
|
|||
|
||||
let alt_url = alt_url_val.map_or(String::new(), |val| format_url(val.as_str().unwrap_or_default()));
|
||||
|
||||
let download_name = if post_type == "image" || post_type == "gif" || post_type == "video" {
|
||||
let permalink_base = url_path_basename(data["permalink"].as_str().unwrap_or_default());
|
||||
let media_url_base = url_path_basename(url_val.as_str().unwrap_or_default());
|
||||
|
||||
format!("redlib_{permalink_base}_{media_url_base}")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
(
|
||||
post_type.to_string(),
|
||||
Self {
|
||||
|
@ -245,12 +276,14 @@ impl Media {
|
|||
width: source["width"].as_i64().unwrap_or_default(),
|
||||
height: source["height"].as_i64().unwrap_or_default(),
|
||||
poster: format_url(source["url"].as_str().unwrap_or_default()),
|
||||
download_name,
|
||||
},
|
||||
gallery,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct GalleryMedia {
|
||||
pub url: String,
|
||||
pub width: i64,
|
||||
|
@ -291,6 +324,7 @@ impl GalleryMedia {
|
|||
}
|
||||
|
||||
// Post containing content, metadata and media
|
||||
#[derive(Serialize)]
|
||||
pub struct Post {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
|
@ -298,6 +332,7 @@ pub struct Post {
|
|||
pub body: String,
|
||||
pub author: Author,
|
||||
pub permalink: String,
|
||||
pub link_title: String,
|
||||
pub poll: Option<Poll>,
|
||||
pub score: (String, String),
|
||||
pub upvote_ratio: i64,
|
||||
|
@ -315,6 +350,7 @@ pub struct Post {
|
|||
pub gallery: Vec<GalleryMedia>,
|
||||
pub awards: Awards,
|
||||
pub nsfw: bool,
|
||||
pub out_url: Option<String>,
|
||||
pub ws_url: String,
|
||||
}
|
||||
|
||||
|
@ -388,6 +424,7 @@ impl Post {
|
|||
width: data["thumbnail_width"].as_i64().unwrap_or_default(),
|
||||
height: data["thumbnail_height"].as_i64().unwrap_or_default(),
|
||||
poster: String::new(),
|
||||
download_name: String::new(),
|
||||
},
|
||||
media,
|
||||
domain: val(post, "domain"),
|
||||
|
@ -411,6 +448,7 @@ impl Post {
|
|||
stickied: data["stickied"].as_bool().unwrap_or_default() || data["pinned"].as_bool().unwrap_or_default(),
|
||||
},
|
||||
permalink: val(post, "permalink"),
|
||||
link_title: val(post, "link_title"),
|
||||
poll: Poll::parse(&data["poll_data"]),
|
||||
rel_time,
|
||||
created,
|
||||
|
@ -421,10 +459,9 @@ impl Post {
|
|||
awards,
|
||||
nsfw: post["data"]["over_18"].as_bool().unwrap_or_default(),
|
||||
ws_url: val(post, "websocket_url"),
|
||||
out_url: post["data"]["url_overridden_by_dest"].as_str().map(|a| a.to_string()),
|
||||
});
|
||||
}
|
||||
posts.sort_by(|a, b| b.created_ts.cmp(&a.created_ts));
|
||||
posts.sort_by(|a, b| b.flags.stickied.cmp(&a.flags.stickied));
|
||||
Ok((posts, res["data"]["after"].as_str().unwrap_or_default().to_string()))
|
||||
}
|
||||
}
|
||||
|
@ -454,7 +491,7 @@ pub struct Comment {
|
|||
pub prefs: Preferences,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
#[derive(Default, Clone, Serialize)]
|
||||
pub struct Award {
|
||||
pub name: String,
|
||||
pub icon_url: String,
|
||||
|
@ -468,6 +505,7 @@ impl std::fmt::Display for Award {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Awards(pub Vec<Award>);
|
||||
|
||||
impl std::ops::Deref for Awards {
|
||||
|
@ -480,7 +518,7 @@ impl std::ops::Deref for Awards {
|
|||
|
||||
impl std::fmt::Display for Awards {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
self.iter().fold(Ok(()), |result, award| result.and_then(|()| writeln!(f, "{award}")))
|
||||
self.iter().try_fold((), |_, award| writeln!(f, "{award}"))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -515,6 +553,14 @@ pub struct ErrorTemplate {
|
|||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "info.html")]
|
||||
pub struct InfoTemplate {
|
||||
pub msg: String,
|
||||
pub prefs: Preferences,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
/// Template for NSFW landing page. The landing page is displayed when a page's
|
||||
/// content is wholly NSFW, but a user has not enabled the option to view NSFW
|
||||
/// posts.
|
||||
|
@ -574,28 +620,74 @@ pub struct Params {
|
|||
pub before: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
#[derive(Default, Serialize, Deserialize, Debug, PartialEq, Eq)]
|
||||
#[revisioned(revision = 1)]
|
||||
pub struct Preferences {
|
||||
#[revision(start = 1)]
|
||||
#[serde(skip_serializing, skip_deserializing)]
|
||||
pub available_themes: Vec<String>,
|
||||
#[revision(start = 1)]
|
||||
pub theme: String,
|
||||
#[revision(start = 1)]
|
||||
pub front_page: String,
|
||||
#[revision(start = 1)]
|
||||
pub layout: String,
|
||||
#[revision(start = 1)]
|
||||
pub wide: String,
|
||||
#[revision(start = 1)]
|
||||
pub blur_spoiler: String,
|
||||
#[revision(start = 1)]
|
||||
pub show_nsfw: String,
|
||||
#[revision(start = 1)]
|
||||
pub blur_nsfw: String,
|
||||
#[revision(start = 1)]
|
||||
pub hide_hls_notification: String,
|
||||
#[revision(start = 1)]
|
||||
pub video_quality: String,
|
||||
#[revision(start = 1)]
|
||||
pub hide_sidebar_and_summary: String,
|
||||
#[revision(start = 1)]
|
||||
pub use_hls: String,
|
||||
#[revision(start = 1)]
|
||||
pub autoplay_videos: String,
|
||||
#[revision(start = 1)]
|
||||
pub fixed_navbar: String,
|
||||
#[revision(start = 1)]
|
||||
pub disable_visit_reddit_confirmation: String,
|
||||
#[revision(start = 1)]
|
||||
pub comment_sort: String,
|
||||
#[revision(start = 1)]
|
||||
pub post_sort: String,
|
||||
#[revision(start = 1)]
|
||||
#[serde(serialize_with = "serialize_vec_with_plus", deserialize_with = "deserialize_vec_with_plus")]
|
||||
pub subscriptions: Vec<String>,
|
||||
#[revision(start = 1)]
|
||||
#[serde(serialize_with = "serialize_vec_with_plus", deserialize_with = "deserialize_vec_with_plus")]
|
||||
pub filters: Vec<String>,
|
||||
#[revision(start = 1)]
|
||||
pub hide_awards: String,
|
||||
#[revision(start = 1)]
|
||||
pub hide_score: String,
|
||||
#[revision(start = 1)]
|
||||
pub remove_default_feeds: String,
|
||||
}
|
||||
|
||||
fn serialize_vec_with_plus<S>(vec: &[String], serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.serialize_str(&vec.join("+"))
|
||||
}
|
||||
|
||||
fn deserialize_vec_with_plus<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let string = String::deserialize(deserializer)?;
|
||||
if string.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
Ok(string.split('+').map(|s| s.to_string()).collect())
|
||||
}
|
||||
|
||||
#[derive(RustEmbed)]
|
||||
|
@ -625,6 +717,7 @@ impl Preferences {
|
|||
blur_nsfw: setting(req, "blur_nsfw"),
|
||||
use_hls: setting(req, "use_hls"),
|
||||
hide_hls_notification: setting(req, "hide_hls_notification"),
|
||||
video_quality: setting(req, "video_quality"),
|
||||
autoplay_videos: setting(req, "autoplay_videos"),
|
||||
fixed_navbar: setting_or_default(req, "fixed_navbar", "on".to_string()),
|
||||
disable_visit_reddit_confirmation: setting(req, "disable_visit_reddit_confirmation"),
|
||||
|
@ -634,8 +727,36 @@ impl Preferences {
|
|||
filters: setting(req, "filters").split('+').map(String::from).filter(|s| !s.is_empty()).collect(),
|
||||
hide_awards: setting(req, "hide_awards"),
|
||||
hide_score: setting(req, "hide_score"),
|
||||
remove_default_feeds: setting(req, "remove_default_feeds"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_urlencoded(&self) -> Result<String, String> {
|
||||
serde_urlencoded::to_string(self).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn to_bincode(&self) -> Result<Vec<u8>, String> {
|
||||
bincode::serialize(self).map_err(|e| e.to_string())
|
||||
}
|
||||
pub fn to_compressed_bincode(&self) -> Result<Vec<u8>, String> {
|
||||
deflate_compress(self.to_bincode()?)
|
||||
}
|
||||
pub fn to_bincode_str(&self) -> Result<String, String> {
|
||||
Ok(base2048::encode(&self.to_compressed_bincode()?))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn deflate_compress(i: Vec<u8>) -> Result<Vec<u8>, String> {
|
||||
let mut e = Encoder::new(Vec::new());
|
||||
e.write_all(&i).map_err(|e| e.to_string())?;
|
||||
e.finish().into_result().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn deflate_decompress(i: Vec<u8>) -> Result<Vec<u8>, String> {
|
||||
let mut decoder = Decoder::new(&i[..]);
|
||||
let mut out = Vec::new();
|
||||
decoder.read_to_end(&mut out).map_err(|e| format!("Failed to read from gzip decoder: {}", e))?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Gets a `HashSet` of filters from the cookie in the given `Request`.
|
||||
|
@ -691,7 +812,15 @@ pub async fn parse_post(post: &Value) -> Post {
|
|||
get_setting("REDLIB_PUSHSHIFT_FRONTEND").unwrap_or_else(|| String::from(crate::config::DEFAULT_PUSHSHIFT_FRONTEND)),
|
||||
)
|
||||
} else {
|
||||
rewrite_urls(&val(post, "selftext_html"))
|
||||
let selftext = val(post, "selftext");
|
||||
if selftext.contains("```") {
|
||||
let mut html_output = String::new();
|
||||
let parser = pulldown_cmark::Parser::new(&selftext);
|
||||
pulldown_cmark::html::push_html(&mut html_output, parser);
|
||||
rewrite_urls(&html_output)
|
||||
} else {
|
||||
rewrite_urls(&val(post, "selftext_html"))
|
||||
}
|
||||
};
|
||||
|
||||
// Build a post using data parsed from Reddit post API
|
||||
|
@ -715,6 +844,7 @@ pub async fn parse_post(post: &Value) -> Post {
|
|||
distinguished: val(post, "distinguished"),
|
||||
},
|
||||
permalink,
|
||||
link_title: val(post, "link_title"),
|
||||
poll,
|
||||
score: format_num(score),
|
||||
upvote_ratio: ratio as i64,
|
||||
|
@ -726,6 +856,7 @@ pub async fn parse_post(post: &Value) -> Post {
|
|||
width: post["data"]["thumbnail_width"].as_i64().unwrap_or_default(),
|
||||
height: post["data"]["thumbnail_height"].as_i64().unwrap_or_default(),
|
||||
poster: String::new(),
|
||||
download_name: String::new(),
|
||||
},
|
||||
flair: Flair {
|
||||
flair_parts: FlairPart::parse(
|
||||
|
@ -756,6 +887,7 @@ pub async fn parse_post(post: &Value) -> Post {
|
|||
awards,
|
||||
nsfw: post["data"]["over_18"].as_bool().unwrap_or_default(),
|
||||
ws_url: val(post, "websocket_url"),
|
||||
out_url: post["data"]["url_overridden_by_dest"].as_str().map(|a| a.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -779,18 +911,72 @@ pub fn param(path: &str, value: &str) -> Option<String> {
|
|||
// Retrieve the value of a setting by name
|
||||
pub fn setting(req: &Request<Body>, name: &str) -> String {
|
||||
// Parse a cookie value from request
|
||||
req
|
||||
.cookie(name)
|
||||
.unwrap_or_else(|| {
|
||||
// If there is no cookie for this setting, try receiving a default from the config
|
||||
if let Some(default) = get_setting(&format!("REDLIB_DEFAULT_{}", name.to_uppercase())) {
|
||||
Cookie::new(name, default)
|
||||
} else {
|
||||
Cookie::from(name)
|
||||
}
|
||||
})
|
||||
.value()
|
||||
.to_string()
|
||||
|
||||
// If this was called with "subscriptions" and the "subscriptions" cookie has a value
|
||||
if name == "subscriptions" && req.cookie("subscriptions").is_some() {
|
||||
// Create subscriptions string
|
||||
let mut subscriptions = String::new();
|
||||
|
||||
// Default subscriptions cookie
|
||||
if req.cookie("subscriptions").is_some() {
|
||||
subscriptions.push_str(req.cookie("subscriptions").unwrap().value());
|
||||
}
|
||||
|
||||
// Start with first numbered subscription cookie
|
||||
let mut subscriptions_number = 1;
|
||||
|
||||
// While whatever subscriptionsNUMBER cookie we're looking at has a value
|
||||
while req.cookie(&format!("subscriptions{}", subscriptions_number)).is_some() {
|
||||
// Push whatever subscriptionsNUMBER cookie we're looking at into the subscriptions string
|
||||
subscriptions.push_str(req.cookie(&format!("subscriptions{}", subscriptions_number)).unwrap().value());
|
||||
|
||||
// Increment subscription cookie number
|
||||
subscriptions_number += 1;
|
||||
}
|
||||
|
||||
// Return the subscriptions cookies as one large string
|
||||
subscriptions
|
||||
}
|
||||
// If this was called with "filters" and the "filters" cookie has a value
|
||||
else if name == "filters" && req.cookie("filters").is_some() {
|
||||
// Create filters string
|
||||
let mut filters = String::new();
|
||||
|
||||
// Default filters cookie
|
||||
if req.cookie("filters").is_some() {
|
||||
filters.push_str(req.cookie("filters").unwrap().value());
|
||||
}
|
||||
|
||||
// Start with first numbered filters cookie
|
||||
let mut filters_number = 1;
|
||||
|
||||
// While whatever filtersNUMBER cookie we're looking at has a value
|
||||
while req.cookie(&format!("filters{}", filters_number)).is_some() {
|
||||
// Push whatever filtersNUMBER cookie we're looking at into the filters string
|
||||
filters.push_str(req.cookie(&format!("filters{}", filters_number)).unwrap().value());
|
||||
|
||||
// Increment filters cookie number
|
||||
filters_number += 1;
|
||||
}
|
||||
|
||||
// Return the filters cookies as one large string
|
||||
filters
|
||||
}
|
||||
// The above two still come to this if there was no existing value
|
||||
else {
|
||||
req
|
||||
.cookie(name)
|
||||
.unwrap_or_else(|| {
|
||||
// If there is no cookie for this setting, try receiving a default from the config
|
||||
if let Some(default) = get_setting(&format!("REDLIB_DEFAULT_{}", name.to_uppercase())) {
|
||||
Cookie::new(name, default)
|
||||
} else {
|
||||
Cookie::from(name)
|
||||
}
|
||||
})
|
||||
.value()
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
// Retrieve the value of a setting by name or the default value
|
||||
|
@ -806,11 +992,12 @@ pub fn setting_or_default(req: &Request<Body>, name: &str, default: String) -> S
|
|||
// Detect and redirect in the event of a random subreddit
|
||||
pub async fn catch_random(sub: &str, additional: &str) -> Result<Response<Body>, String> {
|
||||
if sub == "random" || sub == "randnsfw" {
|
||||
let new_sub = json(format!("/r/{sub}/about.json?raw_json=1"), false).await?["data"]["display_name"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
Ok(redirect(&format!("/r/{new_sub}{additional}")))
|
||||
Ok(redirect(&format!(
|
||||
"/r/{}{additional}",
|
||||
json(format!("/r/{sub}/about.json?raw_json=1"), false).await?["data"]["display_name"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
)))
|
||||
} else {
|
||||
Err("No redirect needed".to_string())
|
||||
}
|
||||
|
@ -888,9 +1075,20 @@ pub fn format_url(url: &str) -> String {
|
|||
}
|
||||
}
|
||||
|
||||
static REGEX_BULLET: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?m)^- (.*)$").unwrap());
|
||||
static REGEX_BULLET_CONSECUTIVE_LINES: Lazy<Regex> = Lazy::new(|| Regex::new(r"</ul>\n<ul>").unwrap());
|
||||
|
||||
pub fn render_bullet_lists(input_text: &str) -> String {
|
||||
// ref: https://stackoverflow.com/a/4902622
|
||||
// First enclose each bullet with <ul> <li> tags
|
||||
let text1 = REGEX_BULLET.replace_all(input_text, "<ul><li>$1</li></ul>").to_string();
|
||||
// Then remove any consecutive </ul> <ul> tags
|
||||
REGEX_BULLET_CONSECUTIVE_LINES.replace_all(&text1, "").to_string()
|
||||
}
|
||||
|
||||
// These are links we want to replace in-body
|
||||
static REDDIT_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r#"href="(https|http|)://(www\.|old\.|np\.|amp\.|new\.|)(reddit\.com|redd\.it)/"#).unwrap());
|
||||
static REDDIT_PREVIEW_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"https?://(external-preview|preview|i)\.redd\.it(.*)[^?]").unwrap());
|
||||
static REDDIT_PREVIEW_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"https?://(external-preview|preview|i)\.redd\.it(.*)").unwrap());
|
||||
static REDDIT_EMOJI_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"https?://(www|).redditstatic\.com/(.*)").unwrap());
|
||||
static REDLIB_PREVIEW_LINK_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r#"/(img|preview/)(pre|external-pre)?/(.*?)>"#).unwrap());
|
||||
static REDLIB_PREVIEW_TEXT_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r">(.*?)</a>").unwrap());
|
||||
|
@ -899,14 +1097,20 @@ static REDLIB_PREVIEW_TEXT_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r">(.*?)
|
|||
pub fn rewrite_urls(input_text: &str) -> String {
|
||||
let mut text1 =
|
||||
// Rewrite Reddit links to Redlib
|
||||
REDDIT_REGEX.replace_all(input_text, r#"href="/"#)
|
||||
.to_string();
|
||||
text1 = REDDIT_EMOJI_REGEX
|
||||
.replace_all(&text1, format_url(REDDIT_EMOJI_REGEX.find(&text1).map(|x| x.as_str()).unwrap_or_default()))
|
||||
.to_string()
|
||||
// Remove (html-encoded) "\" from URLs.
|
||||
.replace("%5C", "")
|
||||
.replace("\\_", "_");
|
||||
REDDIT_REGEX.replace_all(input_text, r#"href="/"#).to_string();
|
||||
|
||||
loop {
|
||||
if REDDIT_EMOJI_REGEX.find(&text1).is_none() {
|
||||
break;
|
||||
} else {
|
||||
text1 = REDDIT_EMOJI_REGEX
|
||||
.replace_all(&text1, format_url(REDDIT_EMOJI_REGEX.find(&text1).map(|x| x.as_str()).unwrap_or_default()))
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
// Remove (html-encoded) "\" from URLs.
|
||||
text1 = text1.replace("%5C", "").replace("\\_", "_");
|
||||
|
||||
// Rewrite external media previews to Redlib
|
||||
loop {
|
||||
|
@ -915,53 +1119,129 @@ pub fn rewrite_urls(input_text: &str) -> String {
|
|||
} else {
|
||||
let formatted_url = format_url(REDDIT_PREVIEW_REGEX.find(&text1).map(|x| x.as_str()).unwrap_or_default());
|
||||
|
||||
let image_url = REDLIB_PREVIEW_LINK_REGEX.find(&formatted_url).map_or("", |m| m.as_str()).to_string();
|
||||
let mut image_caption = REDLIB_PREVIEW_TEXT_REGEX.find(&formatted_url).map_or("", |m| m.as_str()).to_string();
|
||||
let image_url = REDLIB_PREVIEW_LINK_REGEX.find(&formatted_url).map_or("", |m| m.as_str());
|
||||
let mut image_caption = REDLIB_PREVIEW_TEXT_REGEX.find(&formatted_url).map_or("", |m| m.as_str());
|
||||
|
||||
/* As long as image_caption isn't empty remove first and last four characters of image_text to leave us with just the text in the caption without any HTML.
|
||||
This makes it possible to enclose it in a <figcaption> later on without having stray HTML breaking it */
|
||||
if !image_caption.is_empty() {
|
||||
image_caption = image_caption[1..image_caption.len() - 4].to_string();
|
||||
image_caption = &image_caption[1..image_caption.len() - 4];
|
||||
}
|
||||
|
||||
// image_url contains > at the end of it, and right above this we remove image_text's front >, leaving us with just a single > between them
|
||||
let image_to_replace = format!("<a href=\"{image_url}{image_caption}</a>");
|
||||
|
||||
// _image_replacement needs to be in scope for the replacement at the bottom of the loop
|
||||
let mut _image_replacement = String::new();
|
||||
let image_to_replace = format!("<p><a href=\"{image_url}{image_caption}</a></p>");
|
||||
|
||||
/* We don't want to show a caption that's just the image's link, so we check if we find a Reddit preview link within the image's caption.
|
||||
If we don't find one we must have actual text, so we include a <figcaption> block that contains it.
|
||||
Otherwise we don't include the <figcaption> block as we don't need it. */
|
||||
if REDDIT_PREVIEW_REGEX.find(&image_caption).is_none() {
|
||||
let _image_replacement = if REDDIT_PREVIEW_REGEX.find(image_caption).is_none() {
|
||||
// Without this " would show as \" instead. "\"" is how the quotes are formatted within image_text beforehand
|
||||
image_caption = image_caption.replace("\\"", "\"");
|
||||
|
||||
_image_replacement = format!("<figure><a href=\"{image_url}<img loading=\"lazy\" src=\"{image_url}</a><figcaption>{image_caption}</figcaption></figure>");
|
||||
format!(
|
||||
"<figure><a href=\"{image_url}<img loading=\"lazy\" src=\"{image_url}</a><figcaption>{}</figcaption></figure>",
|
||||
image_caption.replace("\\"", "\"")
|
||||
)
|
||||
} else {
|
||||
_image_replacement = format!("<figure><a href=\"{image_url}<img loading=\"lazy\" src=\"{image_url}</a></figure>");
|
||||
}
|
||||
format!("<figure><a href=\"{image_url}<img loading=\"lazy\" src=\"{image_url}</a></figure>")
|
||||
};
|
||||
|
||||
/* In order to know if we're dealing with a normal or external preview we need to take a look at the first capture group of REDDIT_PREVIEW_REGEX
|
||||
if it's preview we're dealing with something that needs /preview/pre, external-preview is /preview/external-pre, and i is /img */
|
||||
let reddit_preview_regex_capture = REDDIT_PREVIEW_REGEX.captures(&text1).unwrap().get(1).map_or("", |m| m.as_str()).to_string();
|
||||
let mut _preview_type = String::new();
|
||||
if reddit_preview_regex_capture == "preview" {
|
||||
_preview_type = "/preview/pre".to_string();
|
||||
} else if reddit_preview_regex_capture == "external-preview" {
|
||||
_preview_type = "/preview/external-pre".to_string();
|
||||
} else {
|
||||
_preview_type = "/img".to_string();
|
||||
}
|
||||
let reddit_preview_regex_capture = REDDIT_PREVIEW_REGEX.captures(&text1).unwrap().get(1).map_or("", |m| m.as_str());
|
||||
|
||||
let _preview_type = match reddit_preview_regex_capture {
|
||||
"preview" => "/preview/pre",
|
||||
"external-preview" => "/preview/external-pre",
|
||||
_ => "/img",
|
||||
};
|
||||
|
||||
text1 = REDDIT_PREVIEW_REGEX
|
||||
.replace(&text1, format!("{_preview_type}$2"))
|
||||
.replace(&image_to_replace, &_image_replacement)
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// These links all follow a pattern of "https://reddit-econ-prod-assets-permanent.s3.amazonaws.com/asset-manager/SUBREDDIT_ID/RANDOM_FILENAME.png"
|
||||
static REDDIT_EMOTE_LINK_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r#"https://reddit-econ-prod-assets-permanent.s3.amazonaws.com/asset-manager/(.*)"#).unwrap());
|
||||
|
||||
// These all follow a pattern of '"emote|SUBREDDIT_IT|NUMBER"', we want the number
|
||||
static REDDIT_EMOTE_ID_NUMBER_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r#""emote\|.*\|(.*)""#).unwrap());
|
||||
|
||||
pub fn rewrite_emotes(media_metadata: &Value, comment: String) -> String {
|
||||
/* Create the paths we'll use to look for our data inside the json.
|
||||
Because we don't know the name of any given emote we use a wildcard to parse them. */
|
||||
let link_path = JsonPath::parse("$[*].s.u").expect("valid JSON Path");
|
||||
let id_path = JsonPath::parse("$[*].id").expect("valid JSON Path");
|
||||
let size_path = JsonPath::parse("$[*].s.y").expect("valid JSON Path");
|
||||
|
||||
// Extract all of the results from those json paths
|
||||
let link_nodes = media_metadata.json_path(&link_path);
|
||||
let id_nodes = media_metadata.json_path(&id_path);
|
||||
|
||||
// Initialize our vectors
|
||||
let mut id_vec = Vec::new();
|
||||
let mut link_vec = Vec::new();
|
||||
|
||||
// Add the relevant data to each of our vectors so we can access it by number later
|
||||
for current_id in id_nodes {
|
||||
id_vec.push(current_id)
|
||||
}
|
||||
for current_link in link_nodes {
|
||||
link_vec.push(current_link)
|
||||
}
|
||||
|
||||
/* Set index to the length of link_vec.
|
||||
This is one larger than we'll actually be looking at, but we correct that later */
|
||||
let mut index = link_vec.len();
|
||||
|
||||
// Comment needs to be in scope for when we call rewrite_urls()
|
||||
let mut comment = comment;
|
||||
|
||||
/* Loop until index hits zero.
|
||||
This also prevents us from trying to do anything on an empty vector */
|
||||
while index != 0 {
|
||||
/* Subtract 1 from index to get the real index we should be looking at.
|
||||
Then continue on each subsequent loop to continue until we hit the last entry in the vector.
|
||||
This is how we get this to deal with multiple emotes in a single message and properly replace each ID with it's link */
|
||||
index -= 1;
|
||||
|
||||
// Convert our current index in id_vec into a string so we can search through it with regex
|
||||
let current_id = id_vec[index].to_string();
|
||||
|
||||
/* The ID number can be multiple lengths, so we capture it with regex.
|
||||
We also want to only attempt anything when we get matches to avoid panicking */
|
||||
if let Some(id_capture) = REDDIT_EMOTE_ID_NUMBER_REGEX.captures(¤t_id) {
|
||||
// Format the ID to include the colons it has in the comment text
|
||||
let id = format!(":{}:", &id_capture[1]);
|
||||
|
||||
// Convert current link to string to search through it with the regex
|
||||
let link = link_vec[index].to_string();
|
||||
|
||||
// Make sure we only do operations when we get matches, otherwise we panic when trying to access the first match
|
||||
if let Some(link_capture) = REDDIT_EMOTE_LINK_REGEX.captures(&link) {
|
||||
/* Reddit sends a size for the image based on whether it's alone or accompanied by text.
|
||||
It's a good idea and makes everything look nicer, so we'll do the same. */
|
||||
let size = media_metadata.json_path(&size_path).first().unwrap().to_string();
|
||||
|
||||
// Replace the ID we found earlier in the comment with the respective image and it's link from the regex capture
|
||||
let to_replace_with = format!(
|
||||
"<img loading=\"lazy\" src=\"/emote/{} width=\"{size}\" height=\"{size}\" style=\"vertical-align:text-bottom\">",
|
||||
&link_capture[1]
|
||||
);
|
||||
|
||||
// Inside the comment replace the ID we found with the string that will embed the image
|
||||
comment = comment.replace(&id, &to_replace_with);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// render bullet (unordered) lists
|
||||
comment = render_bullet_lists(&comment);
|
||||
|
||||
// Call rewrite_urls() to transform any other Reddit links
|
||||
rewrite_urls(&comment)
|
||||
}
|
||||
|
||||
// Format vote count to a string that will be displayed.
|
||||
// Append `m` and `k` for millions and thousands respectively, and
|
||||
// round to the nearest tenth.
|
||||
|
@ -1054,6 +1334,20 @@ pub async fn error(req: Request<Body>, msg: &str) -> Result<Response<Body>, Stri
|
|||
Ok(Response::builder().status(404).header("content-type", "text/html").body(body.into()).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Renders a generic info landing page.
|
||||
pub async fn info(req: Request<Body>, msg: &str) -> Result<Response<Body>, String> {
|
||||
let url = req.uri().to_string();
|
||||
let body = InfoTemplate {
|
||||
msg: msg.to_string(),
|
||||
prefs: Preferences::new(&req),
|
||||
url,
|
||||
}
|
||||
.render()
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(Response::builder().status(200).header("content-type", "text/html").body(body.into()).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Returns true if the config/env variable `REDLIB_SFW_ONLY` carries the
|
||||
/// value `on`.
|
||||
///
|
||||
|
@ -1068,6 +1362,28 @@ pub fn sfw_only() -> bool {
|
|||
}
|
||||
}
|
||||
|
||||
/// Returns true if the config/env variable REDLIB_ENABLE_RSS is set to "on".
|
||||
/// If this variable is set as such, the instance will enable RSS feeds.
|
||||
/// Otherwise, the instance will not provide RSS feeds.
|
||||
pub fn enable_rss() -> bool {
|
||||
match get_setting("REDLIB_ENABLE_RSS") {
|
||||
Some(val) => val == "on",
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if the config/env variable `REDLIB_ROBOTS_DISABLE_INDEXING` carries the
|
||||
/// value `on`.
|
||||
///
|
||||
/// If this variable is set as such, the instance will block all robots in robots.txt and
|
||||
/// insert the noindex, nofollow meta tag on every page.
|
||||
pub fn disable_indexing() -> bool {
|
||||
match get_setting("REDLIB_ROBOTS_DISABLE_INDEXING") {
|
||||
Some(val) => val == "on",
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
// Determines if a request shoud redirect to a nsfw landing gate.
|
||||
pub fn should_be_nsfw_gated(req: &Request<Body>, req_url: &str) -> bool {
|
||||
let sfw_instance = sfw_only();
|
||||
|
@ -1109,9 +1425,37 @@ pub async fn nsfw_landing(req: Request<Body>, req_url: String) -> Result<Respons
|
|||
Ok(Response::builder().status(403).header("content-type", "text/html").body(body.into()).unwrap_or_default())
|
||||
}
|
||||
|
||||
// Returns the last (non-empty) segment of a path string
|
||||
pub fn url_path_basename(path: &str) -> String {
|
||||
let url_result = Url::parse(format!("https://libredd.it/{path}").as_str());
|
||||
|
||||
if url_result.is_err() {
|
||||
path.to_string()
|
||||
} else {
|
||||
let mut url = url_result.unwrap();
|
||||
url.path_segments_mut().unwrap().pop_if_empty();
|
||||
|
||||
url.path_segments().unwrap().next_back().unwrap().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
// Returns the URL of a post, as needed by RSS feeds
|
||||
pub fn get_post_url(post: &Post) -> String {
|
||||
if let Some(out_url) = &post.out_url {
|
||||
// Handle cross post
|
||||
if out_url.starts_with("/r/") {
|
||||
format!("{}{}", config::get_setting("REDLIB_FULL_URL").unwrap_or_default(), out_url)
|
||||
} else {
|
||||
out_url.to_string()
|
||||
}
|
||||
} else {
|
||||
format!("{}{}", config::get_setting("REDLIB_FULL_URL").unwrap_or_default(), post.permalink)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{format_num, format_url, rewrite_urls};
|
||||
use super::{format_num, format_url, rewrite_urls, Preferences};
|
||||
|
||||
#[test]
|
||||
fn format_num_works() {
|
||||
|
@ -1178,6 +1522,36 @@ mod tests {
|
|||
assert_eq!(format_url("nsfw"), "");
|
||||
assert_eq!(format_url("spoiler"), "");
|
||||
}
|
||||
#[test]
|
||||
fn serialize_prefs() {
|
||||
let prefs = Preferences {
|
||||
available_themes: vec![],
|
||||
theme: "laserwave".to_owned(),
|
||||
front_page: "default".to_owned(),
|
||||
layout: "compact".to_owned(),
|
||||
wide: "on".to_owned(),
|
||||
blur_spoiler: "on".to_owned(),
|
||||
show_nsfw: "off".to_owned(),
|
||||
blur_nsfw: "on".to_owned(),
|
||||
hide_hls_notification: "off".to_owned(),
|
||||
video_quality: "best".to_owned(),
|
||||
hide_sidebar_and_summary: "off".to_owned(),
|
||||
use_hls: "on".to_owned(),
|
||||
autoplay_videos: "on".to_owned(),
|
||||
fixed_navbar: "on".to_owned(),
|
||||
disable_visit_reddit_confirmation: "on".to_owned(),
|
||||
comment_sort: "confidence".to_owned(),
|
||||
post_sort: "top".to_owned(),
|
||||
subscriptions: vec!["memes".to_owned(), "mildlyinteresting".to_owned()],
|
||||
filters: vec![],
|
||||
hide_awards: "off".to_owned(),
|
||||
hide_score: "off".to_owned(),
|
||||
remove_default_feeds: "off".to_owned(),
|
||||
};
|
||||
let urlencoded = serde_urlencoded::to_string(prefs).expect("Failed to serialize Prefs");
|
||||
|
||||
assert_eq!(urlencoded, "theme=laserwave&front_page=default&layout=compact&wide=on&blur_spoiler=on&show_nsfw=off&blur_nsfw=on&hide_hls_notification=off&video_quality=best&hide_sidebar_and_summary=off&use_hls=on&autoplay_videos=on&fixed_navbar=on&disable_visit_reddit_confirmation=on&comment_sort=confidence&post_sort=top&subscriptions=memes%2Bmildlyinteresting&filters=&hide_awards=off&hide_score=off&remove_default_feeds=off");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
@ -1196,7 +1570,10 @@ async fn test_fetching_subreddit_quarantined() {
|
|||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn test_fetching_nsfw_subreddit() {
|
||||
let subreddit = Post::fetch("/r/randnsfw", false).await;
|
||||
// Gonwild is a place for closed, Euclidean Geometric shapes to exchange their nth terms for karma; showing off their edges in a comfortable environment without pressure.
|
||||
// Find a good sub that is tagged NSFW but that actually isn't in case my future employers are watching (they probably are)
|
||||
// switched from randnsfw as it is no longer functional.
|
||||
let subreddit = Post::fetch("/r/gonwild", false).await;
|
||||
assert!(subreddit.is_ok());
|
||||
assert!(!subreddit.unwrap().0.is_empty());
|
||||
}
|
||||
|
@ -1214,6 +1591,104 @@ async fn test_fetching_ws() {
|
|||
fn test_rewriting_image_links() {
|
||||
let input =
|
||||
r#"<p><a href="https://preview.redd.it/6awags382xo31.png?width=2560&format=png&auto=webp&s=9c563aed4f07a91bdd249b5a3cea43a79710dcfc">caption 1</a></p>"#;
|
||||
let output = r#"<p><figure><a href="/preview/pre/6awags382xo31.png?width=2560&format=png&auto=webp&s=9c563aed4f07a91bdd249b5a3cea43a79710dcfc"><img loading="lazy" src="/preview/pre/6awags382xo31.png?width=2560&format=png&auto=webp&s=9c563aed4f07a91bdd249b5a3cea43a79710dcfc"></a><figcaption>caption 1</figcaption></figure></p"#;
|
||||
let output = r#"<figure><a href="/preview/pre/6awags382xo31.png?width=2560&format=png&auto=webp&s=9c563aed4f07a91bdd249b5a3cea43a79710dcfc"><img loading="lazy" src="/preview/pre/6awags382xo31.png?width=2560&format=png&auto=webp&s=9c563aed4f07a91bdd249b5a3cea43a79710dcfc"></a><figcaption>caption 1</figcaption></figure>"#;
|
||||
assert_eq!(rewrite_urls(input), output);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_url_path_basename() {
|
||||
// without trailing slash
|
||||
assert_eq!(url_path_basename("/first/last"), "last");
|
||||
// with trailing slash
|
||||
assert_eq!(url_path_basename("/first/last/"), "last");
|
||||
// with query parameters
|
||||
assert_eq!(url_path_basename("/first/last/?some=query"), "last");
|
||||
// file path
|
||||
assert_eq!(url_path_basename("/cdn/image.jpg"), "image.jpg");
|
||||
// when a full url is passed instead of just a path
|
||||
assert_eq!(url_path_basename("https://doma.in/first/last"), "last");
|
||||
// empty path
|
||||
assert_eq!(url_path_basename("/"), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rewriting_emotes() {
|
||||
let json_input = serde_json::from_str(r#"{"emote|t5_31hpy|2028":{"e":"Image","id":"emote|t5_31hpy|2028","m":"image/png","s":{"u":"https://reddit-econ-prod-assets-permanent.s3.amazonaws.com/asset-manager/t5_31hpy/PW6WsOaLcd.png","x":60,"y":60},"status":"valid","t":"sticker"}}"#).expect("Valid JSON");
|
||||
let comment_input = r#"<div class="comment_body "><div class="md"><p>:2028:</p></div></div>"#;
|
||||
let output = r#"<div class="comment_body "><div class="md"><p><img loading="lazy" src="/emote/t5_31hpy/PW6WsOaLcd.png" width="60" height="60" style="vertical-align:text-bottom"></p></div></div>"#;
|
||||
assert_eq!(rewrite_emotes(&json_input, comment_input.to_string()), output);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rewriting_bullet_list() {
|
||||
let input = r#"<div class="md"><p>Hi, I've bought this very same monitor and found no calibration whatsoever. I have an ICC profile that has been set up since I've installed its driver from the LG website and it works ok. I also used <a href="http://www.lagom.nl/lcd-test/">http://www.lagom.nl/lcd-test/</a> to calibrate it. After some good tinkering I've found the following settings + the color profile from the driver gets me past all the tests perfectly:
|
||||
- Brightness 50 (still have to settle on this one, it's personal preference, it controls the backlight, not the colors)
|
||||
- Contrast 70 (which for me was the default one)
|
||||
- Picture mode Custom
|
||||
- Super resolution + Off (it looks horrible anyway)
|
||||
- Sharpness 50 (default one I think)
|
||||
- Black level High (low messes up gray colors)
|
||||
- DFC Off
|
||||
- Response Time Middle (personal preference, <a href="https://www.blurbusters.com/">https://www.blurbusters.com/</a> show horrible overdrive with it on high)
|
||||
- Freesync doesn't matter
|
||||
- Black stabilizer 50
|
||||
- Gamma setting on 0
|
||||
- Color Temp Medium
|
||||
How`s your monitor by the way? Any IPS bleed whatsoever? I either got lucky or the panel is pretty good, 0 bleed for me, just the usual IPS glow. How about the pixels? I see the pixels even at one meter away, especially on Microsoft Edge's icon for example, the blue background is just blocky, don't know why.</p>
|
||||
</div>"#;
|
||||
let output = r#"<div class="md"><p>Hi, I've bought this very same monitor and found no calibration whatsoever. I have an ICC profile that has been set up since I've installed its driver from the LG website and it works ok. I also used <a href="http://www.lagom.nl/lcd-test/">http://www.lagom.nl/lcd-test/</a> to calibrate it. After some good tinkering I've found the following settings + the color profile from the driver gets me past all the tests perfectly:
|
||||
<ul><li>Brightness 50 (still have to settle on this one, it's personal preference, it controls the backlight, not the colors)</li><li>Contrast 70 (which for me was the default one)</li><li>Picture mode Custom</li><li>Super resolution + Off (it looks horrible anyway)</li><li>Sharpness 50 (default one I think)</li><li>Black level High (low messes up gray colors)</li><li>DFC Off </li><li>Response Time Middle (personal preference, <a href="https://www.blurbusters.com/">https://www.blurbusters.com/</a> show horrible overdrive with it on high)</li><li>Freesync doesn't matter</li><li>Black stabilizer 50</li><li>Gamma setting on 0 </li><li>Color Temp Medium</li></ul>
|
||||
How`s your monitor by the way? Any IPS bleed whatsoever? I either got lucky or the panel is pretty good, 0 bleed for me, just the usual IPS glow. How about the pixels? I see the pixels even at one meter away, especially on Microsoft Edge's icon for example, the blue background is just blocky, don't know why.</p>
|
||||
</div>"#;
|
||||
|
||||
assert_eq!(render_bullet_lists(input), output);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_prefs_serialization_loop_json() {
|
||||
let prefs = Preferences::default();
|
||||
let serialized = serde_json::to_string(&prefs).unwrap();
|
||||
let deserialized: Preferences = serde_json::from_str(&serialized).unwrap();
|
||||
assert_eq!(prefs, deserialized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_prefs_serialization_loop_bincode() {
|
||||
let prefs = Preferences::default();
|
||||
test_round_trip(&prefs, false);
|
||||
test_round_trip(&prefs, true);
|
||||
}
|
||||
|
||||
static KNOWN_GOOD_CONFIGS: &[&str] = &[
|
||||
"ఴӅβØØҞÉဏႢձĬ༧ȒʯऌԔӵ୮༏",
|
||||
"ਧՊΥÀÃǎƱГ۸ඣമĖฤ႙ʟาúໜϾௐɥঀĜໃહཞઠѫҲɂఙ࿔DzઉƲӟӻĻฅΜδ໖ԜǗဖငƦơ৶Ą௩ԹʛใЛʃශаΏ",
|
||||
"ਧԩΥÀÃΊ౭൩ඔႠϼҭöҪƸռઇԾॐნɔາǒՍҰच௨ಖມŃЉŐདƦ๙ϩএఠȝഽйʮჯඒϰळՋ௮ສ৵ऎΦѧਹಧଟƙŃ३î༦ŌပղयƟแҜ།",
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn test_known_good_configs_deserialization() {
|
||||
for config in KNOWN_GOOD_CONFIGS {
|
||||
let bytes = base2048::decode(config).unwrap();
|
||||
let decompressed = deflate_decompress(bytes).unwrap();
|
||||
assert!(bincode::deserialize::<Preferences>(&decompressed).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_known_good_configs_full_round_trip() {
|
||||
for config in KNOWN_GOOD_CONFIGS {
|
||||
let bytes = base2048::decode(config).unwrap();
|
||||
let decompressed = deflate_decompress(bytes).unwrap();
|
||||
let prefs: Preferences = bincode::deserialize(&decompressed).unwrap();
|
||||
test_round_trip(&prefs, false);
|
||||
test_round_trip(&prefs, true);
|
||||
}
|
||||
}
|
||||
|
||||
fn test_round_trip(input: &Preferences, compression: bool) {
|
||||
let serialized = bincode::serialize(input).unwrap();
|
||||
let compressed = if compression { deflate_compress(serialized).unwrap() } else { serialized };
|
||||
let decompressed = if compression { deflate_decompress(compressed).unwrap() } else { compressed };
|
||||
let deserialized: Preferences = bincode::deserialize(&decompressed).unwrap();
|
||||
assert_eq!(*input, deserialized);
|
||||
}
|
||||
|
|
Before Width: | Height: | Size: 8 KiB After Width: | Height: | Size: 1.5 KiB |
58
static/check_update.js
Normal file
|
@ -0,0 +1,58 @@
|
|||
async function checkInstanceUpdateStatus() {
|
||||
try {
|
||||
const response = await fetch('/commits.atom');
|
||||
const text = await response.text();
|
||||
const parser = new DOMParser();
|
||||
const xmlDoc = parser.parseFromString(text, "application/xml");
|
||||
const entries = xmlDoc.getElementsByTagName('entry');
|
||||
const localCommit = document.getElementById('git_commit').dataset.value;
|
||||
|
||||
let statusMessage = '';
|
||||
|
||||
if (entries.length > 0) {
|
||||
const commitHashes = Array.from(entries).map(entry => {
|
||||
const id = entry.getElementsByTagName('id')[0].textContent;
|
||||
return id.split('/').pop();
|
||||
});
|
||||
|
||||
const commitIndex = commitHashes.indexOf(localCommit);
|
||||
|
||||
if (commitIndex === 0) {
|
||||
statusMessage = '✅ Instance is up to date.';
|
||||
} else if (commitIndex > 0) {
|
||||
statusMessage = `⚠️ This instance is not up to date and is ${commitIndex} commits old. Test and confirm on an up-to-date instance before reporting.`;
|
||||
document.getElementById('error-318').remove();
|
||||
} else {
|
||||
statusMessage = `⚠️ This instance is not up to date and is at least ${commitHashes.length} commits old. Test and confirm on an up-to-date instance before reporting.`;
|
||||
document.getElementById('error-318').remove();
|
||||
}
|
||||
} else {
|
||||
statusMessage = '⚠️ Unable to fetch commit information.';
|
||||
}
|
||||
|
||||
document.getElementById('update-status').innerText = statusMessage;
|
||||
} catch (error) {
|
||||
console.error('Error fetching commits:', error);
|
||||
document.getElementById('update-status').innerText = '⚠️ Error checking update status: ' + error;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkOtherInstances() {
|
||||
try {
|
||||
const response = await fetch('/instances.json');
|
||||
const data = await response.json();
|
||||
const randomInstance = data.instances[Math.floor(Math.random() * data.instances.length)];
|
||||
const instanceUrl = randomInstance.url;
|
||||
// Set the href of the <a> tag to the instance URL with path included
|
||||
document.getElementById('random-instance').href = instanceUrl + window.location.pathname;
|
||||
document.getElementById('random-instance').innerText = "Visit Random Instance";
|
||||
} catch (error) {
|
||||
console.error('Error fetching instances:', error);
|
||||
document.getElementById('update-status').innerText = '⚠️ Error checking other instances: ' + error;
|
||||
}
|
||||
}
|
||||
|
||||
// Set the target URL when the page loads
|
||||
window.addEventListener('load', checkOtherInstances);
|
||||
|
||||
checkInstanceUpdateStatus();
|
9
static/copy.js
Normal file
|
@ -0,0 +1,9 @@
|
|||
async function copy() {
|
||||
await navigator.clipboard.writeText(document.getElementById('bincode_str').value);
|
||||
}
|
||||
|
||||
async function set_listener() {
|
||||
document.getElementById('copy').addEventListener('click', copy);
|
||||
}
|
||||
|
||||
window.addEventListener('load', set_listener);
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.3 KiB |
Before Width: | Height: | Size: 969 B After Width: | Height: | Size: 564 B |
BIN
static/logo.png
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 19 KiB |
|
@ -1 +1,7 @@
|
|||
<svg version="1.1" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><g transform="matrix(.75272 0 0 .75272 -.75945 -.16518)"><circle cx="341.11" cy="340.32" r="340.1" fill="#1a1a1a"/><path d="m320.64 126.73v300.8h92.264v-207.92h75.803v-92.83h-75.803v-0.0508z" fill="#0ff"/><path d="m193.1 126.74v383.96h6e-3v43.543h295.82v-92.338h-202.74v-335.16z" fill="#f9f9f9"/></g></svg>
|
||||
<svg version="1.1" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="matrix(1 0 0 -1 0 512)">
|
||||
<circle cx="256" cy="256" r="256" fill="#1a1a1a"/>
|
||||
<path d="M144,96 v320 h224 v-70 h-152 V96 z" fill="#d54455"/>
|
||||
<path d="M240,96 v226 h70 v-156 h58 V96 z" fill="#f9f9f9"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
|
Before Width: | Height: | Size: 383 B After Width: | Height: | Size: 317 B |
|
@ -3,9 +3,9 @@
|
|||
<ShortName>Search Redlib</ShortName>
|
||||
<Description>Search for whatever you want on Redlib, awesome Reddit frontend</Description>
|
||||
<InputEncoding>UTF-8</InputEncoding>
|
||||
<Image width="32" height="32" type="image/x-icon">/favicon.ico</Image>
|
||||
<Url type="text/html" template="/search">
|
||||
<Image width="32" height="32" type="image/x-icon">https://localhost:8080/favicon.ico</Image>
|
||||
<Url type="text/html" template="https://localhost:8080/search">
|
||||
<Param name="q" value="{searchTerms}"/>
|
||||
</Url>
|
||||
<moz:SearchForm>/search</moz:SearchForm>
|
||||
<moz:SearchForm>https://localhost:8080/search</moz:SearchForm>
|
||||
</OpenSearchDescription>
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
// @license http://www.gnu.org/licenses/agpl-3.0.html AGPL-3.0
|
||||
(function () {
|
||||
const configElement = document.getElementById('video_quality');
|
||||
const qualitySetting = configElement.getAttribute('data-value');
|
||||
if (Hls.isSupported()) {
|
||||
var videoSources = document.querySelectorAll("video source[type='application/vnd.apple.mpegurl']");
|
||||
videoSources.forEach(function (source) {
|
||||
|
@ -28,13 +30,26 @@
|
|||
|
||||
oldVideo.parentNode.replaceChild(newVideo, oldVideo);
|
||||
|
||||
function getIndexOfDefault(length) {
|
||||
switch (qualitySetting) {
|
||||
case 'best':
|
||||
return length - 1;
|
||||
case 'medium':
|
||||
return Math.floor(length / 2);
|
||||
case 'worst':
|
||||
return 0;
|
||||
default:
|
||||
return length - 1;
|
||||
}
|
||||
}
|
||||
|
||||
function initializeHls() {
|
||||
newVideo.removeEventListener('play', initializeHls);
|
||||
var hls = new Hls({ autoStartLoad: false });
|
||||
hls.loadSource(playlist);
|
||||
hls.attachMedia(newVideo);
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, function () {
|
||||
hls.loadLevel = hls.levels.length - 1;
|
||||
hls.loadLevel = getIndexOfDefault(hls.levels.length);
|
||||
var availableLevels = hls.levels.map(function(level) {
|
||||
return {
|
||||
height: level.height,
|
||||
|
@ -73,18 +88,18 @@
|
|||
function addQualitySelector(videoElement, hlsInstance, availableLevels) {
|
||||
var qualitySelector = document.createElement('select');
|
||||
qualitySelector.classList.add('quality-selector');
|
||||
var last = availableLevels.length - 1;
|
||||
var defaultIndex = getIndexOfDefault(availableLevels.length);
|
||||
availableLevels.forEach(function (level, index) {
|
||||
var option = document.createElement('option');
|
||||
option.value = index.toString();
|
||||
var bitrate = (level.bitrate / 1_000).toFixed(0);
|
||||
option.text = level.height + 'p (' + bitrate + ' kbps)';
|
||||
if (index === last) {
|
||||
if (index === defaultIndex) {
|
||||
option.selected = "selected";
|
||||
}
|
||||
qualitySelector.appendChild(option);
|
||||
});
|
||||
qualitySelector.selectedIndex = availableLevels.length - 1;
|
||||
qualitySelector.selectedIndex = defaultIndex;
|
||||
qualitySelector.addEventListener('change', function () {
|
||||
var selectedIndex = qualitySelector.selectedIndex;
|
||||
hlsInstance.nextLevel = selectedIndex;
|
||||
|
|
2351
static/style.css
|
@ -1,6 +1,6 @@
|
|||
/* Black theme setting */
|
||||
.black {
|
||||
--accent: #009a9a;
|
||||
--accent: #bb2b3b;
|
||||
--green: #00a229;
|
||||
--text: white;
|
||||
--foreground: #0f0f0f;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
/* Dark theme setting */
|
||||
.dark{
|
||||
--accent: aqua;
|
||||
--accent: #d54455;
|
||||
--green: #5cff85;
|
||||
--text: white;
|
||||
--foreground: #222;
|
||||
|
|
14
static/themes/libredditBlack.css
Normal file
|
@ -0,0 +1,14 @@
|
|||
/* Libreddit black theme setting */
|
||||
.libredditBlack {
|
||||
--accent: #009a9a;
|
||||
--green: #00a229;
|
||||
--text: white;
|
||||
--foreground: #0f0f0f;
|
||||
--background: black;
|
||||
--outside: black;
|
||||
--post: black;
|
||||
--panel-border: 2px solid #0f0f0f;
|
||||
--highlighted: #0f0f0f;
|
||||
--visited: #aaa;
|
||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
14
static/themes/libredditDark.css
Normal file
|
@ -0,0 +1,14 @@
|
|||
/* Libreddit dark theme setting */
|
||||
.libredditDark{
|
||||
--accent: aqua;
|
||||
--green: #5cff85;
|
||||
--text: white;
|
||||
--foreground: #222;
|
||||
--background: #0f0f0f;
|
||||
--outside: #1f1f1f;
|
||||
--post: #161616;
|
||||
--panel-border: 1px solid #333;
|
||||
--highlighted: #333;
|
||||
--visited: #aaa;
|
||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
|
||||
}
|
19
static/themes/libredditLight.css
Normal file
|
@ -0,0 +1,19 @@
|
|||
/* Libreddit light theme setting */
|
||||
.libredditLight {
|
||||
--accent: #009a9a;
|
||||
--green: #00a229;
|
||||
--text: black;
|
||||
--foreground: #f5f5f5;
|
||||
--background: #ddd;
|
||||
--outside: #ececec;
|
||||
--post: #eee;
|
||||
--panel-border: 1px solid #ccc;
|
||||
--highlighted: white;
|
||||
--visited: #555;
|
||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
html:has(> .libredditLight) {
|
||||
/* Hint color theme to browser for scrollbar */
|
||||
color-scheme: light;
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
/* Light theme setting */
|
||||
.light {
|
||||
--accent: #009a9a;
|
||||
--accent: #bb2b3b;
|
||||
--green: #00a229;
|
||||
--text: black;
|
||||
--foreground: #f5f5f5;
|
||||
|
|
14
static/themes/midnightPurple.css
Normal file
|
@ -0,0 +1,14 @@
|
|||
/* midnightpurple theme setting */
|
||||
.midnightPurple{
|
||||
--accent: #be6ede;
|
||||
--green: #268F02;
|
||||
--text: white;
|
||||
--foreground: #222;
|
||||
--background: #000000;
|
||||
--outside: #1f1f1f;
|
||||
--post: #000000;
|
||||
--panel-border: 1px solid #4E1764;
|
||||
--highlighted: #333;
|
||||
--visited: #aaa;
|
||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
|
||||
}
|
|
@ -8,6 +8,9 @@
|
|||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<meta name="description" content="View on Redlib, an alternative private front-end to Reddit.">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
{% if crate::utils::disable_indexing() %}
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
{% endif %}
|
||||
<!-- General PWA -->
|
||||
<meta name="theme-color" content="#1F1F1F">
|
||||
<!-- iOS Application -->
|
||||
|
@ -24,6 +27,8 @@
|
|||
<link rel="manifest" type="application/json" href="/manifest.json">
|
||||
<link rel="shortcut icon" type="image/x-icon" href="/favicon.ico">
|
||||
<link rel="stylesheet" type="text/css" href="/style.css?v={{ env!("CARGO_PKG_VERSION") }}">
|
||||
<!-- Video quality -->
|
||||
<div id="video_quality" data-value="{{ prefs.video_quality }}"></div>
|
||||
{% endblock %}
|
||||
</head>
|
||||
<body class="
|
||||
|
@ -35,7 +40,7 @@
|
|||
<nav class="
|
||||
{% if prefs.fixed_navbar == "on" %} fixed_navbar{% endif %}">
|
||||
<div id="logo">
|
||||
<a id="redlib" href="/"><span id="lib">red</span><span id="reddit">lib.</span></a>
|
||||
<a id="redlib" href="/"><span id="red">red</span><span id="lib">lib.</span></a>
|
||||
{% block subscriptions %}{% endblock %}
|
||||
</div>
|
||||
{% block search %}{% endblock %}
|
||||
|
@ -71,12 +76,8 @@
|
|||
<!-- FOOTER -->
|
||||
{% block footer %}
|
||||
<footer>
|
||||
<p id="version">v{{ env!("CARGO_PKG_VERSION") }}</p>
|
||||
<div class="footer-button">
|
||||
<a href="/info" title="View instance information">ⓘ View instance info</a>
|
||||
</div>
|
||||
<div class="footer-button">
|
||||
<a href="https://github.com/redlib-org/redlib" title="View code on GitHub"><> Code</a>
|
||||
<div class="footer-buttons">
|
||||
<p><span id="version">v{{ env!("CARGO_PKG_VERSION") }} </span><a href="/info" title="View instance information">ⓘ View instance info</a> <a href="https://github.com/redlib-org/redlib" title="View code on GitHub"><> Code</a></p>
|
||||
</div>
|
||||
</footer>
|
||||
{% endblock %}
|
||||
|
|
|
@ -24,7 +24,7 @@
|
|||
{% if author.flair.flair_parts.len() > 0 %}
|
||||
<small class="author_flair">{% call utils::render_flair(author.flair.flair_parts) %}</small>
|
||||
{% endif %}
|
||||
<a href="{{ post_link }}{{ id }}/?context=3" class="created" title="{{ created }}">{{ rel_time }}</a>
|
||||
<a href="{{ post_link }}{{ id }}/?context=3#{{ id }}" class="created" title="{{ created }}">{{ rel_time }}</a>
|
||||
{% if edited.0 != "".to_string() %}<span class="edited" title="{{ edited.1 }}">edited {{ edited.0 }}</span>{% endif %}
|
||||
{% if !awards.is_empty() && prefs.hide_awards != "on" %}
|
||||
<span class="dot">•</span>
|
||||
|
|
|
@ -6,10 +6,19 @@
|
|||
<h1>{{ msg }}</h1>
|
||||
<h3><a href="https://www.redditstatus.com/">Reddit Status</a></h3>
|
||||
<br />
|
||||
<h3 id="update-status"></h3>
|
||||
<br />
|
||||
<h3 id="update-status"><a id="random-instance"></a></h3>
|
||||
<br>
|
||||
<div id="git_commit" data-value="{{ crate::instance_info::INSTANCE_INFO.git_commit }}"></div>
|
||||
<script src="/check_update.js"></script>
|
||||
|
||||
<h3>Expected something to work? <a
|
||||
href="https://github.com/redlib-org/redlib/issues/new?assignees=&labels=bug&projects=&template=bug_report.md&title=%F0%9F%90%9B+Bug+Report%3A+{{ msg }}">Report
|
||||
an issue</a></h3>
|
||||
<br />
|
||||
<p id="error-318">If you're getting a "Failed to parse page JSON data" error, please check <a href="https://github.com/redlib-org/redlib/issues/318" target="_blank">#318</a></p>
|
||||
<br />
|
||||
<h3>Head back <a href="/">home</a>?</h3>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
|
20
templates/info.html
Normal file
|
@ -0,0 +1,20 @@
|
|||
{% extends "base.html" %}
|
||||
{% import "utils.html" as utils %}
|
||||
|
||||
{% block title %}Info: {{ msg }}{% endblock %}
|
||||
{% block sortstyle %}{% endblock %}
|
||||
|
||||
{% block subscriptions %}
|
||||
{% call utils::sub_list("") %}
|
||||
{% endblock %}
|
||||
|
||||
{% block search %}
|
||||
{% call utils::search("".to_owned(), "") %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="error">
|
||||
<h2>{{ msg }}</h2>
|
||||
<br />
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -1,7 +1,13 @@
|
|||
{% extends "base.html" %}
|
||||
{% import "utils.html" as utils %}
|
||||
|
||||
{% block title %}{{ post.title }} - r/{{ post.community }}{% endblock %}
|
||||
{% block title %}
|
||||
{% if single_thread %}
|
||||
{{ comments[0].author.name }} comments on {{ post.title }} - r/{{ post.community }}
|
||||
{% else %}
|
||||
{{ post.title }} - r/{{ post.community }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block search %}
|
||||
{% call utils::search(["/r/", post.community.as_str()].concat(), "") %}
|
||||
|
|
|
@ -10,25 +10,34 @@
|
|||
{% block content %}
|
||||
<div id="column_one">
|
||||
<form id="search_sort">
|
||||
<input id="search" type="text" name="q" placeholder="Search" value="{{ params.q|safe }}" title="Search redlib">
|
||||
{% if sub != "" %}
|
||||
<div id="inside">
|
||||
<input type="checkbox" name="restrict_sr" id="restrict_sr" {% if params.restrict_sr != "" %}checked{% endif %}>
|
||||
<label for="restrict_sr" class="search_label">in r/{{ sub }}</label>
|
||||
<div class="search_widget_divider_box">
|
||||
<input id="search" type="text" name="q" placeholder="Search" value="{{ params.q|safe }}" title="Search redlib">
|
||||
<div class="search_widget_divider_box">
|
||||
{% if sub != "" %}
|
||||
<div id="inside">
|
||||
<input type="checkbox" name="restrict_sr" id="restrict_sr" {% if params.restrict_sr != "" %}checked{% endif %}>
|
||||
<label for="restrict_sr" class="search_label">in r/{{ sub }}</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if params.typed == "sr_user" %}<input type="hidden" name="type" value="sr_user">{% endif %}
|
||||
<select id="sort_options" name="sort" title="Sort results by">
|
||||
{% call utils::options(params.sort, ["relevance", "hot", "top", "new", "comments"], "") %}
|
||||
</select>
|
||||
{% if params.sort != "new" %}
|
||||
<select id="timeframe" name="t" title="Timeframe">
|
||||
{% call utils::options(params.t, ["hour", "day", "week", "month", "year", "all"], "all") %}
|
||||
</select>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if params.typed == "sr_user" %}<input type="hidden" name="type" value="sr_user">{% endif %}
|
||||
<select id="sort_options" name="sort" title="Sort results by">
|
||||
{% call utils::options(params.sort, ["relevance", "hot", "top", "new", "comments"], "") %}
|
||||
</select>{% if params.sort != "new" %}<select id="timeframe" name="t" title="Timeframe">
|
||||
{% call utils::options(params.t, ["hour", "day", "week", "month", "year", "all"], "all") %}
|
||||
</select>{% endif %}<button id="sort_submit" class="submit">
|
||||
<svg width="15" viewBox="0 0 110 100" fill="none" stroke-width="10" stroke-linecap="round">
|
||||
<path d="M20 50 H100" />
|
||||
<path d="M75 15 L100 50 L75 85" />
|
||||
→
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button id="sort_submit" class="submit">
|
||||
<svg width="15" viewBox="0 0 110 100" fill="none" stroke-width="10" stroke-linecap="round">
|
||||
<path d="M20 50 H100" />
|
||||
<path d="M75 15 L100 50 L75 85" />
|
||||
→
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{% if !is_filtered %}
|
||||
|
|
|
@ -3,157 +3,215 @@
|
|||
|
||||
{% block title %}Redlib Settings{% endblock %}
|
||||
|
||||
{% block subscriptions %}
|
||||
{% call utils::sub_list("") %}
|
||||
{% endblock %}
|
||||
|
||||
{% block search %}
|
||||
{% call utils::search("".to_owned(), "") %}
|
||||
{% call utils::search("".to_owned(), "") %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="settings">
|
||||
<div id="settings">
|
||||
<form action="/settings" method="POST">
|
||||
<div class="prefs">
|
||||
<fieldset>
|
||||
<legend>Appearance</legend>
|
||||
<div class="prefs-group">
|
||||
<label for="theme">Theme:</label>
|
||||
<select name="theme" id="theme">
|
||||
<select name="theme" id="theme">
|
||||
{% call utils::options(prefs.theme, prefs.available_themes, "system") %}
|
||||
</select>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>Interface</legend>
|
||||
<div class="prefs-group">
|
||||
<label for="remove_default_feeds">Remove default feeds</label>
|
||||
<input type="hidden" value="off" name="remove_default_feeds">
|
||||
<input type="checkbox" name="remove_default_feeds" id="remove_default_feeds" {% if
|
||||
prefs.remove_default_feeds=="on" %}checked{% endif %}>
|
||||
</div>
|
||||
<div class="prefs-group">
|
||||
<label for="front_page">Front page:</label>
|
||||
<select name="front_page" id="front_page">
|
||||
<select name="front_page" id="front_page">
|
||||
{% call utils::options(prefs.front_page, ["default", "popular", "all"], "default") %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="prefs-group">
|
||||
<label for="layout">Layout:</label>
|
||||
<select name="layout" id="layout">
|
||||
<select name="layout" id="layout">
|
||||
{% call utils::options(prefs.layout, ["card", "clean", "compact"], "card") %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="prefs-group">
|
||||
<label for="wide">Wide UI:</label>
|
||||
<input type="hidden" value="off" name="wide">
|
||||
<input type="checkbox" name="wide" id="wide" {% if prefs.wide == "on" %}checked{% endif %}>
|
||||
<input type="checkbox" name="wide" id="wide" {% if prefs.wide=="on" %}checked{% endif %}>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>Content</legend>
|
||||
<div class="prefs-group">
|
||||
<label for="video_quality">Video quality:</label>
|
||||
<select name="video_quality" id="video_quality">
|
||||
{% call utils::options(prefs.video_quality, ["best", "medium", "worst"], "best") %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="prefs-group">
|
||||
<label for="post_sort" title="Applies only to subreddit feeds">Default subreddit post sort:</label>
|
||||
<select name="post_sort">
|
||||
{% call utils::options(prefs.post_sort, ["hot", "new", "top", "rising", "controversial"], "hot") %}
|
||||
<select name="post_sort">
|
||||
{% call utils::options(prefs.post_sort, ["hot", "new", "top", "rising", "controversial"], "hot")
|
||||
%}
|
||||
</select>
|
||||
</div>
|
||||
<div class="prefs-group">
|
||||
<label for="comment_sort">Default comment sort:</label>
|
||||
<select name="comment_sort" id="comment_sort">
|
||||
{% call utils::options(prefs.comment_sort, ["confidence", "top", "new", "controversial", "old"], "confidence") %}
|
||||
<select name="comment_sort" id="comment_sort">
|
||||
{% call utils::options(prefs.comment_sort, ["confidence", "top", "new", "controversial", "old"],
|
||||
"confidence") %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="prefs-group">
|
||||
<label for="blur_spoiler">Blur spoiler previews:</label>
|
||||
<input type="hidden" value="off" name="blur_spoiler">
|
||||
<input type="checkbox" name="blur_spoiler" id="blur_spoiler" {% if prefs.blur_spoiler == "on" %}checked{% endif %}>
|
||||
<input type="checkbox" name="blur_spoiler" id="blur_spoiler" {% if prefs.blur_spoiler=="on"
|
||||
%}checked{% endif %}>
|
||||
</div>
|
||||
{% if !crate::utils::sfw_only() %}
|
||||
{% if !crate::utils::sfw_only() %}
|
||||
<div class="prefs-group">
|
||||
<label for="show_nsfw">Show NSFW posts:</label>
|
||||
<input type="hidden" value="off" name="show_nsfw">
|
||||
<input type="checkbox" name="show_nsfw" id="show_nsfw" {% if prefs.show_nsfw == "on" %}checked{% endif %}>
|
||||
<input type="checkbox" name="show_nsfw" id="show_nsfw" {% if prefs.show_nsfw=="on" %}checked{% endif
|
||||
%}>
|
||||
</div>
|
||||
<div class="prefs-group">
|
||||
<label for="blur_nsfw">Blur NSFW previews:</label>
|
||||
<input type="hidden" value="off" name="blur_nsfw">
|
||||
<input type="checkbox" name="blur_nsfw" id="blur_nsfw" {% if prefs.blur_nsfw == "on" %}checked{% endif %}>
|
||||
<input type="checkbox" name="blur_nsfw" id="blur_nsfw" {% if prefs.blur_nsfw=="on" %}checked{% endif
|
||||
%}>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<div class="prefs-group">
|
||||
<label for="autoplay_videos">Autoplay videos</label>
|
||||
<input type="hidden" value="off" name="autoplay_videos">
|
||||
<input type="checkbox" name="autoplay_videos" id="autoplay_videos" {% if prefs.autoplay_videos == "on" %}checked{% endif %}>
|
||||
<input type="checkbox" name="autoplay_videos" id="autoplay_videos" {% if prefs.autoplay_videos=="on"
|
||||
%}checked{% endif %}>
|
||||
</div>
|
||||
<div class="prefs-group">
|
||||
<label for="fixed_navbar">Keep navbar fixed</label>
|
||||
<input type="hidden" value="off" name="fixed_navbar">
|
||||
<input type="checkbox" name="fixed_navbar" {% if prefs.fixed_navbar == "on" %}checked{% endif %}>
|
||||
<input type="checkbox" name="fixed_navbar" {% if prefs.fixed_navbar=="on" %}checked{% endif %}>
|
||||
</div>
|
||||
<div class="prefs-group">
|
||||
<label for="hide_sidebar_and_summary">Hide the summary and sidebar</label>
|
||||
<input type="hidden" value="off" name="hide_sidebar_and_summary">
|
||||
<input type="checkbox" name="hide_sidebar_and_summary" {% if prefs.hide_sidebar_and_summary == "on" %}checked{% endif %}>
|
||||
<input type="checkbox" name="hide_sidebar_and_summary" {% if prefs.hide_sidebar_and_summary=="on"
|
||||
%}checked{% endif %}>
|
||||
</div>
|
||||
<div class="prefs-group">
|
||||
<label for="use_hls">Use HLS for videos</label>
|
||||
<details id="feeds">
|
||||
<summary>Why?</summary>
|
||||
<div id="feed_list" class="helper">Reddit videos require JavaScript (via HLS.js) to be enabled to be played with audio. Therefore, this toggle lets you either use Redlib JS-free or utilize this feature.</div>
|
||||
<div id="feed_list" class="helper">Reddit videos require JavaScript (via HLS.js) to be enabled
|
||||
to be played with audio. Therefore, this toggle lets you either use Redlib JS-free or
|
||||
utilize this feature.</div>
|
||||
</details>
|
||||
<input type="hidden" value="off" name="use_hls">
|
||||
<input type="checkbox" name="use_hls" id="use_hls" {% if prefs.use_hls == "on" %}checked{% endif %}>
|
||||
<input type="checkbox" name="use_hls" id="use_hls" {% if prefs.use_hls=="on" %}checked{% endif %}>
|
||||
</div>
|
||||
<div class="prefs-group">
|
||||
<label for="hide_hls_notification">Hide notification about possible HLS usage</label>
|
||||
<input type="hidden" value="off" name="hide_hls_notification">
|
||||
<input type="checkbox" name="hide_hls_notification" id="hide_hls_notification" {% if prefs.hide_hls_notification == "on" %}checked{% endif %}>
|
||||
<input type="checkbox" name="hide_hls_notification" id="hide_hls_notification" {% if
|
||||
prefs.hide_hls_notification=="on" %}checked{% endif %}>
|
||||
</div>
|
||||
<div class="prefs-group">
|
||||
<label for="hide_awards">Hide awards</label>
|
||||
<input type="hidden" value="off" name="hide_awards">
|
||||
<input type="checkbox" name="hide_awards" id="hide_awards" {% if prefs.hide_awards == "on" %}checked{% endif %}>
|
||||
<input type="checkbox" name="hide_awards" id="hide_awards" {% if prefs.hide_awards=="on" %}checked{%
|
||||
endif %}>
|
||||
</div>
|
||||
<div class="prefs-group">
|
||||
<label for="hide_score">Hide score</label>
|
||||
<input type="hidden" value="off" name="hide_score">
|
||||
<input type="checkbox" name="hide_score" id="hide_score" {% if prefs.hide_score == "on" %}checked{% endif %}>
|
||||
<input type="checkbox" name="hide_score" id="hide_score" {% if prefs.hide_score=="on" %}checked{%
|
||||
endif %}>
|
||||
</div>
|
||||
<div class="prefs-group">
|
||||
<label for="disable_visit_reddit_confirmation">Do not confirm before visiting content on Reddit</label>
|
||||
<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 %}>
|
||||
<input type="checkbox" name="disable_visit_reddit_confirmation" {% if
|
||||
prefs.disable_visit_reddit_confirmation=="on" %}checked{% endif %}>
|
||||
</div>
|
||||
</fieldset>
|
||||
<input id="save" type="submit" value="Save">
|
||||
</div>
|
||||
</form>
|
||||
{% if prefs.subscriptions.len() > 0 %}
|
||||
<div class="prefs" id="settings_subs">
|
||||
<legend>Subscribed Feeds</legend>
|
||||
{% for sub in prefs.subscriptions %}
|
||||
<div>
|
||||
{% let feed -%}
|
||||
{% if sub.starts_with("u_") -%}{% let feed = format!("u/{}", &sub[2..]) -%}{% else -%}{% let feed = format!("r/{}", sub) -%}{% endif -%}
|
||||
<a href="/{{ feed }}">{{ feed }}</a>
|
||||
<form action="/r/{{ sub }}/unsubscribe/?redirect=settings" method="POST">
|
||||
<button class="unsubscribe">Unsubscribe</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="prefs" id="settings_subs">
|
||||
<legend>Subscribed Feeds</legend>
|
||||
{% for sub in prefs.subscriptions %}
|
||||
<div>
|
||||
{% let feed -%}
|
||||
{% if sub.starts_with("u_") -%}{% let feed = format!("u/{}", &sub[2..]) -%}{% else -%}{% let feed =
|
||||
format!("r/{}", sub) -%}{% endif -%}
|
||||
<a href="/{{ feed }}">{{ feed }}</a>
|
||||
<form action="/r/{{ sub }}/unsubscribe/?redirect=settings" method="POST">
|
||||
<button class="unsubscribe">Unsubscribe</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if !prefs.filters.is_empty() %}
|
||||
<div class="prefs" id="settings_filters">
|
||||
<legend>Filtered Feeds</legend>
|
||||
{% for sub in prefs.filters %}
|
||||
<div>
|
||||
{% let feed -%}
|
||||
{% if sub.starts_with("u_") -%}{% let feed = format!("u/{}", &sub[2..]) -%}{% else -%}{% let feed = format!("r/{}", sub) -%}{% endif -%}
|
||||
<a href="/{{ feed }}">{{ feed }}</a>
|
||||
<form action="/r/{{ sub }}/unfilter/?redirect=settings" method="POST">
|
||||
<button class="unfilter">Unfilter</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="prefs" id="settings_filters">
|
||||
<legend>Filtered Feeds</legend>
|
||||
{% for sub in prefs.filters %}
|
||||
<div>
|
||||
{% let feed -%}
|
||||
{% if sub.starts_with("u_") -%}{% let feed = format!("u/{}", &sub[2..]) -%}{% else -%}{% let feed =
|
||||
format!("r/{}", sub) -%}{% endif -%}
|
||||
<a href="/{{ feed }}">{{ feed }}</a>
|
||||
<form action="/r/{{ sub }}/unfilter/?redirect=settings" method="POST">
|
||||
<button class="unfilter">Unfilter</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div id="settings_note">
|
||||
<p><b>Note:</b> settings and subscriptions are saved in browser cookies. Clearing your cookies will reset them.</p><br>
|
||||
<p>You can restore your current settings and subscriptions after clearing your cookies using <a href="/settings/restore/?theme={{ prefs.theme }}&front_page={{ prefs.front_page }}&layout={{ prefs.layout }}&wide={{ prefs.wide }}&post_sort={{ prefs.post_sort }}&comment_sort={{ prefs.comment_sort }}&show_nsfw={{ prefs.show_nsfw }}&use_hls={{ prefs.use_hls }}&hide_hls_notification={{ prefs.hide_hls_notification }}&hide_awards={{ prefs.hide_awards }}&fixed_navbar={{ prefs.fixed_navbar }}&subscriptions={{ prefs.subscriptions.join("%2B") }}&filters={{ prefs.filters.join("%2B") }}">this link</a>.</p>
|
||||
<p><b>Note:</b> settings and subscriptions are saved in browser cookies. Clearing your cookies will reset them.
|
||||
</p>
|
||||
<br>
|
||||
{% match prefs.to_urlencoded() %}
|
||||
{% when Ok with (encoded_prefs) %}
|
||||
<p>You can restore your current settings and subscriptions after clearing your cookies using <a
|
||||
href="/settings/restore/?{{ encoded_prefs }}">this link</a>.</p>
|
||||
{% when Err with (err) %}
|
||||
<p>There was an error creating your restore link: {{ err }}</p>
|
||||
<p>Please report this issue</p>
|
||||
{% endmatch %}
|
||||
<br />
|
||||
<div>
|
||||
<script src="/copy.js"></script>
|
||||
<label for="bincode_str">Or, export/import here (be sure to save first):</label>
|
||||
<br />
|
||||
<input type="text" id="bincode_str" name="bincode_str"
|
||||
value="{% match prefs.to_bincode_str() %}{% when Ok with (bincode_str) %}{{ bincode_str }}{% when Err with (err) %}Error: {{ err }}{% endmatch %}"
|
||||
readonly>
|
||||
<button id="copy" class="copy">Copy</button>
|
||||
|
||||
<br />
|
||||
<form action="/settings/encoded-restore/" method="POST">
|
||||
<input type="text" id="encoded_prefs" name="encoded_prefs" value=""
|
||||
placeholder="Paste your encoded settings here">
|
||||
<button class="import" type="submit">Import</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
{% endblock %}
|
|
@ -27,7 +27,7 @@
|
|||
{% call utils::sort(["/r/", sub.name.as_str()].concat(), ["hot", "new", "top", "rising", "controversial"], sort.0) %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if sort.0 == "top" || sort.0 == "controversial" %}<select id="timeframe" name="t" title="Timeframe">
|
||||
{% if sort.0 == "top" || sort.0 == "controversial" %}<select id="timeframe" name="t" title="Timeframe">
|
||||
{% call utils::options(sort.1, ["hour", "day", "week", "month", "year", "all"], "day") %}
|
||||
</select>
|
||||
<button id="sort_submit" class="submit">
|
||||
|
@ -130,7 +130,13 @@
|
|||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if crate::utils::enable_rss() %}
|
||||
<div id="sub_rss">
|
||||
<a href="/r/{{ sub.name }}.rss" title="RSS feed for r/{{ sub.name }}">
|
||||
<button class="subscribe">RSS feed</button >
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</details>
|
||||
<details class="panel" id="sidebar">
|
||||
|
|
|
@ -1,135 +1,178 @@
|
|||
{% extends "base.html" %}
|
||||
{% import "utils.html" as utils %}
|
||||
{% extends "base.html" %} {% import "utils.html" as utils %} {% block search %}
|
||||
{% call utils::search("".to_owned(), "") %} {% endblock %} {% block title %}{{
|
||||
user.name.replace("u/", "") }} (u/{{ user.name }}) - Redlib{% endblock %} {%
|
||||
block subscriptions %} {% call utils::sub_list("") %} {% endblock %} {% block
|
||||
body %}
|
||||
<main>
|
||||
{% if !is_filtered %}
|
||||
<div id="column_one">
|
||||
<form id="sort">
|
||||
<div id="listing_options">
|
||||
{% call utils::sort(["/user/", user.name.as_str()].concat(),
|
||||
["overview", "comments", "submitted"], listing) %}
|
||||
</div>
|
||||
<select id="sort_select" name="sort">
|
||||
{% call utils::options(sort.0, ["hot", "new", "top",
|
||||
"controversial"], "") %}</select
|
||||
>{% if sort.0 == "top" || sort.0 == "controversial" %}<select
|
||||
id="timeframe"
|
||||
name="t"
|
||||
>
|
||||
{% call utils::options(sort.1, ["hour", "day", "week", "month",
|
||||
"year", "all"], "all") %}</select
|
||||
>{% endif %}<button id="sort_submit" class="submit">
|
||||
<svg
|
||||
width="15"
|
||||
viewBox="0 0 110 100"
|
||||
fill="none"
|
||||
stroke-width="10"
|
||||
stroke-linecap="round"
|
||||
>
|
||||
<path d="M20 50 H100" />
|
||||
<path d="M75 15 L100 50 L75 85" />
|
||||
→
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{% block search %}
|
||||
{% call utils::search("".to_owned(), "") %}
|
||||
{% endblock %}
|
||||
|
||||
{% block title %}{{ user.name.replace("u/", "") }} (u/{{ user.name }}) - Redlib{% endblock %}
|
||||
|
||||
{% block subscriptions %}
|
||||
{% call utils::sub_list("") %}
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<main>
|
||||
{% if !is_filtered %}
|
||||
<div id="column_one">
|
||||
<form id="sort">
|
||||
<div id="listing_options">
|
||||
{% call utils::sort(["/user/", user.name.as_str()].concat(), ["overview", "comments", "submitted"], listing) %}
|
||||
</div>
|
||||
<select id="sort_select" name="sort">
|
||||
{% call utils::options(sort.0, ["hot", "new", "top", "controversial"], "") %}
|
||||
</select>{% if sort.0 == "top" || sort.0 == "controversial" %}<select id="timeframe" name="t">
|
||||
{% call utils::options(sort.1, ["hour", "day", "week", "month", "year", "all"], "all") %}
|
||||
</select>{% endif %}<button id="sort_submit" class="submit">
|
||||
<svg width="15" viewBox="0 0 110 100" fill="none" stroke-width="10" stroke-linecap="round">
|
||||
<path d="M20 50 H100" />
|
||||
<path d="M75 15 L100 50 L75 85" />
|
||||
→
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{% if all_posts_hidden_nsfw %}
|
||||
<center>All posts are hidden because they are NSFW. Enable "Show NSFW posts" in settings to view.</center>
|
||||
{% endif %}
|
||||
|
||||
{% if no_posts %}
|
||||
<center>No posts were found.</center>
|
||||
{% endif %}
|
||||
|
||||
{% if all_posts_filtered %}
|
||||
<center>(All content on this page has been filtered)</center>
|
||||
{% else %}
|
||||
<div id="posts">
|
||||
{% for post in posts %}
|
||||
|
||||
{% if post.flags.nsfw && prefs.show_nsfw != "on" %}
|
||||
{% else if !post.title.is_empty() %}
|
||||
{% call utils::post_in_list(post) %}
|
||||
{% else %}
|
||||
<div class="comment">
|
||||
<div class="comment_left">
|
||||
<p class="comment_score" title="{{ post.score.1 }}">
|
||||
{% if prefs.hide_score != "on" %}
|
||||
{{ post.score.0 }}
|
||||
{% else %}
|
||||
•
|
||||
{% endif %}
|
||||
{% if all_posts_hidden_nsfw %}
|
||||
<center>
|
||||
All posts are hidden because they are NSFW. Enable "Show NSFW posts"
|
||||
in settings to view.
|
||||
</center>
|
||||
{% endif %} {% if no_posts %}
|
||||
<center>No posts were found.</center>
|
||||
{% endif %} {% if all_posts_filtered %}
|
||||
<center>(All content on this page has been filtered)</center>
|
||||
{% else %}
|
||||
<div id="posts">
|
||||
{% for post in posts %} {% if post.flags.nsfw && prefs.show_nsfw !=
|
||||
"on" %} {% else if !post.title.is_empty() %} {% call
|
||||
utils::post_in_list(post) %} {% else %}
|
||||
<div class="comment user-comment">
|
||||
<div class="comment_left">
|
||||
<p class="comment_score" title="{{ post.score.1 }}">
|
||||
{% if prefs.hide_score != "on" %} {{ post.score.0 }} {%
|
||||
else %} • {% endif %}
|
||||
</p>
|
||||
<div class="line"></div>
|
||||
</div>
|
||||
<details class="comment_right" open>
|
||||
<summary class="comment_data">
|
||||
<a class="comment_link" href="{{ post.permalink }}">Comment on r/{{ post.community }}</a>
|
||||
<span class="created" title="{{ post.created }}">{{ post.rel_time }}</span>
|
||||
</summary>
|
||||
<p class="comment_body">{{ post.body|safe }}</p>
|
||||
</details>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if prefs.use_hls == "on" %}
|
||||
<script src="/hls.min.js"></script>
|
||||
<script src="/playHLSVideo.js"></script>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="line"></div>
|
||||
</div>
|
||||
<details class="comment_right" open>
|
||||
<summary class="comment_data">
|
||||
<a
|
||||
class="comment_link"
|
||||
href="{{ post.permalink }}#{{ post.id }}"
|
||||
title="{{ post.link_title }}"
|
||||
>{{ post.link_title }}</a
|
||||
>
|
||||
<div class="user_comment_data_divider">
|
||||
<span class="created-in"> in </span>
|
||||
<a
|
||||
class="comment_subreddit"
|
||||
href="/r/{{ post.community }}"
|
||||
>r/{{ post.community }}</a
|
||||
>
|
||||
<span class="dot">•</span>
|
||||
<span class="created" title="{{ post.created }}"
|
||||
> {{ post.rel_time }}</span
|
||||
>
|
||||
</div>
|
||||
</summary>
|
||||
<p class="comment_body">{{ post.body|safe }}</p>
|
||||
</details>
|
||||
</div>
|
||||
{% endif %} {% endfor %} {% if prefs.use_hls == "on" %}
|
||||
<script src="/hls.min.js"></script>
|
||||
<script src="/playHLSVideo.js"></script>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<footer>
|
||||
{% if ends.0 != "" %}
|
||||
<a href="?sort={{ sort.0 }}&t={{ sort.1 }}&before={{ ends.0 }}" accesskey="P">PREV</a>
|
||||
{% endif %}
|
||||
|
||||
{% if ends.1 != "" %}
|
||||
<a href="?sort={{ sort.0 }}&t={{ sort.1 }}&after={{ ends.1 }}" accesskey="N">NEXT</a>
|
||||
{% endif %}
|
||||
</footer>
|
||||
</div>
|
||||
{% endif %}
|
||||
<aside>
|
||||
{% if is_filtered %}
|
||||
<center>(Content from u/{{ user.name }} has been filtered)</center>
|
||||
{% endif %}
|
||||
<div class="panel" id="user">
|
||||
<img loading="lazy" id="user_icon" src="{{ user.icon }}" alt="User icon">
|
||||
<h1 id="user_title">{{ user.title }}</h1>
|
||||
<p id="user_name">u/{{ user.name }}</p>
|
||||
<div id="user_description">{{ user.description }}</div>
|
||||
<div id="user_details">
|
||||
<label>Karma</label>
|
||||
<label>Created</label>
|
||||
<div>{{ user.karma }}</div>
|
||||
<div>{{ user.created }}</div>
|
||||
</div>
|
||||
<div id="user_actions">
|
||||
{% let name = ["u_", user.name.as_str()].join("") %}
|
||||
<div id="user_subscription">
|
||||
{% if prefs.subscriptions.contains(name) %}
|
||||
<form action="/r/{{ name }}/unsubscribe?redirect={{ redirect_url }}" method="POST">
|
||||
<button class="unsubscribe">Unfollow</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form action="/r/{{ name }}/subscribe?redirect={{ redirect_url }}" method="POST">
|
||||
<button class="subscribe">Follow</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div id="user_filter">
|
||||
{% if prefs.filters.contains(name) %}
|
||||
<form action="/r/{{ name }}/unfilter?redirect={{ redirect_url }}" method="POST">
|
||||
<button class="unfilter">Unfilter</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form action="/r/{{ name }}/filter?redirect={{ redirect_url }}" method="POST">
|
||||
<button class="filter">Filter</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
<footer>
|
||||
{% if ends.0 != "" %}
|
||||
<a
|
||||
href="?sort={{ sort.0 }}&t={{ sort.1 }}&before={{ ends.0 }}"
|
||||
accesskey="P"
|
||||
>PREV</a
|
||||
>
|
||||
{% endif %} {% if ends.1 != "" %}
|
||||
<a
|
||||
href="?sort={{ sort.0 }}&t={{ sort.1 }}&after={{ ends.1 }}"
|
||||
accesskey="N"
|
||||
>NEXT</a
|
||||
>
|
||||
{% endif %}
|
||||
</footer>
|
||||
</div>
|
||||
{% endif %}
|
||||
<aside>
|
||||
{% if is_filtered %}
|
||||
<center>(Content from u/{{ user.name }} has been filtered)</center>
|
||||
{% endif %}
|
||||
<div class="panel" id="user">
|
||||
<img
|
||||
loading="lazy"
|
||||
id="user_icon"
|
||||
src="{{ user.icon }}"
|
||||
alt="User icon"
|
||||
/>
|
||||
<h1 id="user_title">{{ user.title }}</h1>
|
||||
<p id="user_name">u/{{ user.name }}</p>
|
||||
<div id="user_description">{{ user.description }}</div>
|
||||
<div id="user_details">
|
||||
<label>Karma</label>
|
||||
<label>Created</label>
|
||||
<div>{{ user.karma }}</div>
|
||||
<div>{{ user.created }}</div>
|
||||
</div>
|
||||
<div id="user_actions">
|
||||
{% let name = ["u_", user.name.as_str()].join("") %}
|
||||
<div id="user_subscription">
|
||||
{% if prefs.subscriptions.contains(name) %}
|
||||
<form
|
||||
action="/r/{{ name }}/unsubscribe?redirect={{ redirect_url }}"
|
||||
method="POST"
|
||||
>
|
||||
<button class="unsubscribe">Unfollow</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form
|
||||
action="/r/{{ name }}/subscribe?redirect={{ redirect_url }}"
|
||||
method="POST"
|
||||
>
|
||||
<button class="subscribe">Follow</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div id="user_filter">
|
||||
{% if prefs.filters.contains(name) %}
|
||||
<form
|
||||
action="/r/{{ name }}/unfilter?redirect={{ redirect_url }}"
|
||||
method="POST"
|
||||
>
|
||||
<button class="unfilter">Unfilter</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form
|
||||
action="/r/{{ name }}/filter?redirect={{ redirect_url }}"
|
||||
method="POST"
|
||||
>
|
||||
<button class="filter">Filter</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if crate::utils::enable_rss() %}
|
||||
<div id="user_rss">
|
||||
<a
|
||||
href="/u/{{ user.name }}.rss"
|
||||
title="RSS feed for u/{{ user.name }}"
|
||||
>
|
||||
<button class="subscribe">RSS feed</button>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
{% endblock %}
|
||||
|
|
|
@ -43,8 +43,10 @@
|
|||
<div id="feed_list">
|
||||
<p>MAIN FEEDS</p>
|
||||
<a href="/">Home</a>
|
||||
<a href="/r/popular">Popular</a>
|
||||
<a href="/r/all">All</a>
|
||||
{% if prefs.remove_default_feeds != "on" %}
|
||||
<a href="/r/popular">Popular</a>
|
||||
<a href="/r/all">All</a>
|
||||
{% endif %}
|
||||
{% if prefs.subscriptions.len() > 0 %}
|
||||
<p>REDDIT FEEDS</p>
|
||||
{% for sub in prefs.subscriptions %}
|
||||
|
@ -64,7 +66,7 @@
|
|||
{% macro post(post) -%}
|
||||
{% set post_should_be_blurred = post.flags.spoiler && prefs.blur_spoiler=="on" -%}
|
||||
<!-- POST CONTENT -->
|
||||
<div class="post highlighted">
|
||||
<div class="post highlighted{% if post_should_be_blurred %} post_blurred{% endif %}">
|
||||
<p class="post_header">
|
||||
<a class="post_subreddit" href="/r/{{ post.community }}">r/{{ post.community }}</a>
|
||||
<span class="dot">•</span>
|
||||
|
@ -87,12 +89,12 @@
|
|||
{% endif %}
|
||||
</p>
|
||||
<h1 class="post_title">
|
||||
{{ post.title }}
|
||||
{% if post.flair.flair_parts.len() > 0 %}
|
||||
<a href="/r/{{ post.community }}/search?q=flair_name%3A%22{{ post.flair.text }}%22&restrict_sr=on"
|
||||
class="post_flair"
|
||||
style="color:{{ post.flair.foreground_color }}; background:{{ post.flair.background_color }};">{% call render_flair(post.flair.flair_parts) %}</a>
|
||||
{% endif %}
|
||||
{{ post.title }}
|
||||
{% if post.flags.nsfw %} <small class="nsfw">NSFW</small>{% endif %}
|
||||
{% if post.flags.spoiler %} <small class="spoiler">Spoiler</small>{% endif %}
|
||||
</h1>
|
||||
|
@ -104,12 +106,11 @@
|
|||
<a href="{{ post.media.url }}" class="post_media_image" >
|
||||
{% if post.media.height == 0 || post.media.width == 0 %}
|
||||
<!-- i.redd.it images special case -->
|
||||
<img width="100%" height="100%" loading="lazy" alt="Post image" src="{{ post.media.url }}"{%if post_should_be_blurred %} class="post_nsfw_blur"{% endif %}/>
|
||||
<img width="100%" height="100%" loading="lazy" alt="Post image" src="{{ post.media.url }}"/>
|
||||
{% else %}
|
||||
<svg
|
||||
width="{{ post.media.width }}px"
|
||||
height="{{ post.media.height }}px"
|
||||
{%if post_should_be_blurred %}class="post_nsfw_blur"{% endif %}
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<image width="100%" height="100%" href="{{ post.media.url }}"/>
|
||||
<desc>
|
||||
|
@ -123,7 +124,7 @@
|
|||
{% if prefs.use_hls == "on" && !post.media.alt_url.is_empty() %}
|
||||
<script src="/hls.min.js"></script>
|
||||
<div class="post_media_content">
|
||||
<video class="post_media_video short {% if prefs.autoplay_videos == "on" %}hls_autoplay{% endif %}{%if post_should_be_blurred %} post_nsfw_blur{% endif %}" {% if post.media.width > 0 && post.media.height > 0 %}width="{{ post.media.width }}" height="{{ post.media.height }}"{% endif %} poster="{{ post.media.poster }}" preload="none" controls>
|
||||
<video class="post_media_video short {% if prefs.autoplay_videos == "on" %}hls_autoplay{% endif %}" {% if post.media.width > 0 && post.media.height > 0 %}width="{{ post.media.width }}" height="{{ post.media.height }}"{% endif %} poster="{{ post.media.poster }}" preload="none" controls>
|
||||
<source src="{{ post.media.alt_url }}" type="application/vnd.apple.mpegurl" />
|
||||
<source src="{{ post.media.url }}" type="video/mp4" />
|
||||
</video>
|
||||
|
@ -131,7 +132,7 @@
|
|||
<script src="/playHLSVideo.js"></script>
|
||||
{% else %}
|
||||
<div class="post_media_content">
|
||||
<video class="post_media_video{%if post_should_be_blurred %} post_nsfw_blur{% endif %}" src="{{ post.media.url }}" controls {% if prefs.autoplay_videos == "on" %}autoplay{% endif %} loop><a href={{ post.media.url }}>Video</a></video>
|
||||
<video class="post_media_video" src="{{ post.media.url }}" controls {% if prefs.autoplay_videos == "on" %}autoplay{% endif %} loop><a href={{ post.media.url }}>Video</a></video>
|
||||
</div>
|
||||
{% call render_hls_notification(post.permalink[1..]) %}
|
||||
{% endif %}
|
||||
|
@ -154,7 +155,10 @@
|
|||
{% endif %}
|
||||
|
||||
<!-- POST BODY -->
|
||||
<div class="post_body">{{ post.body|safe }}</div>
|
||||
<div class="post_body">
|
||||
{{ post.body|safe }}
|
||||
{% call poll(post) %}
|
||||
</div>
|
||||
<div class="post_score" title="{{ post.score.1 }}">
|
||||
{% if prefs.hide_score != "on" %}
|
||||
{{ post.score.0 }}
|
||||
|
@ -164,13 +168,32 @@
|
|||
<span class="label"> Upvotes</span></div>
|
||||
<div class="post_footer">
|
||||
<ul id="post_links">
|
||||
<li class="desktop_item"><a href="{{ post.permalink }}">permalink</a></li>
|
||||
<li class="mobile_item"><a href="{{ post.permalink }}">link</a></li>
|
||||
<li>
|
||||
<a href="{{ post.permalink }}">
|
||||
<span class="desktop_item">perma</span>link
|
||||
</a>
|
||||
</li>
|
||||
{% if post.num_duplicates > 0 %}
|
||||
<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>
|
||||
<a href="/r/{{ post.community }}/duplicates/{{ post.id }}">
|
||||
dup<span class="desktop_item">licat</span>es
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if post.post_type == "link" %}
|
||||
<li class="desktop_item"><a target="_blank" href="https://archive.is/latest/{{ post.media.url }}">archive.is</a></li>
|
||||
<li class="mobile_item"><a target="_blank" href="https://archive.is/latest/{{ post.media.url }}">archive</a></li>
|
||||
{% endif %}
|
||||
{% call external_reddit_link(post.permalink) %}
|
||||
|
||||
{% if post.media.download_name != "" %}
|
||||
<li>
|
||||
<a href="{{ post.media.url }}" download="{{ post.media.download_name }}">
|
||||
<span class="mobile_item">dl</span>
|
||||
<span class="desktop_item">download</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
<p>{{ post.upvote_ratio }}%<span id="upvoted"> Upvoted</span></p>
|
||||
</div>
|
||||
|
@ -178,8 +201,7 @@
|
|||
{%- endmacro %}
|
||||
|
||||
{% macro external_reddit_link(permalink) %}
|
||||
{% for dev_type in ["desktop", "mobile"] %}
|
||||
<li class="{{ dev_type }}_item">
|
||||
<li>
|
||||
<a
|
||||
{% if prefs.disable_visit_reddit_confirmation != "on" %}
|
||||
href="#popup"
|
||||
|
@ -193,12 +215,11 @@
|
|||
{% call visit_reddit_confirmation(permalink) %}
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro post_in_list(post) -%}
|
||||
{% set post_should_be_blurred = (post.flags.nsfw && prefs.blur_nsfw=="on") || (post.flags.spoiler && prefs.blur_spoiler=="on") -%}
|
||||
<div class="post {% if post.flags.stickied %}stickied{% endif %}" id="{{ post.id }}">
|
||||
<div class="post{% if post.flags.stickied %} stickied{% endif %}{% if post_should_be_blurred %} post_blurred{% endif %}" id="{{ post.id }}">
|
||||
<p class="post_header">
|
||||
{% let community -%}
|
||||
{% if post.community.starts_with("u_") -%}
|
||||
|
@ -237,7 +258,6 @@
|
|||
<img width="100%" height="100%" loading="lazy" alt="Post image" src="{{ post.media.url }}"/>
|
||||
{% else %}
|
||||
<svg
|
||||
{%if post_should_be_blurred %}class="post_nsfw_blur"{% endif %}
|
||||
width="{{ post.media.width }}px"
|
||||
height="{{ post.media.height }}px"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
|
@ -249,26 +269,22 @@
|
|||
{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
{% else if (prefs.layout.is_empty() || prefs.layout == "card") && post.post_type == "gif" %}
|
||||
<div class="post_media_content">
|
||||
<video class="post_media_video short {%if post_should_be_blurred %}post_nsfw_blur{% endif %}" src="{{ post.media.url }}" {% if post.media.width > 0 && post.media.height > 0 %}width="{{ post.media.width }}" height="{{ post.media.height }}"{% endif %} poster="{{ post.media.poster }}" preload="none" controls loop {% if prefs.autoplay_videos == "on" %}autoplay{% endif %}><a href={{ post.media.url }}>Video</a></video>
|
||||
</div>
|
||||
{% else if (prefs.layout.is_empty() || prefs.layout == "card") && post.post_type == "video" %}
|
||||
{% else if (prefs.layout.is_empty() || prefs.layout == "card") && (post.post_type == "gif" || post.post_type == "video") %}
|
||||
{% if prefs.use_hls == "on" && !post.media.alt_url.is_empty() %}
|
||||
<div class="post_media_content">
|
||||
<video class="post_media_video short {%if post_should_be_blurred %}post_nsfw_blur{% endif %} {% if prefs.autoplay_videos == "on" %}hls_autoplay{% endif %}" {% if post.media.width > 0 && post.media.height > 0 %}width="{{ post.media.width }}" height="{{ post.media.height }}"{% endif %} poster="{{ post.media.poster }}" controls preload="none">
|
||||
<video class="post_media_video short{% if prefs.autoplay_videos == "on" %} hls_autoplay{% endif %}" {% if post.media.width > 0 && post.media.height > 0 %}width="{{ post.media.width }}" height="{{ post.media.height }}"{% endif %} poster="{{ post.media.poster }}" controls preload="none">
|
||||
<source src="{{ post.media.alt_url }}" type="application/vnd.apple.mpegurl" />
|
||||
<source src="{{ post.media.url }}" type="video/mp4" />
|
||||
</video>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="post_media_content">
|
||||
<video class="post_media_video short {%if post_should_be_blurred %}post_nsfw_blur{% endif %}" src="{{ post.media.url }}" {% if post.media.width > 0 && post.media.height > 0 %}width="{{ post.media.width }}" height="{{ post.media.height }}"{% endif %} poster="{{ post.media.poster }}" preload="none" controls {% if prefs.autoplay_videos == "on" %}autoplay{% endif %}><a href={{ post.media.url }}>Video</a></video>
|
||||
<video class="post_media_video short" src="{{ post.media.url }}" {% if post.media.width > 0 && post.media.height > 0 %}width="{{ post.media.width }}" height="{{ post.media.height }}"{% endif %} poster="{{ post.media.poster }}" preload="none" controls {% if prefs.autoplay_videos == "on" %}autoplay{% endif %}><a href={{ post.media.url }}>Video</a></video>
|
||||
</div>
|
||||
{% call render_hls_notification(format!("{}%23{}", &self.url[1..].replace("&", "%26").replace("+", "%2B"), post.id)) %}
|
||||
{% endif %}
|
||||
{% else if post.post_type != "self" %}
|
||||
<a class="post_thumbnail {% if post.thumbnail.url.is_empty() %}no_thumbnail{% endif %}" href="{% if post.post_type == "link" %}{{ post.media.url }}{% else %}{{ post.permalink }}{% endif %}" rel="nofollow">
|
||||
<a class="post_thumbnail{% if post.thumbnail.url.is_empty() %} no_thumbnail{% endif %}" href="{% if post.post_type == "link" %}{{ post.media.url }}{% else %}{{ post.permalink }}{% endif %}" rel="nofollow">
|
||||
{% if post.thumbnail.url.is_empty() %}
|
||||
<svg viewBox="0 0 100 106" width="140" height="53" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Thumbnail</title>
|
||||
|
@ -276,7 +292,7 @@
|
|||
</svg>
|
||||
{% else %}
|
||||
<div style="max-width:{{ post.thumbnail.width }}px;max-height:{{ post.thumbnail.height }}px;">
|
||||
<svg {% if post_should_be_blurred %} class="thumb_nsfw_blur" {% endif %} width="{{ post.thumbnail.width }}px" height="{{ post.thumbnail.height }}px" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg width="{{ post.thumbnail.width }}px" height="{{ post.thumbnail.height }}px" xmlns="http://www.w3.org/2000/svg">
|
||||
<image width="100%" height="100%" href="{{ post.thumbnail.url }}"/>
|
||||
<desc>
|
||||
<img loading="lazy" alt="Thumbnail" src="{{ post.thumbnail.url }}"/>
|
||||
|
|