Compare commits
232 Commits
Author | SHA1 | Date | |
---|---|---|---|
4defb58f2a | |||
ba42fc066f | |||
2cd35fb3b6 | |||
b9af6f47f3 | |||
73732a2a44 | |||
43ed9756dc | |||
8bb247af3b | |||
ed05f5a092 | |||
4f09333cd7 | |||
31bf8c802e | |||
e4f9bd7b8d | |||
83a667347d | |||
499a56aed4 | |||
928907086c | |||
dc9fbc1a05 | |||
7ae7a88eed | |||
536a766960 | |||
e34329cfee | |||
97a0680bd0 | |||
c1560f4eba | |||
242ffab0da | |||
1211d781d0 | |||
9e4066658c | |||
560de4e91f | |||
bd1c890961 | |||
6f799b2617 | |||
38e176f59f | |||
8248eca95c | |||
ffc3bfe72d | |||
d713746407 | |||
21b45760eb | |||
e3fb93946a | |||
b6134a39d0 | |||
c844655c98 | |||
cac83493da | |||
b47cfd1ba5 | |||
28ca3589ed | |||
3cf787cf98 | |||
46e22cf74e | |||
5c2e134924 | |||
c6244585fa | |||
9f1ba274eb | |||
93ed1c6f0c | |||
6ce82c36fb | |||
2974d92e30 | |||
34dfcb2512 | |||
6b42e97bda | |||
49bfe4d27c | |||
c8965ae51b | |||
0b64a52a63 | |||
a18db1e2b7 | |||
3b53e5be4c | |||
42e8351285 | |||
b3e4b7bfae | |||
4a42a25ed3 | |||
2bacaa163f | |||
48c3a8c0d0 | |||
c23d2dc50b | |||
46dbd88d91 | |||
f0f484288e | |||
90d39b121f | |||
44dee302c9 | |||
c7f9386c01 | |||
66ac72beab | |||
14f9ac4ca7 | |||
6a7f725c12 | |||
2533e8cef5 | |||
772d20615b | |||
0bb1677520 | |||
da4883db29 | |||
d50b6ca4b3 | |||
4c66e75f6b | |||
966e0ce921 | |||
ab886d1e67 | |||
dc7e087ed0 | |||
0d6e18d97d | |||
f872baa1fe | |||
9b5176f7b9 | |||
60c89197e5 | |||
7d94876d90 | |||
467342edf4 | |||
3c5b4037e2 | |||
a81502dde1 | |||
0ce2d9054e | |||
a5203fe8dd | |||
038fafa378 | |||
e15c15c390 | |||
07363e47a9 | |||
fb7faf6477 | |||
b14b4ff551 | |||
4b1195f221 | |||
a472461ee8 | |||
baf5e3d7ee | |||
f209757ed6 | |||
4173362ce1 | |||
b2ae5e486f | |||
cda19a1912 | |||
f0b69f8a4a | |||
118ff9485c | |||
4a51b7cfb0 | |||
f877face80 | |||
f0e8deb000 | |||
e70dfe2c0b | |||
2e89a85858 | |||
e59b2b1346 | |||
1c36549134 | |||
5fb88d4744 | |||
6c7188a1b9 | |||
84009fbb8e | |||
bf783c2f3a | |||
213babb057 | |||
7dbc02d930 | |||
10873dd0c6 | |||
c0d1519341 | |||
8709c49f39 | |||
56cfeba9e5 | |||
890d5ae625 | |||
caa8f1d49e | |||
dd51b23dc4 | |||
52d9698879 | |||
20f6945160 | |||
10c73fad7f | |||
2bddc952cb | |||
1de01d7283 | |||
9183ce1921 | |||
a197df89ff | |||
be2a1d876b | |||
686d61801f | |||
5d643277bc | |||
a3ec44149c | |||
83ba0fb913 | |||
55e9915bb0 | |||
5cd5b553b0 | |||
2b2bd8421b | |||
47d01a0dca | |||
0a69937238 | |||
6d08f2dd24 | |||
4a06882dc8 | |||
3e567d9acf | |||
8034594006 | |||
2f3315dcfc | |||
df118764df | |||
d78f82649e | |||
80fb3a5c18 | |||
518d5753a7 | |||
de38f7ef18 | |||
dd67b52199 | |||
9cfab348eb | |||
e1f7b6d0c0 | |||
a606e48435 | |||
2091f26bda | |||
b3341b49c0 | |||
65e4ceff7b | |||
bacb22f7f9 | |||
902c9a6e42 | |||
c586de66ba | |||
e466be8946 | |||
bed3465475 | |||
8560e8a37a | |||
3652342f46 | |||
58127b17d8 | |||
2f4deb221a | |||
38230ed473 | |||
71501b064c | |||
47a58ea05c | |||
14ecf3cf60 | |||
aa7c8c85df | |||
0cb7031c36 | |||
93cfc713c6 | |||
ff8685ae4c | |||
f06320a4ae | |||
809be42e01 | |||
58ca085521 | |||
4a40e16277 | |||
fee2cb1b56 | |||
8785bc95f5 | |||
16454213cf | |||
6feb347c27 | |||
e731cfbac4 | |||
008924fff8 | |||
ebbdd7185f | |||
402b3149e1 | |||
ac5ef89dff | |||
7edca18f8d | |||
cf45d53fdd | |||
2a475d127a | |||
3fa523e67b | |||
3fbb433e37 | |||
5fbcfd850f | |||
c758db84ec | |||
90d3063f93 | |||
82a601d534 | |||
12a1b3f459 | |||
e23eaf0be0 | |||
821709c8d2 | |||
653b0e7024 | |||
c7a2c43287 | |||
9824370771 | |||
d87b96d0ea | |||
6eae4bc47a | |||
1bcb070fbb | |||
24bc758090 | |||
ffbb1cf7cd | |||
cbf1f540d6 | |||
f8e0d2d4b9 | |||
8a27b2bac8 | |||
69941d9efd | |||
956de50419 | |||
d790264a62 | |||
f4f2d8a377 | |||
dd908c9f68 | |||
9e1948733d | |||
9df1dfae32 | |||
cfbee1bb81 | |||
8430cbc6f3 | |||
a9dd2e6f2c | |||
36964982fb | |||
0742a33304 | |||
7f320b3143 | |||
58f4fc4e77 | |||
7d8faefad0 | |||
ba9b5afd4e | |||
ae09f77bf6 | |||
5030c418de | |||
4ccd6b1751 | |||
7d17aa0627 | |||
4b73e2d914 | |||
0a140a6ffc | |||
e837d84105 | |||
f6d791ccd9 | |||
effaeb7508 | |||
6257faf9dc |
27
.github/ISSUE_TEMPLATE/bug_report.md
vendored
27
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@ -1,24 +1,33 @@
|
|||||||
---
|
---
|
||||||
name: Bug report
|
name: 🐛 Bug report
|
||||||
about: Create a report to help us improve
|
about: Create a report to help us improve
|
||||||
title: Bug Report | [title]
|
title: ''
|
||||||
labels: bug
|
labels: bug
|
||||||
assignees: ''
|
assignees: ''
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Describe the bug**
|
## Describe the bug
|
||||||
A clear and concise description of what the bug is.
|
<!--
|
||||||
|
A clear and concise description of what the bug is.
|
||||||
|
-->
|
||||||
|
|
||||||
**To reproduce**
|
## To reproduce
|
||||||
|
|
||||||
|
<!--
|
||||||
Steps to reproduce the behavior:
|
Steps to reproduce the behavior:
|
||||||
1. Go to '...'
|
1. Go to '...'
|
||||||
2. Click on '....'
|
2. Click on '....'
|
||||||
3. Scroll down to '....'
|
3. Scroll down to '....'
|
||||||
4. See error
|
4. See error
|
||||||
|
-->
|
||||||
|
|
||||||
**Expected behavior**
|
## Expected behavior
|
||||||
A clear and concise description of what you expected to happen.
|
<!--
|
||||||
|
A clear and concise description of what you expected to happen.
|
||||||
|
-->
|
||||||
|
|
||||||
**Additional context**
|
## Additional context
|
||||||
Add any other context about the problem here.
|
<!--
|
||||||
|
Add any other context about the problem here.
|
||||||
|
-->
|
||||||
|
28
.github/ISSUE_TEMPLATE/feature_request.md
vendored
28
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@ -1,20 +1,28 @@
|
|||||||
---
|
---
|
||||||
name: Feature request
|
name: 💡 Feature request
|
||||||
about: Suggest an idea for this project
|
about: Suggest an idea for this project
|
||||||
title: Feature Request | [title]
|
title: ''
|
||||||
labels: enhancement
|
labels: enhancement
|
||||||
assignees: ''
|
assignees: ''
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Is your feature request related to a problem? Please describe.**
|
## Is your feature request related to a problem? Please describe.
|
||||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
<!--
|
||||||
|
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||||
|
-->
|
||||||
|
|
||||||
**Describe the solution you'd like**
|
## Describe the solution you'd like
|
||||||
A clear and concise description of what you want to happen.
|
<!--
|
||||||
|
A clear and concise description of what you want to happen.
|
||||||
|
-->
|
||||||
|
|
||||||
**Describe alternatives you've considered**
|
## Describe alternatives you've considered
|
||||||
A clear and concise description of any alternative solutions or features you've considered.
|
<!--
|
||||||
|
A clear and concise description of any alternative solutions or features you've considered.
|
||||||
|
-->
|
||||||
|
|
||||||
**Additional context**
|
## Additional context
|
||||||
Add any other context or screenshots about the feature request here.
|
<!--
|
||||||
|
Add any other context or screenshots about the feature request here.
|
||||||
|
-->
|
||||||
|
36
.github/workflows/docker-arm.yml
vendored
Normal file
36
.github/workflows/docker-arm.yml
vendored
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
name: Docker ARM Build
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths-ignore:
|
||||||
|
- "**.md"
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-docker:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v1
|
||||||
|
with:
|
||||||
|
platforms: all
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
id: buildx
|
||||||
|
uses: docker/setup-buildx-action@v1
|
||||||
|
with:
|
||||||
|
version: latest
|
||||||
|
- name: Login to DockerHub
|
||||||
|
uses: docker/login-action@v1
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v2
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile.arm
|
||||||
|
platforms: linux/arm64
|
||||||
|
push: true
|
||||||
|
tags: spikecodes/libreddit:arm
|
30
.github/workflows/rust.yml
vendored
30
.github/workflows/rust.yml
vendored
@ -11,7 +11,7 @@ env:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-18.04
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
@ -21,9 +21,35 @@ jobs:
|
|||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: cargo build --release
|
run: cargo build --release
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v2.2.1
|
- uses: actions/upload-artifact@v2.2.1
|
||||||
name: Upload a Build Artifact
|
name: Upload a Build Artifact
|
||||||
with:
|
with:
|
||||||
name: libreddit
|
name: libreddit
|
||||||
path: target/release/libreddit
|
path: target/release/libreddit
|
||||||
|
|
||||||
|
- name: Versions
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
echo "::set-output name=version::$(cargo metadata --format-version 1 --no-deps | jq .packages[0].version -r | sed 's/^/v/')"
|
||||||
|
echo "::set-output name=tag::$(git describe --tags)"
|
||||||
|
|
||||||
|
- name: Calculate SHA512 checksum
|
||||||
|
run: sha512sum target/release/libreddit > libreddit.sha512
|
||||||
|
|
||||||
|
- name: Release
|
||||||
|
uses: softprops/action-gh-release@v1
|
||||||
|
if: github.base_ref != 'master'
|
||||||
|
with:
|
||||||
|
tag_name: ${{ steps.version.outputs.version }}
|
||||||
|
name: ${{ steps.version.outputs.version }} - NAME
|
||||||
|
draft: true
|
||||||
|
files: |
|
||||||
|
target/release/libreddit
|
||||||
|
libreddit.sha512
|
||||||
|
body: |
|
||||||
|
- ${{ github.event.head_commit.message }} ${{ github.sha }}
|
||||||
|
|
||||||
|
See full list of changes [here](https://github.com/spikecodes/libreddit/compare/${{ steps.version.outputs.tag }}...${{ steps.version.outputs.version }}).
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -1 +1 @@
|
|||||||
/target
|
/target
|
1
CODEOWNERS
Normal file
1
CODEOWNERS
Normal file
@ -0,0 +1 @@
|
|||||||
|
* @spikecodes
|
1569
Cargo.lock
generated
1569
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
28
Cargo.toml
28
Cargo.toml
@ -3,19 +3,23 @@ name = "libreddit"
|
|||||||
description = " Alternative private front-end to Reddit"
|
description = " Alternative private front-end to Reddit"
|
||||||
license = "AGPL-3.0"
|
license = "AGPL-3.0"
|
||||||
repository = "https://github.com/spikecodes/libreddit"
|
repository = "https://github.com/spikecodes/libreddit"
|
||||||
version = "0.2.9"
|
version = "0.14.4"
|
||||||
authors = ["spikecodes <19519553+spikecodes@users.noreply.github.com>"]
|
authors = ["spikecodes <19519553+spikecodes@users.noreply.github.com>"]
|
||||||
edition = "2018"
|
edition = "2018"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
base64 = "0.13"
|
askama = { version = "0.10.5", default-features = false }
|
||||||
actix-web = { version = "3.3", features = ["rustls"] }
|
async-recursion = "0.3.2"
|
||||||
futures = "0.3"
|
cached = "0.23.0"
|
||||||
askama = "0.10"
|
clap = { version = "2.33.3", default-features = false }
|
||||||
ureq = "2"
|
regex = "1.5.4"
|
||||||
serde = { version = "1.0", default_features = false, features = ["derive"] }
|
serde = { version = "1.0.126", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
cookie = "0.15.0"
|
||||||
async-recursion = "0.3"
|
futures-lite = "1.11.3"
|
||||||
url = "2.2"
|
hyper = { version = "0.14.7", features = ["full"] }
|
||||||
regex = "1.4"
|
hyper-rustls = "0.22.1"
|
||||||
time = "0.2"
|
route-recognizer = "0.3.0"
|
||||||
|
serde_json = "1.0.64"
|
||||||
|
tokio = { version = "1.6.0", features = ["full"] }
|
||||||
|
time = "0.2.26"
|
||||||
|
url = "2.2.2"
|
||||||
|
39
Dockerfile
39
Dockerfile
@ -1,9 +1,36 @@
|
|||||||
FROM rust:alpine as builder
|
####################################################################################################
|
||||||
WORKDIR /usr/src/libreddit
|
## Builder
|
||||||
COPY . .
|
####################################################################################################
|
||||||
RUN apk add --no-cache g++ openssl-dev
|
FROM rust:alpine AS builder
|
||||||
RUN cargo install --path .
|
|
||||||
|
|
||||||
|
RUN apk add --no-cache musl-dev
|
||||||
|
|
||||||
|
WORKDIR /libreddit
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN cargo build --target x86_64-unknown-linux-musl --release
|
||||||
|
|
||||||
|
####################################################################################################
|
||||||
|
## Final image
|
||||||
|
####################################################################################################
|
||||||
FROM alpine:latest
|
FROM alpine:latest
|
||||||
COPY --from=builder /usr/local/cargo/bin/libreddit /usr/local/bin/libreddit
|
|
||||||
|
# Import ca-certificates from builder
|
||||||
|
COPY --from=builder /usr/share/ca-certificates /usr/share/ca-certificates
|
||||||
|
COPY --from=builder /etc/ssl/certs /etc/ssl/certs
|
||||||
|
|
||||||
|
# Copy our build
|
||||||
|
COPY --from=builder /libreddit/target/x86_64-unknown-linux-musl/release/libreddit /usr/local/bin/libreddit
|
||||||
|
|
||||||
|
# Use an unprivileged user.
|
||||||
|
RUN adduser --home /nonexistent --no-create-home --disabled-password libreddit
|
||||||
|
USER libreddit
|
||||||
|
|
||||||
|
# Tell Docker to expose port 8080
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# Run a healthcheck every minute to make sure Libreddit is functional
|
||||||
|
HEALTHCHECK --interval=1m --timeout=3s CMD wget --spider --q http://localhost:8080/settings || exit 1
|
||||||
|
|
||||||
CMD ["libreddit"]
|
CMD ["libreddit"]
|
36
Dockerfile.arm
Normal file
36
Dockerfile.arm
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
####################################################################################################
|
||||||
|
## Builder
|
||||||
|
####################################################################################################
|
||||||
|
FROM rust:alpine AS builder
|
||||||
|
|
||||||
|
RUN apk add --no-cache g++
|
||||||
|
|
||||||
|
WORKDIR /usr/src/libreddit
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN cargo install --path .
|
||||||
|
|
||||||
|
####################################################################################################
|
||||||
|
## Final image
|
||||||
|
####################################################################################################
|
||||||
|
FROM alpine:latest
|
||||||
|
|
||||||
|
# Import ca-certificates from builder
|
||||||
|
COPY --from=builder /usr/share/ca-certificates /usr/share/ca-certificates
|
||||||
|
COPY --from=builder /etc/ssl/certs /etc/ssl/certs
|
||||||
|
|
||||||
|
# Copy our build
|
||||||
|
COPY --from=builder /usr/local/cargo/bin/libreddit /usr/local/bin/libreddit
|
||||||
|
|
||||||
|
# Use an unprivileged user.
|
||||||
|
RUN adduser --home /nonexistent --no-create-home --disabled-password libreddit
|
||||||
|
USER libreddit
|
||||||
|
|
||||||
|
# Tell Docker to expose port 8080
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# Run a healthcheck every minute to make sure Libreddit is functional
|
||||||
|
HEALTHCHECK --interval=1m --timeout=3s CMD wget --spider --q http://localhost:8080/settings || exit 1
|
||||||
|
|
||||||
|
CMD ["libreddit"]
|
101
README.md
101
README.md
@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
> An alternative private front-end to Reddit
|
> An alternative private front-end to Reddit
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**10 second pitch:** Libreddit is a portmanteau of "libre" (meaning freedom) and "Reddit". It is a private front-end like [Invidious](https://github.com/iv-org/invidious) but for Reddit. Browse the coldest takes of [r/unpopularopinion](https://libredd.it/r/unpopularopinion) without being [tracked](#reddit).
|
**10 second pitch:** Libreddit is a portmanteau of "libre" (meaning freedom) and "Reddit". It is a private front-end like [Invidious](https://github.com/iv-org/invidious) but for Reddit. Browse the coldest takes of [r/unpopularopinion](https://libreddit.spike.codes/r/unpopularopinion) without being [tracked](#reddit).
|
||||||
|
|
||||||
- 🚀 Fast: written in Rust for blazing fast speeds and memory safety
|
- 🚀 Fast: written in Rust for blazing fast speeds and memory safety
|
||||||
- ☁️ Light: no JavaScript, no ads, no tracking, no bloat
|
- ☁️ Light: no JavaScript, no ads, no tracking, no bloat
|
||||||
@ -15,17 +15,9 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Jump to...
|
**BTC:** bc1qwyxjnafpu3gypcpgs025cw9wa7ryudtecmwa6y
|
||||||
- [About](#about)
|
|
||||||
- [Teddit Comparison](#how-does-it-compare-to-teddit)
|
**XMR:** 45FJrEuFPtG2o7QZz2Nps77TbHD4sPqxViwbdyV9A6ktfHiWs47UngG5zXPcLoDXAc8taeuBgeNjfeprwgeXYXhN3C9tVSR
|
||||||
- [Comparison](#comparison)
|
|
||||||
- [Installation](#installation)
|
|
||||||
- [Cargo](#a-cargo)
|
|
||||||
- [Docker](#b-docker)
|
|
||||||
- [AUR](#c-aur)
|
|
||||||
- [GitHub Releases](#d-github-releases)
|
|
||||||
- [Repl.it](#e-replit)
|
|
||||||
- [Deployment](#deployment)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -35,13 +27,23 @@ Feel free to [open an issue](https://github.com/spikecodes/libreddit/issues/new)
|
|||||||
|
|
||||||
| Website | Country | Cloudflare |
|
| Website | Country | Cloudflare |
|
||||||
|-|-|-|
|
|-|-|-|
|
||||||
| [libredd.it](https://libredd.it) (official) | 🇺🇸 US | |
|
| [libredd.it](https://libredd.it) (official) | 🇺🇸 US | |
|
||||||
| [libreddit.spike.codes](https://libreddit.spike.codes) (official) | 🇺🇸 US | |
|
| [libreddit.spike.codes](https://libreddit.spike.codes) (official) | 🇺🇸 US | |
|
||||||
| [libreddit.dothq.co](https://libreddit.dothq.co) | 🇺🇸 US | ✅ |
|
| [libreddit.dothq.co](https://libreddit.dothq.co) | 🇺🇸 US | |
|
||||||
| [libreddit.insanity.wtf](https://libreddit.insanity.wtf) | 🇺🇸 US | ✅ |
|
| [libreddit.kavin.rocks](https://libreddit.kavin.rocks) | 🇮🇳 IN | ✅ |
|
||||||
| [libreddit.kavin.rocks](https://libreddit.kavin.rocks) | 🇮🇳 IN | ✅ |
|
| [libreddit.bcow.xyz](https://libreddit.bcow.xyz) | 🇺🇸 US | |
|
||||||
| [libreddit.himiko.cloud](https://libreddit.himiko.cloud) | 🇧🇬 BG | |
|
| [libreddit.40two.app](https://libreddit.40two.app) | 🇳🇱 NL | |
|
||||||
|
| [reddit.invak.id](https://reddit.invak.id) | 🇧🇬 BG | |
|
||||||
|
| [reddit.phii.me](https://reddit.phii.me) | 🇺🇸 US | |
|
||||||
|
| [lr.riverside.rocks](https://lr.riverside.rocks) | 🇺🇸 US | |
|
||||||
|
| [libreddit.silkky.cloud](https://libreddit.silkky.cloud) | 🇫🇮 FI | |
|
||||||
|
| [libreddit.database.red](https://libreddit.database.red) | 🇺🇸 US | ✅ |
|
||||||
|
| [libreddit.exonip.de](https://libreddit.exonip.de) | 🇩🇪 DE | |
|
||||||
|
| [libreddit.domain.glass](https://libreddit.domain.glass) | 🇺🇸 US | ✅ |
|
||||||
| [spjmllawtheisznfs7uryhxumin26ssv2draj7oope3ok3wuhy43eoyd.onion](http://spjmllawtheisznfs7uryhxumin26ssv2draj7oope3ok3wuhy43eoyd.onion) | 🇮🇳 IN | |
|
| [spjmllawtheisznfs7uryhxumin26ssv2draj7oope3ok3wuhy43eoyd.onion](http://spjmllawtheisznfs7uryhxumin26ssv2draj7oope3ok3wuhy43eoyd.onion) | 🇮🇳 IN | |
|
||||||
|
| [fwhhsbrbltmrct5hshrnqlqygqvcgmnek3cnka55zj4y7nuus5muwyyd.onion](http://fwhhsbrbltmrct5hshrnqlqygqvcgmnek3cnka55zj4y7nuus5muwyyd.onion) | 🇩🇪 DE | |
|
||||||
|
| [dflv6yjt7il3n3tggf4qhcmkzbti2ppytqx3o7pjrzwgntutpewscyid.onion](http://dflv6yjt7il3n3tggf4qhcmkzbti2ppytqx3o7pjrzwgntutpewscyid.onion/) | 🇺🇸 US | |
|
||||||
|
| [kphht2jcflojtqte4b4kyx7p2ahagv4debjj32nre67dxz7y57seqwyd.onion](http://kphht2jcflojtqte4b4kyx7p2ahagv4debjj32nre67dxz7y57seqwyd.onion/) | 🇳🇱 NL | |
|
||||||
|
|
||||||
A checkmark in the "Cloudflare" category here refers to the use of the reverse proxy, [Cloudflare](https://cloudflare). The checkmark will not be listed for a site which uses Cloudflare DNS but rather the proxying service which grants Cloudflare the ability to monitor traffic to the website.
|
A checkmark in the "Cloudflare" category here refers to the use of the reverse proxy, [Cloudflare](https://cloudflare). The checkmark will not be listed for a site which uses Cloudflare DNS but rather the proxying service which grants Cloudflare the ability to monitor traffic to the website.
|
||||||
|
|
||||||
@ -54,9 +56,9 @@ Find Libreddit on 💬 [Matrix](https://matrix.to/#/#libreddit:kde.org), 🐋 [D
|
|||||||
## Built with
|
## Built with
|
||||||
|
|
||||||
- [Rust](https://www.rust-lang.org/) - Programming language
|
- [Rust](https://www.rust-lang.org/) - Programming language
|
||||||
- [Actix Web](https://github.com/actix/actix-web) - Web server
|
- [Hyper](https://github.com/hyperium/hyper) - HTTP server and client
|
||||||
- [Askama](https://github.com/djc/askama) - Templating engine
|
- [Askama](https://github.com/djc/askama) - Templating engine
|
||||||
- [ureq](https://github.com/algesten/ureq) - HTTP client
|
- [Rustls](https://github.com/ctz/rustls) - TLS library
|
||||||
|
|
||||||
## Info
|
## Info
|
||||||
Libreddit hopes to provide an easier way to browse Reddit, without the ads, trackers, and bloat. Libreddit was inspired by other alternative front-ends to popular services such as [Invidious](https://github.com/iv-org/invidious) for YouTube, [Nitter](https://github.com/zedeus/nitter) for Twitter, and [Bibliogram](https://sr.ht/~cadence/bibliogram/) for Instagram.
|
Libreddit hopes to provide an easier way to browse Reddit, without the ads, trackers, and bloat. Libreddit was inspired by other alternative front-ends to popular services such as [Invidious](https://github.com/iv-org/invidious) for YouTube, [Nitter](https://github.com/zedeus/nitter) for Twitter, and [Bibliogram](https://sr.ht/~cadence/bibliogram/) for Instagram.
|
||||||
@ -69,7 +71,7 @@ Teddit is another awesome open source project designed to provide an alternative
|
|||||||
|
|
||||||
If you are looking to compare, the biggest differences I have noticed are:
|
If you are looking to compare, the biggest differences I have noticed are:
|
||||||
- Libreddit is themed around Reddit's redesign whereas Teddit appears to stick much closer to Reddit's old design. This may suit some users better as design is always subjective.
|
- Libreddit is themed around Reddit's redesign whereas Teddit appears to stick much closer to Reddit's old design. This may suit some users better as design is always subjective.
|
||||||
- Libreddit is written in [Rust](https://www.rust-lang.org) for speed and memory safety. It uses [Actix Web](https://actix.rs), which was [benchmarked as the fastest web server for single queries](https://www.techempower.com/benchmarks/#hw=ph&test=db).
|
- Libreddit is written in [Rust](https://www.rust-lang.org) for speed and memory safety. It uses [Hyper](https://hyper.rs), a speedy and lightweight HTTP server/client implementation.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -128,9 +130,9 @@ For transparency, I hope to describe all the ways Libreddit handles user privacy
|
|||||||
|
|
||||||
**DNS:** Both official domains (`libredd.it` and `libreddit.spike.codes`) use Cloudflare as the DNS resolver. Though, the sites are not proxied through Cloudflare meaning Cloudflare doesn't have access to user traffic.
|
**DNS:** Both official domains (`libredd.it` and `libreddit.spike.codes`) use Cloudflare as the DNS resolver. Though, the sites are not proxied through Cloudflare meaning Cloudflare doesn't have access to user traffic.
|
||||||
|
|
||||||
**Cookies:** Libreddit uses optional cookies to store any configured settings in [the settings menu](https://libredd.it/settings). This is not a cross-site cookie and the cookie holds no personal data, only a value of the possible layout.
|
**Cookies:** Libreddit uses optional cookies to store any configured settings in [the settings menu](https://libreddit.spike.codes/settings). This is not a cross-site cookie and the cookie holds no personal data, only a value of the possible layout.
|
||||||
|
|
||||||
**Hosting:** The official instances are hosted on [Repl.it](https://repl.it/) which monitors usage to prevent abuse. I can understand if this invalidates certain users' threat models and therefore, selfhosting and browsing through Tor are welcomed.
|
**Hosting:** The official instances are hosted on [Replit](https://replit.com/) which monitors usage to prevent abuse. I can understand if this invalidates certain users' threat models and therefore, selfhosting and browsing through Tor are welcomed.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -148,14 +150,18 @@ cargo install libreddit
|
|||||||
|
|
||||||
Deploy the [Docker image](https://hub.docker.com/r/spikecodes/libreddit) of Libreddit:
|
Deploy the [Docker image](https://hub.docker.com/r/spikecodes/libreddit) of Libreddit:
|
||||||
```
|
```
|
||||||
|
docker pull spikecodes/libreddit
|
||||||
docker run -d --name libreddit -p 8080:8080 spikecodes/libreddit
|
docker run -d --name libreddit -p 8080:8080 spikecodes/libreddit
|
||||||
```
|
```
|
||||||
|
|
||||||
Deploy using a different port (in this case, port 80):
|
Deploy using a different port (in this case, port 80):
|
||||||
```
|
```
|
||||||
|
docker pull spikecodes/libreddit
|
||||||
docker run -d --name libreddit -p 80:8080 spikecodes/libreddit
|
docker run -d --name libreddit -p 80:8080 spikecodes/libreddit
|
||||||
```
|
```
|
||||||
|
|
||||||
|
To deploy on `arm64` platforms, simply replace `spikecodes/libreddit` in the commands above with `spikecodes/libreddit:arm`.
|
||||||
|
|
||||||
## 3) AUR
|
## 3) AUR
|
||||||
|
|
||||||
For ArchLinux users, Libreddit is available from the AUR as [`libreddit-git`](https://aur.archlinux.org/packages/libreddit-git).
|
For ArchLinux users, Libreddit is available from the AUR as [`libreddit-git`](https://aur.archlinux.org/packages/libreddit-git).
|
||||||
@ -168,15 +174,15 @@ yay -S libreddit-git
|
|||||||
|
|
||||||
If you're on Linux and none of these methods work for you, you can grab a Linux binary from [the newest release](https://github.com/spikecodes/libreddit/releases/latest).
|
If you're on Linux and none of these methods work for you, you can grab a Linux binary from [the newest release](https://github.com/spikecodes/libreddit/releases/latest).
|
||||||
|
|
||||||
## 5) Repl.it
|
## 5) Replit
|
||||||
|
|
||||||
**Note:** Repl.it is a free option but they are *not* private and will monitor server usage to prevent abuse. If you need a free and easy setup, this method may work best for you.
|
**Note:** Replit is a free option but they are *not* private and will monitor server usage to prevent abuse. If you need a free and easy setup, this method may work best for you.
|
||||||
|
|
||||||
1. Create a Repl.it account (see note above)
|
1. Create a Replit account (see note above)
|
||||||
2. Visit [the official Repl](https://repl.it/@spikethecoder/libreddit) and fork it
|
2. Visit [the official Repl](https://replit.com/@spikethecoder/libreddit) and fork it
|
||||||
3. Hit the run button to download the latest Libreddit version and start it
|
3. Hit the run button to download the latest Libreddit version and start it
|
||||||
|
|
||||||
In the web preview (defaults to top right), you should see your instance hosted where you can assign a [custom domain](https://docs.repl.it/repls/web-hosting#custom-domains).
|
In the web preview (defaults to top right), you should see your instance hosted where you can assign a [custom domain](https://docs.replit.com/repls/web-hosting#custom-domains).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -188,12 +194,39 @@ Once installed, deploy Libreddit to `0.0.0.0:8080` by running:
|
|||||||
libreddit
|
libreddit
|
||||||
```
|
```
|
||||||
|
|
||||||
## Options
|
## Change Default Settings
|
||||||
|
|
||||||
| Short | Long | Example |
|
Assign a default value for each setting by passing environment variables to Libreddit in the format `LIBREDDIT_DEFAULT_{X}`. Replace `{X}` with the setting name (see list below) in capital letters.
|
||||||
|-------|--------------------|-----------------------------------|
|
|
||||||
| `-a` | `--address` | `libreddit --adress=0.0.0.0:8111` |
|
| Name | Possible values | Default value |
|
||||||
| `-r` | `--redirect-https` | `libreddit --redirect-https` |
|
|-----------------------|----------------------------------------------------------------------------------------|---------------|
|
||||||
|
| theme | ["system", "light", "dark", "black", "dracula", "nord", "laserwave", "violet", "gold"] | system |
|
||||||
|
| front_page | ["default", "popular", "all"] | default |
|
||||||
|
| layout | ["card", "clean", "compact"] | card |
|
||||||
|
| wide | ["on", "off"] | off |
|
||||||
|
| comment_sort | ["hot", "new", "top", "rising", "controversial"] | hot |
|
||||||
|
| post_sort | ["confidence", "top", "new", "controversial", "old"] | confidence |
|
||||||
|
| show_nsfw | ["on", "off"] | off |
|
||||||
|
| use_hls | ["on", "off"] | off |
|
||||||
|
| hide_hls_notification | ["on", "off"] | off |
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
```bash
|
||||||
|
LIBREDDIT_DEFAULT_SHOW_NSFW=on libreddit
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
LIBREDDIT_DEFAULT_WIDE=on LIBREDDIT_DEFAULT_THEME=dark libreddit -r
|
||||||
|
```
|
||||||
|
|
||||||
|
## Proxying using NGINX
|
||||||
|
|
||||||
|
**NOTE** If you're [proxying Libreddit through a NGINX Reverse Proxy](https://github.com/spikecodes/libreddit/issues/122#issuecomment-782226853), add
|
||||||
|
```nginx
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
```
|
||||||
|
to your NGINX configuration file above your `proxy_pass` line.
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
|
13
docker-compose.yml
Normal file
13
docker-compose.yml
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
version: "3.8"
|
||||||
|
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
build: .
|
||||||
|
restart: always
|
||||||
|
container_name: "libreddit"
|
||||||
|
ports:
|
||||||
|
- 8080:8080
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8080/settings"]
|
||||||
|
interval: 5m
|
||||||
|
timeout: 3s
|
157
src/client.rs
Normal file
157
src/client.rs
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
use cached::proc_macro::cached;
|
||||||
|
use futures_lite::{future::Boxed, FutureExt};
|
||||||
|
use hyper::{body::Buf, client, Body, Request, Response, Uri};
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::{result::Result, str::FromStr};
|
||||||
|
|
||||||
|
use crate::server::RequestExt;
|
||||||
|
|
||||||
|
pub async fn proxy(req: Request<Body>, format: &str) -> Result<Response<Body>, String> {
|
||||||
|
let mut url = format!("{}?{}", format, req.uri().query().unwrap_or_default());
|
||||||
|
|
||||||
|
// For each parameter in request
|
||||||
|
for (name, value) in req.params().iter() {
|
||||||
|
// Fill the parameter value in the url
|
||||||
|
url = url.replace(&format!("{{{}}}", name), value);
|
||||||
|
}
|
||||||
|
|
||||||
|
stream(&url, &req).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stream(url: &str, req: &Request<Body>) -> Result<Response<Body>, String> {
|
||||||
|
// First parameter is target URL (mandatory).
|
||||||
|
let url = Uri::from_str(url).map_err(|_| "Couldn't parse URL".to_string())?;
|
||||||
|
|
||||||
|
// Prepare the HTTPS connector.
|
||||||
|
let https = hyper_rustls::HttpsConnector::with_native_roots();
|
||||||
|
|
||||||
|
// Build the hyper client from the HTTPS connector.
|
||||||
|
let client: client::Client<_, hyper::Body> = client::Client::builder().build(https);
|
||||||
|
|
||||||
|
let mut builder = Request::get(url);
|
||||||
|
|
||||||
|
// Copy useful headers from original request
|
||||||
|
for &key in &["Range", "If-Modified-Since", "Cache-Control"] {
|
||||||
|
if let Some(value) = req.headers().get(key) {
|
||||||
|
builder = builder.header(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let stream_request = builder.body(Body::empty()).map_err(|_| "Couldn't build empty body in stream".to_string())?;
|
||||||
|
|
||||||
|
client
|
||||||
|
.request(stream_request)
|
||||||
|
.await
|
||||||
|
.map(|mut res| {
|
||||||
|
let mut rm = |key: &str| res.headers_mut().remove(key);
|
||||||
|
|
||||||
|
rm("access-control-expose-headers");
|
||||||
|
rm("server");
|
||||||
|
rm("vary");
|
||||||
|
rm("etag");
|
||||||
|
rm("x-cdn");
|
||||||
|
rm("x-cdn-client-region");
|
||||||
|
rm("x-cdn-name");
|
||||||
|
rm("x-cdn-server-region");
|
||||||
|
rm("x-reddit-cdn");
|
||||||
|
rm("x-reddit-video-features");
|
||||||
|
|
||||||
|
res
|
||||||
|
})
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request(url: String, quarantine: bool) -> Boxed<Result<Response<Body>, String>> {
|
||||||
|
// Prepare the HTTPS connector.
|
||||||
|
let https = hyper_rustls::HttpsConnector::with_native_roots();
|
||||||
|
|
||||||
|
// Construct the hyper client from the HTTPS connector.
|
||||||
|
let client: client::Client<_, hyper::Body> = client::Client::builder().build(https);
|
||||||
|
|
||||||
|
// Build request
|
||||||
|
let builder = Request::builder()
|
||||||
|
.method("GET")
|
||||||
|
.uri(&url)
|
||||||
|
.header("User-Agent", format!("web:libreddit:{}", env!("CARGO_PKG_VERSION")))
|
||||||
|
.header("Host", "www.reddit.com")
|
||||||
|
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8")
|
||||||
|
.header("Accept-Language", "en-US,en;q=0.5")
|
||||||
|
.header("Connection", "keep-alive")
|
||||||
|
.header("Cookie", if quarantine { "_options=%7B%22pref_quarantine_optin%22%3A%20true%7D" } else { "" })
|
||||||
|
.body(Body::empty());
|
||||||
|
|
||||||
|
async move {
|
||||||
|
match builder {
|
||||||
|
Ok(req) => match client.request(req).await {
|
||||||
|
Ok(response) => {
|
||||||
|
if response.status().to_string().starts_with('3') {
|
||||||
|
request(
|
||||||
|
response
|
||||||
|
.headers()
|
||||||
|
.get("Location")
|
||||||
|
.map(|val| val.to_str().unwrap_or_default())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string(),
|
||||||
|
quarantine,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
} else {
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => Err(e.to_string()),
|
||||||
|
},
|
||||||
|
Err(_) => Err("Post url contains non-ASCII characters".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.boxed()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make a request to a Reddit API and parse the JSON response
|
||||||
|
#[cached(size = 100, time = 30, result = true)]
|
||||||
|
pub async fn json(path: String, quarantine: bool) -> Result<Value, String> {
|
||||||
|
// Build Reddit url from path
|
||||||
|
let url = format!("https://www.reddit.com{}", path);
|
||||||
|
|
||||||
|
// Closure to quickly build errors
|
||||||
|
let err = |msg: &str, e: String| -> Result<Value, String> {
|
||||||
|
// eprintln!("{} - {}: {}", url, msg, e);
|
||||||
|
Err(format!("{}: {}", msg, e))
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch the url...
|
||||||
|
match request(url.clone(), quarantine).await {
|
||||||
|
Ok(response) => {
|
||||||
|
// asynchronously aggregate the chunks of the body
|
||||||
|
match hyper::body::aggregate(response).await {
|
||||||
|
Ok(body) => {
|
||||||
|
// Parse the response from Reddit as JSON
|
||||||
|
match serde_json::from_reader(body.reader()) {
|
||||||
|
Ok(value) => {
|
||||||
|
let json: Value = value;
|
||||||
|
// If Reddit returned an error
|
||||||
|
if json["error"].is_i64() {
|
||||||
|
Err(
|
||||||
|
json["reason"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
json["message"].as_str().unwrap_or_else(|| {
|
||||||
|
eprintln!("{} - Error parsing reddit error", url);
|
||||||
|
"Error parsing reddit error"
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Ok(json)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => err("Failed to parse page JSON data", e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => err("Failed receiving body from Reddit", e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => err("Couldn't send request to Reddit", e),
|
||||||
|
}
|
||||||
|
}
|
372
src/main.rs
372
src/main.rs
@ -1,142 +1,272 @@
|
|||||||
// Import Crates
|
// Global specifiers
|
||||||
use actix_web::{
|
#![forbid(unsafe_code)]
|
||||||
dev::{Service, ServiceResponse},
|
#![warn(clippy::pedantic, clippy::all)]
|
||||||
middleware, web, App, HttpResponse, HttpServer,
|
#![allow(clippy::needless_pass_by_value, clippy::cast_possible_truncation, clippy::cast_possible_wrap, clippy::find_map)]
|
||||||
};
|
|
||||||
use futures::future::FutureExt;
|
|
||||||
|
|
||||||
// Reference local files
|
// Reference local files
|
||||||
mod post;
|
mod post;
|
||||||
mod proxy;
|
|
||||||
mod search;
|
mod search;
|
||||||
mod settings;
|
mod settings;
|
||||||
mod subreddit;
|
mod subreddit;
|
||||||
mod user;
|
mod user;
|
||||||
mod utils;
|
mod utils;
|
||||||
|
|
||||||
|
// Import Crates
|
||||||
|
use clap::{App as cli, Arg};
|
||||||
|
|
||||||
|
use futures_lite::FutureExt;
|
||||||
|
use hyper::{header::HeaderValue, Body, Request, Response};
|
||||||
|
|
||||||
|
mod client;
|
||||||
|
use client::proxy;
|
||||||
|
use server::RequestExt;
|
||||||
|
use utils::{error, redirect};
|
||||||
|
|
||||||
|
mod server;
|
||||||
|
|
||||||
// Create Services
|
// Create Services
|
||||||
async fn style() -> HttpResponse {
|
|
||||||
HttpResponse::Ok().content_type("text/css").body(include_str!("../static/style.css"))
|
// Required for the manifest to be valid
|
||||||
|
async fn pwa_logo() -> Result<Response<Body>, String> {
|
||||||
|
Ok(
|
||||||
|
Response::builder()
|
||||||
|
.status(200)
|
||||||
|
.header("content-type", "image/png")
|
||||||
|
.body(include_bytes!("../static/logo.png").as_ref().into())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn robots() -> HttpResponse {
|
// Required for iOS App Icons
|
||||||
HttpResponse::Ok()
|
async fn iphone_logo() -> Result<Response<Body>, String> {
|
||||||
.header("Cache-Control", "public, max-age=1209600, s-maxage=86400")
|
Ok(
|
||||||
.body("User-agent: *\nAllow: /")
|
Response::builder()
|
||||||
|
.status(200)
|
||||||
|
.header("content-type", "image/png")
|
||||||
|
.body(include_bytes!("../static/apple-touch-icon.png").as_ref().into())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn favicon() -> HttpResponse {
|
async fn favicon() -> Result<Response<Body>, String> {
|
||||||
HttpResponse::Ok()
|
Ok(
|
||||||
.content_type("image/x-icon")
|
Response::builder()
|
||||||
.header("Cache-Control", "public, max-age=1209600, s-maxage=86400")
|
.status(200)
|
||||||
.body(include_bytes!("../static/favicon.ico").as_ref())
|
.header("content-type", "image/vnd.microsoft.icon")
|
||||||
|
.header("Cache-Control", "public, max-age=1209600, s-maxage=86400")
|
||||||
|
.body(include_bytes!("../static/favicon.ico").as_ref().into())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[actix_web::main]
|
async fn font() -> Result<Response<Body>, String> {
|
||||||
async fn main() -> std::io::Result<()> {
|
Ok(
|
||||||
let mut address = "0.0.0.0:8080".to_string();
|
Response::builder()
|
||||||
let mut force_https = false;
|
.status(200)
|
||||||
|
.header("content-type", "font/woff2")
|
||||||
|
.body(include_bytes!("../static/Inter.var.woff2").as_ref().into())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
for arg in std::env::args().collect::<Vec<String>>() {
|
async fn resource(body: &str, content_type: &str, cache: bool) -> Result<Response<Body>, String> {
|
||||||
match arg.split('=').collect::<Vec<&str>>()[0] {
|
let mut res = Response::builder()
|
||||||
"--address" | "-a" => address = arg.split('=').collect::<Vec<&str>>()[1].to_string(),
|
.status(200)
|
||||||
"--redirect-https" | "-r" => force_https = true,
|
.header("content-type", content_type)
|
||||||
_ => (),
|
.body(body.to_string().into())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
if cache {
|
||||||
|
if let Ok(val) = HeaderValue::from_str("public, max-age=1209600, s-maxage=86400") {
|
||||||
|
res.headers_mut().insert("Cache-Control", val);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// start http server
|
Ok(res)
|
||||||
println!("Running Libreddit v{} on {}!", env!("CARGO_PKG_VERSION"), &address);
|
}
|
||||||
|
|
||||||
HttpServer::new(move || {
|
#[tokio::main]
|
||||||
App::new()
|
async fn main() {
|
||||||
// Redirect to HTTPS if "--redirect-https" enabled
|
let matches = cli::new("Libreddit")
|
||||||
.wrap_fn(move |req, srv| {
|
.version(env!("CARGO_PKG_VERSION"))
|
||||||
let secure = req.connection_info().scheme() == "https";
|
.about("Private front-end for Reddit written in Rust ")
|
||||||
let https_url = format!("https://{}{}", req.connection_info().host(), req.uri().to_string());
|
.arg(
|
||||||
srv.call(req).map(move |res: Result<ServiceResponse, _>| {
|
Arg::with_name("address")
|
||||||
if force_https && !secure {
|
.short("a")
|
||||||
Ok(ServiceResponse::new(
|
.long("address")
|
||||||
res.unwrap().request().to_owned(),
|
.value_name("ADDRESS")
|
||||||
HttpResponse::Found().header("Location", https_url).finish(),
|
.help("Sets address to listen on")
|
||||||
))
|
.default_value("0.0.0.0")
|
||||||
} else {
|
.takes_value(true),
|
||||||
res
|
)
|
||||||
}
|
.arg(
|
||||||
})
|
Arg::with_name("port")
|
||||||
})
|
.short("p")
|
||||||
// Append trailing slash and remove double slashes
|
.long("port")
|
||||||
.wrap(middleware::NormalizePath::default())
|
.value_name("PORT")
|
||||||
// Apply default headers for security
|
.help("Port to listen on")
|
||||||
.wrap(
|
.default_value("8080")
|
||||||
middleware::DefaultHeaders::new()
|
.takes_value(true),
|
||||||
.header("Referrer-Policy", "no-referrer")
|
)
|
||||||
.header("X-Content-Type-Options", "nosniff")
|
.arg(
|
||||||
.header("X-Frame-Options", "DENY")
|
Arg::with_name("redirect-https")
|
||||||
.header(
|
.short("r")
|
||||||
"Content-Security-Policy",
|
.long("redirect-https")
|
||||||
"default-src 'none'; media-src 'self'; style-src 'self' 'unsafe-inline'; base-uri 'none'; img-src 'self' data:; form-action 'self'; frame-ancestors 'none';",
|
.help("Redirect all HTTP requests to HTTPS (no longer functional)")
|
||||||
),
|
.takes_value(false),
|
||||||
)
|
)
|
||||||
// Default service in case no routes match
|
.arg(
|
||||||
.default_service(web::get().to(|| utils::error("Nothing here".to_string())))
|
Arg::with_name("hsts")
|
||||||
// Read static files
|
.short("H")
|
||||||
.route("/style.css/", web::get().to(style))
|
.long("hsts")
|
||||||
.route("/favicon.ico/", web::get().to(favicon))
|
.value_name("EXPIRE_TIME")
|
||||||
.route("/robots.txt/", web::get().to(robots))
|
.help("HSTS header to tell browsers that this site should only be accessed over HTTPS")
|
||||||
// Proxy media through Libreddit
|
.default_value("604800")
|
||||||
.route("/proxy/{url:.*}/", web::get().to(proxy::handler))
|
.takes_value(true),
|
||||||
// Browse user profile
|
)
|
||||||
.service(
|
.get_matches();
|
||||||
web::scope("/{scope:user|u}").service(
|
|
||||||
web::scope("/{username}").route("/", web::get().to(user::profile)).service(
|
let address = matches.value_of("address").unwrap_or("0.0.0.0");
|
||||||
web::scope("/comments/{id}/{title}")
|
let port = matches.value_of("port").unwrap_or("8080");
|
||||||
.route("/", web::get().to(post::item))
|
let hsts = matches.value_of("hsts");
|
||||||
.route("/{comment_id}/", web::get().to(post::item)),
|
|
||||||
),
|
let listener = [address, ":", port].concat();
|
||||||
),
|
|
||||||
)
|
println!("Starting Libreddit...");
|
||||||
// Configure settings
|
|
||||||
.service(web::resource("/settings/").route(web::get().to(settings::get)).route(web::post().to(settings::set)))
|
// Begin constructing a server
|
||||||
// Subreddit services
|
let mut app = server::Server::new();
|
||||||
.service(
|
|
||||||
web::scope("/r/{sub}")
|
// Define default headers (added to all responses)
|
||||||
// See posts and info about subreddit
|
app.default_headers = headers! {
|
||||||
.route("/", web::get().to(subreddit::page))
|
"Referrer-Policy" => "no-referrer",
|
||||||
.route("/{sort:hot|new|top|rising|controversial}/", web::get().to(subreddit::page))
|
"X-Content-Type-Options" => "nosniff",
|
||||||
// Handle subscribe/unsubscribe
|
"X-Frame-Options" => "DENY",
|
||||||
.route("/{action:subscribe|unsubscribe}/", web::post().to(subreddit::subscriptions))
|
"Content-Security-Policy" => "default-src 'none'; font-src 'self'; script-src 'self' blob:; manifest-src 'self'; media-src 'self' data: blob: about:; style-src 'self' 'unsafe-inline'; base-uri 'none'; img-src 'self' data:; form-action 'self'; frame-ancestors 'none'; connect-src 'self'; worker-src blob:;"
|
||||||
// View post on subreddit
|
};
|
||||||
.service(
|
|
||||||
web::scope("/comments/{id}/{title}")
|
if let Some(expire_time) = hsts {
|
||||||
.route("/", web::get().to(post::item))
|
if let Ok(val) = HeaderValue::from_str(&format!("max-age={}", expire_time)) {
|
||||||
.route("/{comment_id}/", web::get().to(post::item)),
|
app.default_headers.insert("Strict-Transport-Security", val);
|
||||||
)
|
}
|
||||||
// Search inside subreddit
|
}
|
||||||
.route("/search/", web::get().to(search::find))
|
|
||||||
// View wiki of subreddit
|
// Read static files
|
||||||
.service(
|
app.at("/style.css").get(|_| resource(include_str!("../static/style.css"), "text/css", false).boxed());
|
||||||
web::scope("/wiki")
|
app
|
||||||
.route("/", web::get().to(subreddit::wiki))
|
.at("/manifest.json")
|
||||||
.route("/{page}/", web::get().to(subreddit::wiki)),
|
.get(|_| resource(include_str!("../static/manifest.json"), "application/json", false).boxed());
|
||||||
),
|
app.at("/robots.txt").get(|_| resource("User-agent: *\nAllow: /", "text/plain", true).boxed());
|
||||||
)
|
app.at("/favicon.ico").get(|_| favicon().boxed());
|
||||||
// Front page
|
app.at("/logo.png").get(|_| pwa_logo().boxed());
|
||||||
.route("/", web::get().to(subreddit::page))
|
app.at("/Inter.var.woff2").get(|_| font().boxed());
|
||||||
.route("/{sort:best|hot|new|top|rising|controversial}/", web::get().to(subreddit::page))
|
app.at("/touch-icon-iphone.png").get(|_| iphone_logo().boxed());
|
||||||
// View Reddit wiki
|
app.at("/apple-touch-icon.png").get(|_| iphone_logo().boxed());
|
||||||
.service(
|
app
|
||||||
web::scope("/wiki")
|
.at("/playHLSVideo.js")
|
||||||
.route("/", web::get().to(subreddit::wiki))
|
.get(|_| resource(include_str!("../static/playHLSVideo.js"), "text/javascript", false).boxed());
|
||||||
.route("/{page}/", web::get().to(subreddit::wiki)),
|
app
|
||||||
)
|
.at("/hls.min.js")
|
||||||
// Search all of Reddit
|
.get(|_| resource(include_str!("../static/hls.min.js"), "text/javascript", false).boxed());
|
||||||
.route("/search/", web::get().to(search::find))
|
|
||||||
// Short link for post
|
// Proxy media through Libreddit
|
||||||
.route("/{id:.{5,6}}/", web::get().to(post::item))
|
app.at("/vid/:id/:size").get(|r| proxy(r, "https://v.redd.it/{id}/DASH_{size}").boxed());
|
||||||
})
|
app.at("/hls/:id/*path").get(|r| proxy(r, "https://v.redd.it/{id}/{path}").boxed());
|
||||||
.bind(&address)
|
app.at("/img/:id").get(|r| proxy(r, "https://i.redd.it/{id}").boxed());
|
||||||
.unwrap_or_else(|e| panic!("Cannot bind to the address {}: {}", address, e))
|
app.at("/thumb/:point/:id").get(|r| proxy(r, "https://{point}.thumbs.redditmedia.com/{id}").boxed());
|
||||||
.run()
|
app.at("/emoji/:id/:name").get(|r| proxy(r, "https://emoji.redditmedia.com/{id}/{name}").boxed());
|
||||||
.await
|
app.at("/preview/:loc/:id").get(|r| proxy(r, "https://{loc}view.redd.it/{id}").boxed());
|
||||||
|
app.at("/style/*path").get(|r| proxy(r, "https://styles.redditmedia.com/{path}").boxed());
|
||||||
|
app.at("/static/*path").get(|r| proxy(r, "https://www.redditstatic.com/{path}").boxed());
|
||||||
|
|
||||||
|
// Browse user profile
|
||||||
|
app
|
||||||
|
.at("/u/:name")
|
||||||
|
.get(|r| async move { Ok(redirect(format!("/user/{}", r.param("name").unwrap_or_default()))) }.boxed());
|
||||||
|
app.at("/u/:name/comments/:id/:title").get(|r| post::item(r).boxed());
|
||||||
|
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".to_string()).boxed());
|
||||||
|
app.at("/user/:name").get(|r| user::profile(r).boxed());
|
||||||
|
app.at("/user/:name/comments/:id").get(|r| post::item(r).boxed());
|
||||||
|
app.at("/user/:name/comments/:id/:title").get(|r| post::item(r).boxed());
|
||||||
|
app.at("/user/:name/comments/:id/:title/:comment_id").get(|r| post::item(r).boxed());
|
||||||
|
|
||||||
|
// 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/update").get(|r| settings::update(r).boxed());
|
||||||
|
|
||||||
|
// Subreddit services
|
||||||
|
app
|
||||||
|
.at("/r/:sub")
|
||||||
|
.get(|r| subreddit::community(r).boxed())
|
||||||
|
.post(|r| subreddit::add_quarantine_exception(r).boxed());
|
||||||
|
|
||||||
|
app
|
||||||
|
.at("/r/u_:name")
|
||||||
|
.get(|r| async move { Ok(redirect(format!("/user/{}", r.param("name").unwrap_or_default()))) }.boxed());
|
||||||
|
|
||||||
|
app.at("/r/:sub/subscribe").post(|r| subreddit::subscriptions(r).boxed());
|
||||||
|
app.at("/r/:sub/unsubscribe").post(|r| subreddit::subscriptions(r).boxed());
|
||||||
|
|
||||||
|
app.at("/r/:sub/comments/:id").get(|r| post::item(r).boxed());
|
||||||
|
app.at("/r/:sub/comments/:id/:title").get(|r| post::item(r).boxed());
|
||||||
|
app.at("/r/:sub/comments/:id/:title/:comment_id").get(|r| post::item(r).boxed());
|
||||||
|
|
||||||
|
app.at("/r/:sub/search").get(|r| search::find(r).boxed());
|
||||||
|
|
||||||
|
app
|
||||||
|
.at("/r/:sub/w")
|
||||||
|
.get(|r| async move { Ok(redirect(format!("/r/{}/wiki", r.param("sub").unwrap_or_default()))) }.boxed());
|
||||||
|
app
|
||||||
|
.at("/r/:sub/w/*page")
|
||||||
|
.get(|r| async move { Ok(redirect(format!("/r/{}/wiki/{}", r.param("sub").unwrap_or_default(), r.param("wiki").unwrap_or_default()))) }.boxed());
|
||||||
|
app.at("/r/:sub/wiki").get(|r| subreddit::wiki(r).boxed());
|
||||||
|
app.at("/r/:sub/wiki/*page").get(|r| subreddit::wiki(r).boxed());
|
||||||
|
|
||||||
|
app.at("/r/:sub/about/sidebar").get(|r| subreddit::sidebar(r).boxed());
|
||||||
|
|
||||||
|
app.at("/r/:sub/:sort").get(|r| subreddit::community(r).boxed());
|
||||||
|
|
||||||
|
// Comments handler
|
||||||
|
app.at("/comments/:id").get(|r| post::item(r).boxed());
|
||||||
|
|
||||||
|
// Front page
|
||||||
|
app.at("/").get(|r| subreddit::community(r).boxed());
|
||||||
|
|
||||||
|
// View Reddit wiki
|
||||||
|
app.at("/w").get(|_| async { Ok(redirect("/wiki".to_string())) }.boxed());
|
||||||
|
app
|
||||||
|
.at("/w/*page")
|
||||||
|
.get(|r| async move { Ok(redirect(format!("/wiki/{}", r.param("page").unwrap_or_default()))) }.boxed());
|
||||||
|
app.at("/wiki").get(|r| subreddit::wiki(r).boxed());
|
||||||
|
app.at("/wiki/*page").get(|r| subreddit::wiki(r).boxed());
|
||||||
|
|
||||||
|
// Search all of Reddit
|
||||||
|
app.at("/search").get(|r| search::find(r).boxed());
|
||||||
|
|
||||||
|
// Handle about pages
|
||||||
|
app.at("/about").get(|req| error(req, "About pages aren't added yet".to_string()).boxed());
|
||||||
|
|
||||||
|
app.at("/:id").get(|req: Request<Body>| match req.param("id").as_deref() {
|
||||||
|
// Sort front page
|
||||||
|
Some("best") | Some("hot") | Some("new") | Some("top") | Some("rising") | Some("controversial") => subreddit::community(req).boxed(),
|
||||||
|
// Short link for post
|
||||||
|
Some(id) if id.len() > 4 && id.len() < 7 => post::item(req).boxed(),
|
||||||
|
// Error message for unknown pages
|
||||||
|
_ => error(req, "Nothing here".to_string()).boxed(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Default service in case no routes match
|
||||||
|
app.at("/*").get(|req| error(req, "Nothing here".to_string()).boxed());
|
||||||
|
|
||||||
|
println!("Running Libreddit v{} on {}!", env!("CARGO_PKG_VERSION"), listener);
|
||||||
|
|
||||||
|
let server = app.listen(listener);
|
||||||
|
|
||||||
|
// Run this server for... forever!
|
||||||
|
if let Err(e) = server.await {
|
||||||
|
eprintln!("Server error: {}", e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
205
src/post.rs
205
src/post.rs
@ -1,8 +1,11 @@
|
|||||||
// CRATES
|
// CRATES
|
||||||
use crate::utils::*;
|
use crate::client::json;
|
||||||
use actix_web::{HttpRequest, HttpResponse};
|
use crate::esc;
|
||||||
|
use crate::server::RequestExt;
|
||||||
|
use crate::subreddit::{can_access_quarantine, quarantine};
|
||||||
|
use crate::utils::{error, format_num, format_url, param, rewrite_urls, setting, template, time, val, Author, Comment, Flags, Flair, FlairPart, Media, Post, Preferences};
|
||||||
|
|
||||||
use async_recursion::async_recursion;
|
use hyper::{Body, Request, Response};
|
||||||
|
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
|
|
||||||
@ -14,49 +17,62 @@ struct PostTemplate {
|
|||||||
post: Post,
|
post: Post,
|
||||||
sort: String,
|
sort: String,
|
||||||
prefs: Preferences,
|
prefs: Preferences,
|
||||||
|
single_thread: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn item(req: HttpRequest) -> HttpResponse {
|
pub async fn item(req: Request<Body>) -> Result<Response<Body>, String> {
|
||||||
// Build Reddit API path
|
// Build Reddit API path
|
||||||
let mut path: String = format!("{}.json?{}&raw_json=1", req.path(), req.query_string());
|
let mut path: String = format!("{}.json?{}&raw_json=1", req.uri().path(), req.uri().query().unwrap_or_default());
|
||||||
|
let sub = req.param("sub").unwrap_or_default();
|
||||||
|
let quarantined = can_access_quarantine(&req, &sub);
|
||||||
|
|
||||||
// Set sort to sort query parameter
|
// Set sort to sort query parameter
|
||||||
let mut sort: String = param(&path, "sort");
|
let sort = param(&path, "sort").unwrap_or_else(|| {
|
||||||
|
// Grab default comment sort method from Cookies
|
||||||
|
let default_sort = setting(&req, "comment_sort");
|
||||||
|
|
||||||
// Grab default comment sort method from Cookies
|
// If there's no sort query but there's a default sort, set sort to default_sort
|
||||||
let default_sort = cookie(&req, "comment_sort");
|
if default_sort.is_empty() {
|
||||||
|
String::new()
|
||||||
// If there's no sort query but there's a default sort, set sort to default_sort
|
} else {
|
||||||
if sort.is_empty() && !default_sort.is_empty() {
|
path = format!("{}.json?{}&sort={}&raw_json=1", req.uri().path(), req.uri().query().unwrap_or_default(), default_sort);
|
||||||
sort = default_sort;
|
default_sort
|
||||||
path = format!("{}.json?{}&sort={}&raw_json=1", req.path(), req.query_string(), sort);
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
// Log the post ID being fetched in debug mode
|
// Log the post ID being fetched in debug mode
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
dbg!(req.match_info().get("id").unwrap_or(""));
|
dbg!(req.param("id").unwrap_or_default());
|
||||||
|
|
||||||
|
let single_thread = req.param("comment_id").is_some();
|
||||||
|
let highlighted_comment = &req.param("comment_id").unwrap_or_default();
|
||||||
|
|
||||||
// Send a request to the url, receive JSON in response
|
// Send a request to the url, receive JSON in response
|
||||||
match request(path).await {
|
match json(path, quarantined).await {
|
||||||
// Otherwise, grab the JSON output from the request
|
// Otherwise, grab the JSON output from the request
|
||||||
Ok(res) => {
|
Ok(response) => {
|
||||||
// Parse the JSON into Post and Comment structs
|
// Parse the JSON into Post and Comment structs
|
||||||
let post = parse_post(&res[0]).await;
|
let post = parse_post(&response[0]).await;
|
||||||
let comments = parse_comments(&res[1]).await;
|
let comments = parse_comments(&response[1], &post.permalink, &post.author.name, highlighted_comment);
|
||||||
|
|
||||||
// Use the Post and Comment structs to generate a website to show users
|
// Use the Post and Comment structs to generate a website to show users
|
||||||
let s = PostTemplate {
|
template(PostTemplate {
|
||||||
comments,
|
comments,
|
||||||
post,
|
post,
|
||||||
sort,
|
sort,
|
||||||
prefs: prefs(req),
|
prefs: Preferences::new(req),
|
||||||
}
|
single_thread,
|
||||||
.render()
|
})
|
||||||
.unwrap();
|
|
||||||
HttpResponse::Ok().content_type("text/html").body(s)
|
|
||||||
}
|
}
|
||||||
// If the Reddit API returns an error, exit and send error page to user
|
// If the Reddit API returns an error, exit and send error page to user
|
||||||
Err(msg) => error(msg).await,
|
Err(msg) => {
|
||||||
|
if msg == "quarantined" {
|
||||||
|
let sub = req.param("sub").unwrap_or_default();
|
||||||
|
quarantine(req, sub)
|
||||||
|
} else {
|
||||||
|
error(req, msg).await
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -72,22 +88,23 @@ async fn parse_post(json: &serde_json::Value) -> Post {
|
|||||||
let ratio: f64 = post["data"]["upvote_ratio"].as_f64().unwrap_or(1.0) * 100.0;
|
let ratio: f64 = post["data"]["upvote_ratio"].as_f64().unwrap_or(1.0) * 100.0;
|
||||||
|
|
||||||
// Determine the type of media along with the media URL
|
// Determine the type of media along with the media URL
|
||||||
let (post_type, media) = media(&post["data"]).await;
|
let (post_type, media, gallery) = Media::parse(&post["data"]).await;
|
||||||
|
|
||||||
// Build a post using data parsed from Reddit post API
|
// Build a post using data parsed from Reddit post API
|
||||||
Post {
|
Post {
|
||||||
id: val(post, "id"),
|
id: val(post, "id"),
|
||||||
title: val(post, "title"),
|
title: esc!(post, "title"),
|
||||||
community: val(post, "subreddit"),
|
community: val(post, "subreddit"),
|
||||||
body: rewrite_url(&val(post, "selftext_html")),
|
body: rewrite_urls(&val(post, "selftext_html")).replace("\\", ""),
|
||||||
author: Author {
|
author: Author {
|
||||||
name: val(post, "author"),
|
name: val(post, "author"),
|
||||||
flair: Flair {
|
flair: Flair {
|
||||||
flair_parts: parse_rich_flair(
|
flair_parts: FlairPart::parse(
|
||||||
val(post, "author_flair_type"),
|
post["data"]["author_flair_type"].as_str().unwrap_or_default(),
|
||||||
post["data"]["author_flair_richtext"].as_array(),
|
post["data"]["author_flair_richtext"].as_array(),
|
||||||
post["data"]["author_flair_text"].as_str(),
|
post["data"]["author_flair_text"].as_str(),
|
||||||
),
|
),
|
||||||
|
text: esc!(post, "link_flair_text"),
|
||||||
background_color: val(post, "author_flair_background_color"),
|
background_color: val(post, "author_flair_background_color"),
|
||||||
foreground_color: val(post, "author_flair_text_color"),
|
foreground_color: val(post, "author_flair_text_color"),
|
||||||
},
|
},
|
||||||
@ -100,15 +117,18 @@ async fn parse_post(json: &serde_json::Value) -> Post {
|
|||||||
media,
|
media,
|
||||||
thumbnail: Media {
|
thumbnail: Media {
|
||||||
url: format_url(val(post, "thumbnail").as_str()),
|
url: format_url(val(post, "thumbnail").as_str()),
|
||||||
|
alt_url: String::new(),
|
||||||
width: post["data"]["thumbnail_width"].as_i64().unwrap_or_default(),
|
width: post["data"]["thumbnail_width"].as_i64().unwrap_or_default(),
|
||||||
height: post["data"]["thumbnail_height"].as_i64().unwrap_or_default(),
|
height: post["data"]["thumbnail_height"].as_i64().unwrap_or_default(),
|
||||||
|
poster: "".to_string(),
|
||||||
},
|
},
|
||||||
flair: Flair {
|
flair: Flair {
|
||||||
flair_parts: parse_rich_flair(
|
flair_parts: FlairPart::parse(
|
||||||
val(post, "link_flair_type"),
|
post["data"]["link_flair_type"].as_str().unwrap_or_default(),
|
||||||
post["data"]["link_flair_richtext"].as_array(),
|
post["data"]["link_flair_richtext"].as_array(),
|
||||||
post["data"]["link_flair_text"].as_str(),
|
post["data"]["link_flair_text"].as_str(),
|
||||||
),
|
),
|
||||||
|
text: esc!(post, "link_flair_text"),
|
||||||
background_color: val(post, "link_flair_background_color"),
|
background_color: val(post, "link_flair_background_color"),
|
||||||
foreground_color: if val(post, "link_flair_text_color") == "dark" {
|
foreground_color: if val(post, "link_flair_text_color") == "dark" {
|
||||||
"black".to_string()
|
"black".to_string()
|
||||||
@ -124,65 +144,76 @@ async fn parse_post(json: &serde_json::Value) -> Post {
|
|||||||
rel_time,
|
rel_time,
|
||||||
created,
|
created,
|
||||||
comments: format_num(post["data"]["num_comments"].as_i64().unwrap_or_default()),
|
comments: format_num(post["data"]["num_comments"].as_i64().unwrap_or_default()),
|
||||||
|
gallery,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// COMMENTS
|
// COMMENTS
|
||||||
#[async_recursion]
|
fn parse_comments(json: &serde_json::Value, post_link: &str, post_author: &str, highlighted_comment: &str) -> Vec<Comment> {
|
||||||
async fn parse_comments(json: &serde_json::Value) -> Vec<Comment> {
|
// Parse the comment JSON into a Vector of Comments
|
||||||
// Separate the comment JSON into a Vector of comments
|
let comments = json["data"]["children"].as_array().map_or(Vec::new(), std::borrow::ToOwned::to_owned);
|
||||||
let comment_data = match json["data"]["children"].as_array() {
|
|
||||||
Some(f) => f.to_owned(),
|
|
||||||
None => Vec::new(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut comments: Vec<Comment> = Vec::new();
|
|
||||||
|
|
||||||
// For each comment, retrieve the values to build a Comment object
|
// For each comment, retrieve the values to build a Comment object
|
||||||
for comment in comment_data {
|
|
||||||
let unix_time = comment["data"]["created_utc"].as_f64().unwrap_or_default();
|
|
||||||
if unix_time == 0.0 {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let (rel_time, created) = time(unix_time);
|
|
||||||
|
|
||||||
let score = comment["data"]["score"].as_i64().unwrap_or(0);
|
|
||||||
let body = rewrite_url(&val(&comment, "body_html"));
|
|
||||||
|
|
||||||
let replies: Vec<Comment> = if comment["data"]["replies"].is_object() {
|
|
||||||
parse_comments(&comment["data"]["replies"]).await
|
|
||||||
} else {
|
|
||||||
Vec::new()
|
|
||||||
};
|
|
||||||
|
|
||||||
dbg!();
|
|
||||||
|
|
||||||
comments.push(Comment {
|
|
||||||
id: val(&comment, "id"),
|
|
||||||
body,
|
|
||||||
author: Author {
|
|
||||||
name: val(&comment, "author"),
|
|
||||||
flair: Flair {
|
|
||||||
flair_parts: parse_rich_flair(
|
|
||||||
val(&comment, "author_flair_type"),
|
|
||||||
comment["data"]["author_flair_richtext"].as_array(),
|
|
||||||
comment["data"]["author_flair_text"].as_str(),
|
|
||||||
),
|
|
||||||
background_color: val(&comment, "author_flair_background_color"),
|
|
||||||
foreground_color: val(&comment, "author_flair_text_color"),
|
|
||||||
},
|
|
||||||
distinguished: val(&comment, "distinguished"),
|
|
||||||
},
|
|
||||||
score: if comment["data"]["score_hidden"].as_bool().unwrap_or_default() {
|
|
||||||
"•".to_string()
|
|
||||||
} else {
|
|
||||||
format_num(score)
|
|
||||||
},
|
|
||||||
rel_time,
|
|
||||||
created,
|
|
||||||
replies,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
comments
|
comments
|
||||||
|
.into_iter()
|
||||||
|
.map(|comment| {
|
||||||
|
let kind = comment["kind"].as_str().unwrap_or_default().to_string();
|
||||||
|
let data = &comment["data"];
|
||||||
|
|
||||||
|
let unix_time = data["created_utc"].as_f64().unwrap_or_default();
|
||||||
|
let (rel_time, created) = time(unix_time);
|
||||||
|
|
||||||
|
let edited = data["edited"].as_f64().map_or((String::new(), String::new()), time);
|
||||||
|
|
||||||
|
let score = data["score"].as_i64().unwrap_or(0);
|
||||||
|
let body = rewrite_urls(&val(&comment, "body_html"));
|
||||||
|
|
||||||
|
// If this comment contains replies, handle those too
|
||||||
|
let replies: Vec<Comment> = if data["replies"].is_object() {
|
||||||
|
parse_comments(&data["replies"], post_link, post_author, highlighted_comment)
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
let parent_kind_and_id = val(&comment, "parent_id");
|
||||||
|
let parent_info = parent_kind_and_id.split('_').collect::<Vec<&str>>();
|
||||||
|
|
||||||
|
let id = val(&comment, "id");
|
||||||
|
let highlighted = id == highlighted_comment;
|
||||||
|
|
||||||
|
Comment {
|
||||||
|
id,
|
||||||
|
kind,
|
||||||
|
parent_id: parent_info[1].to_string(),
|
||||||
|
parent_kind: parent_info[0].to_string(),
|
||||||
|
post_link: post_link.to_string(),
|
||||||
|
post_author: post_author.to_string(),
|
||||||
|
body,
|
||||||
|
author: Author {
|
||||||
|
name: val(&comment, "author"),
|
||||||
|
flair: Flair {
|
||||||
|
flair_parts: FlairPart::parse(
|
||||||
|
data["author_flair_type"].as_str().unwrap_or_default(),
|
||||||
|
data["author_flair_richtext"].as_array(),
|
||||||
|
data["author_flair_text"].as_str(),
|
||||||
|
),
|
||||||
|
text: esc!(&comment, "link_flair_text"),
|
||||||
|
background_color: val(&comment, "author_flair_background_color"),
|
||||||
|
foreground_color: val(&comment, "author_flair_text_color"),
|
||||||
|
},
|
||||||
|
distinguished: val(&comment, "distinguished"),
|
||||||
|
},
|
||||||
|
score: if data["score_hidden"].as_bool().unwrap_or_default() {
|
||||||
|
("\u{2022}".to_string(), "Hidden".to_string())
|
||||||
|
} else {
|
||||||
|
format_num(score)
|
||||||
|
},
|
||||||
|
rel_time,
|
||||||
|
created,
|
||||||
|
edited,
|
||||||
|
replies,
|
||||||
|
highlighted,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
47
src/proxy.rs
47
src/proxy.rs
@ -1,47 +0,0 @@
|
|||||||
use actix_web::{client::Client, error, web, Error, HttpResponse, Result};
|
|
||||||
use url::Url;
|
|
||||||
|
|
||||||
use base64::decode;
|
|
||||||
|
|
||||||
pub async fn handler(web::Path(b64): web::Path<String>) -> Result<HttpResponse> {
|
|
||||||
let domains = vec![
|
|
||||||
// THUMBNAILS
|
|
||||||
"a.thumbs.redditmedia.com",
|
|
||||||
"b.thumbs.redditmedia.com",
|
|
||||||
// EMOJI
|
|
||||||
"emoji.redditmedia.com",
|
|
||||||
// ICONS
|
|
||||||
"styles.redditmedia.com",
|
|
||||||
"www.redditstatic.com",
|
|
||||||
// PREVIEWS
|
|
||||||
"preview.redd.it",
|
|
||||||
"external-preview.redd.it",
|
|
||||||
// MEDIA
|
|
||||||
"i.redd.it",
|
|
||||||
"v.redd.it",
|
|
||||||
];
|
|
||||||
|
|
||||||
let decoded = decode(b64).map(|bytes| String::from_utf8(bytes).unwrap_or_default());
|
|
||||||
|
|
||||||
match decoded {
|
|
||||||
Ok(media) => match Url::parse(media.as_str()) {
|
|
||||||
Ok(url) => {
|
|
||||||
let domain = url.domain().unwrap_or_default();
|
|
||||||
|
|
||||||
if domains.contains(&domain) {
|
|
||||||
Client::default().get(media.replace("&", "&")).send().await.map_err(Error::from).map(|res| {
|
|
||||||
HttpResponse::build(res.status())
|
|
||||||
.header("Cache-Control", "public, max-age=1209600, s-maxage=86400")
|
|
||||||
.header("Content-Length", res.headers().get("Content-Length").unwrap().to_owned())
|
|
||||||
.header("Content-Type", res.headers().get("Content-Type").unwrap().to_owned())
|
|
||||||
.streaming(res)
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
Err(error::ErrorForbidden("Resource must be from Reddit"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => Err(error::ErrorBadRequest("Can't parse base64 into URL")),
|
|
||||||
},
|
|
||||||
_ => Err(error::ErrorBadRequest("Can't decode base64")),
|
|
||||||
}
|
|
||||||
}
|
|
125
src/search.rs
125
src/search.rs
@ -1,7 +1,12 @@
|
|||||||
// CRATES
|
// CRATES
|
||||||
use crate::utils::{cookie, error, fetch_posts, param, prefs, request, val, Post, Preferences};
|
use crate::utils::{catch_random, error, format_num, format_url, param, setting, template, val, Post, Preferences};
|
||||||
use actix_web::{HttpRequest, HttpResponse};
|
use crate::{
|
||||||
|
client::json,
|
||||||
|
subreddit::{can_access_quarantine, quarantine},
|
||||||
|
RequestExt,
|
||||||
|
};
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
|
use hyper::{Body, Request, Response};
|
||||||
|
|
||||||
// STRUCTS
|
// STRUCTS
|
||||||
struct SearchParams {
|
struct SearchParams {
|
||||||
@ -17,8 +22,9 @@ struct SearchParams {
|
|||||||
struct Subreddit {
|
struct Subreddit {
|
||||||
name: String,
|
name: String,
|
||||||
url: String,
|
url: String,
|
||||||
|
icon: String,
|
||||||
description: String,
|
description: String,
|
||||||
subscribers: i64,
|
subscribers: (String, String),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
@ -29,71 +35,78 @@ struct SearchTemplate {
|
|||||||
sub: String,
|
sub: String,
|
||||||
params: SearchParams,
|
params: SearchParams,
|
||||||
prefs: Preferences,
|
prefs: Preferences,
|
||||||
|
url: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
// SERVICES
|
// SERVICES
|
||||||
pub async fn find(req: HttpRequest) -> HttpResponse {
|
pub async fn find(req: Request<Body>) -> Result<Response<Body>, String> {
|
||||||
let nsfw_results = if cookie(&req, "show_nsfw") == "on" { "&include_over_18=on" } else { "" };
|
let nsfw_results = if setting(&req, "show_nsfw") == "on" { "&include_over_18=on" } else { "" };
|
||||||
let path = format!("{}.json?{}{}", req.path(), req.query_string(), nsfw_results);
|
let path = format!("{}.json?{}{}", req.uri().path(), req.uri().query().unwrap_or_default(), nsfw_results);
|
||||||
let sub = req.match_info().get("sub").unwrap_or("").to_string();
|
let sub = req.param("sub").unwrap_or_default();
|
||||||
|
let quarantined = can_access_quarantine(&req, &sub);
|
||||||
|
// Handle random subreddits
|
||||||
|
if let Ok(random) = catch_random(&sub, "/find").await {
|
||||||
|
return Ok(random);
|
||||||
|
}
|
||||||
|
let query = param(&path, "q").unwrap_or_default();
|
||||||
|
|
||||||
let sort = if param(&path, "sort").is_empty() {
|
let sort = param(&path, "sort").unwrap_or_else(|| "relevance".to_string());
|
||||||
"relevance".to_string()
|
|
||||||
} else {
|
|
||||||
param(&path, "sort")
|
|
||||||
};
|
|
||||||
|
|
||||||
let subreddits = if param(&path, "restrict_sr").is_empty() {
|
// If search is not restricted to this subreddit, show other subreddits in search results
|
||||||
search_subreddits(param(&path, "q")).await
|
let subreddits = param(&path, "restrict_sr").map_or(search_subreddits(&query).await, |_| Vec::new());
|
||||||
} else {
|
|
||||||
Vec::new()
|
|
||||||
};
|
|
||||||
|
|
||||||
match fetch_posts(&path, String::new()).await {
|
let url = String::from(req.uri().path_and_query().map_or("", |val| val.as_str()));
|
||||||
Ok((posts, after)) => HttpResponse::Ok().content_type("text/html").body(
|
|
||||||
SearchTemplate {
|
match Post::fetch(&path, String::new(), quarantined).await {
|
||||||
posts,
|
Ok((posts, after)) => template(SearchTemplate {
|
||||||
subreddits,
|
posts,
|
||||||
sub,
|
subreddits,
|
||||||
params: SearchParams {
|
sub,
|
||||||
q: param(&path, "q"),
|
params: SearchParams {
|
||||||
sort,
|
q: query.replace('"', """),
|
||||||
t: param(&path, "t"),
|
sort,
|
||||||
before: param(&path, "after"),
|
t: param(&path, "t").unwrap_or_default(),
|
||||||
after,
|
before: param(&path, "after").unwrap_or_default(),
|
||||||
restrict_sr: param(&path, "restrict_sr"),
|
after,
|
||||||
},
|
restrict_sr: param(&path, "restrict_sr").unwrap_or_default(),
|
||||||
prefs: prefs(req),
|
},
|
||||||
|
prefs: Preferences::new(req),
|
||||||
|
url,
|
||||||
|
}),
|
||||||
|
Err(msg) => {
|
||||||
|
if msg == "quarantined" {
|
||||||
|
let sub = req.param("sub").unwrap_or_default();
|
||||||
|
quarantine(req, sub)
|
||||||
|
} else {
|
||||||
|
error(req, msg).await
|
||||||
}
|
}
|
||||||
.render()
|
}
|
||||||
.unwrap(),
|
|
||||||
),
|
|
||||||
Err(msg) => error(msg).await,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn search_subreddits(q: String) -> Vec<Subreddit> {
|
async fn search_subreddits(q: &str) -> Vec<Subreddit> {
|
||||||
let subreddit_search_path = format!("/subreddits/search.json?q={}&limit=3", q.replace(' ', "+"));
|
let subreddit_search_path = format!("/subreddits/search.json?q={}&limit=3", q.replace(' ', "+"));
|
||||||
|
|
||||||
// Send a request to the url
|
// Send a request to the url
|
||||||
match request(subreddit_search_path).await {
|
json(subreddit_search_path, false).await.unwrap_or_default()["data"]["children"]
|
||||||
// If success, receive JSON in response
|
.as_array()
|
||||||
Ok(response) => {
|
.map(ToOwned::to_owned)
|
||||||
match response["data"]["children"].as_array() {
|
.unwrap_or_default()
|
||||||
// For each subreddit from subreddit list
|
.iter()
|
||||||
Some(list) => list
|
.map(|subreddit| {
|
||||||
.iter()
|
// For each subreddit from subreddit list
|
||||||
.map(|subreddit| Subreddit {
|
// Fetch subreddit icon either from the community_icon or icon_img value
|
||||||
name: val(subreddit, "display_name_prefixed"),
|
let icon = subreddit["data"]["community_icon"]
|
||||||
url: val(subreddit, "url"),
|
.as_str()
|
||||||
description: val(subreddit, "public_description"),
|
.map_or_else(|| val(&subreddit, "icon_img"), ToString::to_string);
|
||||||
subscribers: subreddit["data"]["subscribers"].as_u64().unwrap_or_default() as i64,
|
|
||||||
})
|
Subreddit {
|
||||||
.collect::<Vec<Subreddit>>(),
|
name: val(subreddit, "display_name_prefixed"),
|
||||||
_ => Vec::new(),
|
url: val(subreddit, "url"),
|
||||||
|
icon: format_url(&icon),
|
||||||
|
description: val(subreddit, "public_description"),
|
||||||
|
subscribers: format_num(subreddit["data"]["subscribers"].as_f64().unwrap_or_default() as i64),
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
// If the Reddit API returns an error, exit this function
|
.collect::<Vec<Subreddit>>()
|
||||||
_ => Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
215
src/server.rs
Normal file
215
src/server.rs
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
use cookie::Cookie;
|
||||||
|
use futures_lite::{future::Boxed, Future, FutureExt};
|
||||||
|
use hyper::{
|
||||||
|
header::HeaderValue,
|
||||||
|
service::{make_service_fn, service_fn},
|
||||||
|
HeaderMap,
|
||||||
|
};
|
||||||
|
use hyper::{Body, Method, Request, Response, Server as HyperServer};
|
||||||
|
use route_recognizer::{Params, Router};
|
||||||
|
use std::{pin::Pin, result::Result};
|
||||||
|
use time::Duration;
|
||||||
|
|
||||||
|
type BoxResponse = Pin<Box<dyn Future<Output = Result<Response<Body>, String>> + Send>>;
|
||||||
|
|
||||||
|
pub struct Route<'a> {
|
||||||
|
router: &'a mut Router<fn(Request<Body>) -> BoxResponse>,
|
||||||
|
path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Server {
|
||||||
|
pub default_headers: HeaderMap,
|
||||||
|
router: Router<fn(Request<Body>) -> BoxResponse>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! headers(
|
||||||
|
{ $($key:expr => $value:expr),+ } => {
|
||||||
|
{
|
||||||
|
let mut m = hyper::HeaderMap::new();
|
||||||
|
$(
|
||||||
|
if let Ok(val) = hyper::header::HeaderValue::from_str($value) {
|
||||||
|
m.insert($key, val);
|
||||||
|
}
|
||||||
|
)+
|
||||||
|
m
|
||||||
|
}
|
||||||
|
};
|
||||||
|
);
|
||||||
|
|
||||||
|
pub trait RequestExt {
|
||||||
|
fn params(&self) -> Params;
|
||||||
|
fn param(&self, name: &str) -> Option<String>;
|
||||||
|
fn set_params(&mut self, params: Params) -> Option<Params>;
|
||||||
|
fn cookies(&self) -> Vec<Cookie>;
|
||||||
|
fn cookie(&self, name: &str) -> Option<Cookie>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait ResponseExt {
|
||||||
|
fn cookies(&self) -> Vec<Cookie>;
|
||||||
|
fn insert_cookie(&mut self, cookie: Cookie);
|
||||||
|
fn remove_cookie(&mut self, name: String);
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RequestExt for Request<Body> {
|
||||||
|
fn params(&self) -> Params {
|
||||||
|
self.extensions().get::<Params>().unwrap_or(&Params::new()).to_owned()
|
||||||
|
// self.extensions()
|
||||||
|
// .get::<RequestMeta>()
|
||||||
|
// .and_then(|meta| meta.route_params())
|
||||||
|
// .expect("Routerify: No RouteParams added while processing request")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn param(&self, name: &str) -> Option<String> {
|
||||||
|
self.params().find(name).map(std::borrow::ToOwned::to_owned)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_params(&mut self, params: Params) -> Option<Params> {
|
||||||
|
self.extensions_mut().insert(params)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cookies(&self) -> Vec<Cookie> {
|
||||||
|
self.headers().get("Cookie").map_or(Vec::new(), |header| {
|
||||||
|
header
|
||||||
|
.to_str()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.split("; ")
|
||||||
|
.map(|cookie| Cookie::parse(cookie).unwrap_or_else(|_| Cookie::named("")))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cookie(&self, name: &str) -> Option<Cookie> {
|
||||||
|
self.cookies().into_iter().find(|c| c.name() == name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResponseExt for Response<Body> {
|
||||||
|
fn cookies(&self) -> Vec<Cookie> {
|
||||||
|
self.headers().get("Cookie").map_or(Vec::new(), |header| {
|
||||||
|
header
|
||||||
|
.to_str()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.split("; ")
|
||||||
|
.map(|cookie| Cookie::parse(cookie).unwrap_or_else(|_| Cookie::named("")))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert_cookie(&mut self, cookie: Cookie) {
|
||||||
|
if let Ok(val) = HeaderValue::from_str(&cookie.to_string()) {
|
||||||
|
self.headers_mut().append("Set-Cookie", val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_cookie(&mut self, name: String) {
|
||||||
|
let mut cookie = Cookie::named(name);
|
||||||
|
cookie.set_path("/");
|
||||||
|
cookie.set_max_age(Duration::second());
|
||||||
|
if let Ok(val) = HeaderValue::from_str(&cookie.to_string()) {
|
||||||
|
self.headers_mut().append("Set-Cookie", val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Route<'_> {
|
||||||
|
fn method(&mut self, method: Method, dest: fn(Request<Body>) -> BoxResponse) -> &mut Self {
|
||||||
|
self.router.add(&format!("/{}{}", method.as_str(), self.path), dest);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add an endpoint for `GET` requests
|
||||||
|
pub fn get(&mut self, dest: fn(Request<Body>) -> BoxResponse) -> &mut Self {
|
||||||
|
self.method(Method::GET, dest)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add an endpoint for `POST` requests
|
||||||
|
pub fn post(&mut self, dest: fn(Request<Body>) -> BoxResponse) -> &mut Self {
|
||||||
|
self.method(Method::POST, dest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Server {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Server {
|
||||||
|
default_headers: HeaderMap::new(),
|
||||||
|
router: Router::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn at(&mut self, path: &str) -> Route {
|
||||||
|
Route {
|
||||||
|
path: path.to_owned(),
|
||||||
|
router: &mut self.router,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn listen(self, addr: String) -> Boxed<Result<(), hyper::Error>> {
|
||||||
|
let make_svc = make_service_fn(move |_conn| {
|
||||||
|
// For correct borrowing, these values need to be borrowed
|
||||||
|
let router = self.router.clone();
|
||||||
|
let default_headers = self.default_headers.clone();
|
||||||
|
|
||||||
|
// This is the `Service` that will handle the connection.
|
||||||
|
// `service_fn` is a helper to convert a function that
|
||||||
|
// returns a Response into a `Service`.
|
||||||
|
// let shared_router = router.clone();
|
||||||
|
async move {
|
||||||
|
Ok::<_, String>(service_fn(move |req: Request<Body>| {
|
||||||
|
let headers = default_headers.clone();
|
||||||
|
|
||||||
|
// Remove double slashes
|
||||||
|
let mut path = req.uri().path().replace("//", "/");
|
||||||
|
|
||||||
|
// Remove trailing slashes
|
||||||
|
if path != "/" && path.ends_with('/') {
|
||||||
|
path.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match the visited path with an added route
|
||||||
|
match router.recognize(&format!("/{}{}", req.method().as_str(), path)) {
|
||||||
|
// If a route was configured for this path
|
||||||
|
Ok(found) => {
|
||||||
|
let mut parammed = req;
|
||||||
|
parammed.set_params(found.params().to_owned());
|
||||||
|
|
||||||
|
// Run the route's function
|
||||||
|
let func = (found.handler().to_owned().to_owned())(parammed);
|
||||||
|
async move {
|
||||||
|
let res: Result<Response<Body>, String> = func.await;
|
||||||
|
// Add default headers to response
|
||||||
|
res.map(|mut response| {
|
||||||
|
response.headers_mut().extend(headers);
|
||||||
|
response
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.boxed()
|
||||||
|
}
|
||||||
|
// If there was a routing error
|
||||||
|
Err(e) => async move {
|
||||||
|
// Return a 404 error
|
||||||
|
let res: Result<Response<Body>, String> = Ok(Response::builder().status(404).body(e.into()).unwrap_or_default());
|
||||||
|
// Add default headers to response
|
||||||
|
res.map(|mut response| {
|
||||||
|
response.headers_mut().extend(headers);
|
||||||
|
response
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.boxed(),
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build SocketAddr from provided address
|
||||||
|
let address = &addr.parse().unwrap_or_else(|_| panic!("Cannot parse {} as address (example format: 0.0.0.0:8080)", addr));
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
// Wait for the CTRL+C signal
|
||||||
|
tokio::signal::ctrl_c().await.expect("Failed to install CTRL+C signal handler")
|
||||||
|
});
|
||||||
|
|
||||||
|
server.boxed()
|
||||||
|
}
|
||||||
|
}
|
131
src/settings.rs
131
src/settings.rs
@ -1,7 +1,12 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
// CRATES
|
// CRATES
|
||||||
use crate::utils::{prefs, Preferences};
|
use crate::server::ResponseExt;
|
||||||
use actix_web::{cookie::Cookie, web::Form, HttpRequest, HttpResponse};
|
use crate::utils::{redirect, template, Preferences};
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
|
use cookie::Cookie;
|
||||||
|
use futures_lite::StreamExt;
|
||||||
|
use hyper::{Body, Request, Response};
|
||||||
use time::{Duration, OffsetDateTime};
|
use time::{Duration, OffsetDateTime};
|
||||||
|
|
||||||
// STRUCTS
|
// STRUCTS
|
||||||
@ -11,46 +16,118 @@ struct SettingsTemplate {
|
|||||||
prefs: Preferences,
|
prefs: Preferences,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
// CONSTANTS
|
||||||
pub struct SettingsForm {
|
|
||||||
theme: Option<String>,
|
const PREFS: [&str; 9] = [
|
||||||
front_page: Option<String>,
|
"theme",
|
||||||
layout: Option<String>,
|
"front_page",
|
||||||
wide: Option<String>,
|
"layout",
|
||||||
comment_sort: Option<String>,
|
"wide",
|
||||||
show_nsfw: Option<String>,
|
"comment_sort",
|
||||||
}
|
"post_sort",
|
||||||
|
"show_nsfw",
|
||||||
|
"use_hls",
|
||||||
|
"hide_hls_notification",
|
||||||
|
];
|
||||||
|
|
||||||
// FUNCTIONS
|
// FUNCTIONS
|
||||||
|
|
||||||
// Retrieve cookies from request "Cookie" header
|
// Retrieve cookies from request "Cookie" header
|
||||||
pub async fn get(req: HttpRequest) -> HttpResponse {
|
pub async fn get(req: Request<Body>) -> Result<Response<Body>, String> {
|
||||||
let s = SettingsTemplate { prefs: prefs(req) }.render().unwrap();
|
template(SettingsTemplate { prefs: Preferences::new(req) })
|
||||||
HttpResponse::Ok().content_type("text/html").body(s)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set cookies using response "Set-Cookie" header
|
// Set cookies using response "Set-Cookie" header
|
||||||
pub async fn set(_req: HttpRequest, form: Form<SettingsForm>) -> HttpResponse {
|
pub async fn set(req: Request<Body>) -> Result<Response<Body>, String> {
|
||||||
let mut res = HttpResponse::Found();
|
// Split the body into parts
|
||||||
|
let (parts, mut body) = req.into_parts();
|
||||||
|
|
||||||
let names = vec!["theme", "front_page", "layout", "wide", "comment_sort", "show_nsfw"];
|
// Grab existing cookies
|
||||||
let values = vec![&form.theme, &form.front_page, &form.layout, &form.wide, &form.comment_sort, &form.show_nsfw];
|
let _cookies: Vec<Cookie> = parts
|
||||||
|
.headers
|
||||||
|
.get_all("Cookie")
|
||||||
|
.iter()
|
||||||
|
.filter_map(|header| Cookie::parse(header.to_str().unwrap_or_default()).ok())
|
||||||
|
.collect();
|
||||||
|
|
||||||
for (i, name) in names.iter().enumerate() {
|
// Aggregate the body...
|
||||||
match values[i] {
|
// let whole_body = hyper::body::aggregate(req).await.map_err(|e| e.to_string())?;
|
||||||
Some(value) => res.cookie(
|
let body_bytes = body
|
||||||
Cookie::build(name.to_owned(), value)
|
.try_fold(Vec::new(), |mut data, chunk| {
|
||||||
|
data.extend_from_slice(&chunk);
|
||||||
|
Ok(data)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let form = url::form_urlencoded::parse(&body_bytes).collect::<HashMap<_, _>>();
|
||||||
|
|
||||||
|
let mut response = redirect("/settings".to_string());
|
||||||
|
|
||||||
|
for &name in &PREFS {
|
||||||
|
match form.get(name) {
|
||||||
|
Some(value) => response.insert_cookie(
|
||||||
|
Cookie::build(name.to_owned(), value.to_owned())
|
||||||
.path("/")
|
.path("/")
|
||||||
.http_only(true)
|
.http_only(true)
|
||||||
.expires(OffsetDateTime::now_utc() + Duration::weeks(52))
|
.expires(OffsetDateTime::now_utc() + Duration::weeks(52))
|
||||||
.finish(),
|
.finish(),
|
||||||
),
|
),
|
||||||
None => res.del_cookie(&Cookie::named(name.to_owned())),
|
None => response.remove_cookie(name.to_string()),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
res
|
Ok(response)
|
||||||
.content_type("text/html")
|
}
|
||||||
.set_header("Location", "/settings")
|
|
||||||
.body(r#"Redirecting to <a href="/settings">settings</a>..."#)
|
fn set_cookies_method(req: Request<Body>, remove_cookies: bool) -> Response<Body> {
|
||||||
|
// Split the body into parts
|
||||||
|
let (parts, _) = req.into_parts();
|
||||||
|
|
||||||
|
// Grab existing cookies
|
||||||
|
let _cookies: Vec<Cookie> = parts
|
||||||
|
.headers
|
||||||
|
.get_all("Cookie")
|
||||||
|
.iter()
|
||||||
|
.filter_map(|header| Cookie::parse(header.to_str().unwrap_or_default()).ok())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let query = parts.uri.query().unwrap_or_default().as_bytes();
|
||||||
|
|
||||||
|
let form = url::form_urlencoded::parse(query).collect::<HashMap<_, _>>();
|
||||||
|
|
||||||
|
let path = match form.get("redirect") {
|
||||||
|
Some(value) => format!("/{}", value.replace("%26", "&").replace("%23", "#")),
|
||||||
|
None => "/".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut response = redirect(path);
|
||||||
|
|
||||||
|
for name in [PREFS.to_vec(), vec!["subscriptions"]].concat() {
|
||||||
|
match form.get(name) {
|
||||||
|
Some(value) => response.insert_cookie(
|
||||||
|
Cookie::build(name.to_owned(), value.to_owned())
|
||||||
|
.path("/")
|
||||||
|
.http_only(true)
|
||||||
|
.expires(OffsetDateTime::now_utc() + Duration::weeks(52))
|
||||||
|
.finish(),
|
||||||
|
),
|
||||||
|
None => {
|
||||||
|
if remove_cookies {
|
||||||
|
response.remove_cookie(name.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set cookies using response "Set-Cookie" header
|
||||||
|
pub async fn restore(req: Request<Body>) -> Result<Response<Body>, String> {
|
||||||
|
Ok(set_cookies_method(req, true))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update(req: Request<Body>) -> Result<Response<Body>, String> {
|
||||||
|
Ok(set_cookies_method(req, false))
|
||||||
}
|
}
|
||||||
|
371
src/subreddit.rs
371
src/subreddit.rs
@ -1,7 +1,10 @@
|
|||||||
// CRATES
|
// CRATES
|
||||||
use crate::utils::*;
|
use crate::esc;
|
||||||
use actix_web::{cookie::Cookie, HttpRequest, HttpResponse, Result};
|
use crate::utils::{catch_random, error, format_num, format_url, param, redirect, rewrite_urls, setting, template, val, Post, Preferences, Subreddit};
|
||||||
|
use crate::{client::json, server::ResponseExt, RequestExt};
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
|
use cookie::Cookie;
|
||||||
|
use hyper::{Body, Request, Response};
|
||||||
use time::{Duration, OffsetDateTime};
|
use time::{Duration, OffsetDateTime};
|
||||||
|
|
||||||
// STRUCTS
|
// STRUCTS
|
||||||
@ -13,6 +16,7 @@ struct SubredditTemplate {
|
|||||||
sort: (String, String),
|
sort: (String, String),
|
||||||
ends: (String, String),
|
ends: (String, String),
|
||||||
prefs: Preferences,
|
prefs: Preferences,
|
||||||
|
url: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
@ -24,38 +28,57 @@ struct WikiTemplate {
|
|||||||
prefs: Preferences,
|
prefs: Preferences,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "wall.html", escape = "none")]
|
||||||
|
struct WallTemplate {
|
||||||
|
title: String,
|
||||||
|
sub: String,
|
||||||
|
msg: String,
|
||||||
|
prefs: Preferences,
|
||||||
|
url: String,
|
||||||
|
}
|
||||||
|
|
||||||
// SERVICES
|
// SERVICES
|
||||||
pub async fn page(req: HttpRequest) -> HttpResponse {
|
pub async fn community(req: Request<Body>) -> Result<Response<Body>, String> {
|
||||||
let subscribed = cookie(&req, "subscriptions");
|
// Build Reddit API path
|
||||||
let front_page = cookie(&req, "front_page");
|
let root = req.uri().path() == "/";
|
||||||
let sort = req.match_info().get("sort").unwrap_or("hot").to_string();
|
let subscribed = setting(&req, "subscriptions");
|
||||||
|
let front_page = setting(&req, "front_page");
|
||||||
|
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));
|
||||||
|
|
||||||
let sub = req
|
let sub = req.param("sub").unwrap_or(if front_page == "default" || front_page.is_empty() {
|
||||||
.match_info()
|
if subscribed.is_empty() {
|
||||||
.get("sub")
|
"popular".to_string()
|
||||||
.map(String::from)
|
|
||||||
.unwrap_or(if front_page == "default" || front_page.is_empty() {
|
|
||||||
if subscribed.is_empty() {
|
|
||||||
"popular".to_string()
|
|
||||||
} else {
|
|
||||||
subscribed.to_owned()
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
front_page.to_owned()
|
subscribed.to_owned()
|
||||||
});
|
}
|
||||||
|
} else {
|
||||||
|
front_page.to_owned()
|
||||||
|
});
|
||||||
|
let quarantined = can_access_quarantine(&req, &sub) || root;
|
||||||
|
|
||||||
let path = format!("/r/{}/{}.json?{}", sub, sort, req.query_string());
|
// Handle random subreddits
|
||||||
|
if let Ok(random) = catch_random(&sub, "").await {
|
||||||
|
return Ok(random);
|
||||||
|
}
|
||||||
|
|
||||||
match fetch_posts(&path, String::new()).await {
|
if req.param("sub").is_some() && sub.starts_with("u_") {
|
||||||
|
return Ok(redirect(["/user/", &sub[2..]].concat()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = format!("/r/{}/{}.json?{}&raw_json=1", sub, sort, req.uri().query().unwrap_or_default());
|
||||||
|
|
||||||
|
match Post::fetch(&path, String::new(), quarantined).await {
|
||||||
Ok((posts, after)) => {
|
Ok((posts, after)) => {
|
||||||
// If you can get subreddit posts, also request subreddit metadata
|
// If you can get subreddit posts, also request subreddit metadata
|
||||||
let sub = if !sub.contains('+') && sub != subscribed && sub != "popular" && sub != "all" {
|
let sub = if !sub.contains('+') && sub != subscribed && sub != "popular" && sub != "all" {
|
||||||
// Regular subreddit
|
// Regular subreddit
|
||||||
subreddit(&sub).await.unwrap_or_default()
|
subreddit(&sub, quarantined).await.unwrap_or_default()
|
||||||
} else if sub == subscribed {
|
} else if sub == subscribed {
|
||||||
// Subscription feed
|
// Subscription feed
|
||||||
if req.path().starts_with("/r/") {
|
if req.uri().path().starts_with("/r/") {
|
||||||
subreddit(&sub).await.unwrap_or_default()
|
subreddit(&sub, quarantined).await.unwrap_or_default()
|
||||||
} else {
|
} else {
|
||||||
Subreddit::default()
|
Subreddit::default()
|
||||||
}
|
}
|
||||||
@ -69,42 +92,132 @@ pub async fn page(req: HttpRequest) -> HttpResponse {
|
|||||||
Subreddit::default()
|
Subreddit::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let s = SubredditTemplate {
|
let url = String::from(req.uri().path_and_query().map_or("", |val| val.as_str()));
|
||||||
|
|
||||||
|
template(SubredditTemplate {
|
||||||
sub,
|
sub,
|
||||||
posts,
|
posts,
|
||||||
sort: (sort, param(&path, "t")),
|
sort: (sort, param(&path, "t").unwrap_or_default()),
|
||||||
ends: (param(&path, "after"), after),
|
ends: (param(&path, "after").unwrap_or_default(), after),
|
||||||
prefs: prefs(req),
|
prefs: Preferences::new(req),
|
||||||
}
|
url,
|
||||||
.render()
|
})
|
||||||
.unwrap();
|
|
||||||
HttpResponse::Ok().content_type("text/html").body(s)
|
|
||||||
}
|
}
|
||||||
Err(msg) => error(msg).await,
|
Err(msg) => match msg.as_str() {
|
||||||
|
"quarantined" => quarantine(req, sub),
|
||||||
|
"private" => error(req, format!("r/{} is a private community", sub)).await,
|
||||||
|
"banned" => error(req, format!("r/{} has been banned from Reddit", sub)).await,
|
||||||
|
_ => error(req, msg).await,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn quarantine(req: Request<Body>, sub: String) -> Result<Response<Body>, String> {
|
||||||
|
let wall = WallTemplate {
|
||||||
|
title: format!("r/{} is quarantined", sub),
|
||||||
|
msg: "Please click the button below to continue to this subreddit.".to_string(),
|
||||||
|
url: req.uri().to_string(),
|
||||||
|
sub,
|
||||||
|
prefs: Preferences::new(req),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(
|
||||||
|
Response::builder()
|
||||||
|
.status(403)
|
||||||
|
.header("content-type", "text/html")
|
||||||
|
.body(wall.render().unwrap_or_default().into())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn add_quarantine_exception(req: Request<Body>) -> Result<Response<Body>, String> {
|
||||||
|
let subreddit = req.param("sub").ok_or("Invalid URL")?;
|
||||||
|
let redir = param(&format!("?{}", req.uri().query().unwrap_or_default()), "redir").ok_or("Invalid URL")?;
|
||||||
|
let mut response = redirect(redir);
|
||||||
|
response.insert_cookie(
|
||||||
|
Cookie::build(&format!("allow_quaran_{}", subreddit.to_lowercase()), "true")
|
||||||
|
.path("/")
|
||||||
|
.http_only(true)
|
||||||
|
.expires(cookie::Expiration::Session)
|
||||||
|
.finish(),
|
||||||
|
);
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn can_access_quarantine(req: &Request<Body>, sub: &str) -> bool {
|
||||||
|
// Determine if the subreddit can be accessed
|
||||||
|
setting(&req, &format!("allow_quaran_{}", sub.to_lowercase())).parse().unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
// Sub or unsub by setting subscription cookie using response "Set-Cookie" header
|
// Sub or unsub by setting subscription cookie using response "Set-Cookie" header
|
||||||
pub async fn subscriptions(req: HttpRequest) -> HttpResponse {
|
pub async fn subscriptions(req: Request<Body>) -> Result<Response<Body>, String> {
|
||||||
let mut res = HttpResponse::Found();
|
let sub = req.param("sub").unwrap_or_default();
|
||||||
|
// Handle random subreddits
|
||||||
let sub = req.match_info().get("sub").unwrap_or_default().to_string();
|
if sub == "random" || sub == "randnsfw" {
|
||||||
let action = req.match_info().get("action").unwrap_or_default().to_string();
|
return Err("Can't subscribe to random subreddit!".to_string());
|
||||||
let mut sub_list = prefs(req.to_owned()).subs;
|
|
||||||
|
|
||||||
// Modify sub list based on action
|
|
||||||
if action == "subscribe" && !sub_list.contains(&sub) {
|
|
||||||
sub_list.push(sub.to_owned());
|
|
||||||
sub_list.sort();
|
|
||||||
} else if action == "unsubscribe" {
|
|
||||||
sub_list.retain(|s| s != &sub);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let query = req.uri().query().unwrap_or_default().to_string();
|
||||||
|
let action: Vec<String> = req.uri().path().split('/').map(String::from).collect();
|
||||||
|
|
||||||
|
let mut sub_list = Preferences::new(req).subscriptions;
|
||||||
|
|
||||||
|
// Retrieve list of posts for these subreddits to extract display names
|
||||||
|
let posts = json(format!("/r/{}/hot.json?raw_json=1", sub), true).await?;
|
||||||
|
let display_lookup: Vec<(String, &str)> = posts["data"]["children"]
|
||||||
|
.as_array()
|
||||||
|
.map(|list| {
|
||||||
|
list
|
||||||
|
.iter()
|
||||||
|
.map(|post| {
|
||||||
|
let display_name = post["data"]["subreddit"].as_str().unwrap_or_default();
|
||||||
|
(display_name.to_lowercase(), display_name)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// Find each subreddit name (separated by '+') in sub parameter
|
||||||
|
for part in sub.split('+') {
|
||||||
|
// Retrieve display name for the subreddit
|
||||||
|
let display;
|
||||||
|
let part = if let Some(&(_, display)) = display_lookup.iter().find(|x| x.0 == part.to_lowercase()) {
|
||||||
|
// This is already known, doesn't require seperate request
|
||||||
|
display
|
||||||
|
} else {
|
||||||
|
// This subreddit display name isn't known, retrieve it
|
||||||
|
let path: String = format!("/r/{}/about.json?raw_json=1", part);
|
||||||
|
display = json(path, true).await?;
|
||||||
|
display["data"]["display_name"].as_str().ok_or_else(|| "Failed to query subreddit name".to_string())?
|
||||||
|
};
|
||||||
|
|
||||||
|
// Modify sub list based on action
|
||||||
|
if action.contains(&"subscribe".to_string()) && !sub_list.contains(&part.to_owned()) {
|
||||||
|
// Add each sub name to the subscribed list
|
||||||
|
sub_list.push(part.to_owned());
|
||||||
|
// Reorder sub names alphabettically
|
||||||
|
sub_list.sort_by_key(|a| a.to_lowercase())
|
||||||
|
} else if action.contains(&"unsubscribe".to_string()) {
|
||||||
|
// Remove sub name from subscribed list
|
||||||
|
sub_list.retain(|s| s.to_lowercase() != part.to_lowercase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect back to subreddit
|
||||||
|
// check for redirect parameter if unsubscribing from outside sidebar
|
||||||
|
let path = if let Some(redirect_path) = param(&format!("?{}", query), "redirect") {
|
||||||
|
format!("/{}/", redirect_path)
|
||||||
|
} else {
|
||||||
|
format!("/r/{}", sub)
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut response = redirect(path);
|
||||||
|
|
||||||
// Delete cookie if empty, else set
|
// Delete cookie if empty, else set
|
||||||
if sub_list.is_empty() {
|
if sub_list.is_empty() {
|
||||||
res.del_cookie(&Cookie::build("subscriptions", "").path("/").finish());
|
response.remove_cookie("subscriptions".to_string());
|
||||||
} else {
|
} else {
|
||||||
res.cookie(
|
response.insert_cookie(
|
||||||
Cookie::build("subscriptions", sub_list.join("+"))
|
Cookie::build("subscriptions", sub_list.join("+"))
|
||||||
.path("/")
|
.path("/")
|
||||||
.http_only(true)
|
.http_only(true)
|
||||||
@ -113,73 +226,131 @@ pub async fn subscriptions(req: HttpRequest) -> HttpResponse {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redirect back to subreddit
|
Ok(response)
|
||||||
// check for redirect parameter if unsubscribing from outside sidebar
|
|
||||||
let redirect_path = param(&req.uri().to_string(), "redirect");
|
|
||||||
let path = if !redirect_path.is_empty() && redirect_path.starts_with('/') {
|
|
||||||
redirect_path
|
|
||||||
} else {
|
|
||||||
format!("/r/{}", sub)
|
|
||||||
};
|
|
||||||
|
|
||||||
res
|
|
||||||
.content_type("text/html")
|
|
||||||
.set_header("Location", path.to_owned())
|
|
||||||
.body(format!("Redirecting to <a href=\"{0}\">{0}</a>...", path))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn wiki(req: HttpRequest) -> HttpResponse {
|
pub async fn wiki(req: Request<Body>) -> Result<Response<Body>, String> {
|
||||||
let sub = req.match_info().get("sub").unwrap_or("reddit.com").to_string();
|
let sub = req.param("sub").unwrap_or_else(|| "reddit.com".to_string());
|
||||||
let page = req.match_info().get("page").unwrap_or("index").to_string();
|
let quarantined = can_access_quarantine(&req, &sub);
|
||||||
|
// Handle random subreddits
|
||||||
|
if let Ok(random) = catch_random(&sub, "/wiki").await {
|
||||||
|
return Ok(random);
|
||||||
|
}
|
||||||
|
|
||||||
|
let page = req.param("page").unwrap_or_else(|| "index".to_string());
|
||||||
let path: String = format!("/r/{}/wiki/{}.json?raw_json=1", sub, page);
|
let path: String = format!("/r/{}/wiki/{}.json?raw_json=1", sub, page);
|
||||||
|
|
||||||
match request(path).await {
|
match json(path, quarantined).await {
|
||||||
Ok(res) => {
|
Ok(response) => template(WikiTemplate {
|
||||||
let s = WikiTemplate {
|
sub,
|
||||||
sub,
|
wiki: rewrite_urls(response["data"]["content_html"].as_str().unwrap_or("<h3>Wiki not found</h3>")),
|
||||||
wiki: rewrite_url(res["data"]["content_html"].as_str().unwrap_or_default()),
|
page,
|
||||||
page,
|
prefs: Preferences::new(req),
|
||||||
prefs: prefs(req),
|
}),
|
||||||
|
Err(msg) => {
|
||||||
|
if msg == "quarantined" {
|
||||||
|
quarantine(req, sub)
|
||||||
|
} else {
|
||||||
|
error(req, msg).await
|
||||||
}
|
}
|
||||||
.render()
|
|
||||||
.unwrap();
|
|
||||||
HttpResponse::Ok().content_type("text/html").body(s)
|
|
||||||
}
|
}
|
||||||
Err(msg) => error(msg).await,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SUBREDDIT
|
pub async fn sidebar(req: Request<Body>) -> Result<Response<Body>, String> {
|
||||||
async fn subreddit(sub: &str) -> Result<Subreddit, String> {
|
let sub = req.param("sub").unwrap_or_else(|| "reddit.com".to_string());
|
||||||
|
let quarantined = can_access_quarantine(&req, &sub);
|
||||||
|
|
||||||
|
// Handle random subreddits
|
||||||
|
if let Ok(random) = catch_random(&sub, "/about/sidebar").await {
|
||||||
|
return Ok(random);
|
||||||
|
}
|
||||||
|
|
||||||
// Build the Reddit JSON API url
|
// Build the Reddit JSON API url
|
||||||
let path: String = format!("/r/{}/about.json?raw_json=1", sub);
|
let path: String = format!("/r/{}/about.json?raw_json=1", sub);
|
||||||
|
|
||||||
// Send a request to the url
|
// Send a request to the url
|
||||||
match request(path).await {
|
match json(path, quarantined).await {
|
||||||
// If success, receive JSON in response
|
// If success, receive JSON in response
|
||||||
Ok(res) => {
|
Ok(response) => template(WikiTemplate {
|
||||||
// Metadata regarding the subreddit
|
wiki: format!(
|
||||||
let members: i64 = res["data"]["subscribers"].as_u64().unwrap_or_default() as i64;
|
"{}<hr><h1>Moderators</h1><br><ul>{}</ul>",
|
||||||
let active: i64 = res["data"]["accounts_active"].as_u64().unwrap_or_default() as i64;
|
rewrite_urls(&val(&response, "description_html").replace("\\", "")),
|
||||||
|
moderators(&sub, quarantined).await?.join(""),
|
||||||
// Fetch subreddit icon either from the community_icon or icon_img value
|
),
|
||||||
let community_icon: &str = res["data"]["community_icon"].as_str().map_or("", |s| s.split('?').collect::<Vec<&str>>()[0]);
|
sub,
|
||||||
let icon = if community_icon.is_empty() { val(&res, "icon_img") } else { community_icon.to_string() };
|
page: "Sidebar".to_string(),
|
||||||
|
prefs: Preferences::new(req),
|
||||||
let sub = Subreddit {
|
}),
|
||||||
name: val(&res, "display_name"),
|
Err(msg) => {
|
||||||
title: val(&res, "title"),
|
if msg == "quarantined" {
|
||||||
description: val(&res, "public_description"),
|
quarantine(req, sub)
|
||||||
info: rewrite_url(&val(&res, "description_html").replace("\\", "")),
|
} else {
|
||||||
icon: format_url(icon.as_str()),
|
error(req, msg).await
|
||||||
members: format_num(members),
|
}
|
||||||
active: format_num(active),
|
|
||||||
wiki: res["data"]["wiki_enabled"].as_bool().unwrap_or_default(),
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(sub)
|
|
||||||
}
|
}
|
||||||
// If the Reddit API returns an error, exit this function
|
|
||||||
Err(msg) => return Err(msg),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn moderators(sub: &str, quarantined: bool) -> Result<Vec<String>, String> {
|
||||||
|
// Retrieve and format the html for the moderators list
|
||||||
|
Ok(
|
||||||
|
moderators_list(sub, quarantined)
|
||||||
|
.await?
|
||||||
|
.iter()
|
||||||
|
.map(|m| format!("<li><a style=\"color: var(--accent)\" href=\"/u/{name}\">{name}</a></li>", name = m))
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn moderators_list(sub: &str, quarantined: bool) -> Result<Vec<String>, String> {
|
||||||
|
// Build the moderator list URL
|
||||||
|
let path: String = format!("/r/{}/about/moderators.json?raw_json=1", sub);
|
||||||
|
|
||||||
|
// Retrieve response
|
||||||
|
json(path, quarantined).await.map(|response| {
|
||||||
|
// Traverse json tree and format into list of strings
|
||||||
|
response["data"]["children"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap_or(&Vec::new())
|
||||||
|
.iter()
|
||||||
|
.filter_map(|moderator| {
|
||||||
|
let name = moderator["name"].as_str().unwrap_or_default();
|
||||||
|
if name.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(name.to_string())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SUBREDDIT
|
||||||
|
async fn subreddit(sub: &str, quarantined: bool) -> Result<Subreddit, String> {
|
||||||
|
// Build the Reddit JSON API url
|
||||||
|
let path: String = format!("/r/{}/about.json?raw_json=1", sub);
|
||||||
|
|
||||||
|
// Send a request to the url
|
||||||
|
let res = json(path, quarantined).await?;
|
||||||
|
|
||||||
|
// Metadata regarding the subreddit
|
||||||
|
let members: i64 = res["data"]["subscribers"].as_u64().unwrap_or_default() as i64;
|
||||||
|
let active: i64 = res["data"]["accounts_active"].as_u64().unwrap_or_default() as i64;
|
||||||
|
|
||||||
|
// Fetch subreddit icon either from the community_icon or icon_img value
|
||||||
|
let community_icon: &str = res["data"]["community_icon"].as_str().unwrap_or_default();
|
||||||
|
let icon = if community_icon.is_empty() { val(&res, "icon_img") } else { community_icon.to_string() };
|
||||||
|
|
||||||
|
Ok(Subreddit {
|
||||||
|
name: esc!(&res, "display_name"),
|
||||||
|
title: esc!(&res, "title"),
|
||||||
|
description: esc!(&res, "public_description"),
|
||||||
|
info: rewrite_urls(&val(&res, "description_html").replace("\\", "")),
|
||||||
|
moderators: moderators_list(sub, quarantined).await?,
|
||||||
|
icon: format_url(&icon),
|
||||||
|
members: format_num(members),
|
||||||
|
active: format_num(active),
|
||||||
|
wiki: res["data"]["wiki_enabled"].as_bool().unwrap_or_default(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
76
src/user.rs
76
src/user.rs
@ -1,7 +1,10 @@
|
|||||||
// CRATES
|
// CRATES
|
||||||
use crate::utils::{error, fetch_posts, format_url, param, prefs, request, Post, Preferences, User};
|
use crate::client::json;
|
||||||
use actix_web::{HttpRequest, HttpResponse, Result};
|
use crate::esc;
|
||||||
|
use crate::server::RequestExt;
|
||||||
|
use crate::utils::{error, format_url, param, template, Post, Preferences, User};
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
|
use hyper::{Body, Request, Response};
|
||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
|
|
||||||
// STRUCTS
|
// STRUCTS
|
||||||
@ -13,68 +16,67 @@ struct UserTemplate {
|
|||||||
sort: (String, String),
|
sort: (String, String),
|
||||||
ends: (String, String),
|
ends: (String, String),
|
||||||
prefs: Preferences,
|
prefs: Preferences,
|
||||||
|
url: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
// FUNCTIONS
|
// FUNCTIONS
|
||||||
pub async fn profile(req: HttpRequest) -> HttpResponse {
|
pub async fn profile(req: Request<Body>) -> Result<Response<Body>, String> {
|
||||||
// Build the Reddit JSON API path
|
// Build the Reddit JSON API path
|
||||||
let path = format!("{}.json?{}&raw_json=1", req.path(), req.query_string());
|
let path = format!(
|
||||||
|
"/user/{}.json?{}&raw_json=1",
|
||||||
|
req.param("name").unwrap_or_else(|| "reddit".to_string()),
|
||||||
|
req.uri().query().unwrap_or_default()
|
||||||
|
);
|
||||||
|
|
||||||
// Retrieve other variables from Libreddit request
|
// Retrieve other variables from Libreddit request
|
||||||
let sort = param(&path, "sort");
|
let sort = param(&path, "sort").unwrap_or_default();
|
||||||
let username = req.match_info().get("username").unwrap_or("").to_string();
|
let username = req.param("name").unwrap_or_default();
|
||||||
|
|
||||||
// Request user posts/comments from Reddit
|
// Request user posts/comments from Reddit
|
||||||
let posts = fetch_posts(&path, "Comment".to_string()).await;
|
let posts = Post::fetch(&path, "Comment".to_string(), false).await;
|
||||||
|
let url = String::from(req.uri().path_and_query().map_or("", |val| val.as_str()));
|
||||||
|
|
||||||
match posts {
|
match posts {
|
||||||
Ok((posts, after)) => {
|
Ok((posts, after)) => {
|
||||||
// If you can get user posts, also request user data
|
// If you can get user posts, also request user data
|
||||||
let user = user(&username).await.unwrap_or_default();
|
let user = user(&username).await.unwrap_or_default();
|
||||||
|
|
||||||
let s = UserTemplate {
|
template(UserTemplate {
|
||||||
user,
|
user,
|
||||||
posts,
|
posts,
|
||||||
sort: (sort, param(&path, "t")),
|
sort: (sort, param(&path, "t").unwrap_or_default()),
|
||||||
ends: (param(&path, "after"), after),
|
ends: (param(&path, "after").unwrap_or_default(), after),
|
||||||
prefs: prefs(req),
|
prefs: Preferences::new(req),
|
||||||
}
|
url,
|
||||||
.render()
|
})
|
||||||
.unwrap();
|
|
||||||
HttpResponse::Ok().content_type("text/html").body(s)
|
|
||||||
}
|
}
|
||||||
// If there is an error show error page
|
// If there is an error show error page
|
||||||
Err(msg) => error(msg).await,
|
Err(msg) => error(req, msg).await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// USER
|
// USER
|
||||||
async fn user(name: &str) -> Result<User, String> {
|
async fn user(name: &str) -> Result<User, String> {
|
||||||
// Build the Reddit JSON API path
|
// Build the Reddit JSON API path
|
||||||
let path: String = format!("/user/{}/about.json", name);
|
let path: String = format!("/user/{}/about.json?raw_json=1", name);
|
||||||
|
|
||||||
// Send a request to the url
|
// Send a request to the url
|
||||||
match request(path).await {
|
json(path, false).await.map(|res| {
|
||||||
// If success, receive JSON in response
|
// Grab creation date as unix timestamp
|
||||||
Ok(res) => {
|
let created: i64 = res["data"]["created"].as_f64().unwrap_or(0.0).round() as i64;
|
||||||
// Grab creation date as unix timestamp
|
|
||||||
let created: i64 = res["data"]["created"].as_f64().unwrap_or(0.0).round() as i64;
|
|
||||||
|
|
||||||
// nested_val function used to parse JSON from Reddit APIs
|
// Closure used to parse JSON from Reddit APIs
|
||||||
let about = |item| res["data"]["subreddit"][item].as_str().unwrap_or_default().to_string();
|
let about = |item| res["data"]["subreddit"][item].as_str().unwrap_or_default().to_string();
|
||||||
|
|
||||||
// Parse the JSON output into a User struct
|
// Parse the JSON output into a User struct
|
||||||
Ok(User {
|
User {
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
title: about("title"),
|
title: esc!(about("title")),
|
||||||
icon: format_url(about("icon_img").as_str()),
|
icon: format_url(&about("icon_img")),
|
||||||
karma: res["data"]["total_karma"].as_i64().unwrap_or(0),
|
karma: res["data"]["total_karma"].as_i64().unwrap_or(0),
|
||||||
created: OffsetDateTime::from_unix_timestamp(created).format("%b %d '%y"),
|
created: OffsetDateTime::from_unix_timestamp(created).format("%b %d '%y"),
|
||||||
banner: about("banner_img"),
|
banner: esc!(about("banner_img")),
|
||||||
description: about("public_description"),
|
description: about("public_description"),
|
||||||
})
|
|
||||||
}
|
}
|
||||||
// If the Reddit API returns an error, exit this function
|
})
|
||||||
Err(msg) => return Err(msg),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
759
src/utils.rs
759
src/utils.rs
@ -1,23 +1,20 @@
|
|||||||
//
|
//
|
||||||
// CRATES
|
// CRATES
|
||||||
//
|
//
|
||||||
use actix_web::{cookie::Cookie, HttpRequest, HttpResponse, Result};
|
use crate::{client::json, esc, server::RequestExt};
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
use base64::encode;
|
use cookie::Cookie;
|
||||||
|
use hyper::{Body, Request, Response};
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use serde_json::{from_str, Value};
|
use serde_json::Value;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use time::{Duration, OffsetDateTime};
|
use time::{Duration, OffsetDateTime};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
// use cached::proc_macro::cached;
|
|
||||||
|
|
||||||
//
|
|
||||||
// STRUCTS
|
|
||||||
//
|
|
||||||
|
|
||||||
// Post flair with content, background color and foreground color
|
// Post flair with content, background color and foreground color
|
||||||
pub struct Flair {
|
pub struct Flair {
|
||||||
pub flair_parts: Vec<FlairPart>,
|
pub flair_parts: Vec<FlairPart>,
|
||||||
|
pub text: String,
|
||||||
pub background_color: String,
|
pub background_color: String,
|
||||||
pub foreground_color: String,
|
pub foreground_color: String,
|
||||||
}
|
}
|
||||||
@ -28,6 +25,42 @@ pub struct FlairPart {
|
|||||||
pub value: String,
|
pub value: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl FlairPart {
|
||||||
|
pub fn parse(flair_type: &str, rich_flair: Option<&Vec<Value>>, text_flair: Option<&str>) -> Vec<Self> {
|
||||||
|
// Parse type of flair
|
||||||
|
match flair_type {
|
||||||
|
// If flair contains emojis and text
|
||||||
|
"richtext" => match rich_flair {
|
||||||
|
Some(rich) => rich
|
||||||
|
.iter()
|
||||||
|
// For each part of the flair, extract text and emojis
|
||||||
|
.map(|part| {
|
||||||
|
let value = |name: &str| part[name].as_str().unwrap_or_default();
|
||||||
|
Self {
|
||||||
|
flair_part_type: value("e").to_string(),
|
||||||
|
value: match value("e") {
|
||||||
|
"text" => value("t").to_string(),
|
||||||
|
"emoji" => format_url(value("u")),
|
||||||
|
_ => String::new(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<Self>>(),
|
||||||
|
None => Vec::new(),
|
||||||
|
},
|
||||||
|
// If flair contains only text
|
||||||
|
"text" => match text_flair {
|
||||||
|
Some(text) => vec![Self {
|
||||||
|
flair_part_type: "text".to_string(),
|
||||||
|
value: text.to_string(),
|
||||||
|
}],
|
||||||
|
None => Vec::new(),
|
||||||
|
},
|
||||||
|
_ => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct Author {
|
pub struct Author {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub flair: Flair,
|
pub flair: Flair,
|
||||||
@ -42,8 +75,122 @@ pub struct Flags {
|
|||||||
|
|
||||||
pub struct Media {
|
pub struct Media {
|
||||||
pub url: String,
|
pub url: String,
|
||||||
|
pub alt_url: String,
|
||||||
pub width: i64,
|
pub width: i64,
|
||||||
pub height: i64,
|
pub height: i64,
|
||||||
|
pub poster: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Media {
|
||||||
|
pub async fn parse(data: &Value) -> (String, Self, Vec<GalleryMedia>) {
|
||||||
|
let mut gallery = Vec::new();
|
||||||
|
|
||||||
|
// If post is a video, return the video
|
||||||
|
let (post_type, url_val, alt_url_val) = if data["preview"]["reddit_video_preview"]["fallback_url"].is_string() {
|
||||||
|
// Return reddit video
|
||||||
|
(
|
||||||
|
if data["preview"]["reddit_video_preview"]["is_gif"].as_bool().unwrap_or(false) {
|
||||||
|
"gif"
|
||||||
|
} else {
|
||||||
|
"video"
|
||||||
|
},
|
||||||
|
&data["preview"]["reddit_video_preview"]["fallback_url"],
|
||||||
|
Some(&data["preview"]["reddit_video_preview"]["hls_url"]),
|
||||||
|
)
|
||||||
|
} else if data["secure_media"]["reddit_video"]["fallback_url"].is_string() {
|
||||||
|
// Return reddit video
|
||||||
|
(
|
||||||
|
if data["preview"]["reddit_video_preview"]["is_gif"].as_bool().unwrap_or(false) {
|
||||||
|
"gif"
|
||||||
|
} else {
|
||||||
|
"video"
|
||||||
|
},
|
||||||
|
&data["secure_media"]["reddit_video"]["fallback_url"],
|
||||||
|
Some(&data["secure_media"]["reddit_video"]["hls_url"]),
|
||||||
|
)
|
||||||
|
} else if data["post_hint"].as_str().unwrap_or("") == "image" {
|
||||||
|
// Handle images, whether GIFs or pics
|
||||||
|
let preview = &data["preview"]["images"][0];
|
||||||
|
let mp4 = &preview["variants"]["mp4"];
|
||||||
|
|
||||||
|
if mp4.is_object() {
|
||||||
|
// Return the mp4 if the media is a gif
|
||||||
|
("gif", &mp4["source"]["url"], None)
|
||||||
|
} else {
|
||||||
|
// Return the picture if the media is an image
|
||||||
|
if data["domain"] == "i.redd.it" {
|
||||||
|
("image", &data["url"], None)
|
||||||
|
} else {
|
||||||
|
("image", &preview["source"]["url"], None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if data["is_self"].as_bool().unwrap_or_default() {
|
||||||
|
// If type is self, return permalink
|
||||||
|
("self", &data["permalink"], None)
|
||||||
|
} else if data["is_gallery"].as_bool().unwrap_or_default() {
|
||||||
|
// If this post contains a gallery of images
|
||||||
|
gallery = GalleryMedia::parse(&data["gallery_data"]["items"], &data["media_metadata"]);
|
||||||
|
|
||||||
|
("gallery", &data["url"], None)
|
||||||
|
} else {
|
||||||
|
// If type can't be determined, return url
|
||||||
|
("link", &data["url"], None)
|
||||||
|
};
|
||||||
|
|
||||||
|
let source = &data["preview"]["images"][0]["source"];
|
||||||
|
|
||||||
|
let url = if post_type == "self" || post_type == "link" {
|
||||||
|
url_val.as_str().unwrap_or_default().to_string()
|
||||||
|
} else {
|
||||||
|
format_url(url_val.as_str().unwrap_or_default())
|
||||||
|
};
|
||||||
|
|
||||||
|
let alt_url = alt_url_val.map_or(String::new(), |val| format_url(val.as_str().unwrap_or_default()));
|
||||||
|
|
||||||
|
(
|
||||||
|
post_type.to_string(),
|
||||||
|
Self {
|
||||||
|
url,
|
||||||
|
alt_url,
|
||||||
|
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()),
|
||||||
|
},
|
||||||
|
gallery,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct GalleryMedia {
|
||||||
|
pub url: String,
|
||||||
|
pub width: i64,
|
||||||
|
pub height: i64,
|
||||||
|
pub caption: String,
|
||||||
|
pub outbound_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GalleryMedia {
|
||||||
|
fn parse(items: &Value, metadata: &Value) -> Vec<Self> {
|
||||||
|
items
|
||||||
|
.as_array()
|
||||||
|
.unwrap_or(&Vec::new())
|
||||||
|
.iter()
|
||||||
|
.map(|item| {
|
||||||
|
// For each image in gallery
|
||||||
|
let media_id = item["media_id"].as_str().unwrap_or_default();
|
||||||
|
let image = &metadata[media_id]["s"];
|
||||||
|
|
||||||
|
// Construct gallery items
|
||||||
|
Self {
|
||||||
|
url: format_url(image["u"].as_str().unwrap_or_default()),
|
||||||
|
width: image["x"].as_i64().unwrap_or_default(),
|
||||||
|
height: image["y"].as_i64().unwrap_or_default(),
|
||||||
|
caption: item["caption"].as_str().unwrap_or_default().to_string(),
|
||||||
|
outbound_url: item["outbound_url"].as_str().unwrap_or_default().to_string(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<Self>>()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Post containing content, metadata and media
|
// Post containing content, metadata and media
|
||||||
@ -54,7 +201,7 @@ pub struct Post {
|
|||||||
pub body: String,
|
pub body: String,
|
||||||
pub author: Author,
|
pub author: Author,
|
||||||
pub permalink: String,
|
pub permalink: String,
|
||||||
pub score: String,
|
pub score: (String, String),
|
||||||
pub upvote_ratio: i64,
|
pub upvote_ratio: i64,
|
||||||
pub post_type: String,
|
pub post_type: String,
|
||||||
pub flair: Flair,
|
pub flair: Flair,
|
||||||
@ -64,18 +211,136 @@ pub struct Post {
|
|||||||
pub domain: String,
|
pub domain: String,
|
||||||
pub rel_time: String,
|
pub rel_time: String,
|
||||||
pub created: String,
|
pub created: String,
|
||||||
pub comments: String,
|
pub comments: (String, String),
|
||||||
|
pub gallery: Vec<GalleryMedia>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Post {
|
||||||
|
// Fetch posts of a user or subreddit and return a vector of posts and the "after" value
|
||||||
|
pub async fn fetch(path: &str, fallback_title: String, quarantine: bool) -> Result<(Vec<Self>, String), String> {
|
||||||
|
let res;
|
||||||
|
let post_list;
|
||||||
|
|
||||||
|
// Send a request to the url
|
||||||
|
match json(path.to_string(), quarantine).await {
|
||||||
|
// If success, receive JSON in response
|
||||||
|
Ok(response) => {
|
||||||
|
res = response;
|
||||||
|
}
|
||||||
|
// If the Reddit API returns an error, exit this function
|
||||||
|
Err(msg) => return Err(msg),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the list of posts from the JSON response
|
||||||
|
match res["data"]["children"].as_array() {
|
||||||
|
Some(list) => post_list = list,
|
||||||
|
None => return Err("No posts found".to_string()),
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut posts: Vec<Self> = Vec::new();
|
||||||
|
|
||||||
|
// For each post from posts list
|
||||||
|
for post in post_list {
|
||||||
|
let data = &post["data"];
|
||||||
|
|
||||||
|
let (rel_time, created) = time(data["created_utc"].as_f64().unwrap_or_default());
|
||||||
|
let score = data["score"].as_i64().unwrap_or_default();
|
||||||
|
let ratio: f64 = data["upvote_ratio"].as_f64().unwrap_or(1.0) * 100.0;
|
||||||
|
let title = esc!(post, "title");
|
||||||
|
|
||||||
|
// Determine the type of media along with the media URL
|
||||||
|
let (post_type, media, gallery) = Media::parse(&data).await;
|
||||||
|
|
||||||
|
posts.push(Self {
|
||||||
|
id: val(post, "id"),
|
||||||
|
title: esc!(if title.is_empty() { fallback_title.to_owned() } else { title }),
|
||||||
|
community: val(post, "subreddit"),
|
||||||
|
body: rewrite_urls(&val(post, "body_html")),
|
||||||
|
author: Author {
|
||||||
|
name: val(post, "author"),
|
||||||
|
flair: Flair {
|
||||||
|
flair_parts: FlairPart::parse(
|
||||||
|
data["author_flair_type"].as_str().unwrap_or_default(),
|
||||||
|
data["author_flair_richtext"].as_array(),
|
||||||
|
data["author_flair_text"].as_str(),
|
||||||
|
),
|
||||||
|
text: esc!(post, "link_flair_text"),
|
||||||
|
background_color: val(post, "author_flair_background_color"),
|
||||||
|
foreground_color: val(post, "author_flair_text_color"),
|
||||||
|
},
|
||||||
|
distinguished: val(post, "distinguished"),
|
||||||
|
},
|
||||||
|
score: if data["hide_score"].as_bool().unwrap_or_default() {
|
||||||
|
("\u{2022}".to_string(), "Hidden".to_string())
|
||||||
|
} else {
|
||||||
|
format_num(score)
|
||||||
|
},
|
||||||
|
upvote_ratio: ratio as i64,
|
||||||
|
post_type,
|
||||||
|
thumbnail: Media {
|
||||||
|
url: format_url(val(post, "thumbnail").as_str()),
|
||||||
|
alt_url: String::new(),
|
||||||
|
width: data["thumbnail_width"].as_i64().unwrap_or_default(),
|
||||||
|
height: data["thumbnail_height"].as_i64().unwrap_or_default(),
|
||||||
|
poster: "".to_string(),
|
||||||
|
},
|
||||||
|
media,
|
||||||
|
domain: val(post, "domain"),
|
||||||
|
flair: Flair {
|
||||||
|
flair_parts: FlairPart::parse(
|
||||||
|
data["link_flair_type"].as_str().unwrap_or_default(),
|
||||||
|
data["link_flair_richtext"].as_array(),
|
||||||
|
data["link_flair_text"].as_str(),
|
||||||
|
),
|
||||||
|
text: esc!(post, "link_flair_text"),
|
||||||
|
background_color: val(post, "link_flair_background_color"),
|
||||||
|
foreground_color: if val(post, "link_flair_text_color") == "dark" {
|
||||||
|
"black".to_string()
|
||||||
|
} else {
|
||||||
|
"white".to_string()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
flags: Flags {
|
||||||
|
nsfw: data["over_18"].as_bool().unwrap_or_default(),
|
||||||
|
stickied: data["stickied"].as_bool().unwrap_or_default(),
|
||||||
|
},
|
||||||
|
permalink: val(post, "permalink"),
|
||||||
|
rel_time,
|
||||||
|
created,
|
||||||
|
comments: format_num(data["num_comments"].as_i64().unwrap_or_default()),
|
||||||
|
gallery,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((posts, res["data"]["after"].as_str().unwrap_or_default().to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "comment.html", escape = "none")]
|
||||||
// Comment with content, post, score and data/time that it was posted
|
// Comment with content, post, score and data/time that it was posted
|
||||||
pub struct Comment {
|
pub struct Comment {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
pub kind: String,
|
||||||
|
pub parent_id: String,
|
||||||
|
pub parent_kind: String,
|
||||||
|
pub post_link: String,
|
||||||
|
pub post_author: String,
|
||||||
pub body: String,
|
pub body: String,
|
||||||
pub author: Author,
|
pub author: Author,
|
||||||
pub score: String,
|
pub score: (String, String),
|
||||||
pub rel_time: String,
|
pub rel_time: String,
|
||||||
pub created: String,
|
pub created: String,
|
||||||
|
pub edited: (String, String),
|
||||||
pub replies: Vec<Comment>,
|
pub replies: Vec<Comment>,
|
||||||
|
pub highlighted: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "error.html", escape = "none")]
|
||||||
|
pub struct ErrorTemplate {
|
||||||
|
pub msg: String,
|
||||||
|
pub prefs: Preferences,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
@ -97,9 +362,10 @@ pub struct Subreddit {
|
|||||||
pub title: String,
|
pub title: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
pub info: String,
|
pub info: String,
|
||||||
|
pub moderators: Vec<String>,
|
||||||
pub icon: String,
|
pub icon: String,
|
||||||
pub members: String,
|
pub members: (String, String),
|
||||||
pub active: String,
|
pub active: (String, String),
|
||||||
pub wiki: bool,
|
pub wiki: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -113,14 +379,6 @@ pub struct Params {
|
|||||||
pub before: Option<String>,
|
pub before: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error template
|
|
||||||
#[derive(Template)]
|
|
||||||
#[template(path = "error.html", escape = "none")]
|
|
||||||
pub struct ErrorTemplate {
|
|
||||||
pub msg: String,
|
|
||||||
pub prefs: Preferences,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct Preferences {
|
pub struct Preferences {
|
||||||
pub theme: String,
|
pub theme: String,
|
||||||
@ -128,38 +386,76 @@ pub struct Preferences {
|
|||||||
pub layout: String,
|
pub layout: String,
|
||||||
pub wide: String,
|
pub wide: String,
|
||||||
pub show_nsfw: String,
|
pub show_nsfw: String,
|
||||||
|
pub hide_hls_notification: String,
|
||||||
|
pub use_hls: String,
|
||||||
pub comment_sort: String,
|
pub comment_sort: String,
|
||||||
pub subs: Vec<String>,
|
pub post_sort: String,
|
||||||
|
pub subscriptions: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Preferences {
|
||||||
|
// Build preferences from cookies
|
||||||
|
pub fn new(req: Request<Body>) -> Self {
|
||||||
|
Self {
|
||||||
|
theme: setting(&req, "theme"),
|
||||||
|
front_page: setting(&req, "front_page"),
|
||||||
|
layout: setting(&req, "layout"),
|
||||||
|
wide: setting(&req, "wide"),
|
||||||
|
show_nsfw: setting(&req, "show_nsfw"),
|
||||||
|
use_hls: setting(&req, "use_hls"),
|
||||||
|
hide_hls_notification: setting(&req, "hide_hls_notification"),
|
||||||
|
comment_sort: setting(&req, "comment_sort"),
|
||||||
|
post_sort: setting(&req, "post_sort"),
|
||||||
|
subscriptions: setting(&req, "subscriptions").split('+').map(String::from).filter(|s| !s.is_empty()).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
// FORMATTING
|
// FORMATTING
|
||||||
//
|
//
|
||||||
|
|
||||||
// Build preferences from cookies
|
// Grab a query parameter from a url
|
||||||
pub fn prefs(req: HttpRequest) -> Preferences {
|
pub fn param(path: &str, value: &str) -> Option<String> {
|
||||||
Preferences {
|
Some(
|
||||||
theme: cookie(&req, "theme"),
|
Url::parse(format!("https://libredd.it/{}", path).as_str())
|
||||||
front_page: cookie(&req, "front_page"),
|
.ok()?
|
||||||
layout: cookie(&req, "layout"),
|
.query_pairs()
|
||||||
wide: cookie(&req, "wide"),
|
.into_owned()
|
||||||
show_nsfw: cookie(&req, "show_nsfw"),
|
.collect::<HashMap<_, _>>()
|
||||||
comment_sort: cookie(&req, "comment_sort"),
|
.get(value)?
|
||||||
subs: cookie(&req, "subscriptions").split('+').map(String::from).filter(|s| !s.is_empty()).collect(),
|
.to_owned(),
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Grab a query param from a url
|
// Retrieve the value of a setting by name
|
||||||
pub fn param(path: &str, value: &str) -> String {
|
pub fn setting(req: &Request<Body>, name: &str) -> String {
|
||||||
match Url::parse(format!("https://libredd.it/{}", path).as_str()) {
|
// Parse a cookie value from request
|
||||||
Ok(url) => url.query_pairs().into_owned().collect::<HashMap<_, _>>().get(value).unwrap_or(&String::new()).to_owned(),
|
req
|
||||||
_ => String::new(),
|
.cookie(name)
|
||||||
}
|
.unwrap_or_else(|| {
|
||||||
|
// If there is no cookie for this setting, try receiving a default from an environment variable
|
||||||
|
if let Ok(default) = std::env::var(format!("LIBREDDIT_DEFAULT_{}", name.to_uppercase())) {
|
||||||
|
Cookie::new(name, default)
|
||||||
|
} else {
|
||||||
|
Cookie::named(name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.value()
|
||||||
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse Cookie value from request
|
// Detect and redirect in the event of a random subreddit
|
||||||
pub fn cookie(req: &HttpRequest, name: &str) -> String {
|
pub async fn catch_random(sub: &str, additional: &str) -> Result<Response<Body>, String> {
|
||||||
actix_web::HttpMessage::cookie(req, name).unwrap_or_else(|| Cookie::new(name, "")).value().to_string()
|
if (sub == "random" || sub == "randnsfw") && !sub.contains('+') {
|
||||||
|
let new_sub = json(format!("/r/{}/about.json?raw_json=1", sub), false).await?["data"]["display_name"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string();
|
||||||
|
Ok(redirect(format!("/r/{}{}", new_sub, additional)))
|
||||||
|
} else {
|
||||||
|
Err("No redirect needed".to_string())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Direct urls to proxy if proxy is enabled
|
// Direct urls to proxy if proxy is enabled
|
||||||
@ -167,103 +463,87 @@ pub fn format_url(url: &str) -> String {
|
|||||||
if url.is_empty() || url == "self" || url == "default" || url == "nsfw" || url == "spoiler" {
|
if url.is_empty() || url == "self" || url == "default" || url == "nsfw" || url == "spoiler" {
|
||||||
String::new()
|
String::new()
|
||||||
} else {
|
} else {
|
||||||
format!("/proxy/{}", encode(url).as_str())
|
Url::parse(url).map_or(String::new(), |parsed| {
|
||||||
|
let domain = parsed.domain().unwrap_or_default();
|
||||||
|
|
||||||
|
let capture = |regex: &str, format: &str, segments: i16| {
|
||||||
|
Regex::new(regex).map_or(String::new(), |re| {
|
||||||
|
re.captures(url).map_or(String::new(), |caps| match segments {
|
||||||
|
1 => [format, &caps[1]].join(""),
|
||||||
|
2 => [format, &caps[1], "/", &caps[2]].join(""),
|
||||||
|
_ => String::new(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
macro_rules! chain {
|
||||||
|
() => {
|
||||||
|
{
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
( $first_fn:expr, $($other_fns:expr), *) => {
|
||||||
|
{
|
||||||
|
let result = $first_fn;
|
||||||
|
if result.is_empty() {
|
||||||
|
chain!($($other_fns,)*)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
match domain {
|
||||||
|
"v.redd.it" => chain!(
|
||||||
|
capture(r"https://v\.redd\.it/(.*)/DASH_([0-9]{2,4}(\.mp4|$))", "/vid/", 2),
|
||||||
|
capture(r"https://v\.redd\.it/(.+)/(HLSPlaylist\.m3u8.*)$", "/hls/", 2)
|
||||||
|
),
|
||||||
|
"i.redd.it" => capture(r"https://i\.redd\.it/(.*)", "/img/", 1),
|
||||||
|
"a.thumbs.redditmedia.com" => capture(r"https://a\.thumbs\.redditmedia\.com/(.*)", "/thumb/a/", 1),
|
||||||
|
"b.thumbs.redditmedia.com" => capture(r"https://b\.thumbs\.redditmedia\.com/(.*)", "/thumb/b/", 1),
|
||||||
|
"emoji.redditmedia.com" => capture(r"https://emoji\.redditmedia\.com/(.*)/(.*)", "/emoji/", 2),
|
||||||
|
"preview.redd.it" => capture(r"https://preview\.redd\.it/(.*)", "/preview/pre/", 1),
|
||||||
|
"external-preview.redd.it" => capture(r"https://external\-preview\.redd\.it/(.*)", "/preview/external-pre/", 1),
|
||||||
|
"styles.redditmedia.com" => capture(r"https://styles\.redditmedia\.com/(.*)", "/style/", 1),
|
||||||
|
"www.redditstatic.com" => capture(r"https://www\.redditstatic\.com/(.*)", "/static/", 1),
|
||||||
|
_ => String::new(),
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rewrite Reddit links to Libreddit in body of text
|
// Rewrite Reddit links to Libreddit in body of text
|
||||||
pub fn rewrite_url(text: &str) -> String {
|
pub fn rewrite_urls(input_text: &str) -> String {
|
||||||
let re = Regex::new(r#"href="(https://|http://|)(www.|)(reddit).(com)/"#).unwrap();
|
let text1 = Regex::new(r#"href="(https|http|)://(www.|old.|np.|amp.|)(reddit).(com)/"#).map_or(String::new(), |re| re.replace_all(input_text, r#"href="/"#).to_string());
|
||||||
re.replace_all(text, r#"href="/"#).to_string()
|
|
||||||
|
// Rewrite external media previews to Libreddit
|
||||||
|
Regex::new(r"https://external-preview\.redd\.it(.*)[^?]").map_or(String::new(), |re| {
|
||||||
|
if re.is_match(&text1) {
|
||||||
|
re.replace_all(&text1, format_url(re.find(&text1).map(|x| x.as_str()).unwrap_or_default())).to_string()
|
||||||
|
} else {
|
||||||
|
text1
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Append `m` and `k` for millions and thousands respectively
|
// Append `m` and `k` for millions and thousands respectively
|
||||||
pub fn format_num(num: i64) -> String {
|
pub fn format_num(num: i64) -> (String, String) {
|
||||||
if num >= 1_000_000 {
|
let truncated = if num >= 1_000_000 || num <= -1_000_000 {
|
||||||
format!("{}m", num / 1_000_000)
|
format!("{}m", num / 1_000_000)
|
||||||
} else if num >= 1000 {
|
} else if num >= 1000 || num <= -1000 {
|
||||||
format!("{}k", num / 1_000)
|
format!("{}k", num / 1_000)
|
||||||
} else {
|
} else {
|
||||||
num.to_string()
|
num.to_string()
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn media(data: &Value) -> (String, Media) {
|
|
||||||
let post_type: &str;
|
|
||||||
// If post is a video, return the video
|
|
||||||
let url = if data["preview"]["reddit_video_preview"]["fallback_url"].is_string() {
|
|
||||||
post_type = "video";
|
|
||||||
format_url(data["preview"]["reddit_video_preview"]["fallback_url"].as_str().unwrap_or_default())
|
|
||||||
} else if data["secure_media"]["reddit_video"]["fallback_url"].is_string() {
|
|
||||||
post_type = "video";
|
|
||||||
format_url(data["secure_media"]["reddit_video"]["fallback_url"].as_str().unwrap_or_default())
|
|
||||||
// Handle images, whether GIFs or pics
|
|
||||||
} else if data["post_hint"].as_str().unwrap_or("") == "image" {
|
|
||||||
let preview = data["preview"]["images"][0].clone();
|
|
||||||
match preview["variants"]["mp4"].as_object() {
|
|
||||||
// Return the mp4 if the media is a gif
|
|
||||||
Some(gif) => {
|
|
||||||
post_type = "gif";
|
|
||||||
format_url(gif["source"]["url"].as_str().unwrap_or_default())
|
|
||||||
}
|
|
||||||
// Return the picture if the media is an image
|
|
||||||
None => {
|
|
||||||
post_type = "image";
|
|
||||||
format_url(preview["source"]["url"].as_str().unwrap_or_default())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if data["is_self"].as_bool().unwrap_or_default() {
|
|
||||||
post_type = "self";
|
|
||||||
data["permalink"].as_str().unwrap_or_default().to_string()
|
|
||||||
} else {
|
|
||||||
post_type = "link";
|
|
||||||
data["url"].as_str().unwrap_or_default().to_string()
|
|
||||||
};
|
};
|
||||||
|
|
||||||
(
|
(truncated, num.to_string())
|
||||||
post_type.to_string(),
|
|
||||||
Media {
|
|
||||||
url,
|
|
||||||
width: data["preview"]["images"][0]["source"]["width"].as_i64().unwrap_or_default(),
|
|
||||||
height: data["preview"]["images"][0]["source"]["height"].as_i64().unwrap_or_default(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn parse_rich_flair(flair_type: String, rich_flair: Option<&Vec<Value>>, text_flair: Option<&str>) -> Vec<FlairPart> {
|
|
||||||
// Parse type of flair
|
|
||||||
match flair_type.as_str() {
|
|
||||||
// If flair contains emojis and text
|
|
||||||
"richtext" => match rich_flair {
|
|
||||||
Some(rich) => rich
|
|
||||||
.iter()
|
|
||||||
// For each part of the flair, extract text and emojis
|
|
||||||
.map(|part| {
|
|
||||||
let value = |name: &str| part[name].as_str().unwrap_or_default();
|
|
||||||
FlairPart {
|
|
||||||
flair_part_type: value("e").to_string(),
|
|
||||||
value: match value("e") {
|
|
||||||
"text" => value("t").to_string(),
|
|
||||||
"emoji" => format_url(value("u")),
|
|
||||||
_ => String::new(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect::<Vec<FlairPart>>(),
|
|
||||||
None => Vec::new(),
|
|
||||||
},
|
|
||||||
// If flair contains only text
|
|
||||||
"text" => match text_flair {
|
|
||||||
Some(text) => vec![FlairPart {
|
|
||||||
flair_part_type: "text".to_string(),
|
|
||||||
value: text.to_string(),
|
|
||||||
}],
|
|
||||||
None => Vec::new(),
|
|
||||||
},
|
|
||||||
_ => Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse a relative and absolute time from a UNIX timestamp
|
||||||
pub fn time(created: f64) -> (String, String) {
|
pub fn time(created: f64) -> (String, String) {
|
||||||
let time = OffsetDateTime::from_unix_timestamp(created.round() as i64);
|
let time = OffsetDateTime::from_unix_timestamp(created.round() as i64);
|
||||||
let time_delta = OffsetDateTime::now_utc() - time;
|
let time_delta = OffsetDateTime::now_utc() - time;
|
||||||
@ -280,210 +560,65 @@ pub fn time(created: f64) -> (String, String) {
|
|||||||
format!("{}m ago", time_delta.whole_minutes())
|
format!("{}m ago", time_delta.whole_minutes())
|
||||||
};
|
};
|
||||||
|
|
||||||
(rel_time, time.format("%b %d %Y, %H:%M UTC"))
|
(rel_time, time.format("%b %d %Y, %H:%M:%S UTC"))
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
|
||||||
// JSON PARSING
|
|
||||||
//
|
|
||||||
|
|
||||||
// val() function used to parse JSON from Reddit APIs
|
// val() function used to parse JSON from Reddit APIs
|
||||||
pub fn val(j: &Value, k: &str) -> String {
|
pub fn val(j: &Value, k: &str) -> String {
|
||||||
j["data"][k].as_str().unwrap_or_default().to_string()
|
j["data"][k].as_str().unwrap_or_default().to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch posts of a user or subreddit and return a vector of posts and the "after" value
|
#[macro_export]
|
||||||
pub async fn fetch_posts(path: &str, fallback_title: String) -> Result<(Vec<Post>, String), String> {
|
macro_rules! esc {
|
||||||
let res;
|
($f:expr) => {
|
||||||
let post_list;
|
$f.replace('<', "<").replace('>', ">")
|
||||||
|
};
|
||||||
// Send a request to the url
|
($j:expr, $k:expr) => {
|
||||||
match request(path.to_string()).await {
|
$j["data"][$k].as_str().unwrap_or_default().to_string().replace('<', "<").replace('>', ">")
|
||||||
// If success, receive JSON in response
|
};
|
||||||
Ok(response) => {
|
|
||||||
res = response;
|
|
||||||
}
|
|
||||||
// If the Reddit API returns an error, exit this function
|
|
||||||
Err(msg) => return Err(msg),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch the list of posts from the JSON response
|
|
||||||
match res["data"]["children"].as_array() {
|
|
||||||
Some(list) => post_list = list,
|
|
||||||
None => return Err("No posts found".to_string()),
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut posts: Vec<Post> = Vec::new();
|
|
||||||
|
|
||||||
// For each post from posts list
|
|
||||||
for post in post_list {
|
|
||||||
let (rel_time, created) = time(post["data"]["created_utc"].as_f64().unwrap_or_default());
|
|
||||||
let score = post["data"]["score"].as_i64().unwrap_or_default();
|
|
||||||
let ratio: f64 = post["data"]["upvote_ratio"].as_f64().unwrap_or(1.0) * 100.0;
|
|
||||||
let title = val(post, "title");
|
|
||||||
|
|
||||||
// Determine the type of media along with the media URL
|
|
||||||
let (post_type, media) = media(&post["data"]).await;
|
|
||||||
|
|
||||||
posts.push(Post {
|
|
||||||
id: val(post, "id"),
|
|
||||||
title: if title.is_empty() { fallback_title.to_owned() } else { title },
|
|
||||||
community: val(post, "subreddit"),
|
|
||||||
body: rewrite_url(&val(post, "body_html")),
|
|
||||||
author: Author {
|
|
||||||
name: val(post, "author"),
|
|
||||||
flair: Flair {
|
|
||||||
flair_parts: parse_rich_flair(
|
|
||||||
val(post, "author_flair_type"),
|
|
||||||
post["data"]["author_flair_richtext"].as_array(),
|
|
||||||
post["data"]["author_flair_text"].as_str(),
|
|
||||||
),
|
|
||||||
background_color: val(post, "author_flair_background_color"),
|
|
||||||
foreground_color: val(post, "author_flair_text_color"),
|
|
||||||
},
|
|
||||||
distinguished: val(post, "distinguished"),
|
|
||||||
},
|
|
||||||
score: if post["data"]["hide_score"].as_bool().unwrap_or_default() {
|
|
||||||
"•".to_string()
|
|
||||||
} else {
|
|
||||||
format_num(score)
|
|
||||||
},
|
|
||||||
upvote_ratio: ratio as i64,
|
|
||||||
post_type,
|
|
||||||
thumbnail: Media {
|
|
||||||
url: format_url(val(post, "thumbnail").as_str()),
|
|
||||||
width: post["data"]["thumbnail_width"].as_i64().unwrap_or_default(),
|
|
||||||
height: post["data"]["thumbnail_height"].as_i64().unwrap_or_default(),
|
|
||||||
},
|
|
||||||
media,
|
|
||||||
domain: val(post, "domain"),
|
|
||||||
flair: Flair {
|
|
||||||
flair_parts: parse_rich_flair(
|
|
||||||
val(post, "link_flair_type"),
|
|
||||||
post["data"]["link_flair_richtext"].as_array(),
|
|
||||||
post["data"]["link_flair_text"].as_str(),
|
|
||||||
),
|
|
||||||
background_color: val(post, "link_flair_background_color"),
|
|
||||||
foreground_color: if val(post, "link_flair_text_color") == "dark" {
|
|
||||||
"black".to_string()
|
|
||||||
} else {
|
|
||||||
"white".to_string()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
flags: Flags {
|
|
||||||
nsfw: post["data"]["over_18"].as_bool().unwrap_or_default(),
|
|
||||||
stickied: post["data"]["stickied"].as_bool().unwrap_or_default(),
|
|
||||||
},
|
|
||||||
permalink: val(post, "permalink"),
|
|
||||||
rel_time,
|
|
||||||
created,
|
|
||||||
comments: format_num(post["data"]["num_comments"].as_i64().unwrap_or_default()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok((posts, res["data"]["after"].as_str().unwrap_or_default().to_string()))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Escape < and > to accurately render HTML
|
||||||
|
// pub fn esc(j: &Value, k: &str) -> String {
|
||||||
|
// val(j,k)
|
||||||
|
// // .replace('&', "&")
|
||||||
|
// .replace('<', "<")
|
||||||
|
// .replace('>', ">")
|
||||||
|
// // .replace('"', """)
|
||||||
|
// // .replace('\'', "'")
|
||||||
|
// // .replace('/', "/")
|
||||||
|
// }
|
||||||
|
|
||||||
//
|
//
|
||||||
// NETWORKING
|
// NETWORKING
|
||||||
//
|
//
|
||||||
|
|
||||||
pub async fn error(msg: String) -> HttpResponse {
|
pub fn template(t: impl Template) -> Result<Response<Body>, String> {
|
||||||
|
Ok(
|
||||||
|
Response::builder()
|
||||||
|
.status(200)
|
||||||
|
.header("content-type", "text/html")
|
||||||
|
.body(t.render().unwrap_or_default().into())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn redirect(path: String) -> Response<Body> {
|
||||||
|
Response::builder()
|
||||||
|
.status(302)
|
||||||
|
.header("content-type", "text/html")
|
||||||
|
.header("Location", &path)
|
||||||
|
.body(format!("Redirecting to <a href=\"{0}\">{0}</a>...", path).into())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn error(req: Request<Body>, msg: String) -> Result<Response<Body>, String> {
|
||||||
let body = ErrorTemplate {
|
let body = ErrorTemplate {
|
||||||
msg,
|
msg,
|
||||||
prefs: Preferences::default(),
|
prefs: Preferences::new(req),
|
||||||
}
|
}
|
||||||
.render()
|
.render()
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
HttpResponse::NotFound().content_type("text/html").body(body)
|
|
||||||
}
|
Ok(Response::builder().status(404).header("content-type", "text/html").body(body.into()).unwrap_or_default())
|
||||||
|
|
||||||
// Make a request to a Reddit API and parse the JSON response
|
|
||||||
// #[cached(size=100,time=60, result = true)]
|
|
||||||
pub async fn request(path: String) -> Result<Value, String> {
|
|
||||||
let url = format!("https://www.reddit.com{}", path);
|
|
||||||
let user_agent = format!("web:libreddit:{}", env!("CARGO_PKG_VERSION"));
|
|
||||||
|
|
||||||
// Send request using awc
|
|
||||||
// async fn send(url: &str) -> Result<String, (bool, String)> {
|
|
||||||
// let client = actix_web::client::Client::default();
|
|
||||||
// let response = client.get(url).header("User-Agent", format!("web:libreddit:{}", env!("CARGO_PKG_VERSION"))).send().await;
|
|
||||||
|
|
||||||
// match response {
|
|
||||||
// Ok(mut payload) => {
|
|
||||||
// // Get first number of response HTTP status code
|
|
||||||
// match payload.status().to_string().chars().next() {
|
|
||||||
// // If success
|
|
||||||
// Some('2') => Ok(String::from_utf8(payload.body().limit(20_000_000).await.unwrap_or_default().to_vec()).unwrap_or_default()),
|
|
||||||
// // If redirection
|
|
||||||
// Some('3') => match payload.headers().get("location") {
|
|
||||||
// Some(location) => Err((true, location.to_str().unwrap_or_default().to_string())),
|
|
||||||
// None => Err((false, "Page not found".to_string())),
|
|
||||||
// },
|
|
||||||
// // Otherwise
|
|
||||||
// _ => Err((false, "Page not found".to_string())),
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// Err(e) => { dbg!(e); Err((false, "Couldn't send request to Reddit, this instance may be being rate-limited. Try another.".to_string())) },
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// // Print error if debugging then return error based on error message
|
|
||||||
// fn err(url: String, msg: String) -> Result<Value, String> {
|
|
||||||
// // #[cfg(debug_assertions)]
|
|
||||||
// dbg!(format!("{} - {}", url, msg));
|
|
||||||
// Err(msg)
|
|
||||||
// };
|
|
||||||
|
|
||||||
// // Parse JSON from body. If parsing fails, return error
|
|
||||||
// fn json(url: String, body: String) -> Result<Value, String> {
|
|
||||||
// match from_str(body.as_str()) {
|
|
||||||
// Ok(json) => Ok(json),
|
|
||||||
// Err(_) => err(url, "Failed to parse page JSON data".to_string()),
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// // Make request to Reddit using send function
|
|
||||||
// match send(&url).await {
|
|
||||||
// // If success, parse and return body
|
|
||||||
// Ok(body) => json(url, body),
|
|
||||||
// // Follow any redirects
|
|
||||||
// Err((true, location)) => match send(location.as_str()).await {
|
|
||||||
// // If success, parse and return body
|
|
||||||
// Ok(body) => json(url, body),
|
|
||||||
// // Follow any redirects again
|
|
||||||
// Err((true, location)) => err(url, location),
|
|
||||||
// // Return errors if request fails
|
|
||||||
// Err((_, msg)) => err(url, msg),
|
|
||||||
// },
|
|
||||||
// // Return errors if request fails
|
|
||||||
// Err((_, msg)) => err(url, msg),
|
|
||||||
// }
|
|
||||||
|
|
||||||
// Send request using ureq
|
|
||||||
match ureq::get(&url).set("User-Agent", user_agent.as_str()).call() {
|
|
||||||
// If response is success
|
|
||||||
Ok(response) => {
|
|
||||||
// Parse the response from Reddit as JSON
|
|
||||||
let json_string = &response.into_string().unwrap_or_default();
|
|
||||||
match from_str(json_string) {
|
|
||||||
Ok(json) => Ok(json),
|
|
||||||
Err(e) => {
|
|
||||||
println!("{} - Failed to parse page JSON data: {} - {}", url, e, json_string);
|
|
||||||
Err("Failed to parse page JSON data".to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If response is error
|
|
||||||
Err(ureq::Error::Status(_, _)) => {
|
|
||||||
#[cfg(debug_assertions)]
|
|
||||||
dbg!(format!("{} - Page not found", url));
|
|
||||||
Err("Page not found".to_string())
|
|
||||||
}
|
|
||||||
// If failed to send request
|
|
||||||
Err(e) => {
|
|
||||||
println!("{} - Couldn't send request to Reddit: {}", url, e);
|
|
||||||
Err("Couldn't send request to Reddit, this instance may be being rate-limited. Try another.".to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
BIN
static/Inter.var.woff2
Normal file
BIN
static/Inter.var.woff2
Normal file
Binary file not shown.
BIN
static/apple-touch-icon.png
Normal file
BIN
static/apple-touch-icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 8.0 KiB |
Binary file not shown.
Before Width: | Height: | Size: 789 B After Width: | Height: | Size: 4.2 KiB |
BIN
static/favicon.png
Normal file
BIN
static/favicon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 969 B |
5
static/hls.min.js
vendored
Normal file
5
static/hls.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
static/logo.png
Normal file
BIN
static/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 7.9 KiB |
23
static/manifest.json
Normal file
23
static/manifest.json
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "Libreddit",
|
||||||
|
"short_name": "Libreddit",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#1f1f1f",
|
||||||
|
"description": "An alternative private front-end to Reddit",
|
||||||
|
"theme_color": "#1f1f1f",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "logo.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "apple-touch-icon.png",
|
||||||
|
"sizes": "180x180"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "favicon.ico",
|
||||||
|
"sizes": "32x32"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
77
static/playHLSVideo.js
Normal file
77
static/playHLSVideo.js
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
// @license http://www.gnu.org/licenses/agpl-3.0.html AGPL-3.0
|
||||||
|
(function () {
|
||||||
|
if (Hls.isSupported()) {
|
||||||
|
var videoSources = document.querySelectorAll("video source[type='application/vnd.apple.mpegurl']");
|
||||||
|
videoSources.forEach(function (source) {
|
||||||
|
var playlist = source.src;
|
||||||
|
|
||||||
|
var oldVideo = source.parentNode;
|
||||||
|
var autoplay = oldVideo.classList.contains("hls_autoplay");
|
||||||
|
|
||||||
|
// If HLS is supported natively then don't use hls.js
|
||||||
|
if (oldVideo.canPlayType(source.type)) {
|
||||||
|
if (autoplay) {
|
||||||
|
oldVideo.play();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace video with copy that will have all "source" elements removed
|
||||||
|
var newVideo = oldVideo.cloneNode(true);
|
||||||
|
var allSources = newVideo.querySelectorAll("source");
|
||||||
|
allSources.forEach(function (source) {
|
||||||
|
source.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Empty source to enable play event
|
||||||
|
newVideo.src = "about:blank";
|
||||||
|
|
||||||
|
oldVideo.parentNode.replaceChild(newVideo, oldVideo);
|
||||||
|
|
||||||
|
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.startLoad();
|
||||||
|
newVideo.play();
|
||||||
|
});
|
||||||
|
|
||||||
|
hls.on(Hls.Events.ERROR, function (event, data) {
|
||||||
|
var errorType = data.type;
|
||||||
|
var errorFatal = data.fatal;
|
||||||
|
if (errorFatal) {
|
||||||
|
switch (errorType) {
|
||||||
|
case Hls.ErrorType.NETWORK_ERROR:
|
||||||
|
hls.startLoad();
|
||||||
|
break;
|
||||||
|
case Hls.ErrorType.MEDIA_ERROR:
|
||||||
|
hls.recoverMediaError();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
hls.destroy();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error("HLS error", data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
newVideo.addEventListener('play', initializeHls);
|
||||||
|
|
||||||
|
if (autoplay) {
|
||||||
|
newVideo.play();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
var videos = document.querySelectorAll("video.hls_autoplay");
|
||||||
|
videos.forEach(function (video) {
|
||||||
|
video.setAttribute("autoplay", "");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
// @license-end
|
399
static/style.css
399
static/style.css
@ -6,6 +6,12 @@
|
|||||||
--admin: #ea0027;
|
--admin: #ea0027;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
src: url('/Inter.var.woff2') format('woff2-variations');
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
/* Automatic theme selection */
|
/* Automatic theme selection */
|
||||||
:root, .dark{
|
:root, .dark{
|
||||||
/* Default & fallback theme (dark) */
|
/* Default & fallback theme (dark) */
|
||||||
@ -51,6 +57,90 @@
|
|||||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Black theme setting */
|
||||||
|
.black {
|
||||||
|
--accent: #009a9a;
|
||||||
|
--green: #00a229;
|
||||||
|
--text: white;
|
||||||
|
--foreground: #0f0f0f;
|
||||||
|
--background: black;
|
||||||
|
--outside: black;
|
||||||
|
--post: black;
|
||||||
|
--panel-border: 2px solid #0f0f0f;
|
||||||
|
--highlighted: #0f0f0f;
|
||||||
|
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dracula theme setting */
|
||||||
|
.dracula {
|
||||||
|
--accent: #bd93f9;
|
||||||
|
--green: #50fa7b;
|
||||||
|
--text: #f8f8f2;
|
||||||
|
--foreground: #3d4051;
|
||||||
|
--background: #282a36;
|
||||||
|
--outside: #44475a;
|
||||||
|
--post: #44475a;
|
||||||
|
--panel-border: 2px solid #44475a;
|
||||||
|
--highlighted: #4e5267;
|
||||||
|
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nord theme setting */
|
||||||
|
.nord {
|
||||||
|
--accent: #8fbcbb;
|
||||||
|
--green: #a3be8c;
|
||||||
|
--text: #eceff4;
|
||||||
|
--foreground: #3b4252;
|
||||||
|
--background: #2e3440;
|
||||||
|
--outside: #434c5e;
|
||||||
|
--post: #434c5e;
|
||||||
|
--panel-border: 2px solid #4c566a;
|
||||||
|
--highlighted: #3b4252;
|
||||||
|
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Laserwave theme setting */
|
||||||
|
.laserwave {
|
||||||
|
--accent: #eb64b9;
|
||||||
|
--green: #74dfc4;
|
||||||
|
--text: #e0dfe1;
|
||||||
|
--foreground: #302a36;
|
||||||
|
--background: #27212e;
|
||||||
|
--outside: #3e3647;
|
||||||
|
--post: #3e3647;
|
||||||
|
--panel-border: 2px solid #2f2738;
|
||||||
|
--highlighted: #302a36;
|
||||||
|
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Violet theme setting */
|
||||||
|
.violet {
|
||||||
|
--accent: #7c71dd;
|
||||||
|
--green: #5cff85;
|
||||||
|
--text: white;
|
||||||
|
--foreground: #1F2347;
|
||||||
|
--background: #12152b;
|
||||||
|
--outside: #181c3a;
|
||||||
|
--post: #181c3a;
|
||||||
|
--panel-border: 1px solid #1F2347;
|
||||||
|
--highlighted: #1F2347;
|
||||||
|
--shadow: 0 2px 5px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gold theme setting */
|
||||||
|
.gold {
|
||||||
|
--accent: #f2aa4c;
|
||||||
|
--green: #5cff85;
|
||||||
|
--text: white;
|
||||||
|
--foreground: #234;
|
||||||
|
--background: #101820;
|
||||||
|
--outside: #1b2936;
|
||||||
|
--post: #1b2936;
|
||||||
|
--panel-border: 0px solid black;
|
||||||
|
--highlighted: #234;
|
||||||
|
--shadow: 0 2px 5px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
/* General */
|
/* General */
|
||||||
|
|
||||||
::selection {
|
::selection {
|
||||||
@ -58,11 +148,15 @@
|
|||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
html, body, div, h1, h2, h3, h4, h5, h6, ul, ol, dl, li, dt, dd, p, blockquote,
|
html, body, div, h1, h2, h3, h4, h5, h6, ul, ol, dl, li, dt, dd, p, blockquote,
|
||||||
pre, form, fieldset, table, th, td, select, input {
|
pre, form, fieldset, table, th, td, select, input {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-family: sans-serif;
|
font-family: "Inter";
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@ -241,10 +335,11 @@ aside {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#user_description, #sub_description {
|
#user_description, #sub_description {
|
||||||
margin: 0 20px;
|
margin: 0 15px;
|
||||||
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
#user_name, #user_description:not(:empty), #user_icon
|
#user_name, #user_description:not(:empty), #user_icon,
|
||||||
#sub_name, #sub_icon, #sub_description:not(:empty) {
|
#sub_name, #sub_icon, #sub_description:not(:empty) {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
@ -261,10 +356,14 @@ aside {
|
|||||||
|
|
||||||
/* Subscriptions */
|
/* Subscriptions */
|
||||||
|
|
||||||
#sub_subscription {
|
#sub_subscription, #user_subscription {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#multisub {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
.subscribe, .unsubscribe {
|
.subscribe, .unsubscribe {
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
@ -281,9 +380,9 @@ aside {
|
|||||||
background-color: var(--highlighted);
|
background-color: var(--highlighted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Subscribed subreddit list */
|
/* Feeds */
|
||||||
|
|
||||||
#subscriptions {
|
#feeds {
|
||||||
position: relative;
|
position: relative;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
border: var(--panel-border);
|
border: var(--panel-border);
|
||||||
@ -294,14 +393,15 @@ aside {
|
|||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
#subscriptions > summary {
|
#feeds > summary {
|
||||||
padding: 8px 15px;
|
padding: 8px 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#sub_list {
|
#feed_list {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
|
max-height: 500px;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
background: var(--outside);
|
background: var(--outside);
|
||||||
@ -310,17 +410,24 @@ aside {
|
|||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
#sub_list > a {
|
#feed_list > p {
|
||||||
|
font-size: 13px;
|
||||||
|
opacity: 0.5;
|
||||||
|
padding: 5px 20px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#feed_list > a {
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
transition: 0.2s background;
|
transition: 0.2s background;
|
||||||
}
|
}
|
||||||
|
|
||||||
#sub_list > .selected {
|
#feed_list > .selected {
|
||||||
background-color: var(--accent);
|
background-color: var(--accent);
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
#sub_list > a:not(.selected):hover {
|
#feed_list > a:not(.selected):hover {
|
||||||
background-color: var(--foreground);
|
background-color: var(--foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -490,7 +597,26 @@ button.submit:hover > svg { stroke: var(--accent); }
|
|||||||
|
|
||||||
.search_subreddit {
|
.search_subreddit {
|
||||||
padding: 16px 20px;
|
padding: 16px 20px;
|
||||||
display: block;
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search_subreddit_left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search_subreddit_left:not(:empty) {
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search_subreddit_left img {
|
||||||
|
width: 35px;
|
||||||
|
height: 35px;
|
||||||
|
border-radius: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search_subreddit_right {
|
||||||
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
a.search_subreddit:hover {
|
a.search_subreddit:hover {
|
||||||
@ -528,6 +654,12 @@ a.search_subreddit:hover {
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.thread_nav {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
.post {
|
.post {
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
background: var(--post);
|
background: var(--post);
|
||||||
@ -538,6 +670,7 @@ a.search_subreddit:hover {
|
|||||||
"post_score post_title post_thumbnail" 1fr
|
"post_score post_title post_thumbnail" 1fr
|
||||||
"post_score post_media post_thumbnail" auto
|
"post_score post_media post_thumbnail" auto
|
||||||
"post_score post_body post_thumbnail" auto
|
"post_score post_body post_thumbnail" auto
|
||||||
|
"post_score post_notification post_thumbnail" auto
|
||||||
"post_score post_footer post_thumbnail" auto
|
"post_score post_footer post_thumbnail" auto
|
||||||
/ minmax(40px, auto) minmax(0, 1fr) fit-content(min(20%, 152px));
|
/ minmax(40px, auto) minmax(0, 1fr) fit-content(min(20%, 152px));
|
||||||
}
|
}
|
||||||
@ -580,6 +713,17 @@ a.search_subreddit:hover {
|
|||||||
grid-area: post_title;
|
grid-area: post_title;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.post_notification {
|
||||||
|
grid-area: post_notification;
|
||||||
|
margin: 5px 15px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post_notification a {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
.post_flair {
|
.post_flair {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: var(--background);
|
color: var(--background);
|
||||||
@ -590,9 +734,13 @@ a.search_subreddit:hover {
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.author_flair:empty, .post_flair:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.emoji {
|
.emoji {
|
||||||
width: 1em;
|
width: 1.25em;
|
||||||
height: 1em;
|
height: 1.25em;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
background-size: contain;
|
background-size: contain;
|
||||||
background-position: 50% 50%;
|
background-position: 50% 50%;
|
||||||
@ -610,27 +758,61 @@ a.search_subreddit:hover {
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post_media {
|
.post_media_image, .post .__NoScript_PlaceHolder__, .post_media_video, .gallery {
|
||||||
max-width: calc(100% - 40px);
|
max-width: calc(100% - 40px);
|
||||||
height: auto;
|
|
||||||
align-self: center;
|
|
||||||
margin-top: 15px;
|
|
||||||
margin: 5px auto;
|
|
||||||
grid-area: post_media;
|
grid-area: post_media;
|
||||||
background-color: var(--highlighted);
|
margin: 15px auto 5px auto;
|
||||||
background-image: url("data:image/svg+xml;utf8,<svg viewBox='0 0 100 100' width='100' height='100' xmlns='http://www.w3.org/2000/svg'><path d='M15,20 h70 a10,10 0 0 1 10,10 v45 a10,10 0 0 1 -10,10 h-70 a10,10 0 0 1 -10,-10 v-45 a10,10 0 0 1 10,-10 z' fill='none' stroke='rgba(128,128,128,0.5)' stroke-width='3' /><path d='M15,75 l25,-35 l15,20 l10,-10 l20, 25 z' stroke='none' fill='rgba(128,128,128,0.5)' /><circle cx='75' cy='35' r='7' stroke='none' fill='rgba(128,128,128,0.5)'/></svg>");
|
height: auto;
|
||||||
background-position: 50%;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.post_media.short {
|
|
||||||
|
.post_media_video.short {
|
||||||
max-height: 512px;
|
max-height: 512px;
|
||||||
width: auto;
|
width: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.post_media_image.short svg, .post_media_image.short img{
|
||||||
|
max-height: 512px;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post_media_image svg{
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
align-self: center;
|
||||||
|
background-color: var(--highlighted);
|
||||||
|
background-image: url("data:image/svg+xml;utf8,<svg viewBox='0 0 100 100' width='100' height='100' xmlns='http://www.w3.org/2000/svg'><path d='M15,20 h70 a10,10 0 0 1 10,10 v45 a10,10 0 0 1 -10,10 h-70 a10,10 0 0 1 -10,-10 v-45 a10,10 0 0 1 10,-10 z' fill='none' stroke='rgba(128,128,128,0.5)' stroke-width='3' /><path d='M15,75 l25,-35 l15,20 l10,-10 l20, 25 z' stroke='none' fill='rgba(128,128,128,0.5)' /><circle cx='75' cy='35' r='7' stroke='none' fill='rgba(128,128,128,0.5)'/></svg>");
|
||||||
|
background-position: 50%;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
vertical-align: bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post_media_image img {
|
||||||
|
max-width: 100%;
|
||||||
|
vertical-align: bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery img {
|
||||||
|
max-width: 100%;
|
||||||
|
vertical-align: bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery figcaption {
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery .outbound_url {
|
||||||
|
color: var(--accent);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
display: block;
|
||||||
|
padding-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
#post_url {
|
#post_url {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
margin: 5px 20px;
|
margin: 5px 15px;
|
||||||
grid-area: post_media;
|
grid-area: post_media;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -639,6 +821,7 @@ a.search_subreddit:hover {
|
|||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
margin: 5px 15px;
|
margin: 5px 15px;
|
||||||
grid-area: post_body;
|
grid-area: post_body;
|
||||||
|
width: calc(100% - 30px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.post_footer {
|
.post_footer {
|
||||||
@ -707,6 +890,10 @@ a.search_subreddit:hover {
|
|||||||
align-self: end;
|
align-self: end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.post_thumbnail img {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.stickied {
|
.stickied {
|
||||||
--accent: var(--green);
|
--accent: var(--green);
|
||||||
border: 1px solid var(--green);
|
border: 1px solid var(--green);
|
||||||
@ -779,7 +966,12 @@ a.search_subreddit:hover {
|
|||||||
.comment_body {
|
.comment_body {
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
margin: 10px 5px;
|
padding: 5px 5px;
|
||||||
|
margin: 5px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment_body.highlighted {
|
||||||
|
background: var(--highlighted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.comment_body > p:not(:first-child) {
|
.comment_body > p:not(:first-child) {
|
||||||
@ -809,6 +1001,12 @@ a.search_subreddit:hover {
|
|||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.edited {
|
||||||
|
opacity: 0.4;
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.line {
|
.line {
|
||||||
width: 2px;
|
width: 2px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@ -877,19 +1075,21 @@ a.search_subreddit:hover {
|
|||||||
|
|
||||||
/* Settings */
|
/* Settings */
|
||||||
|
|
||||||
#settings, #settings > form {
|
#settings {
|
||||||
display: flex;
|
max-width: 450px;
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#settings_note {
|
#settings_note {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
max-width: 300px;
|
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
opacity: 0.75;
|
opacity: 0.75;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#settings_note a {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.prefs {
|
.prefs {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -898,6 +1098,7 @@ a.search_subreddit:hover {
|
|||||||
padding: 20px;
|
padding: 20px;
|
||||||
background: var(--post);
|
background: var(--post);
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prefs > div {
|
.prefs > div {
|
||||||
@ -906,10 +1107,7 @@ a.search_subreddit:hover {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 35px;
|
height: 35px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
margin-top: 10px;
|
||||||
|
|
||||||
.prefs > div:not(:last-of-type) {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.prefs select {
|
.prefs select {
|
||||||
@ -936,28 +1134,16 @@ input[type="submit"] {
|
|||||||
-moz-appearance: none;
|
-moz-appearance: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
#settings_subs {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#settings_subs > li {
|
|
||||||
display: flex;
|
|
||||||
margin: 10px 0;
|
|
||||||
}
|
|
||||||
#settings_subs > li:last-of-type { margin-bottom: 0; }
|
|
||||||
|
|
||||||
#settings_subs > li > span {
|
|
||||||
padding: 10px 0;
|
|
||||||
margin-right: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
#settings_subs .unsubscribe {
|
#settings_subs .unsubscribe {
|
||||||
margin-left: 30px;
|
margin-left: 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Markdown */
|
/* Markdown */
|
||||||
|
|
||||||
|
.md {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.md > *:not(:first-child) {
|
.md > *:not(:first-child) {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
}
|
}
|
||||||
@ -1004,6 +1190,8 @@ input[type="submit"] {
|
|||||||
|
|
||||||
.md table {
|
.md table {
|
||||||
margin: 5px;
|
margin: 5px;
|
||||||
|
display: block;
|
||||||
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.md code {
|
.md code {
|
||||||
@ -1026,48 +1214,15 @@ td, th {
|
|||||||
padding: 10px;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Errors */
|
||||||
|
|
||||||
|
#error { text-align: center; }
|
||||||
|
#error h1 { margin-bottom: 10px; }
|
||||||
|
#error h3 { opacity: 0.85; }
|
||||||
|
#error a { color: var(--accent); }
|
||||||
|
|
||||||
/* Mobile */
|
/* Mobile */
|
||||||
|
|
||||||
@media screen and (max-width: 480px) {
|
|
||||||
#version { display: none; }
|
|
||||||
|
|
||||||
.post {
|
|
||||||
grid-template: "post_header post_header post_thumbnail" auto
|
|
||||||
"post_title post_title post_thumbnail" 1fr
|
|
||||||
"post_media post_media post_thumbnail" auto
|
|
||||||
"post_body post_body post_thumbnail" auto
|
|
||||||
"post_score post_footer post_thumbnail" auto
|
|
||||||
/ auto 1fr fit-content(min(20%, 152px));
|
|
||||||
}
|
|
||||||
|
|
||||||
.post_score {
|
|
||||||
margin: 5px 0px 20px 15px;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.compact .post_score { padding: 0; }
|
|
||||||
|
|
||||||
.post_score::before { content: "↑" }
|
|
||||||
|
|
||||||
.post_header { font-size: 14px; }
|
|
||||||
.post_footer { margin-left: 15px; }
|
|
||||||
|
|
||||||
.replies > .comment {
|
|
||||||
margin-left: -25px;
|
|
||||||
padding: 5px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comment_left {
|
|
||||||
min-width: 45px;
|
|
||||||
padding: 5px 0px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comment_author { margin-left: 10px; }
|
|
||||||
.comment_score { min-width: 35px; }
|
|
||||||
.comment_data::marker { font-size: 18px; }
|
|
||||||
.created { width: 100%; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (max-width: 800px) {
|
@media screen and (max-width: 800px) {
|
||||||
body { padding-top: 120px }
|
body { padding-top: 120px }
|
||||||
|
|
||||||
@ -1095,6 +1250,10 @@ td, th {
|
|||||||
right: 10px;
|
right: 10px;
|
||||||
min-width: auto;
|
min-width: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#settings {
|
||||||
|
max-width: unset;
|
||||||
|
}
|
||||||
|
|
||||||
aside, #subreddit, #user {
|
aside, #subreddit, #user {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@ -1105,3 +1264,61 @@ td, th {
|
|||||||
#logo, #links { margin-bottom: 5px; }
|
#logo, #links { margin-bottom: 5px; }
|
||||||
#searchbox { width: calc(100vw - 35px); }
|
#searchbox { width: calc(100vw - 35px); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 480px) {
|
||||||
|
body { padding-top: 100px; }
|
||||||
|
#version { display: none; }
|
||||||
|
|
||||||
|
.post {
|
||||||
|
grid-template: "post_header post_header post_thumbnail" auto
|
||||||
|
"post_title post_title post_thumbnail" 1fr
|
||||||
|
"post_media post_media post_thumbnail" auto
|
||||||
|
"post_body post_body post_thumbnail" auto
|
||||||
|
"post_notification post_notification post_thumbnail" auto
|
||||||
|
"post_score post_footer post_thumbnail" auto
|
||||||
|
/ auto 1fr fit-content(min(20%, 152px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.post_score {
|
||||||
|
margin: 5px 0px 20px 15px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact .post_score { padding: 0; }
|
||||||
|
|
||||||
|
.post_score::before { content: "↑" }
|
||||||
|
|
||||||
|
.post_header { font-size: 14px; }
|
||||||
|
.post_footer { margin-left: 15px; }
|
||||||
|
|
||||||
|
.replies > .comment {
|
||||||
|
margin-left: -12px;
|
||||||
|
padding: 5px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment_left {
|
||||||
|
min-width: auto;
|
||||||
|
padding: 5px 0px;
|
||||||
|
align-items: initial;
|
||||||
|
margin-top: -5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line {
|
||||||
|
margin-left: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* .thread { margin-left: -5px; } */
|
||||||
|
.comment_right { padding: 5px 0 10px 2px; }
|
||||||
|
.comment_author { margin-left: 12px; }
|
||||||
|
.comment_data { margin-left: 12px; }
|
||||||
|
.comment_data::marker { font-size: 25px; }
|
||||||
|
.created { width: 100%; }
|
||||||
|
|
||||||
|
.comment_score {
|
||||||
|
min-width: 32px;
|
||||||
|
height: 20px;
|
||||||
|
font-size: 15px;
|
||||||
|
padding: 7px 0px;
|
||||||
|
margin-right: -5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -6,6 +6,18 @@
|
|||||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||||
<meta name="description" content="View on Libreddit, an alternative private front-end to Reddit.">
|
<meta name="description" content="View on Libreddit, an alternative private front-end to Reddit.">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<!-- General PWA -->
|
||||||
|
<meta name="theme-color" content="#1F1F1F">
|
||||||
|
<!-- iOS Application -->
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Libreddit">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||||
|
<!-- Android -->
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
<!-- iOS Logo -->
|
||||||
|
<link href="/touch-icon-iphone.png" rel="apple-touch-icon">
|
||||||
|
<!-- PWA Manifest -->
|
||||||
|
<link rel="manifest" type="application/json" href="/manifest.json">
|
||||||
<link rel="shortcut icon" type="image/x-icon" href="/favicon.ico">
|
<link rel="shortcut icon" type="image/x-icon" href="/favicon.ico">
|
||||||
<link rel="stylesheet" type="text/css" href="/style.css">
|
<link rel="stylesheet" type="text/css" href="/style.css">
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@ -17,9 +29,7 @@
|
|||||||
<!-- NAVIGATION BAR -->
|
<!-- NAVIGATION BAR -->
|
||||||
<nav>
|
<nav>
|
||||||
<div id="logo">
|
<div id="logo">
|
||||||
<a id="libreddit" href="/">
|
<a id="libreddit" href="/"><span id="lib">lib</span><span id="reddit">reddit.</span></a>
|
||||||
<span id="lib">lib</span><span id="reddit">reddit.</span>
|
|
||||||
</a>
|
|
||||||
<span id="version">v{{ env!("CARGO_PKG_VERSION") }}</span>
|
<span id="version">v{{ env!("CARGO_PKG_VERSION") }}</span>
|
||||||
{% block subscriptions %}{% endblock %}
|
{% block subscriptions %}{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
@ -27,11 +37,17 @@
|
|||||||
<div id="links">
|
<div id="links">
|
||||||
<a id="settings_link" href="/settings">
|
<a id="settings_link" href="/settings">
|
||||||
<span>settings</span>
|
<span>settings</span>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<title>settings</title>
|
||||||
|
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||||
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
<a id="code" href="https://github.com/spikecodes/libreddit">
|
<a id="code" href="https://github.com/spikecodes/libreddit">
|
||||||
<span>code</span>
|
<span>code</span>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<title>code</title>
|
||||||
|
<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>
|
||||||
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
25
templates/comment.html
Normal file
25
templates/comment.html
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
{% import "utils.html" as utils %}
|
||||||
|
|
||||||
|
{% if kind == "more" && parent_kind == "t1" %}
|
||||||
|
<a class="deeper_replies" href="{{ post_link }}{{ parent_id }}">→ More replies</a>
|
||||||
|
{% else if kind == "t1" %}
|
||||||
|
<div id="{{ id }}" class="comment">
|
||||||
|
<div class="comment_left">
|
||||||
|
<p class="comment_score" title="{{ score.1 }}">{{ score.0 }}</p>
|
||||||
|
<div class="line"></div>
|
||||||
|
</div>
|
||||||
|
<details class="comment_right" open>
|
||||||
|
<summary class="comment_data">
|
||||||
|
<a class="comment_author {{ author.distinguished }} {% if author.name == post_author %}op{% endif %}" href="/user/{{ author.name }}">u/{{ author.name }}</a>
|
||||||
|
{% 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>
|
||||||
|
{% if edited.0 != "".to_string() %}<span class="edited" title="{{ edited.1 }}">edited {{ edited.0 }}</span>{% endif %}
|
||||||
|
</summary>
|
||||||
|
<div class="comment_body {% if highlighted %}highlighted{% endif %}">{{ body }}</div>
|
||||||
|
<blockquote class="replies">{% for c in replies -%}{{ c.render().unwrap() }}{%- endfor %}
|
||||||
|
</blockquote>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
@ -2,5 +2,8 @@
|
|||||||
{% block title %}Error: {{ msg }}{% endblock %}
|
{% block title %}Error: {{ msg }}{% endblock %}
|
||||||
{% block sortstyle %}{% endblock %}
|
{% block sortstyle %}{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1 style="text-align: center; font-size: 50px;">{{ msg }}</h1>
|
<div id="error">
|
||||||
|
<h1>{{ msg }}</h1>
|
||||||
|
<h3>Head back <a href="/">home</a>?</h3>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
@ -10,36 +10,25 @@
|
|||||||
{% block root %}/r/{{ post.community }}{% endblock %}{% block location %}r/{{ post.community }}{% endblock %}
|
{% block root %}/r/{{ post.community }}{% endblock %}{% block location %}r/{{ post.community }}{% endblock %}
|
||||||
{% block head %}
|
{% block head %}
|
||||||
{% call super() %}
|
{% call super() %}
|
||||||
|
<!-- Meta Tags -->
|
||||||
<meta name="author" content="u/{{ post.author.name }}">
|
<meta name="author" content="u/{{ post.author.name }}">
|
||||||
|
<meta name="title" content="{{ post.title }} - r/{{ post.community }}">
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:url" content="{{ post.permalink }}">
|
||||||
|
<meta property="og:title" content="{{ post.title }} - r/{{ post.community }}">
|
||||||
|
<meta property="og:description" content="View on Libreddit, an alternative private front-end to Reddit.">
|
||||||
|
<meta property="og:image" content="{{ post.thumbnail.url }}">
|
||||||
|
<meta property="twitter:card" content="summary_large_image">
|
||||||
|
<meta property="twitter:url" content="{{ post.permalink }}">
|
||||||
|
<meta property="twitter:title" content="{{ post.title }} - r/{{ post.community }}">
|
||||||
|
<meta property="twitter:description" content="View on Libreddit, an alternative private front-end to Reddit.">
|
||||||
|
<meta property="twitter:image" content="{{ post.thumbnail.url }}">
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block subscriptions %}
|
{% block subscriptions %}
|
||||||
{% call utils::sub_list(post.community.as_str()) %}
|
{% call utils::sub_list(post.community.as_str()) %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
<!-- OPEN COMMENT MACRO -->
|
|
||||||
{% macro comment(item) -%}
|
|
||||||
<div id="{{ item.id }}" class="comment">
|
|
||||||
<div class="comment_left">
|
|
||||||
<p class="comment_score">{{ item.score }}</p>
|
|
||||||
<div class="line"></div>
|
|
||||||
</div>
|
|
||||||
<details class="comment_right" open>
|
|
||||||
<summary class="comment_data">
|
|
||||||
<a class="comment_author {{ item.author.distinguished }} {% if item.author.name == post.author.name %}op{% endif %}" href="/u/{{ item.author.name }}">u/{{ item.author.name }}</a>
|
|
||||||
{% if item.author.flair.flair_parts.len() > 0 %}
|
|
||||||
<small class="author_flair">{% call utils::render_flair(item.author.flair.flair_parts) %}</small>
|
|
||||||
{% endif %}
|
|
||||||
<span class="created" title="{{ post.created }}">{{ item.rel_time }}</span>
|
|
||||||
</summary>
|
|
||||||
<div class="comment_body">{{ item.body }}</div>
|
|
||||||
{%- endmacro %}
|
|
||||||
|
|
||||||
<!-- CLOSE COMMENT MACRO -->
|
|
||||||
{% macro close() %}
|
|
||||||
</details></div>
|
|
||||||
{% endmacro %}
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div id="column_one">
|
<div id="column_one">
|
||||||
|
|
||||||
@ -48,7 +37,7 @@
|
|||||||
<p class="post_header">
|
<p class="post_header">
|
||||||
<a class="post_subreddit" href="/r/{{ post.community }}">r/{{ post.community }}</a>
|
<a class="post_subreddit" href="/r/{{ post.community }}">r/{{ post.community }}</a>
|
||||||
<span class="dot">•</span>
|
<span class="dot">•</span>
|
||||||
<a class="post_author" href="/u/{{ post.author.name }}">u/{{ post.author.name }}</a>
|
<a class="post_author" href="/user/{{ post.author.name }}">u/{{ post.author.name }}</a>
|
||||||
{% if post.author.flair.flair_parts.len() > 0 %}
|
{% if post.author.flair.flair_parts.len() > 0 %}
|
||||||
<small class="author_flair">{% call utils::render_flair(post.author.flair.flair_parts) %}</small>
|
<small class="author_flair">{% call utils::render_flair(post.author.flair.flair_parts) %}</small>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -58,37 +47,63 @@
|
|||||||
<p class="post_title">
|
<p class="post_title">
|
||||||
<a href="{{ post.permalink }}">{{ post.title }}</a>
|
<a href="{{ post.permalink }}">{{ post.title }}</a>
|
||||||
{% if post.flair.flair_parts.len() > 0 %}
|
{% if post.flair.flair_parts.len() > 0 %}
|
||||||
<small class="post_flair" style="color:{{ post.flair.foreground_color }}; background:{{ post.flair.background_color }};">{% call utils::render_flair(post.flair.flair_parts) %}</small>
|
<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 utils::render_flair(post.flair.flair_parts) %}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if post.flags.nsfw %} <small class="nsfw">NSFW</small>{% endif %}
|
{% if post.flags.nsfw %} <small class="nsfw">NSFW</small>{% endif %}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- POST MEDIA -->
|
<!-- POST MEDIA -->
|
||||||
{% if post.post_type == "image" %}
|
{% if post.post_type == "image" %}
|
||||||
<a href="{{ post.media.url }}" style="display:contents" >
|
<a href="{{ post.media.url }}" class="post_media_image" >
|
||||||
<svg class="post_media"
|
<svg
|
||||||
width="{{ post.media.width }}px"
|
width="{{ post.media.width }}px"
|
||||||
height="{{ post.media.height }}px"
|
height="{{ post.media.height }}px"
|
||||||
xmlns="http://www.w3.org/2000/svg">
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
<image width="100%" height="100%" href="{{ post.media.url }}"/>
|
<image width="100%" height="100%" href="{{ post.media.url }}"/>
|
||||||
<desc>
|
<desc>
|
||||||
<img alt="Post image" src="{{ post.media.url }}"/>
|
<img alt="Post image" src="{{ post.media.url }}"/>
|
||||||
</dev>
|
</desc>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
{% else if post.post_type == "video" || post.post_type == "gif" %}
|
{% else if post.post_type == "video" || post.post_type == "gif" %}
|
||||||
<video class="post_media" src="{{ post.media.url }}" controls autoplay loop></video>
|
{% if prefs.use_hls == "on" && !post.media.alt_url.is_empty() %}
|
||||||
|
<script src="/hls.min.js"></script>
|
||||||
|
<video class="post_media_video short hls_autoplay" width="{{ post.media.width }}" height="{{ post.media.height }}" 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>
|
||||||
|
<script src="/playHLSVideo.js"></script>
|
||||||
|
{% else %}
|
||||||
|
<video class="post_media_video" src="{{ post.media.url }}" controls autoplay loop><a href={{ post.media.url }}>Video</a></video>
|
||||||
|
{% call utils::render_hls_notification(post.permalink[1..]) %}
|
||||||
|
{% endif %}
|
||||||
|
{% else if post.post_type == "gallery" %}
|
||||||
|
<div class="gallery">
|
||||||
|
{% for image in post.gallery -%}
|
||||||
|
<figure>
|
||||||
|
<a href="{{ image.url }}" ><img alt="Gallery image" src="{{ image.url }}"/></a>
|
||||||
|
<figcaption>
|
||||||
|
<p>{{ image.caption }}</p>
|
||||||
|
{% if image.outbound_url.len() > 0 %}
|
||||||
|
<p><a class="outbound_url" href="{{ image.outbound_url }}" rel="nofollow">{{ image.outbound_url }}</a>
|
||||||
|
{% endif %}
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
{%- endfor %}
|
||||||
|
</div>
|
||||||
{% else if post.post_type == "link" %}
|
{% else if post.post_type == "link" %}
|
||||||
<a id="post_url" href="{{ post.media.url }}">{{ post.media.url }}</a>
|
<a id="post_url" href="{{ post.media.url }}" rel="nofollow">{{ post.media.url }}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- POST BODY -->
|
<!-- POST BODY -->
|
||||||
<div class="post_body">{{ post.body }}</div>
|
<div class="post_body">{{ post.body }}</div>
|
||||||
<div class="post_score">{{ post.score }}<span class="label"> Upvotes</span></div>
|
<div class="post_score" title="{{ post.score.1 }}">{{ post.score.0 }}<span class="label"> Upvotes</span></div>
|
||||||
<div class="post_footer">
|
<div class="post_footer">
|
||||||
<ul id="post_links">
|
<ul id="post_links">
|
||||||
<li><a href="/{{ post.id }}">permalink</a></li>
|
<li><a href="/{{ post.id }}">permalink</a></li>
|
||||||
<li><a href="https://reddit.com/{{ post.id }}">reddit</a></li>
|
<li><a href="https://reddit.com/{{ post.id }}" rel="nofollow">reddit</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
<p>{{ post.upvote_ratio }}% Upvoted</p>
|
<p>{{ post.upvote_ratio }}% Upvoted</p>
|
||||||
</div>
|
</div>
|
||||||
@ -110,25 +125,14 @@
|
|||||||
<!-- COMMENTS -->
|
<!-- COMMENTS -->
|
||||||
{% for c in comments -%}
|
{% for c in comments -%}
|
||||||
<div class="thread">
|
<div class="thread">
|
||||||
<!-- EACH COMMENT -->
|
{% if single_thread %}
|
||||||
{% call comment(c) %}
|
<p class="thread_nav"><a href="/{{ post.id }}">View all comments</a></p>
|
||||||
<blockquote class="replies">{% for reply1 in c.replies %}{% call comment(reply1) %}
|
{% if c.parent_kind == "t1" %}
|
||||||
<!-- FIRST-LEVEL REPLIES -->
|
<p class="thread_nav"><a href="?context=9999">Show parent comments</a></p>
|
||||||
<blockquote class="replies">{% for reply2 in reply1.replies %}{% call comment(reply2) %}
|
{% endif %}
|
||||||
<!-- SECOND-LEVEL REPLIES -->
|
{% endif %}
|
||||||
<blockquote class="replies">{% for reply3 in reply2.replies %}{% call comment(reply3) %}
|
|
||||||
<!-- THIRD-LEVEL REPLIES -->
|
{{ c.render().unwrap() }}
|
||||||
{% if reply3.replies.len() > 0 %}
|
|
||||||
<!-- LINK TO CONTINUE REPLIES -->
|
|
||||||
<a class="deeper_replies" href="{{ post.permalink }}{{ reply3.id }}">→ More replies</a>
|
|
||||||
{% endif %}
|
|
||||||
{% call close() %}
|
|
||||||
{% endfor %}
|
|
||||||
</blockquote>{% call close() %}
|
|
||||||
{% endfor %}
|
|
||||||
</blockquote>{% call close() %}
|
|
||||||
{% endfor %}
|
|
||||||
</blockquote>{% call close() %}
|
|
||||||
</div>
|
</div>
|
||||||
{%- endfor %}
|
{%- endfor %}
|
||||||
|
|
||||||
|
@ -34,12 +34,15 @@
|
|||||||
<div id="search_subreddits">
|
<div id="search_subreddits">
|
||||||
{% for subreddit in subreddits %}
|
{% for subreddit in subreddits %}
|
||||||
<a href="{{ subreddit.url }}" class="search_subreddit">
|
<a href="{{ subreddit.url }}" class="search_subreddit">
|
||||||
<p class="search_subreddit_header">
|
<div class="search_subreddit_left">{% if subreddit.icon != "" %}<img src="{{ subreddit.icon }}" alt="r/{{ subreddit.name }} icon">{% endif %}</div>
|
||||||
<span class="search_subreddit_name">{{ subreddit.name }}</span>
|
<div class="search_subreddit_right">
|
||||||
<span class="dot">•</span>
|
<p class="search_subreddit_header">
|
||||||
<span class="search_subreddit_members">{{ subreddit.subscribers }} Members</span>
|
<span class="search_subreddit_name">{{ subreddit.name }}</span>
|
||||||
</p>
|
<span class="dot">•</span>
|
||||||
<p class="search_subreddit_description">{{ subreddit.description }}</p>
|
<span class="search_subreddit_members" title="{{ subreddit.subscribers.1 }} Members">{{ subreddit.subscribers.0 }} Members</span>
|
||||||
|
</p>
|
||||||
|
<p class="search_subreddit_description">{{ subreddit.description }}</p>
|
||||||
|
</div>
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
@ -48,58 +51,11 @@
|
|||||||
|
|
||||||
{% if post.flags.nsfw && prefs.show_nsfw != "on" %}
|
{% if post.flags.nsfw && prefs.show_nsfw != "on" %}
|
||||||
{% else if post.title != "Comment" %}
|
{% else if post.title != "Comment" %}
|
||||||
<div class="post {% if post.flags.stickied %}stickied{% endif %}">
|
{% call utils::post_in_list(post) %}
|
||||||
<p class="post_header">
|
|
||||||
<a class="post_subreddit" href="/r/{{ post.community }}">r/{{ post.community }}</a>
|
|
||||||
<span class="dot">•</span>
|
|
||||||
<a class="post_author" href="/u/{{ post.author.name }}">u/{{ post.author.name }}</a>
|
|
||||||
<span class="dot">•</span>
|
|
||||||
<span class="created" title="{{ post.created }}">{{ post.rel_time }}</span>
|
|
||||||
</p>
|
|
||||||
<p class="post_title">
|
|
||||||
{% if post.flair.flair_parts.len() > 0 %}
|
|
||||||
<small class="post_flair" style="color:{{ post.flair.foreground_color }}; background:{{ post.flair.background_color }};">{% call utils::render_flair(post.flair.flair_parts) %}</small>
|
|
||||||
{% endif %}
|
|
||||||
<a href="{{ post.permalink }}">{{ post.title }}</a>{% if post.flags.nsfw %} <small class="nsfw">NSFW</small>{% endif %}
|
|
||||||
</p>
|
|
||||||
<!-- POST MEDIA/THUMBNAIL -->
|
|
||||||
{% if (prefs.layout.is_empty() || prefs.layout == "card") && post.post_type == "image" %}
|
|
||||||
<a href="{{ post.media.url }}" style="display:contents" >
|
|
||||||
<svg class="post_media {% if post.media.height / post.media.width < 2 %}short{% endif %}"
|
|
||||||
width="{{ post.media.width }}px"
|
|
||||||
height="{{ post.media.height }}px"
|
|
||||||
xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<image width="100%" height="100%" href="{{ post.media.url }}"/>
|
|
||||||
<desc>
|
|
||||||
<img alt="Post image" src="{{ post.media.url }}"/>
|
|
||||||
</dev>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
{% 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 %}">
|
|
||||||
{% 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>
|
|
||||||
<path d="M35,15h-15a10,10 0,0,0 0,20h25a10,10 0,0,0 10,-10m-12.5,0a10, 10 0,0,1 10, -10h25a10,10 0,0,1 0,20h-15" fill="none" stroke-width="5" stroke-linecap="round"/>
|
|
||||||
</svg>
|
|
||||||
{% else %}
|
|
||||||
<svg width="{{ post.thumbnail.width }}px" height="{{ post.thumbnail.height }}px" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<image alt="Thumbnail" width="100%" height="100%" href="{{ post.thumbnail.url }}"/>
|
|
||||||
</svg>
|
|
||||||
{% endif %}
|
|
||||||
<span>{% if post.post_type == "link" %}{{ post.domain }}{% else %}{{ post.post_type }}{% endif %}</span>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="post_score">{{ post.score }}<span class="label"> Upvotes</span></div>
|
|
||||||
<div class="post_footer">
|
|
||||||
<a href="{{ post.permalink }}" class="post_comments">{{ post.comments }} comments</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="comment">
|
<div class="comment">
|
||||||
<div class="comment_left">
|
<div class="comment_left">
|
||||||
<p class="comment_score">{{ post.score }}</p>
|
<p class="comment_score" title="{{ post.score.1 }}">{{ post.score.0 }}</p>
|
||||||
<div class="line"></div>
|
<div class="line"></div>
|
||||||
</div>
|
</div>
|
||||||
<details class="comment_right" open>
|
<details class="comment_right" open>
|
||||||
|
@ -15,7 +15,7 @@
|
|||||||
<div id="theme">
|
<div id="theme">
|
||||||
<label for="theme">Theme:</label>
|
<label for="theme">Theme:</label>
|
||||||
<select name="theme">
|
<select name="theme">
|
||||||
{% call utils::options(prefs.theme, ["system", "light", "dark"], "system") %}
|
{% call utils::options(prefs.theme, ["system", "light", "dark", "black", "dracula", "nord", "laserwave", "violet", "gold"], "system") %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<p>Interface</p>
|
<p>Interface</p>
|
||||||
@ -33,9 +33,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="wide">
|
<div id="wide">
|
||||||
<label for="wide">Wide UI:</label>
|
<label for="wide">Wide UI:</label>
|
||||||
|
<input type="hidden" value="off" name="wide">
|
||||||
<input type="checkbox" name="wide" {% if prefs.wide == "on" %}checked{% endif %}>
|
<input type="checkbox" name="wide" {% if prefs.wide == "on" %}checked{% endif %}>
|
||||||
</div>
|
</div>
|
||||||
<p>Content</p>
|
<p>Content</p>
|
||||||
|
<div id="post_sort">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
<div id="comment_sort">
|
<div id="comment_sort">
|
||||||
<label for="comment_sort">Default comment sort:</label>
|
<label for="comment_sort">Default comment sort:</label>
|
||||||
<select name="comment_sort">
|
<select name="comment_sort">
|
||||||
@ -44,27 +51,40 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="show_nsfw">
|
<div id="show_nsfw">
|
||||||
<label for="show_nsfw">Show NSFW posts:</label>
|
<label for="show_nsfw">Show NSFW posts:</label>
|
||||||
|
<input type="hidden" value="off" name="show_nsfw">
|
||||||
<input type="checkbox" name="show_nsfw" {% if prefs.show_nsfw == "on" %}checked{% endif %}>
|
<input type="checkbox" name="show_nsfw" {% if prefs.show_nsfw == "on" %}checked{% endif %}>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="use_hls">
|
||||||
|
<label for="use_hls">Use HLS for videos</label>
|
||||||
|
<input type="hidden" value="off" name="use_hls">
|
||||||
|
<input type="checkbox" name="use_hls" {% if prefs.use_hls == "on" %}checked{% endif %}>
|
||||||
|
</div>
|
||||||
|
<div id="hide_hls_notification">
|
||||||
|
<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" {% if prefs.hide_hls_notification == "on" %}checked{% endif %}>
|
||||||
|
</div>
|
||||||
|
<input id="save" type="submit" value="Save">
|
||||||
</div>
|
</div>
|
||||||
<p id="settings_note"><b>Note:</b> settings are saved in browser cookies. Clearing your cookie data will reset them.</p>
|
|
||||||
<input id="save" type="submit" value="Save">
|
|
||||||
</form>
|
</form>
|
||||||
{% if prefs.subs.len() > 0 %}
|
{% if prefs.subscriptions.len() > 0 %}
|
||||||
<aside class="prefs">
|
<div class="prefs" id="settings_subs">
|
||||||
<p>Subscribed Subreddits</p>
|
<p>Subscribed Feeds</p>
|
||||||
<ul id="settings_subs">
|
{% for sub in prefs.subscriptions %}
|
||||||
{% for sub in prefs.subs %}
|
<div>
|
||||||
<li>
|
<span>{% if sub.starts_with("u_") -%}{{ format!("u/{}", &sub[2..]) }}{% else -%}{{ format!("r/{}", sub) }}{% endif -%}</span>
|
||||||
<span>{{ sub }}</span>
|
<form action="/r/{{ sub }}/unsubscribe/?redirect=settings" method="POST">
|
||||||
<form action="/r/{{ sub }}/unsubscribe/?redirect=/settings" method="POST">
|
<button class="unsubscribe">Unsubscribe</button>
|
||||||
<button class="unsubscribe">Unsubscribe</button>
|
</form>
|
||||||
</form>
|
</div>
|
||||||
</li>
|
{% endfor %}
|
||||||
{% endfor %}
|
</div>
|
||||||
</ul>
|
|
||||||
</aside>
|
|
||||||
{% endif %}
|
{% 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 }}&comment_sort={{ prefs.comment_sort }}&show_nsfw={{ prefs.show_nsfw }}&use_hls={{ prefs.use_hls }}&hide_hls_notification={{ prefs.hide_hls_notification }}&subscriptions={{ prefs.subscriptions.join("%2B") }}">this link</a>.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -39,60 +39,23 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{% if sub.name.contains("+") %}
|
||||||
|
<form action="/r/{{ sub.name }}/subscribe" method="POST">
|
||||||
|
<button id="multisub" class="subscribe" title="Subscribe to each sub in this multireddit">Subscribe to Multireddit</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div id="posts">
|
<div id="posts">
|
||||||
{% for post in posts %}
|
{% for post in posts %}
|
||||||
{% if !(post.flags.nsfw && prefs.show_nsfw != "on") %}
|
{% if !(post.flags.nsfw && prefs.show_nsfw != "on") %}
|
||||||
<hr class="sep" />
|
<hr class="sep" />
|
||||||
<div class="post {% if post.flags.stickied %}stickied{% endif %}">
|
{% call utils::post_in_list(post) %}
|
||||||
<p class="post_header">
|
|
||||||
<a class="post_subreddit" href="/r/{{ post.community }}">r/{{ post.community }}</a>
|
|
||||||
<span class="dot">•</span>
|
|
||||||
<a class="post_author" href="/u/{{ post.author.name }}">u/{{ post.author.name }}</a>
|
|
||||||
<span class="dot">•</span>
|
|
||||||
<span class="created" title="{{ post.created }}">{{ post.rel_time }}</span>
|
|
||||||
</p>
|
|
||||||
<p class="post_title">
|
|
||||||
{% if post.flair.flair_parts.len() > 0 %}
|
|
||||||
<small class="post_flair" style="color:{{ post.flair.foreground_color }}; background:{{ post.flair.background_color }};">{% call utils::render_flair(post.flair.flair_parts) %}</small>
|
|
||||||
{% endif %}
|
|
||||||
<a href="{{ post.permalink }}">{{ post.title }}</a>{% if post.flags.nsfw %} <small class="nsfw">NSFW</small>{% endif %}
|
|
||||||
</p>
|
|
||||||
<!-- POST MEDIA/THUMBNAIL -->
|
|
||||||
{% if (prefs.layout.is_empty() || prefs.layout == "card") && post.post_type == "image" %}
|
|
||||||
<a href="{{ post.media.url }}" style="display:contents" >
|
|
||||||
<svg class="post_media {% if post.media.height / post.media.width < 2 %}short{% endif %}"
|
|
||||||
width="{{ post.media.width }}px"
|
|
||||||
height="{{ post.media.height }}px"
|
|
||||||
xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<image width="100%" height="100%" href="{{ post.media.url }}"/>
|
|
||||||
<desc>
|
|
||||||
<img alt="Post image" src="{{ post.media.url }}"/>
|
|
||||||
</dev>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
{% 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 %}">
|
|
||||||
{% 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>
|
|
||||||
<path d="M35,15h-15a10,10 0,0,0 0,20h25a10,10 0,0,0 10,-10m-12.5,0a10, 10 0,0,1 10, -10h25a10,10 0,0,1 0,20h-15" fill="none" stroke-width="5" stroke-linecap="round"/>
|
|
||||||
</svg>
|
|
||||||
{% else %}
|
|
||||||
<svg width="{{ post.thumbnail.width }}px" height="{{ post.thumbnail.height }}px" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<image alt="Thumbnail" width="100%" height="100%" href="{{ post.thumbnail.url }}"/>
|
|
||||||
</svg>
|
|
||||||
{% endif %}
|
|
||||||
<span>{% if post.post_type == "link" %}{{ post.domain }}{% else %}{{ post.post_type }}{% endif %}</span>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="post_score">{{ post.score }}<span class="label"> Upvotes</span></div>
|
|
||||||
<div class="post_footer">
|
|
||||||
<a href="{{ post.permalink }}" class="post_comments">{{ post.comments }} comments</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
{% if prefs.use_hls == "on" %}
|
||||||
|
<script src="/hls.min.js"></script>
|
||||||
|
<script src="/playHLSVideo.js"></script>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
@ -122,11 +85,11 @@
|
|||||||
<div id="sub_details">
|
<div id="sub_details">
|
||||||
<label>Members</label>
|
<label>Members</label>
|
||||||
<label>Active</label>
|
<label>Active</label>
|
||||||
<div>{{ sub.members }}</div>
|
<div title="{{ sub.members.1 }}">{{ sub.members.0 }}</div>
|
||||||
<div>{{ sub.active }}</div>
|
<div title="{{ sub.active.1 }}">{{ sub.active.0 }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="sub_subscription">
|
<div id="sub_subscription">
|
||||||
{% if prefs.subs.contains(sub.name) %}
|
{% if prefs.subscriptions.contains(sub.name) %}
|
||||||
<form action="/r/{{ sub.name }}/unsubscribe" method="POST">
|
<form action="/r/{{ sub.name }}/unsubscribe" method="POST">
|
||||||
<button class="unsubscribe">Unsubscribe</button>
|
<button class="unsubscribe">Unsubscribe</button>
|
||||||
</form>
|
</form>
|
||||||
@ -140,7 +103,17 @@
|
|||||||
</div>
|
</div>
|
||||||
<details class="panel" id="sidebar">
|
<details class="panel" id="sidebar">
|
||||||
<summary id="sidebar_label">Sidebar</summary>
|
<summary id="sidebar_label">Sidebar</summary>
|
||||||
<div id="sidebar_contents">{{ sub.info }}</div>
|
<div id="sidebar_contents">
|
||||||
|
{{ sub.info }}
|
||||||
|
<hr>
|
||||||
|
<h2>Moderators</h2>
|
||||||
|
<br>
|
||||||
|
<ul>
|
||||||
|
{% for moderator in sub.moderators %}
|
||||||
|
<li><a style="color: var(--accent)" href="/u/{{ moderator }}">{{ moderator }}</a></li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</aside>
|
</aside>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -33,58 +33,11 @@
|
|||||||
|
|
||||||
{% if post.flags.nsfw && prefs.show_nsfw != "on" %}
|
{% if post.flags.nsfw && prefs.show_nsfw != "on" %}
|
||||||
{% else if post.title != "Comment" %}
|
{% else if post.title != "Comment" %}
|
||||||
<div class="post {% if post.flags.stickied %}stickied{% endif %}">
|
{% call utils::post_in_list(post) %}
|
||||||
<p class="post_header">
|
|
||||||
<a class="post_subreddit" href="/r/{{ post.community }}">r/{{ post.community }}</a>
|
|
||||||
<span class="dot">•</span>
|
|
||||||
<a class="post_author" href="/u/{{ post.author.name }}">u/{{ post.author.name }}</a>
|
|
||||||
<span class="dot">•</span>
|
|
||||||
<span class="created" title="{{ post.created }}">{{ post.rel_time }}</span>
|
|
||||||
</p>
|
|
||||||
<p class="post_title">
|
|
||||||
{% if post.flair.flair_parts.len() > 0 %}
|
|
||||||
<small class="post_flair" style="color:{{ post.flair.foreground_color }}; background:{{ post.flair.background_color }};">{% call utils::render_flair(post.flair.flair_parts) %}</small>
|
|
||||||
{% endif %}
|
|
||||||
<a href="{{ post.permalink }}">{{ post.title }}</a>{% if post.flags.nsfw %} <small class="nsfw">NSFW</small>{% endif %}
|
|
||||||
</p>
|
|
||||||
<!-- POST MEDIA/THUMBNAIL -->
|
|
||||||
{% if (prefs.layout.is_empty() || prefs.layout == "card") && post.post_type == "image" %}
|
|
||||||
<a href="{{ post.media.url }}" style="display:contents" >
|
|
||||||
<svg class="post_media {% if post.media.height / post.media.width < 2 %}short{% endif %}"
|
|
||||||
width="{{ post.media.width }}px"
|
|
||||||
height="{{ post.media.height }}px"
|
|
||||||
xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<image width="100%" height="100%" href="{{ post.media.url }}"/>
|
|
||||||
<desc>
|
|
||||||
<img alt="Post image" src="{{ post.media.url }}"/>
|
|
||||||
</dev>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
{% 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 %}">
|
|
||||||
{% 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>
|
|
||||||
<path d="M35,15h-15a10,10 0,0,0 0,20h25a10,10 0,0,0 10,-10m-12.5,0a10, 10 0,0,1 10, -10h25a10,10 0,0,1 0,20h-15" fill="none" stroke-width="5" stroke-linecap="round"/>
|
|
||||||
</svg>
|
|
||||||
{% else %}
|
|
||||||
<svg width="{{ post.thumbnail.width }}px" height="{{ post.thumbnail.height }}px" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<image alt="Thumbnail" width="100%" height="100%" href="{{ post.thumbnail.url }}"/>
|
|
||||||
</svg>
|
|
||||||
{% endif %}
|
|
||||||
<span>{% if post.post_type == "link" %}{{ post.domain }}{% else %}{{ post.post_type }}{% endif %}</span>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="post_score">{{ post.score }}<span class="label"> Upvotes</span></div>
|
|
||||||
<div class="post_footer">
|
|
||||||
<a href="{{ post.permalink }}" class="post_comments">{{ post.comments }} comments</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="comment">
|
<div class="comment">
|
||||||
<div class="comment_left">
|
<div class="comment_left">
|
||||||
<p class="comment_score">{{ post.score }}</p>
|
<p class="comment_score" title="{{ post.score.1 }}">{{ post.score.0 }}</p>
|
||||||
<div class="line"></div>
|
<div class="line"></div>
|
||||||
</div>
|
</div>
|
||||||
<details class="comment_right" open>
|
<details class="comment_right" open>
|
||||||
@ -112,7 +65,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<aside>
|
<aside>
|
||||||
<div class="panel" id="user">
|
<div class="panel" id="user">
|
||||||
<img id="user_icon" src="{{ user.icon }}">
|
<img id="user_icon" src="{{ user.icon }}" alt="User icon">
|
||||||
<p id="user_title">{{ user.title }}</p>
|
<p id="user_title">{{ user.title }}</p>
|
||||||
<p id="user_name">u/{{ user.name }}</p>
|
<p id="user_name">u/{{ user.name }}</p>
|
||||||
<div id="user_description">{{ user.description }}</div>
|
<div id="user_description">{{ user.description }}</div>
|
||||||
@ -122,6 +75,18 @@
|
|||||||
<div>{{ user.karma }}</div>
|
<div>{{ user.karma }}</div>
|
||||||
<div>{{ user.created }}</div>
|
<div>{{ user.created }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="user_subscription">
|
||||||
|
{% let name = ["u_", user.name.as_str()].join("") %}
|
||||||
|
{% if prefs.subscriptions.contains(name) %}
|
||||||
|
<form action="/r/u_{{ user.name }}/unsubscribe" method="POST">
|
||||||
|
<button class="unsubscribe">Unfollow</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<form action="/r/u_{{ user.name }}/subscribe" method="POST">
|
||||||
|
<button class="subscribe">Follow</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</main>
|
</main>
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
{% macro options(current, values, default) -%}
|
{% macro options(current, values, default) -%}
|
||||||
{% for value in values %}
|
{% for value in values %}
|
||||||
<option value="{{ value }}" {% if current == value || (current == "" && value == default) %}selected{% endif %}>
|
<option value="{{ value }}" {% if current == value || (current == "" && value == default) %}selected{% endif %}>
|
||||||
{{ format!("{}{}", value.get(0..1).unwrap().to_uppercase(), value.get(1..).unwrap()) }}
|
{{ format!("{}{}", value.get(0..1).unwrap_or_default().to_uppercase(), value.get(1..).unwrap_or_default()) }}
|
||||||
</option>
|
</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{%- endmacro %}
|
{%- endmacro %}
|
||||||
@ -9,18 +9,18 @@
|
|||||||
{% macro sort(root, methods, selected) -%}
|
{% macro sort(root, methods, selected) -%}
|
||||||
{% for method in methods %}
|
{% for method in methods %}
|
||||||
<a {% if method == selected %}class="selected"{% endif %} href="{{ root }}/{{ method }}">
|
<a {% if method == selected %}class="selected"{% endif %} href="{{ root }}/{{ method }}">
|
||||||
{{ format!("{}{}", method.get(0..1).unwrap().to_uppercase(), method.get(1..).unwrap()) }}
|
{{ format!("{}{}", method.get(0..1).unwrap_or_default().to_uppercase(), method.get(1..).unwrap_or_default()) }}
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{%- endmacro %}
|
{%- endmacro %}
|
||||||
|
|
||||||
{% macro search(root, search) -%}
|
{% macro search(root, search) -%}
|
||||||
<form action="{% if root != "/r/" && !root.is_empty() %}{{ root }}{% endif %}/search/" id="searchbox">
|
<form action="{% if root != "/r/" && !root.is_empty() %}{{ root }}{% endif %}/search" id="searchbox">
|
||||||
<input id="search" type="text" name="q" placeholder="Search" title="Search libreddit" value="{{ search }}">
|
<input id="search" type="text" name="q" placeholder="Search" title="Search libreddit" value="{{ search }}">
|
||||||
{% if root != "/r/" && !root.is_empty() %}
|
{% if root != "/r/" && !root.is_empty() %}
|
||||||
<div id="inside">
|
<div id="inside">
|
||||||
<input type="checkbox" name="restrict_sr" id="restrict_sr">
|
<input type="checkbox" name="restrict_sr" id="restrict_sr">
|
||||||
<label for="restrict_sr" class="search_label">in {{ root }}</label>
|
<label for="restrict_sr" class="search_label" title="Restrict search to this subreddit">in {{ root }}</label>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<button class="submit">
|
<button class="submit">
|
||||||
@ -33,22 +33,105 @@
|
|||||||
</form>
|
</form>
|
||||||
{%- endmacro %}
|
{%- endmacro %}
|
||||||
|
|
||||||
{% macro render_flair(flair) -%}
|
{% macro render_flair(flair_parts) -%}
|
||||||
{% for flair_part in flair %}
|
{% for flair_part in flair_parts %}{% if flair_part.flair_part_type == "emoji" %}<span class="emoji" style="background-image:url('{{ flair_part.value }}');"></span>{% else if flair_part.flair_part_type == "text" && !flair_part.value.is_empty() %}<span>{{ flair_part.value }}</span>{% endif %}{% endfor %}
|
||||||
{% if flair_part.flair_part_type == "emoji" %}<span class="emoji" style="background-image:url('{{ flair_part.value }}');"></span>
|
|
||||||
{% else if flair_part.flair_part_type == "text" %}<span>{{ flair_part.value }}</span>{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
{%- endmacro %}
|
{%- endmacro %}
|
||||||
|
|
||||||
{% macro sub_list(current) -%}
|
{% macro sub_list(current) -%}
|
||||||
{% if prefs.subs.len() > 0 %}
|
{% if prefs.subscriptions.len() > 0 %}
|
||||||
<details id="subscriptions">
|
<details id="feeds">
|
||||||
<summary>Subscriptions</summary>
|
<summary>Feeds</summary>
|
||||||
<div id="sub_list">
|
<div id="feed_list">
|
||||||
{% for sub in prefs.subs %}
|
<p>MAIN FEEDS</p>
|
||||||
|
<a href="/">Home</a>
|
||||||
|
<a href="/r/popular">Popular</a>
|
||||||
|
<a href="/r/all">All</a>
|
||||||
|
<p>REDDIT FEEDS</p>
|
||||||
|
{% for sub in prefs.subscriptions %}
|
||||||
<a href="/r/{{ sub }}" {% if sub == current %}class="selected"{% endif %}>{{ sub }}</a>
|
<a href="/r/{{ sub }}" {% if sub == current %}class="selected"{% endif %}>{{ sub }}</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{%- endmacro %}
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{% macro render_hls_notification(redirect_url) -%}
|
||||||
|
{% if post.post_type == "video" && !post.media.alt_url.is_empty() && prefs.hide_hls_notification != "on" %}
|
||||||
|
<div class="post_notification"><p><a href="/settings/update/?use_hls=on&redirect={{ redirect_url }}">Enable HLS</a> to view with audio, or <a href="/settings/update/?hide_hls_notification=on&redirect={{ redirect_url }}">disable this notification</a></p></div>
|
||||||
|
{% endif %}
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{% macro post_in_list(post) -%}
|
||||||
|
<div class="post {% if post.flags.stickied %}stickied{% endif %}" id="{{ post.id }}">
|
||||||
|
<p class="post_header">
|
||||||
|
{% let community -%}
|
||||||
|
{% if post.community.starts_with("u_") -%}
|
||||||
|
{% let community = format!("u/{}", &post.community[2..]) -%}
|
||||||
|
{% else -%}
|
||||||
|
{% let community = format!("r/{}", post.community) -%}
|
||||||
|
{% endif -%}
|
||||||
|
<a class="post_subreddit" href="/{{ community }}">{{ community }}</a>
|
||||||
|
<span class="dot">•</span>
|
||||||
|
<a class="post_author" href="/u/{{ post.author.name }}">u/{{ post.author.name }}</a>
|
||||||
|
<span class="dot">•</span>
|
||||||
|
<span class="created" title="{{ post.created }}">{{ post.rel_time }}</span>
|
||||||
|
</p>
|
||||||
|
<p class="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 }};"
|
||||||
|
dir="ltr">{% call render_flair(post.flair.flair_parts) %}</a>
|
||||||
|
{% endif %}
|
||||||
|
<a href="{{ post.permalink }}">{{ post.title }}</a>{% if post.flags.nsfw %} <small class="nsfw">NSFW</small>{% endif %}
|
||||||
|
</p>
|
||||||
|
<!-- POST MEDIA/THUMBNAIL -->
|
||||||
|
{% if (prefs.layout.is_empty() || prefs.layout == "card") && post.post_type == "image" %}
|
||||||
|
<a href="{{ post.media.url }}" class="post_media_image {% if post.media.height / post.media.width < 2 %}short{% endif %}" >
|
||||||
|
<svg
|
||||||
|
width="{{ post.media.width }}px"
|
||||||
|
height="{{ post.media.height }}px"
|
||||||
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<image width="100%" height="100%" href="{{ post.media.url }}"/>
|
||||||
|
<desc>
|
||||||
|
<img alt="Post image" src="{{ post.media.url }}"/>
|
||||||
|
</desc>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
{% else if (prefs.layout.is_empty() || prefs.layout == "card") && post.post_type == "gif" %}
|
||||||
|
<video class="post_media_video short" src="{{ post.media.url }}" width="{{ post.media.width }}" height="{{ post.media.height }}" controls loop autoplay><a href={{ post.media.url }}>Video</a></video>
|
||||||
|
{% else if (prefs.layout.is_empty() || prefs.layout == "card") && post.post_type == "video" %}
|
||||||
|
{% if prefs.use_hls == "on" && !post.media.alt_url.is_empty() %}
|
||||||
|
<video class="post_media_video short" width="{{ post.media.width }}" height="{{ post.media.height }}" 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>
|
||||||
|
{% else %}
|
||||||
|
<video class="post_media_video short" src="{{ post.media.url }}" width="{{ post.media.width }}" height="{{ post.media.height }}" poster="{{ post.media.poster }}" preload="none" controls autoplay><a href={{ post.media.url }}>Video</a></video>
|
||||||
|
{% call render_hls_notification(format!("{}%23{}", &self.url[1..].replace("&", "%26"), 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">
|
||||||
|
{% 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>
|
||||||
|
<path d="M35,15h-15a10,10 0,0,0 0,20h25a10,10 0,0,0 10,-10m-12.5,0a10, 10 0,0,1 10, -10h25a10,10 0,0,1 0,20h-15" fill="none" stroke-width="5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
{% else %}
|
||||||
|
<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 alt="Thumbnail" src="{{ post.thumbnail.url }}"/>
|
||||||
|
</desc>
|
||||||
|
</svg>
|
||||||
|
{% endif %}
|
||||||
|
<span>{% if post.post_type == "link" %}{{ post.domain }}{% else %}{{ post.post_type }}{% endif %}</span>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="post_score" title="{{ post.score.1 }}">{{ post.score.0 }}<span class="label"> Upvotes</span></div>
|
||||||
|
<div class="post_footer">
|
||||||
|
<a href="{{ post.permalink }}" class="post_comments" title="{{ post.comments.1 }} comments">{{ post.comments.0 }} comments</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{%- endmacro %}
|
||||||
|
13
templates/wall.html
Normal file
13
templates/wall.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ msg }}{% endblock %}
|
||||||
|
{% block sortstyle %}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div id="wall">
|
||||||
|
<h1>{{ title }}</h1>
|
||||||
|
<br>
|
||||||
|
<p>{{ msg }}</p>
|
||||||
|
<form action="/r/{{ sub }}?redir={{ url }}" method="POST">
|
||||||
|
<input id="save" type="submit" value="Continue">
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
Reference in New Issue
Block a user