Compare commits
88 Commits
Author | SHA1 | Date | |
---|---|---|---|
473a498bea | |||
92f5286667 | |||
0a6bf6bbee | |||
618b074ad5 | |||
d86cebf975 | |||
ab39b62533 | |||
5aee695bae | |||
c9633e1464 | |||
0152752913 | |||
6912307349 | |||
f76243e0af | |||
f0fa2f2709 | |||
88bed73e5e | |||
3a33c70e7c | |||
40dfddc44d | |||
3f3d9e9c3b | |||
501b47894c | |||
d8c661177b | |||
fade305f90 | |||
e62d33ccae | |||
465d9b7ba7 | |||
5c366e14a3 | |||
d4ca376e8d | |||
371b7b2635 | |||
cc27dc2a26 | |||
bfe03578f0 | |||
c6487799ed | |||
584cd4aac1 | |||
377634841c | |||
c0e37443ae | |||
8348e20724 | |||
ae3ea2da7c | |||
8435b8eab9 | |||
510c8679d6 | |||
98674310bc | |||
170ea384fb | |||
1b5e9a4279 | |||
b170a8dd99 | |||
aa54301054 | |||
b4d3f03335 | |||
1a1ff2e600 | |||
4fc07c02b5 | |||
8d58cf61d2 | |||
711e3c205d | |||
0704eb10b8 | |||
ef86c1be86 | |||
8141b74817 | |||
57d304161b | |||
b5f21bcb97 | |||
36c560144a | |||
2bc714d0c5 | |||
ff4a515e24 | |||
93f089c2cf | |||
23569206cc | |||
5f20e8ee27 | |||
a8a8980b98 | |||
fd7d977835 | |||
50f26333cb | |||
f5cd48b07f | |||
50665bbeb3 | |||
d558127306 | |||
0c757023f9 | |||
90828cc71c | |||
7f5bfc04b3 | |||
322aa97a18 | |||
7e07ca3df1 | |||
428dc58e3c | |||
0ec8e4e9a2 | |||
60c7b6b23f | |||
1c8bcf33c1 | |||
3bdc21f90a | |||
c3dade257d | |||
62b2bbb231 | |||
653aee9294 | |||
bb7fb1313d | |||
01bc729a80 | |||
39e6e6bf81 | |||
8c94c0dd17 | |||
1c50c8f30d | |||
3facaefb53 | |||
aec45311cc | |||
47ab857103 | |||
a9ef5bc08b | |||
eb6c5e5e1e | |||
ed11135af8 | |||
3a1af78e26 | |||
345770c64d | |||
9eb42932df |
1
.github/FUNDING.yml
vendored
1
.github/FUNDING.yml
vendored
@ -1 +1,2 @@
|
|||||||
liberapay: spike
|
liberapay: spike
|
||||||
|
custom: ['https://www.buymeacoffee.com/spikecodes']
|
||||||
|
8
.github/ISSUE_TEMPLATE/bug_report.md
vendored
8
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: 🐛 Bug report
|
name: 🐛 Bug report
|
||||||
about: Create a report to help us improve
|
about: Create a report to help us improve
|
||||||
title: ''
|
title: '🐛 Bug Report: '
|
||||||
labels: bug
|
labels: bug
|
||||||
assignees: ''
|
assignees: ''
|
||||||
|
|
||||||
@ -12,7 +12,7 @@ assignees: ''
|
|||||||
A clear and concise description of what the bug is.
|
A clear and concise description of what the bug is.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
## To reproduce
|
## Steps to reproduce the bug
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Steps to reproduce the behavior:
|
Steps to reproduce the behavior:
|
||||||
@ -22,12 +22,12 @@ Steps to reproduce the behavior:
|
|||||||
4. See error
|
4. See error
|
||||||
-->
|
-->
|
||||||
|
|
||||||
## Expected behavior
|
## What's the 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 / screenshot
|
||||||
<!--
|
<!--
|
||||||
Add any other context about the problem here.
|
Add any other context about the problem here.
|
||||||
-->
|
-->
|
||||||
|
6
.github/ISSUE_TEMPLATE/feature_parity.md
vendored
6
.github/ISSUE_TEMPLATE/feature_parity.md
vendored
@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: ✨ Feature parity
|
name: ✨ Feature parity
|
||||||
about: Suggest implementing a feature into Libreddit that is found in Reddit.com
|
about: Suggest implementing a feature into Libreddit that is found in Reddit.com
|
||||||
title: ''
|
title: '✨ Feature parity: '
|
||||||
labels: feature parity
|
labels: feature parity
|
||||||
assignees: ''
|
assignees: ''
|
||||||
|
|
||||||
@ -12,7 +12,7 @@ assignees: ''
|
|||||||
A clear and concise description of what the feature is.
|
A clear and concise description of what the feature is.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
## Describe the implementation into Libreddit
|
## Describe how this could be implemented into Libreddit
|
||||||
<!--
|
<!--
|
||||||
A clear and concise description of what you want to happen.
|
A clear and concise description of what you want to happen.
|
||||||
-->
|
-->
|
||||||
@ -22,7 +22,7 @@ assignees: ''
|
|||||||
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 / screenshot
|
||||||
<!--
|
<!--
|
||||||
Add any other context or screenshots about the feature parity request here.
|
Add any other context or screenshots about the feature parity request here.
|
||||||
-->
|
-->
|
||||||
|
6
.github/ISSUE_TEMPLATE/feature_request.md
vendored
6
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: 💡 Feature request
|
name: 💡 Feature request
|
||||||
about: Suggest a feature for Libreddit that is not found in Reddit
|
about: Suggest a feature for Libreddit that is not found in Reddit
|
||||||
title: ''
|
title: '💡 Feature request: '
|
||||||
labels: enhancement
|
labels: enhancement
|
||||||
assignees: ''
|
assignees: ''
|
||||||
|
|
||||||
@ -12,7 +12,7 @@ assignees: ''
|
|||||||
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 feature you would like to be implemented
|
||||||
<!--
|
<!--
|
||||||
A clear and concise description of what you want to happen.
|
A clear and concise description of what you want to happen.
|
||||||
-->
|
-->
|
||||||
@ -22,7 +22,7 @@ assignees: ''
|
|||||||
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 / screenshot
|
||||||
<!--
|
<!--
|
||||||
Add any other context or screenshots about the feature request here.
|
Add any other context or screenshots about the feature request here.
|
||||||
-->
|
-->
|
||||||
|
2
.github/workflows/docker-arm.yml
vendored
2
.github/workflows/docker-arm.yml
vendored
@ -34,3 +34,5 @@ jobs:
|
|||||||
platforms: linux/arm64
|
platforms: linux/arm64
|
||||||
push: true
|
push: true
|
||||||
tags: spikecodes/libreddit:arm
|
tags: spikecodes/libreddit:arm
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
2
.github/workflows/docker-armv7.yml
vendored
2
.github/workflows/docker-armv7.yml
vendored
@ -37,3 +37,5 @@ jobs:
|
|||||||
platforms: linux/arm/v7
|
platforms: linux/arm/v7
|
||||||
push: true
|
push: true
|
||||||
tags: spikecodes/libreddit:armv7
|
tags: spikecodes/libreddit:armv7
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
3
.github/workflows/docker.yml
vendored
3
.github/workflows/docker.yml
vendored
@ -34,4 +34,5 @@ jobs:
|
|||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
push: true
|
push: true
|
||||||
tags: spikecodes/libreddit:latest
|
tags: spikecodes/libreddit:latest
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
2
.replit
2
.replit
@ -1,2 +1,2 @@
|
|||||||
run = "while true; do wget -O libreddit https://github.com/spikecodes/libreddit/releases/latest/download/libreddit;chmod +x libreddit;./libreddit -H 63115200;sleep 1;done"
|
run = "while :; do set -ex; curl -o./libreddit -fsSL -- https://github.com/libreddit/libreddit/releases/latest/download/libreddit ; chmod +x libreddit; set +e; ./libreddit -H 63115200; sleep 1; done"
|
||||||
language = "bash"
|
language = "bash"
|
81
CREDITS
Normal file
81
CREDITS
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
5trongthany <65565784+5trongthany@users.noreply.github.com>
|
||||||
|
674Y3r <87250374+674Y3r@users.noreply.github.com>
|
||||||
|
accountForIssues <52367365+accountForIssues@users.noreply.github.com>
|
||||||
|
Adrian Lebioda <adrianlebioda@gmail.com>
|
||||||
|
alefvanoon <53198048+alefvanoon@users.noreply.github.com>
|
||||||
|
alyaeanyx <alexandra.hollmeier@mailbox.org>
|
||||||
|
AndreVuillemot160 <84594011+AndreVuillemot160@users.noreply.github.com>
|
||||||
|
Andrew Kaufman <57281817+andrew-kaufman@users.noreply.github.com>
|
||||||
|
Artemis <51862164+artemislena@users.noreply.github.com>
|
||||||
|
arthomnix <35371030+arthomnix@users.noreply.github.com>
|
||||||
|
Arya K <73596856+gi-yt@users.noreply.github.com>
|
||||||
|
Austin Huang <im@austinhuang.me>
|
||||||
|
Basti <pred2k@users.noreply.github.com>
|
||||||
|
Ben Smith <37027883+smithbm2316@users.noreply.github.com>
|
||||||
|
BobIsMyManager <ahoumatt@yahoo.com>
|
||||||
|
curlpipe <11898833+curlpipe@users.noreply.github.com>
|
||||||
|
dacousb <53299044+dacousb@users.noreply.github.com>
|
||||||
|
Daniel Valentine <Daniel-Valentine@users.noreply.github.com>
|
||||||
|
dbrennand <52419383+dbrennand@users.noreply.github.com>
|
||||||
|
Diego Magdaleno <38844659+DiegoMagdaleno@users.noreply.github.com>
|
||||||
|
Dyras <jevwmguf@duck.com>
|
||||||
|
Edward <101938856+EdwardLangdon@users.noreply.github.com>
|
||||||
|
erdnaxe <erdnaxe@users.noreply.github.com>
|
||||||
|
Esmail EL BoB <github.defilable@simplelogin.co>
|
||||||
|
FireMasterK <20838718+FireMasterK@users.noreply.github.com>
|
||||||
|
George Roubos <cowkingdom@hotmail.com>
|
||||||
|
git-bruh <e817509a-8ee9-4332-b0ad-3a6bdf9ab63f@aleeas.com>
|
||||||
|
guaddy <67671414+guaddy@users.noreply.github.com>
|
||||||
|
Harsh Mishra <erbeusgriffincasper@gmail.com>
|
||||||
|
igna <igna@intent.cool>
|
||||||
|
imabritishcow <bcow@protonmail.com>
|
||||||
|
Josiah <70736638+fres7h@users.noreply.github.com>
|
||||||
|
JPyke3 <pyke.jacob1@gmail.com>
|
||||||
|
Kavin <20838718+FireMasterK@users.noreply.github.com>
|
||||||
|
Kazi <kzshantonu@users.noreply.github.com>
|
||||||
|
Kieran <42723993+EnderDev@users.noreply.github.com>
|
||||||
|
Kieran <kieran@dothq.co>
|
||||||
|
Kyle Roth <kylrth@gmail.com>
|
||||||
|
laazyCmd <laazy.pr00gramming@protonmail.com>
|
||||||
|
Laurențiu Nicola <lnicola@users.noreply.github.com>
|
||||||
|
Lena <102762572+MarshDeer@users.noreply.github.com>
|
||||||
|
Macic <46872282+Macic-Dev@users.noreply.github.com>
|
||||||
|
Mario A <10923513+Midblyte@users.noreply.github.com>
|
||||||
|
Matthew Crossman <matt@crossman.page>
|
||||||
|
Matthew E <matt@matthew.science>
|
||||||
|
Mennaruuk <52135169+Mennaruuk@users.noreply.github.com>
|
||||||
|
mikupls <93015331+mikupls@users.noreply.github.com>
|
||||||
|
Nainar <nainar.mb@gmail.com>
|
||||||
|
Nathan Moos <moosingin3space@gmail.com>
|
||||||
|
Nicholas Christopher <nchristopher@tuta.io>
|
||||||
|
Nick Lowery <ClockVapor@users.noreply.github.com>
|
||||||
|
Nico <github@dr460nf1r3.org>
|
||||||
|
NKIPSC <15067635+NKIPSC@users.noreply.github.com>
|
||||||
|
obeho <71698631+obeho@users.noreply.github.com>
|
||||||
|
obscurity <z@x4.pm>
|
||||||
|
Om G <34579088+OxyMagnesium@users.noreply.github.com>
|
||||||
|
RiversideRocks <59586759+RiversideRocks@users.noreply.github.com>
|
||||||
|
robin <8597693+robrobinbin@users.noreply.github.com>
|
||||||
|
Robin <8597693+robrobinbin@users.noreply.github.com>
|
||||||
|
robrobinbin <>
|
||||||
|
robrobinbin <8597693+robrobinbin@users.noreply.github.com>
|
||||||
|
robrobinbin <robindepril@gmail.com>
|
||||||
|
Ruben Elshof <15641671+rubenelshof@users.noreply.github.com>
|
||||||
|
Scoder12 <34356756+Scoder12@users.noreply.github.com>
|
||||||
|
Slayer <51095261+GhostSlayer@users.noreply.github.com>
|
||||||
|
Soheb <somoso@users.noreply.github.com>
|
||||||
|
somini <somini@users.noreply.github.com>
|
||||||
|
somoso <github@soheb.anonaddy.com>
|
||||||
|
Spike <19519553+spikecodes@users.noreply.github.com>
|
||||||
|
spikecodes <19519553+spikecodes@users.noreply.github.com>
|
||||||
|
sybenx <syb@duck.com>
|
||||||
|
TheCultLeader666 <65368815+TheCultLeader666@users.noreply.github.com>
|
||||||
|
TheFrenchGhosty <47571719+TheFrenchGhosty@users.noreply.github.com>
|
||||||
|
The TwilightBlood <hwengerstickel@protonmail.com>
|
||||||
|
tirz <36501933+tirz@users.noreply.github.com>
|
||||||
|
Tsvetomir Bonev <invakid404@riseup.net>
|
||||||
|
Vladislav Nepogodin <nepogodin.vlad@gmail.com>
|
||||||
|
Walkx <walkxnl@gmail.com>
|
||||||
|
Wichai <1482605+Chengings@users.noreply.github.com>
|
||||||
|
xatier <xatierlike@gmail.com>
|
||||||
|
Zach <72994911+zachjmurphy@users.noreply.github.com>
|
884
Cargo.lock
generated
884
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
29
Cargo.toml
29
Cargo.toml
@ -3,23 +3,30 @@ 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.22.2"
|
version = "0.24.2"
|
||||||
authors = ["spikecodes <19519553+spikecodes@users.noreply.github.com>"]
|
authors = ["spikecodes <19519553+spikecodes@users.noreply.github.com>"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
askama = { version = "0.11.1", default-features = false }
|
askama = { version = "0.11.1", default-features = false }
|
||||||
async-recursion = "1.0.0"
|
async-recursion = "1.0.0"
|
||||||
cached = "0.34.0"
|
cached = "0.40.0"
|
||||||
clap = { version = "3.1.6", default-features = false, features = ["std"] }
|
clap = { version = "4.0.24", default-features = false, features = ["std"] }
|
||||||
regex = "1.5.5"
|
regex = "1.7.0"
|
||||||
serde = { version = "1.0.136", features = ["derive"] }
|
serde = { version = "1.0.147", features = ["derive"] }
|
||||||
cookie = "0.16.0"
|
cookie = "0.16.1"
|
||||||
futures-lite = "1.12.0"
|
futures-lite = "1.12.0"
|
||||||
hyper = { version = "0.14.17", features = ["full"] }
|
hyper = { version = "0.14.23", features = ["full"] }
|
||||||
hyper-rustls = "0.23.0"
|
hyper-rustls = "0.23.0"
|
||||||
|
percent-encoding = "2.2.0"
|
||||||
route-recognizer = "0.3.1"
|
route-recognizer = "0.3.1"
|
||||||
serde_json = "1.0.79"
|
serde_json = "1.0.87"
|
||||||
tokio = { version = "1.17.0", features = ["full"] }
|
tokio = { version = "1.21.2", features = ["full"] }
|
||||||
time = "0.3.7"
|
time = "0.3.17"
|
||||||
url = "2.2.2"
|
url = "2.3.1"
|
||||||
|
rust-embed = { version = "6.4.2", features = ["include-exclude"] }
|
||||||
|
libflate = "1.2.0"
|
||||||
|
brotli = { version = "3.3.4", features = ["std"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
lipsum = "0.8.2"
|
||||||
|
@ -3,13 +3,18 @@
|
|||||||
####################################################################################################
|
####################################################################################################
|
||||||
FROM rust:alpine AS builder
|
FROM rust:alpine AS builder
|
||||||
|
|
||||||
RUN apk add --no-cache g++
|
RUN apk add --no-cache g++ git
|
||||||
|
|
||||||
WORKDIR /usr/src/libreddit
|
WORKDIR /usr/src/libreddit
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN cargo install --path .
|
# net.git-fetch-with-cli is specified in order to prevent a potential OOM kill
|
||||||
|
# in low memory environments. See:
|
||||||
|
# https://users.rust-lang.org/t/cargo-uses-too-much-memory-being-run-in-qemu/76531
|
||||||
|
# This is tracked under issue #641. This also requires us to install git in the
|
||||||
|
# builder.
|
||||||
|
RUN cargo install --config net.git-fetch-with-cli=true --path .
|
||||||
|
|
||||||
####################################################################################################
|
####################################################################################################
|
||||||
## Final image
|
## Final image
|
||||||
|
12
FUNDING.yml
12
FUNDING.yml
@ -1,12 +0,0 @@
|
|||||||
# These are supported funding model platforms
|
|
||||||
|
|
||||||
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
|
||||||
patreon: # Replace with a single Patreon username
|
|
||||||
open_collective: # Replace with a single Open Collective username
|
|
||||||
ko_fi: # Replace with a single Ko-fi username
|
|
||||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
|
||||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
|
||||||
liberapay: spike
|
|
||||||
issuehunt: # Replace with a single IssueHunt username
|
|
||||||
otechie: # Replace with a single Otechie username
|
|
||||||
custom: ['https://www.buymeacoffee.com/spikecodes']
|
|
111
README.md
111
README.md
@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
**10 second pitch:** Libreddit is a portmanteau of "libre" (meaning freedom) and "Reddit". It is a private front-end like [Invidious](https://github.com/iv-org/invidious) but for Reddit. Browse the coldest takes of [r/unpopularopinion](https://libreddit.spike.codes/r/unpopularopinion) without being [tracked](#reddit).
|
**10 second pitch:** Libreddit is a portmanteau of "libre" (meaning freedom) and "Reddit". It is a private front-end like [Invidious](https://github.com/iv-org/invidious) but for Reddit. Browse the coldest takes of [r/unpopularopinion](https://libreddit.spike.codes/r/unpopularopinion) without being [tracked](#reddit).
|
||||||
|
|
||||||
- 🚀 Fast: written in Rust for blazing fast speeds and memory safety
|
- 🚀 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
|
||||||
- 🕵 Private: all requests are proxied through the server, including media
|
- 🕵 Private: all requests are proxied through the server, including media
|
||||||
- 🔒 Secure: strong [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) prevents browser requests to Reddit
|
- 🔒 Secure: strong [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) prevents browser requests to Reddit
|
||||||
@ -21,89 +21,25 @@ I appreciate any donations! Your support allows me to continue developing Libred
|
|||||||
<a href="https://liberapay.com/spike/donate"><img alt="Donate using Liberapay" src="https://liberapay.com/assets/widgets/donate.svg" style="height: 40px"></a>
|
<a href="https://liberapay.com/spike/donate"><img alt="Donate using Liberapay" src="https://liberapay.com/assets/widgets/donate.svg" style="height: 40px"></a>
|
||||||
|
|
||||||
|
|
||||||
**Bitcoin:** [bc1qwyxjnafpu3gypcpgs025cw9wa7ryudtecmwa6y](bitcoin:bc1qwyxjnafpu3gypcpgs025cw9wa7ryudtecmwa6y)
|
**Bitcoin:** `bc1qwyxjnafpu3gypcpgs025cw9wa7ryudtecmwa6y`
|
||||||
|
|
||||||
**Monero:** [45FJrEuFPtG2o7QZz2Nps77TbHD4sPqxViwbdyV9A6ktfHiWs47UngG5zXPcLoDXAc8taeuBgeNjfeprwgeXYXhN3C9tVSR](monero:45FJrEuFPtG2o7QZz2Nps77TbHD4sPqxViwbdyV9A6ktfHiWs47UngG5zXPcLoDXAc8taeuBgeNjfeprwgeXYXhN3C9tVSR)
|
**Monero:** `45FJrEuFPtG2o7QZz2Nps77TbHD4sPqxViwbdyV9A6ktfHiWs47UngG5zXPcLoDXAc8taeuBgeNjfeprwgeXYXhN3C9tVSR`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Instances
|
# Instances
|
||||||
|
|
||||||
Feel free to [open an issue](https://github.com/spikecodes/libreddit/issues/new) to have your [selfhosted instance](#deployment) listed here!
|
|
||||||
|
|
||||||
🔗 **Want to automatically redirect Reddit links to Libreddit? Use [LibRedirect](https://github.com/libredirect/libredirect) or [Privacy Redirect](https://github.com/SimonBrazell/privacy-redirect)!**
|
🔗 **Want to automatically redirect Reddit links to Libreddit? Use [LibRedirect](https://github.com/libredirect/libredirect) or [Privacy Redirect](https://github.com/SimonBrazell/privacy-redirect)!**
|
||||||
|
|
||||||
| Website | Country | Cloudflare |
|
[Follow this link](https://github.com/libreddit/libreddit-instances/blob/master/instances.md) for an up-to-date table of instances in markdown format. This list is also available as [a machine-readable JSON](https://github.com/libreddit/libreddit-instances/blob/master/instances.json).
|
||||||
|-|-|-|
|
|
||||||
| [libredd.it](https://libredd.it) (official) | 🇺🇸 US | |
|
|
||||||
| [libreddit.spike.codes](https://libreddit.spike.codes) (official) | 🇺🇸 US | |
|
|
||||||
| [libreddit.dothq.co](https://libreddit.dothq.co) | 🇩🇪 DE | ✅ |
|
|
||||||
| [libreddit.kavin.rocks](https://libreddit.kavin.rocks) | 🇮🇳 IN | |
|
|
||||||
| [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 | ✅ |
|
|
||||||
| [libreddit.sugoma.tk](https://libreddit.sugoma.tk) | 🇺🇸 US | |
|
|
||||||
| [libreddit.jamiethalacker.dev](https://libreddit.jamiethalacker.dev) | 🇺🇸 US | ✅ |
|
|
||||||
| [reddit.artemislena.eu](https://reddit.artemislena.eu) | 🇩🇪 DE | |
|
|
||||||
| [r.nf](https://r.nf) | 🇩🇪 DE | ✅ |
|
|
||||||
| [libreddit.awesomehub.io](https://libreddit.awesomehub.io) | 🇫🇮 FI | |
|
|
||||||
| [libreddit.some-things.org](https://libreddit.some-things.org) | 🇨🇭 CH | |
|
|
||||||
| [reddit.stuehieyr.com](https://reddit.stuehieyr.com) | 🇩🇪 DE | |
|
|
||||||
| [lr.mint.lgbt](https://lr.mint.lgbt) | 🇨🇦 CA | |
|
|
||||||
| [libreddit.alefvanoon.xyz](https://libreddit.alefvanoon.xyz) | 🇺🇸 US | ✅ |
|
|
||||||
| [libreddit.igna.rocks](https://libreddit.igna.rocks) | 🇺🇸 US | |
|
|
||||||
| [libreddit.autarkic.org](https://libreddit.autarkic.org) | 🇺🇸 US | |
|
|
||||||
| [libreddit.flux.industries](https://libreddit.flux.industries) | 🇩🇪 DE | ✅ |
|
|
||||||
| [libreddit.drivet.xyz](https://libreddit.drivet.xyz) | 🇵🇱 PL | |
|
|
||||||
| [lr.oversold.host](https://lr.oversold.host) | 🇱🇺 LU | |
|
|
||||||
| [libreddit.de](https://libreddit.de) | 🇩🇪 DE | |
|
|
||||||
| [libreddit.pussthecat.org](https://libreddit.pussthecat.org) | 🇩🇪 DE | |
|
|
||||||
| [libreddit.mutahar.rocks](https://libreddit.mutahar.rocks) | 🇫🇷 FR | |
|
|
||||||
| [libreddit.northboot.xyz](https://libreddit.northboot.xyz) | 🇩🇪 DE | |
|
|
||||||
| [leddit.xyz](https://leddit.xyz) | 🇺🇸 US | |
|
|
||||||
| [de.leddit.xyz](https://de.leddit.xyz) | 🇩🇪 DE | |
|
|
||||||
| [lr.cowfee.moe](https://lr.cowfee.moe) | 🇺🇸 US | |
|
|
||||||
| [libreddit.hu](https://libreddit.hu) | 🇫🇮 FI | ✅ |
|
|
||||||
| [libreddit.totaldarkness.net](https://libreddit.totaldarkness.net) | 🇨🇦 CA | |
|
|
||||||
| [libreddit.esmailelbob.xyz](https://libreddit.esmailelbob.xyz) | 🇨🇦 CA | |
|
|
||||||
| [libreddit.nl](https://libreddit.nl) | 🇳🇱 NL | |
|
|
||||||
| [lr.stilic.ml](https://lr.stilic.ml) | 🇫🇷 FR | ✅ |
|
|
||||||
| [reddi.tk](https://reddi.tk) | 🇺🇸 US | ✅ |
|
|
||||||
| [libreddit.bus-hit.me](https://libreddit.bus-hit.me) | 🇨🇦 CA | |
|
|
||||||
| [libreddit.datatunnel.xyz](https://libreddit.datatunnel.xyz) | 🇫🇮 FI | |
|
|
||||||
| [libreddit.crewz.me](https://libreddit.crewz.me) | 🇳🇱 NL | ✅ |
|
|
||||||
| [r.walkx.org](https://r.walkx.org) | 🇩🇪 DE | ✅ |
|
|
||||||
| [libreddit.kylrth.com](https://libreddit.kylrth.com) | 🇨🇦 CA | |
|
|
||||||
| [libreddit.yonalee.eu](https://libreddit.yonalee.eu) | 🇱🇺 LU | ✅ |
|
|
||||||
| [libreddit.winscloud.net](https://libreddit.winscloud.net) | 🇹🇭 TH | ✅ |
|
|
||||||
| [libreddit.tiekoetter.com](https://libreddit.tiekoetter.com) | 🇩🇪 DE | |
|
|
||||||
| [reddit.rtrace.io](https://reddit.rtrace.io) | 🇩🇪 DE | |
|
|
||||||
| [spjmllawtheisznfs7uryhxumin26ssv2draj7oope3ok3wuhy43eoyd.onion](http://spjmllawtheisznfs7uryhxumin26ssv2draj7oope3ok3wuhy43eoyd.onion) | 🇮🇳 IN | |
|
|
||||||
| [fwhhsbrbltmrct5hshrnqlqygqvcgmnek3cnka55zj4y7nuus5muwyyd.onion](http://fwhhsbrbltmrct5hshrnqlqygqvcgmnek3cnka55zj4y7nuus5muwyyd.onion) | 🇩🇪 DE | |
|
|
||||||
| [kphht2jcflojtqte4b4kyx7p2ahagv4debjj32nre67dxz7y57seqwyd.onion](http://kphht2jcflojtqte4b4kyx7p2ahagv4debjj32nre67dxz7y57seqwyd.onion) | 🇳🇱 NL | |
|
|
||||||
| [inytumdgnri7xsqtvpntjevaelxtgbjqkuqhtf6txxhwbll2fwqtakqd.onion](http://inytumdgnri7xsqtvpntjevaelxtgbjqkuqhtf6txxhwbll2fwqtakqd.onion) | 🇨🇭 CH | |
|
|
||||||
| [liredejj74h5xjqr2dylnl5howb2bpikfowqoveub55ru27x43357iid.onion](http://liredejj74h5xjqr2dylnl5howb2bpikfowqoveub55ru27x43357iid.onion) | 🇩🇪 DE | |
|
|
||||||
| [kzhfp3nvb4qp575vy23ccbrgfocezjtl5dx66uthgrhu7nscu6rcwjyd.onion](http://kzhfp3nvb4qp575vy23ccbrgfocezjtl5dx66uthgrhu7nscu6rcwjyd.onion) | 🇺🇸 US | |
|
|
||||||
| [ecue64ybzvn6vjzl37kcsnwt4ycmbsyf74nbttyg7rkc3t3qwnj7mcyd.onion](http://ecue64ybzvn6vjzl37kcsnwt4ycmbsyf74nbttyg7rkc3t3qwnj7mcyd.onion) | 🇩🇪 DE | |
|
|
||||||
| [ledditqo2mxfvlgobxnlhrkq4dh34jss6evfkdkb2thlvy6dn4f4gpyd.onion](http://ledditqo2mxfvlgobxnlhrkq4dh34jss6evfkdkb2thlvy6dn4f4gpyd.onion) | 🇺🇸 US | |
|
|
||||||
| [libredoxhxwnmsb6dvzzd35hmgzmawsq5i764es7witwhddvpc2razid.onion](http://libredoxhxwnmsb6dvzzd35hmgzmawsq5i764es7witwhddvpc2razid.onion) | 🇺🇸 US | |
|
|
||||||
| [libreddit.2syis2nnyytz6jnusnjurva4swlaizlnleiks5mjp46phuwjbdjqwgqd.onion](http://libreddit.2syis2nnyytz6jnusnjurva4swlaizlnleiks5mjp46phuwjbdjqwgqd.onion) | 🇪🇬 EG | |
|
|
||||||
| [ol5begilptoou34emq2sshf3may3hlblvipdjtybbovpb7c7zodxmtqd.onion](http://ol5begilptoou34emq2sshf3may3hlblvipdjtybbovpb7c7zodxmtqd.onion) | 🇩🇪 DE | |
|
|
||||||
| [lbrdtjaj7567ptdd4rv74lv27qhxfkraabnyphgcvptl64ijx2tijwid.onion](http://lbrdtjaj7567ptdd4rv74lv27qhxfkraabnyphgcvptl64ijx2tijwid.onion) | 🇨🇦 CA | |
|
|
||||||
| [libreddit.lqs5fjmajyp7rvp4qvyubwofzi6d4imua7vs237rkc4m5qogitqwrgyd.onion](http://libreddit.lqs5fjmajyp7rvp4qvyubwofzi6d4imua7vs237rkc4m5qogitqwrgyd.onion/) | 🇨🇦 CA | |
|
|
||||||
|
|
||||||
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.
|
Both files are part of the [libreddit-instances](https://github.com/libreddit/libreddit-instances) repository. To contribute your [self-hosted instance](#deployment) to the list, see the [libreddit-instances README](https://github.com/libreddit/libreddit-instances/blob/master/README.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# About
|
# About
|
||||||
|
|
||||||
Find Libreddit on 💬 [Matrix](https://matrix.to/#/#libreddit:kde.org), 🐋 [Docker](https://hub.docker.com/r/spikecodes/libreddit), :octocat: [GitHub](https://github.com/spikecodes/libreddit), and 🦊 [GitLab](https://gitlab.com/spikecodes/libreddit).
|
Find Libreddit on 💬 [Matrix](https://matrix.to/#/#libreddit:kde.org), 🐋 [Docker](https://hub.docker.com/r/spikecodes/libreddit), :octocat: [GitHub](https://github.com/libreddit/libreddit), and 🦊 [GitLab](https://gitlab.com/spikecodes/libreddit).
|
||||||
|
|
||||||
## Built with
|
## Built with
|
||||||
|
|
||||||
@ -115,7 +51,7 @@ Find Libreddit on 💬 [Matrix](https://matrix.to/#/#libreddit:kde.org), 🐋 [D
|
|||||||
## 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.
|
||||||
|
|
||||||
Libreddit currently implements most of Reddit's (signed-out) functionalities but still lacks [a few features](https://github.com/spikecodes/libreddit/issues).
|
Libreddit currently implements most of Reddit's (signed-out) functionalities but still lacks [a few features](https://github.com/libreddit/libreddit/issues).
|
||||||
|
|
||||||
## How does it compare to Teddit?
|
## How does it compare to Teddit?
|
||||||
|
|
||||||
@ -133,15 +69,15 @@ This section outlines how Libreddit compares to Reddit.
|
|||||||
|
|
||||||
## Speed
|
## Speed
|
||||||
|
|
||||||
Lasted tested Jan 17, 2021.
|
Lasted tested Nov 11, 2022.
|
||||||
|
|
||||||
Results from Google Lighthouse ([Libreddit Report](https://lighthouse-dot-webdotdevsite.appspot.com/lh/html?url=https%3A%2F%2Flibredd.it), [Reddit Report](https://lighthouse-dot-webdotdevsite.appspot.com/lh/html?url=https%3A%2F%2Fwww.reddit.com%2F)).
|
Results from Google PageSpeed Insights ([Libreddit Report](https://pagespeed.web.dev/report?url=https%3A%2F%2Flibreddit.spike.codes%2F), [Reddit Report](https://pagespeed.web.dev/report?url=https://www.reddit.com)).
|
||||||
|
|
||||||
| | Libreddit | Reddit |
|
| | Libreddit | Reddit |
|
||||||
|------------------------|---------------|------------|
|
|------------------------|-------------|-----------|
|
||||||
| Requests | 20 | 70 |
|
| Requests | 60 | 83 |
|
||||||
| Resource Size (card ui)| 1,224 KiB | 1,690 KiB |
|
| Speed Index | 2.0s | 10.4s |
|
||||||
| Time to Interactive | **1.5 s** | **11.2 s** |
|
| Time to Interactive | **2.8s** | **12.4s** |
|
||||||
|
|
||||||
## Privacy
|
## Privacy
|
||||||
|
|
||||||
@ -160,7 +96,7 @@ Results from Google Lighthouse ([Libreddit Report](https://lighthouse-dot-webdot
|
|||||||
- The requested URL
|
- The requested URL
|
||||||
- Search terms
|
- Search terms
|
||||||
|
|
||||||
**Location:** The same privacy policy goes on to describe location data may be collected through the use of:
|
**Location:** The same privacy policy goes on to describe that location data may be collected through the use of:
|
||||||
- GPS (consensual)
|
- GPS (consensual)
|
||||||
- Bluetooth (consensual)
|
- Bluetooth (consensual)
|
||||||
- Content associated with a location (consensual)
|
- Content associated with a location (consensual)
|
||||||
@ -184,7 +120,7 @@ For transparency, I hope to describe all the ways Libreddit handles user privacy
|
|||||||
|
|
||||||
**Cookies:** Libreddit uses optional cookies to store any configured settings in [the settings menu](https://libreddit.spike.codes/settings). These are not cross-site cookies and the cookies hold no personal data.
|
**Cookies:** Libreddit uses optional cookies to store any configured settings in [the settings menu](https://libreddit.spike.codes/settings). These are not cross-site cookies and the cookies hold no personal data.
|
||||||
|
|
||||||
**Hosting:** The official instances are hosted on [Replit](https://replit.com/) which monitors usage to prevent abuse. I can understand if this invalidates certain users' threat models and therefore, selfhosting, using unofficial instances 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, self-hosting, using unofficial instances, and browsing through Tor are welcomed.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -226,14 +162,14 @@ yay -S libreddit-git
|
|||||||
|
|
||||||
## 4) GitHub Releases
|
## 4) GitHub Releases
|
||||||
|
|
||||||
If you're on Linux and none of these methods work for you, you can grab a Linux binary from [the newest release](https://github.com/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/libreddit/libreddit/releases/latest).
|
||||||
|
|
||||||
## 5) Replit/Heroku/Glitch
|
## 5) Replit/Heroku/Glitch
|
||||||
|
|
||||||
**Note:** These are free hosting options but they are *not* private and will monitor server usage to prevent abuse. If you need a free and easy setup, this method may work best for you.
|
**Note:** These are free hosting options but they are *not* private and will monitor server usage to prevent abuse. If you need a free and easy setup, this method may work best for you.
|
||||||
|
|
||||||
<a href="https://repl.it/github/spikecodes/libreddit"><img src="https://repl.it/badge/github/spikecodes/libreddit" alt="Run on Repl.it" height="32" /></a>
|
<a href="https://repl.it/github/libreddit/libreddit"><img src="https://repl.it/badge/github/libreddit/libreddit" alt="Run on Repl.it" height="32" /></a>
|
||||||
[](https://heroku.com/deploy?template=https://github.com/spikecodes/libreddit)
|
[](https://heroku.com/deploy?template=https://github.com/libreddit/libreddit)
|
||||||
[](https://glitch.com/edit/#!/remix/libreddit)
|
[](https://glitch.com/edit/#!/remix/libreddit)
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -252,13 +188,14 @@ Assign a default value for each setting by passing environment variables to Libr
|
|||||||
|
|
||||||
| Name | Possible values | Default value |
|
| Name | Possible values | Default value |
|
||||||
|-------------------------|-----------------------------------------------------------------------------------------------------|---------------|
|
|-------------------------|-----------------------------------------------------------------------------------------------------|---------------|
|
||||||
| `THEME` | `["system", "light", "dark", "black", "dracula", "nord", "laserwave", "violet", "gold", "rosebox"]` | `system` |
|
| `THEME` | `["system", "light", "dark", "black", "dracula", "nord", "laserwave", "violet", "gold", "rosebox", "gruvboxdark", "gruvboxlight"]` | `system` |
|
||||||
| `FRONT_PAGE` | `["default", "popular", "all"]` | `default` |
|
| `FRONT_PAGE` | `["default", "popular", "all"]` | `default` |
|
||||||
| `LAYOUT` | `["card", "clean", "compact"]` | `card` |
|
| `LAYOUT` | `["card", "clean", "compact"]` | `card` |
|
||||||
| `WIDE` | `["on", "off"]` | `off` |
|
| `WIDE` | `["on", "off"]` | `off` |
|
||||||
| `COMMENT_SORT` | `["hot", "new", "top", "rising", "controversial"]` | `hot` |
|
| `POST_SORT` | `["hot", "new", "top", "rising", "controversial"]` | `hot` |
|
||||||
| `POST_SORT` | `["confidence", "top", "new", "controversial", "old"]` | `confidence` |
|
| `COMMENT_SORT` | `["confidence", "top", "new", "controversial", "old"]` | `confidence` |
|
||||||
| `SHOW_NSFW` | `["on", "off"]` | `off` |
|
| `SHOW_NSFW` | `["on", "off"]` | `off` |
|
||||||
|
| `BLUR_NSFW` | `["on", "off"]` | `off` |
|
||||||
| `USE_HLS` | `["on", "off"]` | `off` |
|
| `USE_HLS` | `["on", "off"]` | `off` |
|
||||||
| `HIDE_HLS_NOTIFICATION` | `["on", "off"]` | `off` |
|
| `HIDE_HLS_NOTIFICATION` | `["on", "off"]` | `off` |
|
||||||
| `AUTOPLAY_VIDEOS` | `["on", "off"]` | `off` |
|
| `AUTOPLAY_VIDEOS` | `["on", "off"]` | `off` |
|
||||||
@ -275,7 +212,7 @@ LIBREDDIT_DEFAULT_WIDE=on LIBREDDIT_DEFAULT_THEME=dark libreddit -r
|
|||||||
|
|
||||||
## Proxying using NGINX
|
## Proxying using NGINX
|
||||||
|
|
||||||
**NOTE** If you're [proxying Libreddit through a NGINX Reverse Proxy](https://github.com/spikecodes/libreddit/issues/122#issuecomment-782226853), add
|
**NOTE** If you're [proxying Libreddit through an NGINX Reverse Proxy](https://github.com/libreddit/libreddit/issues/122#issuecomment-782226853), add
|
||||||
```nginx
|
```nginx
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
```
|
```
|
||||||
@ -303,7 +240,7 @@ Before=nginx.service
|
|||||||
## Building
|
## Building
|
||||||
|
|
||||||
```
|
```
|
||||||
git clone https://github.com/spikecodes/libreddit
|
git clone https://github.com/libreddit/libreddit
|
||||||
cd libreddit
|
cd libreddit
|
||||||
cargo run
|
cargo run
|
||||||
```
|
```
|
||||||
|
3
app.json
3
app.json
@ -32,6 +32,9 @@
|
|||||||
"LIBREDDIT_DEFAULT_SHOW_NSFW": {
|
"LIBREDDIT_DEFAULT_SHOW_NSFW": {
|
||||||
"required": false
|
"required": false
|
||||||
},
|
},
|
||||||
|
"LIBREDDIT_DEFAULT_BLUR_NSFW": {
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
"LIBREDDIT_USE_HLS": {
|
"LIBREDDIT_USE_HLS": {
|
||||||
"required": false
|
"required": false
|
||||||
},
|
},
|
||||||
|
@ -1,2 +1,2 @@
|
|||||||
ADDRESS=localhost
|
ADDRESS=0.0.0.0
|
||||||
PORT=12345
|
PORT=12345
|
||||||
|
@ -11,5 +11,27 @@ Environment=PORT=8080
|
|||||||
EnvironmentFile=-/etc/libreddit.conf
|
EnvironmentFile=-/etc/libreddit.conf
|
||||||
ExecStart=/usr/bin/libreddit -a ${ADDRESS} -p ${PORT}
|
ExecStart=/usr/bin/libreddit -a ${ADDRESS} -p ${PORT}
|
||||||
|
|
||||||
|
# Hardening
|
||||||
|
DeviceAllow=
|
||||||
|
LockPersonality=yes
|
||||||
|
MemoryDenyWriteExecute=yes
|
||||||
|
PrivateDevices=yes
|
||||||
|
ProcSubset=pid
|
||||||
|
ProtectClock=yes
|
||||||
|
ProtectControlGroups=yes
|
||||||
|
ProtectHome=yes
|
||||||
|
ProtectHostname=yes
|
||||||
|
ProtectKernelLogs=yes
|
||||||
|
ProtectKernelModules=yes
|
||||||
|
ProtectKernelTunables=yes
|
||||||
|
ProtectProc=invisible
|
||||||
|
RestrictAddressFamilies=AF_INET AF_INET6
|
||||||
|
RestrictNamespaces=yes
|
||||||
|
RestrictRealtime=yes
|
||||||
|
RestrictSUIDSGID=yes
|
||||||
|
SystemCallArchitectures=native
|
||||||
|
SystemCallFilter=@system-service ~@privileged ~@resources
|
||||||
|
UMask=0077
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=default.target
|
WantedBy=default.target
|
||||||
|
15
scripts/gen-credits.sh
Executable file
15
scripts/gen-credits.sh
Executable file
@ -0,0 +1,15 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# This scripts generates the CREDITS file in the repository root, which
|
||||||
|
# contains a list of all contributors ot the Libreddit project.
|
||||||
|
#
|
||||||
|
# We use git-log to surface the names and emails of all authors and committers,
|
||||||
|
# and grep will filter any automated commits due to GitHub.
|
||||||
|
|
||||||
|
set -o pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "${BASH_SOURCE[0]}")/../" || exit 1
|
||||||
|
git --no-pager log --pretty='%an <%ae>%n%cn <%ce>' master \
|
||||||
|
| sort -t'<' -u -k1,1 -k2,2 \
|
||||||
|
| grep -Fv -- 'GitHub <noreply@github.com>' \
|
||||||
|
> CREDITS
|
164
src/client.rs
164
src/client.rs
@ -1,11 +1,55 @@
|
|||||||
use cached::proc_macro::cached;
|
use cached::proc_macro::cached;
|
||||||
use futures_lite::{future::Boxed, FutureExt};
|
use futures_lite::{future::Boxed, FutureExt};
|
||||||
use hyper::{body::Buf, client, Body, Request, Response, Uri};
|
use hyper::{body, body::Buf, client, header, Body, Method, Request, Response, Uri};
|
||||||
|
use libflate::gzip;
|
||||||
|
use percent_encoding::{percent_encode, CONTROLS};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::result::Result;
|
use std::{io, result::Result};
|
||||||
|
|
||||||
|
use crate::dbg_msg;
|
||||||
use crate::server::RequestExt;
|
use crate::server::RequestExt;
|
||||||
|
|
||||||
|
const REDDIT_URL_BASE: &str = "https://www.reddit.com";
|
||||||
|
|
||||||
|
/// Gets the canonical path for a resource on Reddit. This is accomplished by
|
||||||
|
/// making a `HEAD` request to Reddit at the path given in `path`.
|
||||||
|
///
|
||||||
|
/// This function returns `Ok(Some(path))`, where `path`'s value is identical
|
||||||
|
/// to that of the value of the argument `path`, if Reddit responds to our
|
||||||
|
/// `HEAD` request with a 2xx-family HTTP code. It will also return an
|
||||||
|
/// `Ok(Some(String))` if Reddit responds to our `HEAD` request with a
|
||||||
|
/// `Location` header in the response, and the HTTP code is in the 3xx-family;
|
||||||
|
/// the `String` will contain the path as reported in `Location`. The return
|
||||||
|
/// value is `Ok(None)` if Reddit responded with a 3xx, but did not provide a
|
||||||
|
/// `Location` header. An `Err(String)` is returned if Reddit responds with a
|
||||||
|
/// 429, or if we were unable to decode the value in the `Location` header.
|
||||||
|
#[cached(size = 1024, time = 600, result = true)]
|
||||||
|
pub async fn canonical_path(path: String) -> Result<Option<String>, String> {
|
||||||
|
let res = reddit_head(path.clone(), true).await?;
|
||||||
|
|
||||||
|
if res.status() == 429 {
|
||||||
|
return Err("Too many requests.".to_string());
|
||||||
|
};
|
||||||
|
|
||||||
|
// If Reddit responds with a 2xx, then the path is already canonical.
|
||||||
|
if res.status().to_string().starts_with('2') {
|
||||||
|
return Ok(Some(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
// If Reddit responds with anything other than 3xx (except for the 2xx as
|
||||||
|
// above), return a None.
|
||||||
|
if !res.status().to_string().starts_with('3') {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(
|
||||||
|
res
|
||||||
|
.headers()
|
||||||
|
.get(header::LOCATION)
|
||||||
|
.map(|val| percent_encode(val.as_bytes(), CONTROLS).to_string().trim_start_matches(REDDIT_URL_BASE).to_string()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn proxy(req: Request<Body>, format: &str) -> Result<Response<Body>, String> {
|
pub async fn proxy(req: Request<Body>, format: &str) -> Result<Response<Body>, String> {
|
||||||
let mut url = format!("{}?{}", format, req.uri().query().unwrap_or_default());
|
let mut url = format!("{}?{}", format, req.uri().query().unwrap_or_default());
|
||||||
|
|
||||||
@ -61,20 +105,39 @@ async fn stream(url: &str, req: &Request<Body>) -> Result<Response<Body>, String
|
|||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn request(url: String, quarantine: bool) -> Boxed<Result<Response<Body>, String>> {
|
/// Makes a GET request to Reddit at `path`. By default, this will honor HTTP
|
||||||
|
/// 3xx codes Reddit returns and will automatically redirect.
|
||||||
|
fn reddit_get(path: String, quarantine: bool) -> Boxed<Result<Response<Body>, String>> {
|
||||||
|
request(&Method::GET, path, true, quarantine)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Makes a HEAD request to Reddit at `path`. This will not follow redirects.
|
||||||
|
fn reddit_head(path: String, quarantine: bool) -> Boxed<Result<Response<Body>, String>> {
|
||||||
|
request(&Method::HEAD, path, false, quarantine)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Makes a request to Reddit. If `redirect` is `true`, request_with_redirect
|
||||||
|
/// will recurse on the URL that Reddit provides in the Location HTTP header
|
||||||
|
/// in its response.
|
||||||
|
fn request(method: &'static Method, path: String, redirect: bool, quarantine: bool) -> Boxed<Result<Response<Body>, String>> {
|
||||||
|
// Build Reddit URL from path.
|
||||||
|
let url = format!("{}{}", REDDIT_URL_BASE, path);
|
||||||
|
|
||||||
// Prepare the HTTPS connector.
|
// Prepare the HTTPS connector.
|
||||||
let https = hyper_rustls::HttpsConnectorBuilder::new().with_native_roots().https_or_http().enable_http1().build();
|
let https = hyper_rustls::HttpsConnectorBuilder::new().with_native_roots().https_or_http().enable_http1().build();
|
||||||
|
|
||||||
// Construct the hyper client from the HTTPS connector.
|
// Construct the hyper client from the HTTPS connector.
|
||||||
let client: client::Client<_, hyper::Body> = client::Client::builder().build(https);
|
let client: client::Client<_, hyper::Body> = client::Client::builder().build(https);
|
||||||
|
|
||||||
// Build request
|
// Build request to Reddit. When making a GET, request gzip compression.
|
||||||
|
// (Reddit doesn't do brotli yet.)
|
||||||
let builder = Request::builder()
|
let builder = Request::builder()
|
||||||
.method("GET")
|
.method(method)
|
||||||
.uri(&url)
|
.uri(&url)
|
||||||
.header("User-Agent", format!("web:libreddit:{}", env!("CARGO_PKG_VERSION")))
|
.header("User-Agent", format!("web:libreddit:{}", env!("CARGO_PKG_VERSION")))
|
||||||
.header("Host", "www.reddit.com")
|
.header("Host", "www.reddit.com")
|
||||||
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8")
|
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8")
|
||||||
|
.header("Accept-Encoding", if method == Method::GET { "gzip" } else { "identity" })
|
||||||
.header("Accept-Language", "en-US,en;q=0.5")
|
.header("Accept-Language", "en-US,en;q=0.5")
|
||||||
.header("Connection", "keep-alive")
|
.header("Connection", "keep-alive")
|
||||||
.header("Cookie", if quarantine { "_options=%7B%22pref_quarantine_optin%22%3A%20true%7D" } else { "" })
|
.header("Cookie", if quarantine { "_options=%7B%22pref_quarantine_optin%22%3A%20true%7D" } else { "" })
|
||||||
@ -83,26 +146,94 @@ fn request(url: String, quarantine: bool) -> Boxed<Result<Response<Body>, String
|
|||||||
async move {
|
async move {
|
||||||
match builder {
|
match builder {
|
||||||
Ok(req) => match client.request(req).await {
|
Ok(req) => match client.request(req).await {
|
||||||
Ok(response) => {
|
Ok(mut response) => {
|
||||||
|
// Reddit may respond with a 3xx. Decide whether or not to
|
||||||
|
// redirect based on caller params.
|
||||||
if response.status().to_string().starts_with('3') {
|
if response.status().to_string().starts_with('3') {
|
||||||
request(
|
if !redirect {
|
||||||
|
return Ok(response);
|
||||||
|
};
|
||||||
|
|
||||||
|
return request(
|
||||||
|
method,
|
||||||
response
|
response
|
||||||
.headers()
|
.headers()
|
||||||
.get("Location")
|
.get(header::LOCATION)
|
||||||
.map(|val| {
|
.map(|val| {
|
||||||
let new_url = val.to_str().unwrap_or_default();
|
// We need to make adjustments to the URI
|
||||||
format!("{}{}raw_json=1", new_url, if new_url.contains('?') { "&" } else { "?" })
|
// we get back from Reddit. Namely, we
|
||||||
|
// must:
|
||||||
|
//
|
||||||
|
// 1. Remove the authority (e.g.
|
||||||
|
// https://www.reddit.com) that may be
|
||||||
|
// present, so that we recurse on the
|
||||||
|
// path (and query parameters) as
|
||||||
|
// required.
|
||||||
|
//
|
||||||
|
// 2. Percent-encode the path.
|
||||||
|
let new_path = percent_encode(val.as_bytes(), CONTROLS).to_string().trim_start_matches(REDDIT_URL_BASE).to_string();
|
||||||
|
format!("{}{}raw_json=1", new_path, if new_path.contains('?') { "&" } else { "?" })
|
||||||
})
|
})
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.to_string(),
|
.to_string(),
|
||||||
|
true,
|
||||||
quarantine,
|
quarantine,
|
||||||
)
|
)
|
||||||
.await
|
.await;
|
||||||
} else {
|
};
|
||||||
|
|
||||||
|
match response.headers().get(header::CONTENT_ENCODING) {
|
||||||
|
// Content not compressed.
|
||||||
|
None => Ok(response),
|
||||||
|
|
||||||
|
// Content encoded (hopefully with gzip).
|
||||||
|
Some(hdr) => {
|
||||||
|
match hdr.to_str() {
|
||||||
|
Ok(val) => match val {
|
||||||
|
"gzip" => {}
|
||||||
|
"identity" => return Ok(response),
|
||||||
|
_ => return Err("Reddit response was encoded with an unsupported compressor".to_string()),
|
||||||
|
},
|
||||||
|
Err(_) => return Err("Reddit response was invalid".to_string()),
|
||||||
|
}
|
||||||
|
|
||||||
|
// We get here if the body is gzip-compressed.
|
||||||
|
|
||||||
|
// The body must be something that implements
|
||||||
|
// std::io::Read, hence the conversion to
|
||||||
|
// bytes::buf::Buf and then transformation into a
|
||||||
|
// Reader.
|
||||||
|
let mut decompressed: Vec<u8>;
|
||||||
|
{
|
||||||
|
let mut aggregated_body = match body::aggregate(response.body_mut()).await {
|
||||||
|
Ok(b) => b.reader(),
|
||||||
|
Err(e) => return Err(e.to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut decoder = match gzip::Decoder::new(&mut aggregated_body) {
|
||||||
|
Ok(decoder) => decoder,
|
||||||
|
Err(e) => return Err(e.to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
decompressed = Vec::<u8>::new();
|
||||||
|
if let Err(e) = io::copy(&mut decoder, &mut decompressed) {
|
||||||
|
return Err(e.to_string());
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
response.headers_mut().remove(header::CONTENT_ENCODING);
|
||||||
|
response.headers_mut().insert(header::CONTENT_LENGTH, decompressed.len().into());
|
||||||
|
*(response.body_mut()) = Body::from(decompressed);
|
||||||
|
|
||||||
Ok(response)
|
Ok(response)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => Err(e.to_string()),
|
}
|
||||||
|
Err(e) => {
|
||||||
|
dbg_msg!("{} {}: {}", method, path, e);
|
||||||
|
|
||||||
|
Err(e.to_string())
|
||||||
|
}
|
||||||
},
|
},
|
||||||
Err(_) => Err("Post url contains non-ASCII characters".to_string()),
|
Err(_) => Err("Post url contains non-ASCII characters".to_string()),
|
||||||
}
|
}
|
||||||
@ -113,9 +244,6 @@ fn request(url: String, quarantine: bool) -> Boxed<Result<Response<Body>, String
|
|||||||
// Make a request to a Reddit API and parse the JSON response
|
// Make a request to a Reddit API and parse the JSON response
|
||||||
#[cached(size = 100, time = 30, result = true)]
|
#[cached(size = 100, time = 30, result = true)]
|
||||||
pub async fn json(path: String, quarantine: bool) -> Result<Value, String> {
|
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
|
// Closure to quickly build errors
|
||||||
let err = |msg: &str, e: String| -> Result<Value, String> {
|
let err = |msg: &str, e: String| -> Result<Value, String> {
|
||||||
// eprintln!("{} - {}: {}", url, msg, e);
|
// eprintln!("{} - {}: {}", url, msg, e);
|
||||||
@ -123,7 +251,7 @@ pub async fn json(path: String, quarantine: bool) -> Result<Value, String> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Fetch the url...
|
// Fetch the url...
|
||||||
match request(url.clone(), quarantine).await {
|
match reddit_get(path.clone(), quarantine).await {
|
||||||
Ok(response) => {
|
Ok(response) => {
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
|
|
||||||
@ -141,7 +269,7 @@ pub async fn json(path: String, quarantine: bool) -> Result<Value, String> {
|
|||||||
.as_str()
|
.as_str()
|
||||||
.unwrap_or_else(|| {
|
.unwrap_or_else(|| {
|
||||||
json["message"].as_str().unwrap_or_else(|| {
|
json["message"].as_str().unwrap_or_else(|| {
|
||||||
eprintln!("{} - Error parsing reddit error", url);
|
eprintln!("{}{} - Error parsing reddit error", REDDIT_URL_BASE, path);
|
||||||
"Error parsing reddit error"
|
"Error parsing reddit error"
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
67
src/main.rs
67
src/main.rs
@ -11,15 +11,15 @@ mod user;
|
|||||||
mod utils;
|
mod utils;
|
||||||
|
|
||||||
// Import Crates
|
// Import Crates
|
||||||
use clap::{Command, Arg};
|
use clap::{Arg, Command};
|
||||||
|
|
||||||
use futures_lite::FutureExt;
|
use futures_lite::FutureExt;
|
||||||
use hyper::{header::HeaderValue, Body, Request, Response};
|
use hyper::{header::HeaderValue, Body, Request, Response};
|
||||||
|
|
||||||
mod client;
|
mod client;
|
||||||
use client::proxy;
|
use client::{canonical_path, proxy};
|
||||||
use server::RequestExt;
|
use server::RequestExt;
|
||||||
use utils::{error, redirect};
|
use utils::{error, redirect, ThemeAssets};
|
||||||
|
|
||||||
mod server;
|
mod server;
|
||||||
|
|
||||||
@ -85,6 +85,23 @@ async fn resource(body: &str, content_type: &str, cache: bool) -> Result<Respons
|
|||||||
Ok(res)
|
Ok(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn style() -> Result<Response<Body>, String> {
|
||||||
|
let mut res = include_str!("../static/style.css").to_string();
|
||||||
|
for file in ThemeAssets::iter() {
|
||||||
|
res.push('\n');
|
||||||
|
let theme = ThemeAssets::get(file.as_ref()).unwrap();
|
||||||
|
res.push_str(std::str::from_utf8(theme.data.as_ref()).unwrap());
|
||||||
|
}
|
||||||
|
Ok(
|
||||||
|
Response::builder()
|
||||||
|
.status(200)
|
||||||
|
.header("content-type", "text/css")
|
||||||
|
.header("Cache-Control", "public, max-age=1209600, s-maxage=86400")
|
||||||
|
.body(res.to_string().into())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
let matches = Command::new("Libreddit")
|
let matches = Command::new("Libreddit")
|
||||||
@ -95,7 +112,7 @@ async fn main() {
|
|||||||
.short('r')
|
.short('r')
|
||||||
.long("redirect-https")
|
.long("redirect-https")
|
||||||
.help("Redirect all HTTP requests to HTTPS (no longer functional)")
|
.help("Redirect all HTTP requests to HTTPS (no longer functional)")
|
||||||
.takes_value(false),
|
.num_args(0),
|
||||||
)
|
)
|
||||||
.arg(
|
.arg(
|
||||||
Arg::new("address")
|
Arg::new("address")
|
||||||
@ -104,7 +121,7 @@ async fn main() {
|
|||||||
.value_name("ADDRESS")
|
.value_name("ADDRESS")
|
||||||
.help("Sets address to listen on")
|
.help("Sets address to listen on")
|
||||||
.default_value("0.0.0.0")
|
.default_value("0.0.0.0")
|
||||||
.takes_value(true),
|
.num_args(1),
|
||||||
)
|
)
|
||||||
.arg(
|
.arg(
|
||||||
Arg::new("port")
|
Arg::new("port")
|
||||||
@ -113,7 +130,7 @@ async fn main() {
|
|||||||
.value_name("PORT")
|
.value_name("PORT")
|
||||||
.help("Port to listen on")
|
.help("Port to listen on")
|
||||||
.default_value("8080")
|
.default_value("8080")
|
||||||
.takes_value(true),
|
.num_args(1),
|
||||||
)
|
)
|
||||||
.arg(
|
.arg(
|
||||||
Arg::new("hsts")
|
Arg::new("hsts")
|
||||||
@ -122,13 +139,13 @@ async fn main() {
|
|||||||
.value_name("EXPIRE_TIME")
|
.value_name("EXPIRE_TIME")
|
||||||
.help("HSTS header to tell browsers that this site should only be accessed over HTTPS")
|
.help("HSTS header to tell browsers that this site should only be accessed over HTTPS")
|
||||||
.default_value("604800")
|
.default_value("604800")
|
||||||
.takes_value(true),
|
.num_args(1),
|
||||||
)
|
)
|
||||||
.get_matches();
|
.get_matches();
|
||||||
|
|
||||||
let address = matches.value_of("address").unwrap_or("0.0.0.0");
|
let address = matches.get_one("address").map(|m: &String| m.as_str()).unwrap_or("0.0.0.0");
|
||||||
let port = std::env::var("PORT").unwrap_or_else(|_| matches.value_of("port").unwrap_or("8080").to_string());
|
let port = std::env::var("PORT").unwrap_or_else(|_| matches.get_one("port").map(|m: &String| m.as_str()).unwrap_or("8080").to_string());
|
||||||
let hsts = matches.value_of("hsts");
|
let hsts = matches.get_one("hsts").map(|m: &String| m.as_str());
|
||||||
|
|
||||||
let listener = [address, ":", &port].concat();
|
let listener = [address, ":", &port].concat();
|
||||||
|
|
||||||
@ -152,7 +169,7 @@ async fn main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Read static files
|
// Read static files
|
||||||
app.at("/style.css").get(|_| resource(include_str!("../static/style.css"), "text/css", false).boxed());
|
app.at("/style.css").get(|_| style().boxed());
|
||||||
app
|
app
|
||||||
.at("/manifest.json")
|
.at("/manifest.json")
|
||||||
.get(|_| resource(include_str!("../static/manifest.json"), "application/json", false).boxed());
|
.get(|_| resource(include_str!("../static/manifest.json"), "application/json", false).boxed());
|
||||||
@ -221,6 +238,11 @@ async fn main() {
|
|||||||
app.at("/r/:sub/comments/:id").get(|r| post::item(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").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/comments/:id/:title/:comment_id").get(|r| post::item(r).boxed());
|
||||||
|
app.at("/comments/:id").get(|r| post::item(r).boxed());
|
||||||
|
app.at("/comments/:id/comments").get(|r| post::item(r).boxed());
|
||||||
|
app.at("/comments/:id/comments/:comment_id").get(|r| post::item(r).boxed());
|
||||||
|
app.at("/comments/:id/:title").get(|r| post::item(r).boxed());
|
||||||
|
app.at("/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/search").get(|r| search::find(r).boxed());
|
||||||
|
|
||||||
@ -237,9 +259,6 @@ async fn main() {
|
|||||||
|
|
||||||
app.at("/r/:sub/:sort").get(|r| subreddit::community(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
|
// Front page
|
||||||
app.at("/").get(|r| subreddit::community(r).boxed());
|
app.at("/").get(|r| subreddit::community(r).boxed());
|
||||||
|
|
||||||
@ -257,13 +276,25 @@ async fn main() {
|
|||||||
// Handle about pages
|
// Handle about pages
|
||||||
app.at("/about").get(|req| error(req, "About pages aren't added yet".to_string()).boxed());
|
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() {
|
app.at("/:id").get(|req: Request<Body>| {
|
||||||
|
Box::pin(async move {
|
||||||
|
match req.param("id").as_deref() {
|
||||||
// Sort front page
|
// Sort front page
|
||||||
Some("best" | "hot" | "new" | "top" | "rising" | "controversial") => subreddit::community(req).boxed(),
|
Some("best" | "hot" | "new" | "top" | "rising" | "controversial") => subreddit::community(req).await,
|
||||||
|
|
||||||
// Short link for post
|
// Short link for post
|
||||||
Some(id) if id.len() > 4 && id.len() < 7 => post::item(req).boxed(),
|
Some(id) if (5..7).contains(&id.len()) => match canonical_path(format!("/{}", id)).await {
|
||||||
|
Ok(path_opt) => match path_opt {
|
||||||
|
Some(path) => Ok(redirect(path)),
|
||||||
|
None => error(req, "Post ID is invalid. It may point to a post on a community that has been banned.").await,
|
||||||
|
},
|
||||||
|
Err(e) => error(req, e).await,
|
||||||
|
},
|
||||||
|
|
||||||
// Error message for unknown pages
|
// Error message for unknown pages
|
||||||
_ => error(req, "Nothing here".to_string()).boxed(),
|
_ => error(req, "Nothing here".to_string()).await,
|
||||||
|
}
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
// Default service in case no routes match
|
// Default service in case no routes match
|
||||||
|
30
src/post.rs
30
src/post.rs
@ -1,6 +1,5 @@
|
|||||||
// CRATES
|
// CRATES
|
||||||
use crate::client::json;
|
use crate::client::json;
|
||||||
use crate::esc;
|
|
||||||
use crate::server::RequestExt;
|
use crate::server::RequestExt;
|
||||||
use crate::subreddit::{can_access_quarantine, quarantine};
|
use crate::subreddit::{can_access_quarantine, quarantine};
|
||||||
use crate::utils::{
|
use crate::utils::{
|
||||||
@ -13,7 +12,7 @@ use std::collections::HashSet;
|
|||||||
|
|
||||||
// STRUCTS
|
// STRUCTS
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
#[template(path = "post.html", escape = "none")]
|
#[template(path = "post.html")]
|
||||||
struct PostTemplate {
|
struct PostTemplate {
|
||||||
comments: Vec<Comment>,
|
comments: Vec<Comment>,
|
||||||
post: Post,
|
post: Post,
|
||||||
@ -100,15 +99,18 @@ async fn parse_post(json: &serde_json::Value) -> Post {
|
|||||||
let permalink = val(post, "permalink");
|
let permalink = val(post, "permalink");
|
||||||
|
|
||||||
let body = if val(post, "removed_by_category") == "moderator" {
|
let body = if val(post, "removed_by_category") == "moderator" {
|
||||||
format!("<div class=\"md\"><p>[removed] — <a href=\"https://www.reveddit.com{}\">view removed post</a></p></div>", permalink)
|
format!(
|
||||||
|
"<div class=\"md\"><p>[removed] — <a href=\"https://www.unddit.com{}\">view removed post</a></p></div>",
|
||||||
|
permalink
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
rewrite_urls(&val(post, "selftext_html")).replace("\\", "")
|
rewrite_urls(&val(post, "selftext_html"))
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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: esc!(post, "title"),
|
title: val(post, "title"),
|
||||||
community: val(post, "subreddit"),
|
community: val(post, "subreddit"),
|
||||||
body,
|
body,
|
||||||
author: Author {
|
author: Author {
|
||||||
@ -119,7 +121,7 @@ async fn parse_post(json: &serde_json::Value) -> Post {
|
|||||||
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"),
|
text: val(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"),
|
||||||
},
|
},
|
||||||
@ -143,7 +145,7 @@ async fn parse_post(json: &serde_json::Value) -> Post {
|
|||||||
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"),
|
text: val(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()
|
||||||
@ -153,7 +155,8 @@ async fn parse_post(json: &serde_json::Value) -> Post {
|
|||||||
},
|
},
|
||||||
flags: Flags {
|
flags: Flags {
|
||||||
nsfw: post["data"]["over_18"].as_bool().unwrap_or(false),
|
nsfw: post["data"]["over_18"].as_bool().unwrap_or(false),
|
||||||
stickied: post["data"]["stickied"].as_bool().unwrap_or(false),
|
stickied: post["data"]["stickied"].as_bool().unwrap_or(false)
|
||||||
|
|| post["data"]["pinned"].as_bool().unwrap_or(false),
|
||||||
},
|
},
|
||||||
domain: val(post, "domain"),
|
domain: val(post, "domain"),
|
||||||
rel_time,
|
rel_time,
|
||||||
@ -198,10 +201,13 @@ fn parse_comments(json: &serde_json::Value, post_link: &str, post_author: &str,
|
|||||||
let id = val(&comment, "id");
|
let id = val(&comment, "id");
|
||||||
let highlighted = id == highlighted_comment;
|
let highlighted = id == highlighted_comment;
|
||||||
|
|
||||||
let body = if val(&comment, "author") == "[deleted]" && val(&comment, "body") == "[removed]" {
|
let body = if (val(&comment, "author") == "[deleted]" && val(&comment, "body") == "[removed]") || val(&comment, "body") == "[ Removed by Reddit ]" {
|
||||||
format!("<div class=\"md\"><p>[removed] — <a href=\"https://www.reveddit.com{}{}\">view removed comment</a></p></div>", post_link, id)
|
format!(
|
||||||
|
"<div class=\"md\"><p>[removed] — <a href=\"https://www.unddit.com{}{}\">view removed comment</a></p></div>",
|
||||||
|
post_link, id
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
rewrite_urls(&val(&comment, "body_html")).to_string()
|
rewrite_urls(&val(&comment, "body_html"))
|
||||||
};
|
};
|
||||||
|
|
||||||
let author = Author {
|
let author = Author {
|
||||||
@ -212,7 +218,7 @@ fn parse_comments(json: &serde_json::Value, post_link: &str, post_author: &str,
|
|||||||
data["author_flair_richtext"].as_array(),
|
data["author_flair_richtext"].as_array(),
|
||||||
data["author_flair_text"].as_str(),
|
data["author_flair_text"].as_str(),
|
||||||
),
|
),
|
||||||
text: esc!(&comment, "link_flair_text"),
|
text: val(&comment, "link_flair_text"),
|
||||||
background_color: val(&comment, "author_flair_background_color"),
|
background_color: val(&comment, "author_flair_background_color"),
|
||||||
foreground_color: val(&comment, "author_flair_text_color"),
|
foreground_color: val(&comment, "author_flair_text_color"),
|
||||||
},
|
},
|
||||||
|
@ -29,7 +29,7 @@ struct Subreddit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
#[template(path = "search.html", escape = "none")]
|
#[template(path = "search.html")]
|
||||||
struct SearchTemplate {
|
struct SearchTemplate {
|
||||||
posts: Vec<Post>,
|
posts: Vec<Post>,
|
||||||
subreddits: Vec<Subreddit>,
|
subreddits: Vec<Subreddit>,
|
||||||
@ -42,6 +42,8 @@ struct SearchTemplate {
|
|||||||
/// Whether all fetched posts are filtered (to differentiate between no posts fetched in the first place,
|
/// Whether all fetched posts are filtered (to differentiate between no posts fetched in the first place,
|
||||||
/// and all fetched posts being filtered).
|
/// and all fetched posts being filtered).
|
||||||
all_posts_filtered: bool,
|
all_posts_filtered: bool,
|
||||||
|
/// Whether all posts were hidden because they are NSFW (and user has disabled show NSFW)
|
||||||
|
all_posts_hidden_nsfw: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
// SERVICES
|
// SERVICES
|
||||||
@ -100,12 +102,13 @@ pub async fn find(req: Request<Body>) -> Result<Response<Body>, String> {
|
|||||||
url,
|
url,
|
||||||
is_filtered: true,
|
is_filtered: true,
|
||||||
all_posts_filtered: false,
|
all_posts_filtered: false,
|
||||||
|
all_posts_hidden_nsfw: false,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
match Post::fetch(&path, quarantined).await {
|
match Post::fetch(&path, quarantined).await {
|
||||||
Ok((mut posts, after)) => {
|
Ok((mut posts, after)) => {
|
||||||
let all_posts_filtered = filter_posts(&mut posts, &filters);
|
let all_posts_filtered = filter_posts(&mut posts, &filters);
|
||||||
|
let all_posts_hidden_nsfw = posts.iter().all(|p| p.flags.nsfw) && setting(&req, "show_nsfw") != "on";
|
||||||
template(SearchTemplate {
|
template(SearchTemplate {
|
||||||
posts,
|
posts,
|
||||||
subreddits,
|
subreddits,
|
||||||
@ -123,6 +126,7 @@ pub async fn find(req: Request<Body>) -> Result<Response<Body>, String> {
|
|||||||
url,
|
url,
|
||||||
is_filtered: false,
|
is_filtered: false,
|
||||||
all_posts_filtered,
|
all_posts_filtered,
|
||||||
|
all_posts_hidden_nsfw,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
Err(msg) => {
|
Err(msg) => {
|
||||||
|
581
src/server.rs
581
src/server.rs
@ -1,17 +1,80 @@
|
|||||||
|
use brotli::enc::{BrotliCompress, BrotliEncoderParams};
|
||||||
|
use cached::proc_macro::cached;
|
||||||
use cookie::Cookie;
|
use cookie::Cookie;
|
||||||
|
use core::f64;
|
||||||
use futures_lite::{future::Boxed, Future, FutureExt};
|
use futures_lite::{future::Boxed, Future, FutureExt};
|
||||||
use hyper::{
|
use hyper::{
|
||||||
header::HeaderValue,
|
body,
|
||||||
|
body::HttpBody,
|
||||||
|
header,
|
||||||
service::{make_service_fn, service_fn},
|
service::{make_service_fn, service_fn},
|
||||||
HeaderMap,
|
HeaderMap,
|
||||||
};
|
};
|
||||||
use hyper::{Body, Method, Request, Response, Server as HyperServer};
|
use hyper::{Body, Method, Request, Response, Server as HyperServer};
|
||||||
|
use libflate::gzip;
|
||||||
use route_recognizer::{Params, Router};
|
use route_recognizer::{Params, Router};
|
||||||
use std::{pin::Pin, result::Result};
|
use std::{
|
||||||
|
cmp::Ordering,
|
||||||
|
io,
|
||||||
|
pin::Pin,
|
||||||
|
result::Result,
|
||||||
|
str::{from_utf8, Split},
|
||||||
|
string::ToString,
|
||||||
|
};
|
||||||
use time::Duration;
|
use time::Duration;
|
||||||
|
|
||||||
|
use crate::dbg_msg;
|
||||||
|
|
||||||
type BoxResponse = Pin<Box<dyn Future<Output = Result<Response<Body>, String>> + Send>>;
|
type BoxResponse = Pin<Box<dyn Future<Output = Result<Response<Body>, String>> + Send>>;
|
||||||
|
|
||||||
|
/// Compressors for the response Body, in ascending order of preference.
|
||||||
|
#[derive(Copy, Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||||
|
enum CompressionType {
|
||||||
|
Passthrough,
|
||||||
|
Gzip,
|
||||||
|
Brotli,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All browsers support gzip, so if we are given `Accept-Encoding: *`, deliver
|
||||||
|
/// gzipped-content.
|
||||||
|
///
|
||||||
|
/// Brotli would be nice universally, but Safari (iOS, iPhone, macOS) reportedly
|
||||||
|
/// doesn't support it yet.
|
||||||
|
const DEFAULT_COMPRESSOR: CompressionType = CompressionType::Gzip;
|
||||||
|
|
||||||
|
impl CompressionType {
|
||||||
|
/// Returns a `CompressionType` given a content coding
|
||||||
|
/// in [RFC 7231](https://datatracker.ietf.org/doc/html/rfc7231#section-5.3.4)
|
||||||
|
/// format.
|
||||||
|
fn parse(s: &str) -> Option<CompressionType> {
|
||||||
|
let c = match s {
|
||||||
|
// Compressors we support.
|
||||||
|
"gzip" => CompressionType::Gzip,
|
||||||
|
"br" => CompressionType::Brotli,
|
||||||
|
|
||||||
|
// The wildcard means that we can choose whatever
|
||||||
|
// compression we prefer. In this case, use the
|
||||||
|
// default.
|
||||||
|
"*" => DEFAULT_COMPRESSOR,
|
||||||
|
|
||||||
|
// Compressor not supported.
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToString for CompressionType {
|
||||||
|
fn to_string(&self) -> String {
|
||||||
|
match self {
|
||||||
|
CompressionType::Gzip => "gzip".to_string(),
|
||||||
|
CompressionType::Brotli => "br".to_string(),
|
||||||
|
_ => String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct Route<'a> {
|
pub struct Route<'a> {
|
||||||
router: &'a mut Router<fn(Request<Body>) -> BoxResponse>,
|
router: &'a mut Router<fn(Request<Body>) -> BoxResponse>,
|
||||||
path: String,
|
path: String,
|
||||||
@ -97,7 +160,7 @@ impl ResponseExt for Response<Body> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn insert_cookie(&mut self, cookie: Cookie) {
|
fn insert_cookie(&mut self, cookie: Cookie) {
|
||||||
if let Ok(val) = HeaderValue::from_str(&cookie.to_string()) {
|
if let Ok(val) = header::HeaderValue::from_str(&cookie.to_string()) {
|
||||||
self.headers_mut().append("Set-Cookie", val);
|
self.headers_mut().append("Set-Cookie", val);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -106,7 +169,7 @@ impl ResponseExt for Response<Body> {
|
|||||||
let mut cookie = Cookie::named(name);
|
let mut cookie = Cookie::named(name);
|
||||||
cookie.set_path("/");
|
cookie.set_path("/");
|
||||||
cookie.set_max_age(Duration::seconds(1));
|
cookie.set_max_age(Duration::seconds(1));
|
||||||
if let Ok(val) = HeaderValue::from_str(&cookie.to_string()) {
|
if let Ok(val) = header::HeaderValue::from_str(&cookie.to_string()) {
|
||||||
self.headers_mut().append("Set-Cookie", val);
|
self.headers_mut().append("Set-Cookie", val);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -156,10 +219,11 @@ impl Server {
|
|||||||
// let shared_router = router.clone();
|
// let shared_router = router.clone();
|
||||||
async move {
|
async move {
|
||||||
Ok::<_, String>(service_fn(move |req: Request<Body>| {
|
Ok::<_, String>(service_fn(move |req: Request<Body>| {
|
||||||
let headers = default_headers.clone();
|
let req_headers = req.headers().clone();
|
||||||
|
let def_headers = default_headers.clone();
|
||||||
|
|
||||||
// Remove double slashes
|
// Remove double slashes and decode encoded slashes
|
||||||
let mut path = req.uri().path().replace("//", "/");
|
let mut path = req.uri().path().replace("//", "/").replace("%2F", "/");
|
||||||
|
|
||||||
// Remove trailing slashes
|
// Remove trailing slashes
|
||||||
if path != "/" && path.ends_with('/') {
|
if path != "/" && path.ends_with('/') {
|
||||||
@ -176,26 +240,20 @@ impl Server {
|
|||||||
// Run the route's function
|
// Run the route's function
|
||||||
let func = (found.handler().to_owned().to_owned())(parammed);
|
let func = (found.handler().to_owned().to_owned())(parammed);
|
||||||
async move {
|
async move {
|
||||||
let res: Result<Response<Body>, String> = func.await;
|
match func.await {
|
||||||
// Add default headers to response
|
Ok(mut res) => {
|
||||||
res.map(|mut response| {
|
res.headers_mut().extend(def_headers);
|
||||||
response.headers_mut().extend(headers);
|
let _ = compress_response(req_headers, &mut res).await;
|
||||||
response
|
|
||||||
})
|
Ok(res)
|
||||||
|
}
|
||||||
|
Err(msg) => new_boilerplate(def_headers, req_headers, 500, Body::from(msg)).await,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.boxed()
|
.boxed()
|
||||||
}
|
}
|
||||||
// If there was a routing error
|
// If there was a routing error
|
||||||
Err(e) => async move {
|
Err(e) => async move { new_boilerplate(def_headers, req_headers, 404, e.into()).await }.boxed(),
|
||||||
// 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(),
|
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@ -213,3 +271,480 @@ impl Server {
|
|||||||
server.boxed()
|
server.boxed()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create a boilerplate Response for error conditions. This response will be
|
||||||
|
/// compressed if requested by client.
|
||||||
|
async fn new_boilerplate(
|
||||||
|
default_headers: HeaderMap<header::HeaderValue>,
|
||||||
|
req_headers: HeaderMap<header::HeaderValue>,
|
||||||
|
status: u16,
|
||||||
|
body: Body,
|
||||||
|
) -> Result<Response<Body>, String> {
|
||||||
|
match Response::builder().status(status).body(body) {
|
||||||
|
Ok(mut res) => {
|
||||||
|
let _ = compress_response(req_headers, &mut res).await;
|
||||||
|
|
||||||
|
res.headers_mut().extend(default_headers.clone());
|
||||||
|
Ok(res)
|
||||||
|
}
|
||||||
|
Err(msg) => Err(msg.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Determines the desired compressor based on the Accept-Encoding header.
|
||||||
|
///
|
||||||
|
/// This function will honor the [q-value](https://developer.mozilla.org/en-US/docs/Glossary/Quality_values)
|
||||||
|
/// for each compressor. The q-value is an optional parameter, a decimal value
|
||||||
|
/// on \[0..1\], to order the compressors by preference. An Accept-Encoding value
|
||||||
|
/// with no q-values is also accepted.
|
||||||
|
///
|
||||||
|
/// Here are [examples](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Encoding#examples)
|
||||||
|
/// of valid Accept-Encoding headers.
|
||||||
|
///
|
||||||
|
/// ```http
|
||||||
|
/// Accept-Encoding: gzip
|
||||||
|
/// Accept-Encoding: gzip, compress, br
|
||||||
|
/// Accept-Encoding: br;q=1.0, gzip;q=0.8, *;q=0.1
|
||||||
|
/// ```
|
||||||
|
fn determine_compressor(accept_encoding: &str) -> Option<CompressionType> {
|
||||||
|
if accept_encoding.is_empty() {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keep track of the compressor candidate based on both the client's
|
||||||
|
// preference and our own. Concrete examples:
|
||||||
|
//
|
||||||
|
// 1. "Accept-Encoding: gzip, br" => assuming we like brotli more than
|
||||||
|
// gzip, and the browser supports brotli, we choose brotli
|
||||||
|
//
|
||||||
|
// 2. "Accept-Encoding: gzip;q=0.8, br;q=0.3" => the client has stated a
|
||||||
|
// preference for gzip over brotli, so we choose gzip
|
||||||
|
//
|
||||||
|
// To do this, we need to define a struct which contains the requested
|
||||||
|
// requested compressor (abstracted as a CompressionType enum) and the
|
||||||
|
// q-value. If no q-value is defined for the compressor, we assume one of
|
||||||
|
// 1.0. We first compare compressor candidates by comparing q-values, and
|
||||||
|
// then CompressionTypes. We keep track of whatever is the greatest per our
|
||||||
|
// ordering.
|
||||||
|
|
||||||
|
struct CompressorCandidate {
|
||||||
|
alg: CompressionType,
|
||||||
|
q: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Ord for CompressorCandidate {
|
||||||
|
fn cmp(&self, other: &Self) -> Ordering {
|
||||||
|
// Compare q-values. Break ties with the
|
||||||
|
// CompressionType values.
|
||||||
|
|
||||||
|
match self.q.total_cmp(&other.q) {
|
||||||
|
Ordering::Equal => self.alg.cmp(&other.alg),
|
||||||
|
ord => ord,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PartialOrd for CompressorCandidate {
|
||||||
|
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||||
|
// Guard against NAN, both on our end and on the other.
|
||||||
|
if self.q.is_nan() || other.q.is_nan() {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
|
||||||
|
// f64 and CompressionType are ordered, except in the case
|
||||||
|
// where the f64 is NAN (which we checked against), so we
|
||||||
|
// can safely return a Some here.
|
||||||
|
Some(self.cmp(other))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PartialEq for CompressorCandidate {
|
||||||
|
fn eq(&self, other: &Self) -> bool {
|
||||||
|
(self.q == other.q) && (self.alg == other.alg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Eq for CompressorCandidate {}
|
||||||
|
|
||||||
|
// This is the current candidate.
|
||||||
|
//
|
||||||
|
// Assmume no candidate so far. We do this by assigning the sentinel value
|
||||||
|
// of negative infinity to the q-value. If this value is negative infinity,
|
||||||
|
// that means there was no viable compressor candidate.
|
||||||
|
let mut cur_candidate = CompressorCandidate {
|
||||||
|
alg: CompressionType::Passthrough,
|
||||||
|
q: f64::NEG_INFINITY,
|
||||||
|
};
|
||||||
|
|
||||||
|
// This loop reads the requested compressors and keeps track of whichever
|
||||||
|
// one has the highest priority per our heuristic.
|
||||||
|
for val in accept_encoding.to_string().split(',') {
|
||||||
|
let mut q: f64 = 1.0;
|
||||||
|
|
||||||
|
// The compressor and q-value (if the latter is defined)
|
||||||
|
// will be delimited by semicolons.
|
||||||
|
let mut spl: Split<char> = val.split(';');
|
||||||
|
|
||||||
|
// Get the compressor. For example, in
|
||||||
|
// gzip;q=0.8
|
||||||
|
// this grabs "gzip" in the string. It
|
||||||
|
// will further validate the compressor against the
|
||||||
|
// list of those we support. If it is not supported,
|
||||||
|
// we move onto the next one.
|
||||||
|
let compressor: CompressionType = match spl.next() {
|
||||||
|
// CompressionType::parse will return the appropriate enum given
|
||||||
|
// a string. For example, it will return CompressionType::Gzip
|
||||||
|
// when given "gzip".
|
||||||
|
Some(s) => match CompressionType::parse(s.trim()) {
|
||||||
|
Some(candidate) => candidate,
|
||||||
|
|
||||||
|
// We don't support the requested compression algorithm.
|
||||||
|
None => continue,
|
||||||
|
},
|
||||||
|
|
||||||
|
// We should never get here, but I'm paranoid.
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get the q-value. This might not be defined, in which case assume
|
||||||
|
// 1.0.
|
||||||
|
if let Some(s) = spl.next() {
|
||||||
|
if !(s.len() > 2 && s.starts_with("q=")) {
|
||||||
|
// If the q-value is malformed, the header is malformed, so
|
||||||
|
// abort.
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
match s[2..].parse::<f64>() {
|
||||||
|
Ok(val) => {
|
||||||
|
if (0.0..=1.0).contains(&val) {
|
||||||
|
q = val;
|
||||||
|
} else {
|
||||||
|
// If the value is outside [0..1], header is malformed.
|
||||||
|
// Abort.
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
// If this isn't a f64, then assume a malformed header
|
||||||
|
// value and abort.
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// If new_candidate > cur_candidate, make new_candidate the new
|
||||||
|
// cur_candidate. But do this safely! It is very possible that
|
||||||
|
// someone gave us the string "NAN", which (&str).parse::<f64>
|
||||||
|
// will happily translate to f64::NAN.
|
||||||
|
let new_candidate = CompressorCandidate { alg: compressor, q };
|
||||||
|
if let Some(ord) = new_candidate.partial_cmp(&cur_candidate) {
|
||||||
|
if ord == Ordering::Greater {
|
||||||
|
cur_candidate = new_candidate;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if cur_candidate.q != f64::NEG_INFINITY {
|
||||||
|
Some(cur_candidate.alg)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compress the response body, if possible or desirable. The Body will be
|
||||||
|
/// compressed in place, and a new header Content-Encoding will be set
|
||||||
|
/// indicating the compression algorithm.
|
||||||
|
///
|
||||||
|
/// This function deems Body eligible compression if and only if the following
|
||||||
|
/// conditions are met:
|
||||||
|
///
|
||||||
|
/// 1. the HTTP client requests a compression encoding in the Content-Encoding
|
||||||
|
/// header (hence the need for the req_headers);
|
||||||
|
///
|
||||||
|
/// 2. the content encoding corresponds to a compression algorithm we support;
|
||||||
|
///
|
||||||
|
/// 3. the Media type in the Content-Type response header is text with any
|
||||||
|
/// subtype (e.g. text/plain) or application/json.
|
||||||
|
///
|
||||||
|
/// compress_response returns Ok on successful compression, or if not all three
|
||||||
|
/// conditions above are met. It returns Err if there was a problem decoding
|
||||||
|
/// any header in either req_headers or res, but res will remain intact.
|
||||||
|
///
|
||||||
|
/// This function logs errors to stderr, but only in debug mode. No information
|
||||||
|
/// is logged in release builds.
|
||||||
|
async fn compress_response(req_headers: HeaderMap<header::HeaderValue>, res: &mut Response<Body>) -> Result<(), String> {
|
||||||
|
// Check if the data is eligible for compression.
|
||||||
|
if let Some(hdr) = res.headers().get(header::CONTENT_TYPE) {
|
||||||
|
match from_utf8(hdr.as_bytes()) {
|
||||||
|
Ok(val) => {
|
||||||
|
let s = val.to_string();
|
||||||
|
|
||||||
|
// TODO: better determination of what is eligible for compression
|
||||||
|
if !(s.starts_with("text/") || s.starts_with("application/json")) {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
dbg_msg!(e);
|
||||||
|
return Err(e.to_string());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Response declares no Content-Type. Assume for simplicity that it
|
||||||
|
// cannot be compressed.
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
// Don't bother if the size of the size of the response body will fit
|
||||||
|
// within an IP frame (less the bytes that make up the TCP/IP and HTTP
|
||||||
|
// headers).
|
||||||
|
if res.body().size_hint().lower() < 1452 {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
// Quick and dirty closure for extracting a header from the request and
|
||||||
|
// returning it as a &str.
|
||||||
|
let get_req_header = |k: header::HeaderName| -> Option<&str> {
|
||||||
|
match req_headers.get(k) {
|
||||||
|
Some(hdr) => match from_utf8(hdr.as_bytes()) {
|
||||||
|
Ok(val) => Some(val),
|
||||||
|
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
Err(e) => {
|
||||||
|
dbg_msg!(e);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(debug_assertions))]
|
||||||
|
Err(_) => None,
|
||||||
|
},
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check to see which compressor is requested, and if we can use it.
|
||||||
|
let accept_encoding: &str = match get_req_header(header::ACCEPT_ENCODING) {
|
||||||
|
Some(val) => val,
|
||||||
|
None => return Ok(()), // Client requested no compression.
|
||||||
|
};
|
||||||
|
|
||||||
|
let compressor: CompressionType = match determine_compressor(accept_encoding) {
|
||||||
|
Some(c) => c,
|
||||||
|
None => return Ok(()),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get the body from the response.
|
||||||
|
let body_bytes: Vec<u8> = match body::to_bytes(res.body_mut()).await {
|
||||||
|
Ok(b) => b.to_vec(),
|
||||||
|
Err(e) => {
|
||||||
|
dbg_msg!(e);
|
||||||
|
return Err(e.to_string());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Compress!
|
||||||
|
match compress_body(compressor, body_bytes) {
|
||||||
|
Ok(compressed) => {
|
||||||
|
// We get here iff the compression was successful. Replace the body
|
||||||
|
// with the compressed payload, and add the appropriate
|
||||||
|
// Content-Encoding header in the response.
|
||||||
|
res.headers_mut().insert(header::CONTENT_ENCODING, compressor.to_string().parse().unwrap());
|
||||||
|
*(res.body_mut()) = Body::from(compressed);
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compresses a `Vec<u8>` given a [`CompressionType`].
|
||||||
|
///
|
||||||
|
/// This is a helper function for [`compress_response`] and should not be
|
||||||
|
/// called directly.
|
||||||
|
|
||||||
|
// I've chosen a TTL of 600 (== 10 minutes) since compression is
|
||||||
|
// computationally expensive and we don't want to be doing it often. This is
|
||||||
|
// larger than client::json's TTL, but that's okay, because if client::json
|
||||||
|
// returns a new serde_json::Value, body_bytes changes, so this function will
|
||||||
|
// execute again.
|
||||||
|
#[cached(size = 100, time = 600, result = true)]
|
||||||
|
fn compress_body(compressor: CompressionType, body_bytes: Vec<u8>) -> Result<Vec<u8>, String> {
|
||||||
|
// io::Cursor implements io::Read, required for our encoders.
|
||||||
|
let mut reader = io::Cursor::new(body_bytes);
|
||||||
|
|
||||||
|
let compressed: Vec<u8> = match compressor {
|
||||||
|
CompressionType::Gzip => {
|
||||||
|
let mut gz: gzip::Encoder<Vec<u8>> = match gzip::Encoder::new(Vec::new()) {
|
||||||
|
Ok(gz) => gz,
|
||||||
|
Err(e) => {
|
||||||
|
dbg_msg!(e);
|
||||||
|
return Err(e.to_string());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match io::copy(&mut reader, &mut gz) {
|
||||||
|
Ok(_) => match gz.finish().into_result() {
|
||||||
|
Ok(compressed) => compressed,
|
||||||
|
Err(e) => {
|
||||||
|
dbg_msg!(e);
|
||||||
|
return Err(e.to_string());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
dbg_msg!(e);
|
||||||
|
return Err(e.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CompressionType::Brotli => {
|
||||||
|
// We may want to make the compression parameters configurable
|
||||||
|
// in the future. For now, the defaults are sufficient.
|
||||||
|
let brotli_params = BrotliEncoderParams::default();
|
||||||
|
|
||||||
|
let mut compressed = Vec::<u8>::new();
|
||||||
|
match BrotliCompress(&mut reader, &mut compressed, &brotli_params) {
|
||||||
|
Ok(_) => compressed,
|
||||||
|
Err(e) => {
|
||||||
|
dbg_msg!(e);
|
||||||
|
return Err(e.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// This arm is for any requested compressor for which we don't yet
|
||||||
|
// have an implementation.
|
||||||
|
_ => {
|
||||||
|
let msg = "unsupported compressor".to_string();
|
||||||
|
return Err(msg);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(compressed)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use brotli::Decompressor as BrotliDecompressor;
|
||||||
|
use futures_lite::future::block_on;
|
||||||
|
use lipsum::lipsum;
|
||||||
|
use std::{boxed::Box, io};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_determine_compressor() {
|
||||||
|
// Single compressor given.
|
||||||
|
assert_eq!(determine_compressor("unsupported"), None);
|
||||||
|
assert_eq!(determine_compressor("gzip"), Some(CompressionType::Gzip));
|
||||||
|
assert_eq!(determine_compressor("*"), Some(DEFAULT_COMPRESSOR));
|
||||||
|
|
||||||
|
// Multiple compressors.
|
||||||
|
assert_eq!(determine_compressor("gzip, br"), Some(CompressionType::Brotli));
|
||||||
|
assert_eq!(determine_compressor("gzip;q=0.8, br;q=0.3"), Some(CompressionType::Gzip));
|
||||||
|
assert_eq!(determine_compressor("br, gzip"), Some(CompressionType::Brotli));
|
||||||
|
assert_eq!(determine_compressor("br;q=0.3, gzip;q=0.4"), Some(CompressionType::Gzip));
|
||||||
|
|
||||||
|
// Invalid q-values.
|
||||||
|
assert_eq!(determine_compressor("gzip;q=NAN"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_compress_response() {
|
||||||
|
// This macro generates an Accept-Encoding header value given any number of
|
||||||
|
// compressors.
|
||||||
|
macro_rules! ae_gen {
|
||||||
|
($x:expr) => {
|
||||||
|
$x.to_string().as_str()
|
||||||
|
};
|
||||||
|
|
||||||
|
($x:expr, $($y:expr),+) => {
|
||||||
|
format!("{}, {}", $x.to_string(), ae_gen!($($y),+)).as_str()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
for accept_encoding in [
|
||||||
|
"*",
|
||||||
|
ae_gen!(CompressionType::Gzip),
|
||||||
|
ae_gen!(CompressionType::Brotli, CompressionType::Gzip),
|
||||||
|
ae_gen!(CompressionType::Brotli),
|
||||||
|
] {
|
||||||
|
// Determine what the expected encoding should be based on both the
|
||||||
|
// specific encodings we accept.
|
||||||
|
let expected_encoding: CompressionType = match determine_compressor(accept_encoding) {
|
||||||
|
Some(s) => s,
|
||||||
|
None => panic!("determine_compressor(accept_encoding) => None"),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build headers with our Accept-Encoding.
|
||||||
|
let mut req_headers = HeaderMap::new();
|
||||||
|
req_headers.insert(header::ACCEPT_ENCODING, header::HeaderValue::from_str(accept_encoding).unwrap());
|
||||||
|
|
||||||
|
// Build test response.
|
||||||
|
let lorem_ipsum: String = lipsum(10000);
|
||||||
|
let expected_lorem_ipsum = Vec::<u8>::from(lorem_ipsum.as_str());
|
||||||
|
let mut res = Response::builder()
|
||||||
|
.status(200)
|
||||||
|
.header(header::CONTENT_TYPE, "text/plain")
|
||||||
|
.body(Body::from(lorem_ipsum))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Perform the compression.
|
||||||
|
if let Err(e) = block_on(compress_response(req_headers, &mut res)) {
|
||||||
|
panic!("compress_response(req_headers, &mut res) => Err(\"{}\")", e);
|
||||||
|
};
|
||||||
|
|
||||||
|
// If the content was compressed, we expect the Content-Encoding
|
||||||
|
// header to be modified.
|
||||||
|
assert_eq!(
|
||||||
|
res
|
||||||
|
.headers()
|
||||||
|
.get(header::CONTENT_ENCODING)
|
||||||
|
.unwrap_or_else(|| panic!("missing content-encoding header"))
|
||||||
|
.to_str()
|
||||||
|
.unwrap_or_else(|_| panic!("failed to convert Content-Encoding header::HeaderValue to String")),
|
||||||
|
expected_encoding.to_string()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Decompress body and make sure it's equal to what we started
|
||||||
|
// with.
|
||||||
|
//
|
||||||
|
// In the case of no compression, just make sure the "new" body in
|
||||||
|
// the Response is the same as what with which we start.
|
||||||
|
let body_vec = match block_on(body::to_bytes(res.body_mut())) {
|
||||||
|
Ok(b) => b.to_vec(),
|
||||||
|
Err(e) => panic!("{}", e),
|
||||||
|
};
|
||||||
|
|
||||||
|
if expected_encoding == CompressionType::Passthrough {
|
||||||
|
assert!(body_vec.eq(&expected_lorem_ipsum));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// This provides an io::Read for the underlying body.
|
||||||
|
let mut body_cursor: io::Cursor<Vec<u8>> = io::Cursor::new(body_vec);
|
||||||
|
|
||||||
|
// Match the appropriate decompresor for the given
|
||||||
|
// expected_encoding.
|
||||||
|
let mut decoder: Box<dyn io::Read> = match expected_encoding {
|
||||||
|
CompressionType::Gzip => match gzip::Decoder::new(&mut body_cursor) {
|
||||||
|
Ok(dgz) => Box::new(dgz),
|
||||||
|
Err(e) => panic!("{}", e),
|
||||||
|
},
|
||||||
|
|
||||||
|
CompressionType::Brotli => Box::new(BrotliDecompressor::new(body_cursor, expected_lorem_ipsum.len())),
|
||||||
|
|
||||||
|
_ => panic!("no decompressor for {}", expected_encoding.to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut decompressed = Vec::<u8>::new();
|
||||||
|
match io::copy(&mut decoder, &mut decompressed) {
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => panic!("{}", e),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(decompressed.eq(&expected_lorem_ipsum));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -19,7 +19,7 @@ struct SettingsTemplate {
|
|||||||
|
|
||||||
// CONSTANTS
|
// CONSTANTS
|
||||||
|
|
||||||
const PREFS: [&str; 10] = [
|
const PREFS: [&str; 11] = [
|
||||||
"theme",
|
"theme",
|
||||||
"front_page",
|
"front_page",
|
||||||
"layout",
|
"layout",
|
||||||
@ -27,6 +27,7 @@ const PREFS: [&str; 10] = [
|
|||||||
"comment_sort",
|
"comment_sort",
|
||||||
"post_sort",
|
"post_sort",
|
||||||
"show_nsfw",
|
"show_nsfw",
|
||||||
|
"blur_nsfw",
|
||||||
"use_hls",
|
"use_hls",
|
||||||
"hide_hls_notification",
|
"hide_hls_notification",
|
||||||
"autoplay_videos",
|
"autoplay_videos",
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
// CRATES
|
// CRATES
|
||||||
use crate::esc;
|
|
||||||
use crate::utils::{
|
use crate::utils::{
|
||||||
catch_random, error, filter_posts, format_num, format_url, get_filters, param, redirect, rewrite_urls, setting, template, val, Post, Preferences, Subreddit,
|
catch_random, error, filter_posts, format_num, format_url, get_filters, param, redirect, rewrite_urls, setting, template, val, Post, Preferences, Subreddit,
|
||||||
};
|
};
|
||||||
@ -11,7 +10,7 @@ use time::{Duration, OffsetDateTime};
|
|||||||
|
|
||||||
// STRUCTS
|
// STRUCTS
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
#[template(path = "subreddit.html", escape = "none")]
|
#[template(path = "subreddit.html")]
|
||||||
struct SubredditTemplate {
|
struct SubredditTemplate {
|
||||||
sub: Subreddit,
|
sub: Subreddit,
|
||||||
posts: Vec<Post>,
|
posts: Vec<Post>,
|
||||||
@ -25,10 +24,12 @@ struct SubredditTemplate {
|
|||||||
/// Whether all fetched posts are filtered (to differentiate between no posts fetched in the first place,
|
/// Whether all fetched posts are filtered (to differentiate between no posts fetched in the first place,
|
||||||
/// and all fetched posts being filtered).
|
/// and all fetched posts being filtered).
|
||||||
all_posts_filtered: bool,
|
all_posts_filtered: bool,
|
||||||
|
/// Whether all posts were hidden because they are NSFW (and user has disabled show NSFW)
|
||||||
|
all_posts_hidden_nsfw: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
#[template(path = "wiki.html", escape = "none")]
|
#[template(path = "wiki.html")]
|
||||||
struct WikiTemplate {
|
struct WikiTemplate {
|
||||||
sub: String,
|
sub: String,
|
||||||
wiki: String,
|
wiki: String,
|
||||||
@ -38,7 +39,7 @@ struct WikiTemplate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
#[template(path = "wall.html", escape = "none")]
|
#[template(path = "wall.html")]
|
||||||
struct WallTemplate {
|
struct WallTemplate {
|
||||||
title: String,
|
title: String,
|
||||||
sub: String,
|
sub: String,
|
||||||
@ -97,7 +98,7 @@ pub async fn community(req: Request<Body>) -> Result<Response<Body>, String> {
|
|||||||
|
|
||||||
let path = format!("/r/{}/{}.json?{}&raw_json=1", sub_name.clone(), sort, req.uri().query().unwrap_or_default());
|
let path = format!("/r/{}/{}.json?{}&raw_json=1", sub_name.clone(), sort, req.uri().query().unwrap_or_default());
|
||||||
let url = String::from(req.uri().path_and_query().map_or("", |val| val.as_str()));
|
let url = String::from(req.uri().path_and_query().map_or("", |val| val.as_str()));
|
||||||
let redirect_url = url[1..].replace('?', "%3F").replace('&', "%26");
|
let redirect_url = url[1..].replace('?', "%3F").replace('&', "%26").replace('+', "%2B");
|
||||||
let filters = get_filters(&req);
|
let filters = get_filters(&req);
|
||||||
|
|
||||||
// If all requested subs are filtered, we don't need to fetch posts.
|
// If all requested subs are filtered, we don't need to fetch posts.
|
||||||
@ -112,12 +113,13 @@ pub async fn community(req: Request<Body>) -> Result<Response<Body>, String> {
|
|||||||
redirect_url,
|
redirect_url,
|
||||||
is_filtered: true,
|
is_filtered: true,
|
||||||
all_posts_filtered: false,
|
all_posts_filtered: false,
|
||||||
|
all_posts_hidden_nsfw: false,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
match Post::fetch(&path, quarantined).await {
|
match Post::fetch(&path, quarantined).await {
|
||||||
Ok((mut posts, after)) => {
|
Ok((mut posts, after)) => {
|
||||||
let all_posts_filtered = filter_posts(&mut posts, &filters);
|
let all_posts_filtered = filter_posts(&mut posts, &filters);
|
||||||
|
let all_posts_hidden_nsfw = posts.iter().all(|p| p.flags.nsfw) && setting(&req, "show_nsfw") != "on";
|
||||||
template(SubredditTemplate {
|
template(SubredditTemplate {
|
||||||
sub,
|
sub,
|
||||||
posts,
|
posts,
|
||||||
@ -128,6 +130,7 @@ pub async fn community(req: Request<Body>) -> Result<Response<Body>, String> {
|
|||||||
redirect_url,
|
redirect_url,
|
||||||
is_filtered: false,
|
is_filtered: false,
|
||||||
all_posts_filtered,
|
all_posts_filtered,
|
||||||
|
all_posts_hidden_nsfw,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
Err(msg) => match msg.as_str() {
|
Err(msg) => match msg.as_str() {
|
||||||
@ -336,10 +339,10 @@ pub async fn sidebar(req: Request<Body>) -> Result<Response<Body>, String> {
|
|||||||
match json(path, quarantined).await {
|
match json(path, quarantined).await {
|
||||||
// If success, receive JSON in response
|
// If success, receive JSON in response
|
||||||
Ok(response) => template(WikiTemplate {
|
Ok(response) => template(WikiTemplate {
|
||||||
wiki: rewrite_urls(&val(&response, "description_html").replace("\\", "")),
|
wiki: rewrite_urls(&val(&response, "description_html")),
|
||||||
// wiki: format!(
|
// wiki: format!(
|
||||||
// "{}<hr><h1>Moderators</h1><br><ul>{}</ul>",
|
// "{}<hr><h1>Moderators</h1><br><ul>{}</ul>",
|
||||||
// rewrite_urls(&val(&response, "description_html").replace("\\", "")),
|
// rewrite_urls(&val(&response, "description_html"),
|
||||||
// moderators(&sub, quarantined).await.unwrap_or(vec!["Could not fetch moderators".to_string()]).join(""),
|
// moderators(&sub, quarantined).await.unwrap_or(vec!["Could not fetch moderators".to_string()]).join(""),
|
||||||
// ),
|
// ),
|
||||||
sub,
|
sub,
|
||||||
@ -408,10 +411,10 @@ async fn subreddit(sub: &str, quarantined: bool) -> Result<Subreddit, String> {
|
|||||||
let icon = if community_icon.is_empty() { val(&res, "icon_img") } else { community_icon.to_string() };
|
let icon = if community_icon.is_empty() { val(&res, "icon_img") } else { community_icon.to_string() };
|
||||||
|
|
||||||
Ok(Subreddit {
|
Ok(Subreddit {
|
||||||
name: esc!(&res, "display_name"),
|
name: val(&res, "display_name"),
|
||||||
title: esc!(&res, "title"),
|
title: val(&res, "title"),
|
||||||
description: esc!(&res, "public_description"),
|
description: val(&res, "public_description"),
|
||||||
info: rewrite_urls(&val(&res, "description_html").replace("\\", "")),
|
info: rewrite_urls(&val(&res, "description_html")),
|
||||||
// moderators: moderators_list(sub, quarantined).await.unwrap_or_default(),
|
// moderators: moderators_list(sub, quarantined).await.unwrap_or_default(),
|
||||||
icon: format_url(&icon),
|
icon: format_url(&icon),
|
||||||
members: format_num(members),
|
members: format_num(members),
|
||||||
|
17
src/user.rs
17
src/user.rs
@ -1,15 +1,14 @@
|
|||||||
// CRATES
|
// CRATES
|
||||||
use crate::client::json;
|
use crate::client::json;
|
||||||
use crate::esc;
|
|
||||||
use crate::server::RequestExt;
|
use crate::server::RequestExt;
|
||||||
use crate::utils::{error, filter_posts, format_url, get_filters, param, template, Post, Preferences, User};
|
use crate::utils::{error, filter_posts, format_url, get_filters, param, setting, template, Post, Preferences, User};
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
use hyper::{Body, Request, Response};
|
use hyper::{Body, Request, Response};
|
||||||
use time::{OffsetDateTime, macros::format_description};
|
use time::{macros::format_description, OffsetDateTime};
|
||||||
|
|
||||||
// STRUCTS
|
// STRUCTS
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
#[template(path = "user.html", escape = "none")]
|
#[template(path = "user.html")]
|
||||||
struct UserTemplate {
|
struct UserTemplate {
|
||||||
user: User,
|
user: User,
|
||||||
posts: Vec<Post>,
|
posts: Vec<Post>,
|
||||||
@ -25,6 +24,8 @@ struct UserTemplate {
|
|||||||
/// Whether all fetched posts are filtered (to differentiate between no posts fetched in the first place,
|
/// Whether all fetched posts are filtered (to differentiate between no posts fetched in the first place,
|
||||||
/// and all fetched posts being filtered).
|
/// and all fetched posts being filtered).
|
||||||
all_posts_filtered: bool,
|
all_posts_filtered: bool,
|
||||||
|
/// Whether all posts were hidden because they are NSFW (and user has disabled show NSFW)
|
||||||
|
all_posts_hidden_nsfw: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
// FUNCTIONS
|
// FUNCTIONS
|
||||||
@ -59,13 +60,14 @@ pub async fn profile(req: Request<Body>) -> Result<Response<Body>, String> {
|
|||||||
redirect_url,
|
redirect_url,
|
||||||
is_filtered: true,
|
is_filtered: true,
|
||||||
all_posts_filtered: false,
|
all_posts_filtered: false,
|
||||||
|
all_posts_hidden_nsfw: false,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
// Request user posts/comments from Reddit
|
// Request user posts/comments from Reddit
|
||||||
match Post::fetch(&path, false).await {
|
match Post::fetch(&path, false).await {
|
||||||
Ok((mut posts, after)) => {
|
Ok((mut posts, after)) => {
|
||||||
let all_posts_filtered = filter_posts(&mut posts, &filters);
|
let all_posts_filtered = filter_posts(&mut posts, &filters);
|
||||||
|
let all_posts_hidden_nsfw = posts.iter().all(|p| p.flags.nsfw) && setting(&req, "show_nsfw") != "on";
|
||||||
template(UserTemplate {
|
template(UserTemplate {
|
||||||
user,
|
user,
|
||||||
posts,
|
posts,
|
||||||
@ -77,6 +79,7 @@ pub async fn profile(req: Request<Body>) -> Result<Response<Body>, String> {
|
|||||||
redirect_url,
|
redirect_url,
|
||||||
is_filtered: false,
|
is_filtered: false,
|
||||||
all_posts_filtered,
|
all_posts_filtered,
|
||||||
|
all_posts_hidden_nsfw,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// If there is an error show error page
|
// If there is an error show error page
|
||||||
@ -102,11 +105,11 @@ async fn user(name: &str) -> Result<User, String> {
|
|||||||
// Parse the JSON output into a User struct
|
// Parse the JSON output into a User struct
|
||||||
User {
|
User {
|
||||||
name: res["data"]["name"].as_str().unwrap_or(name).to_owned(),
|
name: res["data"]["name"].as_str().unwrap_or(name).to_owned(),
|
||||||
title: esc!(about("title")),
|
title: about("title"),
|
||||||
icon: format_url(&about("icon_img")),
|
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: created.format(format_description!("[month repr:short] [day] '[year repr:last_two]")).unwrap_or_default(),
|
created: created.format(format_description!("[month repr:short] [day] '[year repr:last_two]")).unwrap_or_default(),
|
||||||
banner: esc!(about("banner_img")),
|
banner: about("banner_img"),
|
||||||
description: about("public_description"),
|
description: about("public_description"),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
140
src/utils.rs
140
src/utils.rs
@ -1,17 +1,33 @@
|
|||||||
//
|
//
|
||||||
// CRATES
|
// CRATES
|
||||||
//
|
//
|
||||||
use crate::{client::json, esc, server::RequestExt};
|
use crate::{client::json, server::RequestExt};
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
use cookie::Cookie;
|
use cookie::Cookie;
|
||||||
use hyper::{Body, Request, Response};
|
use hyper::{Body, Request, Response};
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
|
use rust_embed::RustEmbed;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use time::{Duration, OffsetDateTime, macros::format_description};
|
use time::{macros::format_description, Duration, OffsetDateTime};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
|
/// Write a message to stderr on debug mode. This function is a no-op on
|
||||||
|
/// release code.
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! dbg_msg {
|
||||||
|
($x:expr) => {
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
eprintln!("{}:{}: {}", file!(), line!(), $x.to_string())
|
||||||
|
};
|
||||||
|
|
||||||
|
($($x:expr),+) => {
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
dbg_msg!(format!($($x),+))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// 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>,
|
||||||
@ -41,7 +57,7 @@ impl FlairPart {
|
|||||||
Self {
|
Self {
|
||||||
flair_part_type: value("e").to_string(),
|
flair_part_type: value("e").to_string(),
|
||||||
value: match value("e") {
|
value: match value("e") {
|
||||||
"text" => esc!(value("t")),
|
"text" => value("t").to_string(),
|
||||||
"emoji" => format_url(value("u")),
|
"emoji" => format_url(value("u")),
|
||||||
_ => String::new(),
|
_ => String::new(),
|
||||||
},
|
},
|
||||||
@ -54,7 +70,7 @@ impl FlairPart {
|
|||||||
"text" => match text_flair {
|
"text" => match text_flair {
|
||||||
Some(text) => vec![Self {
|
Some(text) => vec![Self {
|
||||||
flair_part_type: "text".to_string(),
|
flair_part_type: "text".to_string(),
|
||||||
value: esc!(text),
|
value: text.to_string(),
|
||||||
}],
|
}],
|
||||||
None => Vec::new(),
|
None => Vec::new(),
|
||||||
},
|
},
|
||||||
@ -217,24 +233,19 @@ pub struct Post {
|
|||||||
impl Post {
|
impl Post {
|
||||||
// Fetch posts of a user or subreddit and return a vector of posts and the "after" value
|
// Fetch posts of a user or subreddit and return a vector of posts and the "after" value
|
||||||
pub async fn fetch(path: &str, quarantine: bool) -> Result<(Vec<Self>, String), String> {
|
pub async fn fetch(path: &str, quarantine: bool) -> Result<(Vec<Self>, String), String> {
|
||||||
let res;
|
|
||||||
let post_list;
|
|
||||||
|
|
||||||
// Send a request to the url
|
// Send a request to the url
|
||||||
match json(path.to_string(), quarantine).await {
|
let res = match json(path.to_string(), quarantine).await {
|
||||||
// If success, receive JSON in response
|
// If success, receive JSON in response
|
||||||
Ok(response) => {
|
Ok(response) => response,
|
||||||
res = response;
|
|
||||||
}
|
|
||||||
// If the Reddit API returns an error, exit this function
|
// If the Reddit API returns an error, exit this function
|
||||||
Err(msg) => return Err(msg),
|
Err(msg) => return Err(msg),
|
||||||
}
|
};
|
||||||
|
|
||||||
// Fetch the list of posts from the JSON response
|
// Fetch the list of posts from the JSON response
|
||||||
match res["data"]["children"].as_array() {
|
let post_list = match res["data"]["children"].as_array() {
|
||||||
Some(list) => post_list = list,
|
Some(list) => list,
|
||||||
None => return Err("No posts found".to_string()),
|
None => return Err("No posts found".to_string()),
|
||||||
}
|
};
|
||||||
|
|
||||||
let mut posts: Vec<Self> = Vec::new();
|
let mut posts: Vec<Self> = Vec::new();
|
||||||
|
|
||||||
@ -245,7 +256,7 @@ impl Post {
|
|||||||
let (rel_time, created) = time(data["created_utc"].as_f64().unwrap_or_default());
|
let (rel_time, created) = time(data["created_utc"].as_f64().unwrap_or_default());
|
||||||
let score = data["score"].as_i64().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 ratio: f64 = data["upvote_ratio"].as_f64().unwrap_or(1.0) * 100.0;
|
||||||
let title = esc!(post, "title");
|
let title = val(post, "title");
|
||||||
|
|
||||||
// Determine the type of media along with the media URL
|
// Determine the type of media along with the media URL
|
||||||
let (post_type, media, gallery) = Media::parse(data).await;
|
let (post_type, media, gallery) = Media::parse(data).await;
|
||||||
@ -270,7 +281,7 @@ impl Post {
|
|||||||
data["author_flair_richtext"].as_array(),
|
data["author_flair_richtext"].as_array(),
|
||||||
data["author_flair_text"].as_str(),
|
data["author_flair_text"].as_str(),
|
||||||
),
|
),
|
||||||
text: esc!(post, "link_flair_text"),
|
text: val(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"),
|
||||||
},
|
},
|
||||||
@ -298,7 +309,7 @@ impl Post {
|
|||||||
data["link_flair_richtext"].as_array(),
|
data["link_flair_richtext"].as_array(),
|
||||||
data["link_flair_text"].as_str(),
|
data["link_flair_text"].as_str(),
|
||||||
),
|
),
|
||||||
text: esc!(post, "link_flair_text"),
|
text: val(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()
|
||||||
@ -308,7 +319,7 @@ impl Post {
|
|||||||
},
|
},
|
||||||
flags: Flags {
|
flags: Flags {
|
||||||
nsfw: data["over_18"].as_bool().unwrap_or_default(),
|
nsfw: data["over_18"].as_bool().unwrap_or_default(),
|
||||||
stickied: data["stickied"].as_bool().unwrap_or_default(),
|
stickied: data["stickied"].as_bool().unwrap_or_default() || data["pinned"].as_bool().unwrap_or_default(),
|
||||||
},
|
},
|
||||||
permalink: val(post, "permalink"),
|
permalink: val(post, "permalink"),
|
||||||
rel_time,
|
rel_time,
|
||||||
@ -324,7 +335,7 @@ impl Post {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
#[template(path = "comment.html", escape = "none")]
|
#[template(path = "comment.html")]
|
||||||
// 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,
|
||||||
@ -400,7 +411,7 @@ impl Awards {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
#[template(path = "error.html", escape = "none")]
|
#[template(path = "error.html")]
|
||||||
pub struct ErrorTemplate {
|
pub struct ErrorTemplate {
|
||||||
pub msg: String,
|
pub msg: String,
|
||||||
pub prefs: Preferences,
|
pub prefs: Preferences,
|
||||||
@ -445,11 +456,13 @@ pub struct Params {
|
|||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct Preferences {
|
pub struct Preferences {
|
||||||
|
pub available_themes: Vec<String>,
|
||||||
pub theme: String,
|
pub theme: String,
|
||||||
pub front_page: String,
|
pub front_page: String,
|
||||||
pub layout: String,
|
pub layout: String,
|
||||||
pub wide: String,
|
pub wide: String,
|
||||||
pub show_nsfw: String,
|
pub show_nsfw: String,
|
||||||
|
pub blur_nsfw: String,
|
||||||
pub hide_hls_notification: String,
|
pub hide_hls_notification: String,
|
||||||
pub use_hls: String,
|
pub use_hls: String,
|
||||||
pub autoplay_videos: String,
|
pub autoplay_videos: String,
|
||||||
@ -459,15 +472,29 @@ pub struct Preferences {
|
|||||||
pub filters: Vec<String>,
|
pub filters: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(RustEmbed)]
|
||||||
|
#[folder = "static/themes/"]
|
||||||
|
#[include = "*.css"]
|
||||||
|
pub struct ThemeAssets;
|
||||||
|
|
||||||
impl Preferences {
|
impl Preferences {
|
||||||
// Build preferences from cookies
|
// Build preferences from cookies
|
||||||
pub fn new(req: Request<Body>) -> Self {
|
pub fn new(req: Request<Body>) -> Self {
|
||||||
|
// Read available theme names from embedded css files.
|
||||||
|
// Always make the default "system" theme available.
|
||||||
|
let mut themes = vec!["system".to_string()];
|
||||||
|
for file in ThemeAssets::iter() {
|
||||||
|
let chunks: Vec<&str> = file.as_ref().split(".css").collect();
|
||||||
|
themes.push(chunks[0].to_owned())
|
||||||
|
}
|
||||||
Self {
|
Self {
|
||||||
|
available_themes: themes,
|
||||||
theme: setting(&req, "theme"),
|
theme: setting(&req, "theme"),
|
||||||
front_page: setting(&req, "front_page"),
|
front_page: setting(&req, "front_page"),
|
||||||
layout: setting(&req, "layout"),
|
layout: setting(&req, "layout"),
|
||||||
wide: setting(&req, "wide"),
|
wide: setting(&req, "wide"),
|
||||||
show_nsfw: setting(&req, "show_nsfw"),
|
show_nsfw: setting(&req, "show_nsfw"),
|
||||||
|
blur_nsfw: setting(&req, "blur_nsfw"),
|
||||||
use_hls: setting(&req, "use_hls"),
|
use_hls: setting(&req, "use_hls"),
|
||||||
hide_hls_notification: setting(&req, "hide_hls_notification"),
|
hide_hls_notification: setting(&req, "hide_hls_notification"),
|
||||||
autoplay_videos: setting(&req, "autoplay_videos"),
|
autoplay_videos: setting(&req, "autoplay_videos"),
|
||||||
@ -607,8 +634,11 @@ pub fn format_url(url: &str) -> String {
|
|||||||
|
|
||||||
// Rewrite Reddit links to Libreddit in body of text
|
// Rewrite Reddit links to Libreddit in body of text
|
||||||
pub fn rewrite_urls(input_text: &str) -> String {
|
pub fn rewrite_urls(input_text: &str) -> String {
|
||||||
let text1 =
|
let text1 = Regex::new(r#"href="(https|http|)://(www\.|old\.|np\.|amp\.|)(reddit\.com|redd\.it)/"#)
|
||||||
Regex::new(r#"href="(https|http|)://(www\.|old\.|np\.|amp\.|)(reddit\.com|redd\.it)/"#).map_or(String::new(), |re| re.replace_all(input_text, r#"href="/"#).to_string());
|
.map_or(String::new(), |re| re.replace_all(input_text, r#"href="/"#).to_string())
|
||||||
|
// Remove (html-encoded) "\" from URLs.
|
||||||
|
.replace("%5C", "")
|
||||||
|
.replace('\\', "");
|
||||||
|
|
||||||
// Rewrite external media previews to Libreddit
|
// Rewrite external media previews to Libreddit
|
||||||
Regex::new(r"https://external-preview\.redd\.it(.*)[^?]").map_or(String::new(), |re| {
|
Regex::new(r"https://external-preview\.redd\.it(.*)[^?]").map_or(String::new(), |re| {
|
||||||
@ -652,7 +682,12 @@ 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(format_description!("[month repr:short] [day] [year], [hour]:[minute]:[second] UTC")).unwrap_or_default())
|
(
|
||||||
|
rel_time,
|
||||||
|
time
|
||||||
|
.format(format_description!("[month repr:short] [day] [year], [hour]:[minute]:[second] UTC"))
|
||||||
|
.unwrap_or_default(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// val() function used to parse JSON from Reddit APIs
|
// val() function used to parse JSON from Reddit APIs
|
||||||
@ -660,17 +695,6 @@ 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()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Escape < and > to accurately render HTML
|
|
||||||
#[macro_export]
|
|
||||||
macro_rules! esc {
|
|
||||||
($f:expr) => {
|
|
||||||
$f.replace('&', "&").replace('<', "<").replace('>', ">")
|
|
||||||
};
|
|
||||||
($j:expr, $k:expr) => {
|
|
||||||
$j["data"][$k].as_str().unwrap_or_default().to_string().replace('<', "<").replace('>', ">")
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
//
|
||||||
// NETWORKING
|
// NETWORKING
|
||||||
//
|
//
|
||||||
@ -694,10 +718,11 @@ pub fn redirect(path: String) -> Response<Body> {
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn error(req: Request<Body>, msg: String) -> Result<Response<Body>, String> {
|
/// Renders a generic error landing page.
|
||||||
|
pub async fn error(req: Request<Body>, msg: impl ToString) -> Result<Response<Body>, String> {
|
||||||
let url = req.uri().to_string();
|
let url = req.uri().to_string();
|
||||||
let body = ErrorTemplate {
|
let body = ErrorTemplate {
|
||||||
msg,
|
msg: msg.to_string(),
|
||||||
prefs: Preferences::new(req),
|
prefs: Preferences::new(req),
|
||||||
url,
|
url,
|
||||||
}
|
}
|
||||||
@ -709,7 +734,7 @@ pub async fn error(req: Request<Body>, msg: String) -> Result<Response<Body>, St
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::format_num;
|
use super::{format_num, format_url, rewrite_urls};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn format_num_works() {
|
fn format_num_works() {
|
||||||
@ -719,4 +744,43 @@ mod tests {
|
|||||||
assert_eq!(format_num(1001), ("1.0k".to_string(), "1001".to_string()));
|
assert_eq!(format_num(1001), ("1.0k".to_string(), "1001".to_string()));
|
||||||
assert_eq!(format_num(1_999_999), ("2.0m".to_string(), "1999999".to_string()));
|
assert_eq!(format_num(1_999_999), ("2.0m".to_string(), "1999999".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rewrite_urls_removes_backslashes() {
|
||||||
|
let comment_body_html =
|
||||||
|
r#"<a href=\"https://www.reddit.com/r/linux%5C_gaming/comments/x/just%5C_a%5C_test%5C/\">https://www.reddit.com/r/linux\\_gaming/comments/x/just\\_a\\_test/</a>"#;
|
||||||
|
assert_eq!(
|
||||||
|
rewrite_urls(comment_body_html),
|
||||||
|
r#"<a href="https://www.reddit.com/r/linux_gaming/comments/x/just_a_test/">https://www.reddit.com/r/linux_gaming/comments/x/just_a_test/</a>"#
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_format_url() {
|
||||||
|
assert_eq!(format_url("https://a.thumbs.redditmedia.com/XYZ.jpg"), "/thumb/a/XYZ.jpg");
|
||||||
|
assert_eq!(format_url("https://emoji.redditmedia.com/a/b"), "/emoji/a/b");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
format_url("https://external-preview.redd.it/foo.jpg?auto=webp&s=bar"),
|
||||||
|
"/preview/external-pre/foo.jpg?auto=webp&s=bar"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(format_url("https://i.redd.it/foobar.jpg"), "/img/foobar.jpg");
|
||||||
|
assert_eq!(
|
||||||
|
format_url("https://preview.redd.it/qwerty.jpg?auto=webp&s=asdf"),
|
||||||
|
"/preview/pre/qwerty.jpg?auto=webp&s=asdf"
|
||||||
|
);
|
||||||
|
assert_eq!(format_url("https://v.redd.it/foo/DASH_360.mp4?source=fallback"), "/vid/foo/360.mp4");
|
||||||
|
assert_eq!(
|
||||||
|
format_url("https://v.redd.it/foo/HLSPlaylist.m3u8?a=bar&v=1&f=sd"),
|
||||||
|
"/hls/foo/HLSPlaylist.m3u8?a=bar&v=1&f=sd"
|
||||||
|
);
|
||||||
|
assert_eq!(format_url("https://www.redditstatic.com/gold/awards/icon/icon.png"), "/static/gold/awards/icon/icon.png");
|
||||||
|
|
||||||
|
assert_eq!(format_url(""), "");
|
||||||
|
assert_eq!(format_url("self"), "");
|
||||||
|
assert_eq!(format_url("default"), "");
|
||||||
|
assert_eq!(format_url("nsfw"), "");
|
||||||
|
assert_eq!(format_url("spoiler"), "");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
4
static/hls.min.js
vendored
4
static/hls.min.js
vendored
File diff suppressed because one or more lines are too long
183
static/style.css
183
static/style.css
@ -45,124 +45,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Light theme setting */
|
/* Other themes are located in the "themes" folder */
|
||||||
.light {
|
|
||||||
--accent: #009a9a;
|
|
||||||
--green: #00a229;
|
|
||||||
--text: black;
|
|
||||||
--foreground: #f5f5f5;
|
|
||||||
--background: #ddd;
|
|
||||||
--outside: #ececec;
|
|
||||||
--post: #eee;
|
|
||||||
--panel-border: 1px solid #ccc;
|
|
||||||
--highlighted: white;
|
|
||||||
--visited: #555;
|
|
||||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 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;
|
|
||||||
--visited: #aaa;
|
|
||||||
--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: #393c4d;
|
|
||||||
--post: #333544;
|
|
||||||
--panel-border: 2px solid #44475a;
|
|
||||||
--highlighted: #4e5267;
|
|
||||||
--visited: #969692;
|
|
||||||
--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;
|
|
||||||
--visited: #a3a5aa;
|
|
||||||
--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;
|
|
||||||
--visited: #91889b;
|
|
||||||
--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;
|
|
||||||
--visited: #aaa;
|
|
||||||
--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;
|
|
||||||
--visited: #aaa;
|
|
||||||
--shadow: 0 2px 5px rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Rosebox theme setting */
|
|
||||||
.rosebox {
|
|
||||||
--accent: #a57562;
|
|
||||||
--green: #a3be8c;
|
|
||||||
--text: white;
|
|
||||||
--foreground: #222;
|
|
||||||
--background: #262626;
|
|
||||||
--outside: #222;
|
|
||||||
--post: #222;
|
|
||||||
--panel-border: 1px solid #222;
|
|
||||||
--highlighted: #262626;
|
|
||||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* General */
|
/* General */
|
||||||
|
|
||||||
@ -177,6 +60,7 @@
|
|||||||
|
|
||||||
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 {
|
||||||
|
accent-color: var(--accent);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-family: "Inter", sans-serif;
|
font-family: "Inter", sans-serif;
|
||||||
@ -270,6 +154,7 @@ main {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#column_one {
|
#column_one {
|
||||||
|
width: 100%;
|
||||||
max-width: 750px;
|
max-width: 750px;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
overflow: inherit;
|
overflow: inherit;
|
||||||
@ -596,6 +481,10 @@ button.submit:hover > svg { stroke: var(--accent); }
|
|||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#listing_options {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
#sort_options, #listing_options, footer > a {
|
#sort_options, #listing_options, footer > a {
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -828,22 +717,39 @@ a.search_subreddit:hover {
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post_media_image, .post .__NoScript_PlaceHolder__, .post_media_video, .gallery {
|
.post_media_content, .post .__NoScript_PlaceHolder__, .gallery {
|
||||||
max-width: calc(100% - 40px);
|
max-width: calc(100% - 40px);
|
||||||
grid-area: post_media;
|
grid-area: post_media;
|
||||||
margin: 15px auto 5px auto;
|
margin: 15px auto 5px auto;
|
||||||
|
width: auto;
|
||||||
height: auto;
|
height: auto;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.post_media_video {
|
||||||
.post_media_video.short {
|
|
||||||
max-height: 512px;
|
|
||||||
width: auto;
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 512px;
|
||||||
|
display: block;
|
||||||
|
margin: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post_media_image.short svg, .post_media_image.short img{
|
.post_media_image.short svg, .post_media_image.short img{
|
||||||
max-height: 512px;
|
|
||||||
width: auto;
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 512px;
|
||||||
|
display: block;
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post_nsfw_blur {
|
||||||
|
filter: blur(1.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post_nsfw_blur:hover {
|
||||||
|
filter: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post_media_image svg{
|
.post_media_image svg{
|
||||||
@ -884,6 +790,7 @@ a.search_subreddit:hover {
|
|||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
margin: 5px 12px;
|
margin: 5px 12px;
|
||||||
grid-area: post_media;
|
grid-area: post_media;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post_body {
|
.post_body {
|
||||||
@ -892,6 +799,7 @@ a.search_subreddit:hover {
|
|||||||
padding: 5px 15px 5px 12px;
|
padding: 5px 15px 5px 12px;
|
||||||
grid-area: post_body;
|
grid-area: post_body;
|
||||||
width: calc(100% - 30px);
|
width: calc(100% - 30px);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Used only for text post preview */
|
/* Used only for text post preview */
|
||||||
@ -937,13 +845,25 @@ a.search_subreddit:hover {
|
|||||||
margin: 5px;
|
margin: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post_thumbnail svg {
|
.post_thumbnail div {
|
||||||
grid-area: 1 / 1 / 2 / 2;
|
grid-area: 1 / 1 / 2 / 2;
|
||||||
width: 100%;
|
|
||||||
height: auto;
|
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
align-self: center;
|
align-self: center;
|
||||||
justify-self: center;
|
justify-self: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post_thumbnail div svg {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post_thumbnail span {
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb_nsfw_blur {
|
||||||
|
filter: blur(0.3rem)
|
||||||
}
|
}
|
||||||
|
|
||||||
.post_thumbnail.no_thumbnail {
|
.post_thumbnail.no_thumbnail {
|
||||||
@ -1292,16 +1212,21 @@ input[type="submit"] {
|
|||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.md .md-spoiler-text {
|
.md .md-spoiler-text, .md-spoiler-text a {
|
||||||
background: var(--highlighted);
|
background: var(--highlighted);
|
||||||
color: transparent;
|
color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.md .md-spoiler-text:hover {
|
.md-spoiler-text:hover {
|
||||||
background: var(--foreground);
|
background: var(--foreground);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.md-spoiler-text:hover a {
|
||||||
|
background: var(--foreground);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
.md li { margin: 10px 0; }
|
.md li { margin: 10px 0; }
|
||||||
.toc_child { list-style: none; }
|
.toc_child { list-style: none; }
|
||||||
|
|
||||||
@ -1317,6 +1242,8 @@ input[type="submit"] {
|
|||||||
.md table {
|
.md table {
|
||||||
margin: 5px;
|
margin: 5px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
display: block;
|
||||||
|
max-width: fit-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
.md code {
|
.md code {
|
||||||
|
14
static/themes/black.css
Normal file
14
static/themes/black.css
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
/* 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;
|
||||||
|
--visited: #aaa;
|
||||||
|
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
14
static/themes/dark.css
Normal file
14
static/themes/dark.css
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
/* Dark theme setting */
|
||||||
|
.dark{
|
||||||
|
--accent: aqua;
|
||||||
|
--green: #5cff85;
|
||||||
|
--text: white;
|
||||||
|
--foreground: #222;
|
||||||
|
--background: #0f0f0f;
|
||||||
|
--outside: #1f1f1f;
|
||||||
|
--post: #161616;
|
||||||
|
--panel-border: 1px solid #333;
|
||||||
|
--highlighted: #333;
|
||||||
|
--visited: #aaa;
|
||||||
|
--shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
13
static/themes/doomone.css
Normal file
13
static/themes/doomone.css
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
.doomone {
|
||||||
|
--accent: #51afef;
|
||||||
|
--green: #00a229;
|
||||||
|
--text: #bbc2cf;
|
||||||
|
--foreground: #3d4148;
|
||||||
|
--background: #282c34;
|
||||||
|
--outside: #52565c;
|
||||||
|
--post: #24272e;
|
||||||
|
--panel-border: 2px solid #52565c;
|
||||||
|
--highlighted: #686b70;
|
||||||
|
--visited: #969692;
|
||||||
|
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
14
static/themes/dracula.css
Normal file
14
static/themes/dracula.css
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
/* Dracula theme setting */
|
||||||
|
.dracula {
|
||||||
|
--accent: #bd93f9;
|
||||||
|
--green: #50fa7b;
|
||||||
|
--text: #f8f8f2;
|
||||||
|
--foreground: #3d4051;
|
||||||
|
--background: #282a36;
|
||||||
|
--outside: #393c4d;
|
||||||
|
--post: #333544;
|
||||||
|
--panel-border: 2px solid #44475a;
|
||||||
|
--highlighted: #4e5267;
|
||||||
|
--visited: #969692;
|
||||||
|
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
14
static/themes/gold.css
Normal file
14
static/themes/gold.css
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
/* 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;
|
||||||
|
--visited: #aaa;
|
||||||
|
--shadow: 0 2px 5px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
13
static/themes/gruvboxdark.css
Normal file
13
static/themes/gruvboxdark.css
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
/* Gruvbox-Dark theme setting */
|
||||||
|
.gruvboxdark {
|
||||||
|
--accent: #8ec07c;
|
||||||
|
--green: #b8bb26;
|
||||||
|
--text: #ebdbb2;
|
||||||
|
--foreground: #3c3836;
|
||||||
|
--background: #282828;
|
||||||
|
--outside: #3c3836;
|
||||||
|
--post: #3c3836;
|
||||||
|
--panel-border: 1px solid #504945;
|
||||||
|
--highlighted: #282828;
|
||||||
|
--shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
13
static/themes/gruvboxlight.css
Normal file
13
static/themes/gruvboxlight.css
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
/* Gruvbox-Light theme setting */
|
||||||
|
.gruvboxlight {
|
||||||
|
--accent: #427b58;
|
||||||
|
--green: #79740e;
|
||||||
|
--text: #3c3836;
|
||||||
|
--foreground: #ebdbb2;
|
||||||
|
--background: #fbf1c7;
|
||||||
|
--outside: #ebdbb2;
|
||||||
|
--post: #ebdbb2;
|
||||||
|
--panel-border: 1px solid #d5c4a1;
|
||||||
|
--highlighted: #fbf1c7;
|
||||||
|
--shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
14
static/themes/laserwave.css
Normal file
14
static/themes/laserwave.css
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
/* 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;
|
||||||
|
--visited: #91889b;
|
||||||
|
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
14
static/themes/light.css
Normal file
14
static/themes/light.css
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
/* Light theme setting */
|
||||||
|
.light {
|
||||||
|
--accent: #009a9a;
|
||||||
|
--green: #00a229;
|
||||||
|
--text: black;
|
||||||
|
--foreground: #f5f5f5;
|
||||||
|
--background: #ddd;
|
||||||
|
--outside: #ececec;
|
||||||
|
--post: #eee;
|
||||||
|
--panel-border: 1px solid #ccc;
|
||||||
|
--highlighted: white;
|
||||||
|
--visited: #555;
|
||||||
|
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
14
static/themes/nord.css
Normal file
14
static/themes/nord.css
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
/* 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;
|
||||||
|
--visited: #a3a5aa;
|
||||||
|
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
13
static/themes/rosebox.css
Normal file
13
static/themes/rosebox.css
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
/* Rosebox theme setting */
|
||||||
|
.rosebox {
|
||||||
|
--accent: #a57562;
|
||||||
|
--green: #a3be8c;
|
||||||
|
--text: white;
|
||||||
|
--foreground: #222;
|
||||||
|
--background: #262626;
|
||||||
|
--outside: #222;
|
||||||
|
--post: #222;
|
||||||
|
--panel-border: 1px solid #222;
|
||||||
|
--highlighted: #262626;
|
||||||
|
--shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
14
static/themes/violet.css
Normal file
14
static/themes/violet.css
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
/* 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;
|
||||||
|
--visited: #aaa;
|
||||||
|
--shadow: 0 2px 5px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
@ -19,7 +19,7 @@
|
|||||||
<!-- PWA Manifest -->
|
<!-- PWA Manifest -->
|
||||||
<link rel="manifest" type="application/json" href="/manifest.json">
|
<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?v={{ env!("CARGO_PKG_VERSION") }}">
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body class="
|
<body class="
|
||||||
|
@ -32,9 +32,9 @@
|
|||||||
{% if is_filtered %}
|
{% if is_filtered %}
|
||||||
<div class="comment_body_filtered {% if highlighted %}highlighted{% endif %}">(Filtered content)</div>
|
<div class="comment_body_filtered {% if highlighted %}highlighted{% endif %}">(Filtered content)</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="comment_body {% if highlighted %}highlighted{% endif %}">{{ body }}</div>
|
<div class="comment_body {% if highlighted %}highlighted{% endif %}">{{ body|safe }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<blockquote class="replies">{% for c in replies -%}{{ c.render().unwrap() }}{%- endfor %}
|
<blockquote class="replies">{% for c in replies -%}{{ c.render().unwrap()|safe }}{%- endfor %}
|
||||||
</blockquote>
|
</blockquote>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
@ -13,16 +13,25 @@
|
|||||||
<!-- Meta Tags -->
|
<!-- 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 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: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:description" content="View on Libreddit, an alternative private front-end to Reddit.">
|
||||||
<meta property="og:image" content="{{ post.thumbnail.url }}">
|
<meta property="og:url" content="{{ post.permalink }}">
|
||||||
<meta property="twitter:card" content="summary_large_image">
|
|
||||||
<meta property="twitter:url" content="{{ post.permalink }}">
|
<meta property="twitter:url" content="{{ post.permalink }}">
|
||||||
<meta property="twitter:title" content="{{ post.title }} - r/{{ post.community }}">
|
<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:description" content="View on Libreddit, an alternative private front-end to Reddit.">
|
||||||
|
{% if post.post_type == "image" %}
|
||||||
|
<meta property="og:type" content="image">
|
||||||
|
<meta property="og:image" content="{{ post.thumbnail.url }}">
|
||||||
|
<meta property="twitter:card" content="summary_large_image">
|
||||||
<meta property="twitter:image" content="{{ post.thumbnail.url }}">
|
<meta property="twitter:image" content="{{ post.thumbnail.url }}">
|
||||||
|
{% else if post.post_type == "video" || post.post_type == "gif" %}
|
||||||
|
<meta property="twitter:card" content="video">
|
||||||
|
<meta property="og:type" content="video">
|
||||||
|
<meta property="og:video" content="{{ post.media.url }}">
|
||||||
|
<meta property="og:video:type" content="video/mp4">
|
||||||
|
{% else %}
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block subscriptions %}
|
{% block subscriptions %}
|
||||||
@ -55,7 +64,7 @@
|
|||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
<p class="post_title">
|
<h1 class="post_title">
|
||||||
{{ post.title }}
|
{{ post.title }}
|
||||||
{% if post.flair.flair_parts.len() > 0 %}
|
{% 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"
|
<a href="/r/{{ post.community }}/search?q=flair_name%3A%22{{ post.flair.text }}%22&restrict_sr=on"
|
||||||
@ -63,11 +72,12 @@
|
|||||||
style="color:{{ post.flair.foreground_color }}; background:{{ post.flair.background_color }};">{% call utils::render_flair(post.flair.flair_parts) %}</a>
|
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>
|
</h1>
|
||||||
|
|
||||||
<!-- POST MEDIA -->
|
<!-- POST MEDIA -->
|
||||||
<!-- post_type: {{ post.post_type }} -->
|
<!-- post_type: {{ post.post_type }} -->
|
||||||
{% if post.post_type == "image" %}
|
{% if post.post_type == "image" %}
|
||||||
|
<div class="post_media_content">
|
||||||
<a href="{{ post.media.url }}" class="post_media_image" >
|
<a href="{{ post.media.url }}" class="post_media_image" >
|
||||||
<svg
|
<svg
|
||||||
width="{{ post.media.width }}px"
|
width="{{ post.media.width }}px"
|
||||||
@ -79,16 +89,21 @@
|
|||||||
</desc>
|
</desc>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
|
</div>
|
||||||
{% else if post.post_type == "video" || post.post_type == "gif" %}
|
{% else if post.post_type == "video" || post.post_type == "gif" %}
|
||||||
{% if prefs.use_hls == "on" && !post.media.alt_url.is_empty() %}
|
{% if prefs.use_hls == "on" && !post.media.alt_url.is_empty() %}
|
||||||
<script src="/hls.min.js"></script>
|
<script src="/hls.min.js"></script>
|
||||||
|
<div class="post_media_content">
|
||||||
<video class="post_media_video short {% if prefs.autoplay_videos == "on" %}hls_autoplay{% endif %}" width="{{ post.media.width }}" height="{{ post.media.height }}" poster="{{ post.media.poster }}" preload="none" controls>
|
<video class="post_media_video short {% if prefs.autoplay_videos == "on" %}hls_autoplay{% endif %}" width="{{ post.media.width }}" height="{{ post.media.height }}" poster="{{ post.media.poster }}" preload="none" controls>
|
||||||
<source src="{{ post.media.alt_url }}" type="application/vnd.apple.mpegurl" />
|
<source src="{{ post.media.alt_url }}" type="application/vnd.apple.mpegurl" />
|
||||||
<source src="{{ post.media.url }}" type="video/mp4" />
|
<source src="{{ post.media.url }}" type="video/mp4" />
|
||||||
</video>
|
</video>
|
||||||
|
</div>
|
||||||
<script src="/playHLSVideo.js"></script>
|
<script src="/playHLSVideo.js"></script>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
<div class="post_media_content">
|
||||||
<video class="post_media_video" src="{{ post.media.url }}" controls {% if prefs.autoplay_videos == "on" %}autoplay{% endif %} loop><a href={{ post.media.url }}>Video</a></video>
|
<video class="post_media_video" src="{{ post.media.url }}" controls {% if prefs.autoplay_videos == "on" %}autoplay{% endif %} loop><a href={{ post.media.url }}>Video</a></video>
|
||||||
|
</div>
|
||||||
{% call utils::render_hls_notification(post.permalink[1..]) %}
|
{% call utils::render_hls_notification(post.permalink[1..]) %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else if post.post_type == "gallery" %}
|
{% else if post.post_type == "gallery" %}
|
||||||
@ -110,12 +125,12 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- POST BODY -->
|
<!-- POST BODY -->
|
||||||
<div class="post_body">{{ post.body }}</div>
|
<div class="post_body">{{ post.body|safe }}</div>
|
||||||
<div class="post_score" title="{{ post.score.1 }}">{{ post.score.0 }}<span class="label"> Upvotes</span></div>
|
<div class="post_score" title="{{ post.score.1 }}">{{ post.score.0 }}<span class="label"> Upvotes</span></div>
|
||||||
<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.permalink }}">permalink</a></li>
|
||||||
<li><a href="https://reddit.com/{{ post.id }}" rel="nofollow">reddit</a></li>
|
<li><a href="https://reddit.com{{ post.permalink }}" rel="nofollow">reddit</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
<p>{{ post.upvote_ratio }}% Upvoted</p>
|
<p>{{ post.upvote_ratio }}% Upvoted</p>
|
||||||
</div>
|
</div>
|
||||||
@ -138,13 +153,13 @@
|
|||||||
{% for c in comments -%}
|
{% for c in comments -%}
|
||||||
<div class="thread">
|
<div class="thread">
|
||||||
{% if single_thread %}
|
{% if single_thread %}
|
||||||
<p class="thread_nav"><a href="/{{ post.id }}">View all comments</a></p>
|
<p class="thread_nav"><a href="{{ post.permalink }}">View all comments</a></p>
|
||||||
{% if c.parent_kind == "t1" %}
|
{% if c.parent_kind == "t1" %}
|
||||||
<p class="thread_nav"><a href="?context=9999">Show parent comments</a></p>
|
<p class="thread_nav"><a href="?context=9999">Show parent comments</a></p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{{ c.render().unwrap() }}
|
{{ c.render().unwrap()|safe }}
|
||||||
</div>
|
</div>
|
||||||
{%- endfor %}
|
{%- endfor %}
|
||||||
|
|
||||||
|
@ -39,7 +39,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% for subreddit in subreddits %}
|
{% for subreddit in subreddits %}
|
||||||
<a href="{{ subreddit.url }}" class="search_subreddit">
|
<a href="{{ subreddit.url }}" class="search_subreddit">
|
||||||
<div class="search_subreddit_left">{% if subreddit.icon != "" %}<img loading="lazy" src="{{ subreddit.icon }}" alt="r/{{ subreddit.name }} icon">{% endif %}</div>
|
<div class="search_subreddit_left">{% if subreddit.icon != "" %}<img loading="lazy" src="{{ subreddit.icon|safe }}" alt="r/{{ subreddit.name }} icon">{% endif %}</div>
|
||||||
<div class="search_subreddit_right">
|
<div class="search_subreddit_right">
|
||||||
<p class="search_subreddit_header">
|
<p class="search_subreddit_header">
|
||||||
<span class="search_subreddit_name">r/{{ subreddit.name }}</span>
|
<span class="search_subreddit_name">r/{{ subreddit.name }}</span>
|
||||||
@ -56,6 +56,11 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if all_posts_hidden_nsfw %}
|
||||||
|
<center>All posts are hidden because they are NSFW. Enable "Show NSFW posts" in settings to view.</center>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if all_posts_filtered %}
|
{% if all_posts_filtered %}
|
||||||
<center>(All content on this page has been filtered)</center>
|
<center>(All content on this page has been filtered)</center>
|
||||||
{% else if is_filtered %}
|
{% else if is_filtered %}
|
||||||
@ -92,13 +97,13 @@
|
|||||||
{% if params.before != "" %}
|
{% if params.before != "" %}
|
||||||
<a href="?q={{ params.q }}&restrict_sr={{ params.restrict_sr }}
|
<a href="?q={{ params.q }}&restrict_sr={{ params.restrict_sr }}
|
||||||
&sort={{ params.sort }}&t={{ params.t }}
|
&sort={{ params.sort }}&t={{ params.t }}
|
||||||
&before={{ params.before }}">PREV</a>
|
&before={{ params.before }}" accesskey="P">PREV</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if params.after != "" %}
|
{% if params.after != "" %}
|
||||||
<a href="?q={{ params.q }}&restrict_sr={{ params.restrict_sr }}
|
<a href="?q={{ params.q }}&restrict_sr={{ params.restrict_sr }}
|
||||||
&sort={{ params.sort }}&t={{ params.t }}
|
&sort={{ params.sort }}&t={{ params.t }}
|
||||||
&after={{ params.after }}">NEXT</a>
|
&after={{ params.after }}" accesskey="N">NEXT</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</footer>
|
</footer>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -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", "black", "dracula", "nord", "laserwave", "violet", "gold", "rosebox"], "system") %}
|
{% call utils::options(prefs.theme, prefs.available_themes, "system") %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<legend>Interface</legend>
|
<legend>Interface</legend>
|
||||||
@ -54,6 +54,11 @@
|
|||||||
<input type="hidden" value="off" name="show_nsfw">
|
<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="blur_nsfw">
|
||||||
|
<label for="blur_nsfw">Blur NSFW previews:</label>
|
||||||
|
<input type="hidden" value="off" name="blur_nsfw">
|
||||||
|
<input type="checkbox" name="blur_nsfw" {% if prefs.blur_nsfw == "on" %}checked{% endif %}>
|
||||||
|
</div>
|
||||||
<div id="autoplay_videos">
|
<div id="autoplay_videos">
|
||||||
<label for="autoplay_videos">Autoplay videos</label>
|
<label for="autoplay_videos">Autoplay videos</label>
|
||||||
<input type="hidden" value="off" name="autoplay_videos">
|
<input type="hidden" value="off" name="autoplay_videos">
|
||||||
@ -110,7 +115,7 @@
|
|||||||
|
|
||||||
<div id="settings_note">
|
<div id="settings_note">
|
||||||
<p><b>Note:</b> settings and subscriptions are saved in browser cookies. Clearing your cookies will reset them.</p><br>
|
<p><b>Note:</b> settings and subscriptions are saved in browser cookies. Clearing your cookies will reset them.</p><br>
|
||||||
<p>You can restore your current settings and subscriptions after clearing your cookies using <a href="/settings/restore/?theme={{ prefs.theme }}&front_page={{ prefs.front_page }}&layout={{ prefs.layout }}&wide={{ prefs.wide }}&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") }}&filters={{ prefs.filters.join("%2B") }}">this link</a>.</p>
|
<p>You can restore your current settings and subscriptions after clearing your cookies using <a href="/settings/restore/?theme={{ prefs.theme }}&front_page={{ prefs.front_page }}&layout={{ prefs.layout }}&wide={{ prefs.wide }}&post_sort={{ prefs.post_sort }}&comment_sort={{ prefs.comment_sort }}&show_nsfw={{ prefs.show_nsfw }}&blur_nsfw={{ prefs.blur_nsfw }}&use_hls={{ prefs.use_hls }}&hide_hls_notification={{ prefs.hide_hls_notification }}&subscriptions={{ prefs.subscriptions.join("%2B") }}&filters={{ prefs.filters.join("%2B") }}">this link</a>.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -46,6 +46,10 @@
|
|||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if all_posts_hidden_nsfw %}
|
||||||
|
<center>All posts are hidden because they are NSFW. Enable "Show NSFW posts" in settings to view.</center>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if all_posts_filtered %}
|
{% if all_posts_filtered %}
|
||||||
<center>(All content on this page has been filtered)</center>
|
<center>(All content on this page has been filtered)</center>
|
||||||
{% else %}
|
{% else %}
|
||||||
@ -65,21 +69,21 @@
|
|||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
{% if !ends.0.is_empty() %}
|
{% if !ends.0.is_empty() %}
|
||||||
<a href="?sort={{ sort.0 }}&t={{ sort.1 }}&before={{ ends.0 }}">PREV</a>
|
<a href="?sort={{ sort.0 }}&t={{ sort.1 }}&before={{ ends.0 }}" accesskey="P">PREV</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if !ends.1.is_empty() %}
|
{% if !ends.1.is_empty() %}
|
||||||
<a href="?sort={{ sort.0 }}&t={{ sort.1 }}&after={{ ends.1 }}">NEXT</a>
|
<a href="?sort={{ sort.0 }}&t={{ sort.1 }}&after={{ ends.1 }}" accesskey="N">NEXT</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if is_filtered || (sub.name != "all" && sub.name != "popular" && !sub.name.contains("+")) %}
|
{% if is_filtered || (!sub.name.is_empty() && sub.name != "all" && sub.name != "popular" && !sub.name.contains("+")) %}
|
||||||
<aside>
|
<aside>
|
||||||
{% if is_filtered %}
|
{% if is_filtered %}
|
||||||
<center>(Content from r/{{ sub.name }} has been filtered)</center>
|
<center>(Content from r/{{ sub.name }} has been filtered)</center>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if sub.name != "all" && sub.name != "popular" && !sub.name.contains("+") %}
|
{% if !sub.name.is_empty() && sub.name != "all" && sub.name != "popular" && !sub.name.contains("+") %}
|
||||||
<div class="panel" id="subreddit">
|
<div class="panel" id="subreddit">
|
||||||
{% if sub.wiki %}
|
{% if sub.wiki %}
|
||||||
<div id="top">
|
<div id="top">
|
||||||
@ -89,7 +93,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<div id="sub_meta">
|
<div id="sub_meta">
|
||||||
<img loading="lazy" id="sub_icon" src="{{ sub.icon }}" alt="Icon for r/{{ sub.name }}">
|
<img loading="lazy" id="sub_icon" src="{{ sub.icon }}" alt="Icon for r/{{ sub.name }}">
|
||||||
<p id="sub_title">{{ sub.title }}</p>
|
<h1 id="sub_title">{{ sub.title }}</h1>
|
||||||
<p id="sub_name">r/{{ sub.name }}</p>
|
<p id="sub_name">r/{{ sub.name }}</p>
|
||||||
<p id="sub_description">{{ sub.description }}</p>
|
<p id="sub_description">{{ sub.description }}</p>
|
||||||
<div id="sub_details">
|
<div id="sub_details">
|
||||||
@ -127,7 +131,7 @@
|
|||||||
<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">
|
<div id="sidebar_contents">
|
||||||
{{ sub.info }}
|
{{ sub.info|safe }}
|
||||||
{# <hr>
|
{# <hr>
|
||||||
<h2>Moderators</h2>
|
<h2>Moderators</h2>
|
||||||
<br>
|
<br>
|
||||||
|
@ -32,6 +32,10 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{% if all_posts_hidden_nsfw %}
|
||||||
|
<center>All posts are hidden because they are NSFW. Enable "Show NSFW posts" in settings to view.</center>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if all_posts_filtered %}
|
{% if all_posts_filtered %}
|
||||||
<center>(All content on this page has been filtered)</center>
|
<center>(All content on this page has been filtered)</center>
|
||||||
{% else %}
|
{% else %}
|
||||||
@ -52,7 +56,7 @@
|
|||||||
<a class="comment_link" href="{{ post.permalink }}">COMMENT</a>
|
<a class="comment_link" href="{{ post.permalink }}">COMMENT</a>
|
||||||
<span class="created" title="{{ post.created }}">{{ post.rel_time }}</span>
|
<span class="created" title="{{ post.created }}">{{ post.rel_time }}</span>
|
||||||
</summary>
|
</summary>
|
||||||
<p class="comment_body">{{ post.body }}</p>
|
<p class="comment_body">{{ post.body|safe }}</p>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -66,11 +70,11 @@
|
|||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
{% if ends.0 != "" %}
|
{% if ends.0 != "" %}
|
||||||
<a href="?sort={{ sort.0 }}&t={{ sort.1 }}&before={{ ends.0 }}">PREV</a>
|
<a href="?sort={{ sort.0 }}&t={{ sort.1 }}&before={{ ends.0 }}" accesskey="P">PREV</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if ends.1 != "" %}
|
{% if ends.1 != "" %}
|
||||||
<a href="?sort={{ sort.0 }}&t={{ sort.1 }}&after={{ ends.1 }}">NEXT</a>
|
<a href="?sort={{ sort.0 }}&t={{ sort.1 }}&after={{ ends.1 }}" accesskey="N">NEXT</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
@ -81,7 +85,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="panel" id="user">
|
<div class="panel" id="user">
|
||||||
<img loading="lazy" id="user_icon" src="{{ user.icon }}" alt="User icon">
|
<img loading="lazy" id="user_icon" src="{{ user.icon }}" alt="User icon">
|
||||||
<p id="user_title">{{ user.title }}</p>
|
<h1 id="user_title">{{ user.title }}</h1>
|
||||||
<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>
|
||||||
<div id="user_details">
|
<div id="user_details">
|
||||||
|
@ -38,7 +38,6 @@
|
|||||||
{%- endmacro %}
|
{%- endmacro %}
|
||||||
|
|
||||||
{% macro sub_list(current) -%}
|
{% macro sub_list(current) -%}
|
||||||
{% if prefs.subscriptions.len() > 0 %}
|
|
||||||
<details id="feeds">
|
<details id="feeds">
|
||||||
<summary>Feeds</summary>
|
<summary>Feeds</summary>
|
||||||
<div id="feed_list">
|
<div id="feed_list">
|
||||||
@ -46,13 +45,14 @@
|
|||||||
<a href="/">Home</a>
|
<a href="/">Home</a>
|
||||||
<a href="/r/popular">Popular</a>
|
<a href="/r/popular">Popular</a>
|
||||||
<a href="/r/all">All</a>
|
<a href="/r/all">All</a>
|
||||||
|
{% if prefs.subscriptions.len() > 0 %}
|
||||||
<p>REDDIT FEEDS</p>
|
<p>REDDIT FEEDS</p>
|
||||||
{% for sub in prefs.subscriptions %}
|
{% 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 %}
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
{% endif %}
|
|
||||||
{%- endmacro %}
|
{%- endmacro %}
|
||||||
|
|
||||||
{% macro render_hls_notification(redirect_url) -%}
|
{% macro render_hls_notification(redirect_url) -%}
|
||||||
@ -83,7 +83,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
<p class="post_title">
|
<h2 class="post_title">
|
||||||
{% if post.flair.flair_parts.len() > 0 %}
|
{% 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"
|
<a href="/r/{{ post.community }}/search?q=flair_name%3A%22{{ post.flair.text }}%22&restrict_sr=on"
|
||||||
class="post_flair"
|
class="post_flair"
|
||||||
@ -91,11 +91,13 @@
|
|||||||
dir="ltr">{% call render_flair(post.flair.flair_parts) %}</a>
|
dir="ltr">{% call render_flair(post.flair.flair_parts) %}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="{{ post.permalink }}">{{ post.title }}</a>{% if post.flags.nsfw %} <small class="nsfw">NSFW</small>{% endif %}
|
<a href="{{ post.permalink }}">{{ post.title }}</a>{% if post.flags.nsfw %} <small class="nsfw">NSFW</small>{% endif %}
|
||||||
</p>
|
</h2>
|
||||||
<!-- POST MEDIA/THUMBNAIL -->
|
<!-- POST MEDIA/THUMBNAIL -->
|
||||||
{% if (prefs.layout.is_empty() || prefs.layout == "card") && post.post_type == "image" %}
|
{% if (prefs.layout.is_empty() || prefs.layout == "card") && post.post_type == "image" %}
|
||||||
|
<div class="post_media_content">
|
||||||
<a href="{{ post.media.url }}" class="post_media_image {% if post.media.height / post.media.width < 2 %}short{% endif %}" >
|
<a href="{{ post.media.url }}" class="post_media_image {% if post.media.height / post.media.width < 2 %}short{% endif %}" >
|
||||||
<svg
|
<svg
|
||||||
|
{%if post.flags.nsfw && prefs.blur_nsfw=="on" %}class="post_nsfw_blur"{% endif %}
|
||||||
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">
|
||||||
@ -105,16 +107,23 @@
|
|||||||
</desc>
|
</desc>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
|
</div>
|
||||||
{% else if (prefs.layout.is_empty() || prefs.layout == "card") && post.post_type == "gif" %}
|
{% 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 }}" poster="{{ post.media.poster }}" preload="none" controls loop {% if prefs.autoplay_videos == "on" %}autoplay{% endif %}><a href={{ post.media.url }}>Video</a></video>
|
<div class="post_media_content">
|
||||||
|
<video class="post_media_video short {%if post.flags.nsfw && prefs.blur_nsfw=="on" %}post_nsfw_blur{% endif %}" src="{{ post.media.url }}" width="{{ post.media.width }}" height="{{ post.media.height }}" poster="{{ post.media.poster }}" preload="none" controls loop {% if prefs.autoplay_videos == "on" %}autoplay{% endif %}><a href={{ post.media.url }}>Video</a></video>
|
||||||
|
</div>
|
||||||
{% else if (prefs.layout.is_empty() || prefs.layout == "card") && post.post_type == "video" %}
|
{% else if (prefs.layout.is_empty() || prefs.layout == "card") && post.post_type == "video" %}
|
||||||
{% if prefs.use_hls == "on" && !post.media.alt_url.is_empty() %}
|
{% if prefs.use_hls == "on" && !post.media.alt_url.is_empty() %}
|
||||||
<video class="post_media_video short {% if prefs.autoplay_videos == "on" %}hls_autoplay{% endif %}" width="{{ post.media.width }}" height="{{ post.media.height }}" poster="{{ post.media.poster }}" controls preload="none">
|
<div class="post_media_content">
|
||||||
|
<video class="post_media_video short {%if post.flags.nsfw && prefs.blur_nsfw=="on" %}post_nsfw_blur{% endif %} {% if prefs.autoplay_videos == "on" %}hls_autoplay{% endif %}" 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.alt_url }}" type="application/vnd.apple.mpegurl" />
|
||||||
<source src="{{ post.media.url }}" type="video/mp4" />
|
<source src="{{ post.media.url }}" type="video/mp4" />
|
||||||
</video>
|
</video>
|
||||||
|
</div>
|
||||||
{% else %}
|
{% 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 {% if prefs.autoplay_videos == "on" %}autoplay{% endif %}><a href={{ post.media.url }}>Video</a></video>
|
<div class="post_media_content">
|
||||||
|
<video class="post_media_video short {%if post.flags.nsfw && prefs.blur_nsfw=="on" %}post_nsfw_blur{% endif %}" src="{{ post.media.url }}" width="{{ post.media.width }}" height="{{ post.media.height }}" poster="{{ post.media.poster }}" preload="none" controls {% if prefs.autoplay_videos == "on" %}autoplay{% endif %}><a href={{ post.media.url }}>Video</a></video>
|
||||||
|
</div>
|
||||||
{% call render_hls_notification(format!("{}%23{}", &self.url[1..].replace("&", "%26").replace("+", "%2B"), post.id)) %}
|
{% call render_hls_notification(format!("{}%23{}", &self.url[1..].replace("&", "%26").replace("+", "%2B"), post.id)) %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else if post.post_type != "self" %}
|
{% else if post.post_type != "self" %}
|
||||||
@ -125,12 +134,14 @@
|
|||||||
<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"/>
|
<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>
|
</svg>
|
||||||
{% else %}
|
{% else %}
|
||||||
<svg width="{{ post.thumbnail.width }}px" height="{{ post.thumbnail.height }}px" xmlns="http://www.w3.org/2000/svg">
|
<div style="max-width:{{ post.thumbnail.width }}px;max-height:{{ post.thumbnail.height }}px;">
|
||||||
|
<svg {% if post.flags.nsfw && prefs.blur_nsfw=="on" %} class="thumb_nsfw_blur" {% endif %} 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 }}"/>
|
<image width="100%" height="100%" href="{{ post.thumbnail.url }}"/>
|
||||||
<desc>
|
<desc>
|
||||||
<img loading="lazy" alt="Thumbnail" src="{{ post.thumbnail.url }}"/>
|
<img loading="lazy" alt="Thumbnail" src="{{ post.thumbnail.url }}"/>
|
||||||
</desc>
|
</desc>
|
||||||
</svg>
|
</svg>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span>{% if post.post_type == "link" %}{{ post.domain }}{% else %}{{ post.post_type }}{% endif %}</span>
|
<span>{% if post.post_type == "link" %}{{ post.domain }}{% else %}{{ post.post_type }}{% endif %}</span>
|
||||||
</a>
|
</a>
|
||||||
@ -138,10 +149,10 @@
|
|||||||
|
|
||||||
<div class="post_score" title="{{ post.score.1 }}">{{ post.score.0 }}<span class="label"> Upvotes</span></div>
|
<div class="post_score" title="{{ post.score.1 }}">{{ post.score.0 }}<span class="label"> Upvotes</span></div>
|
||||||
<div class="post_body post_preview">
|
<div class="post_body post_preview">
|
||||||
{{ post.body }}
|
{{ post.body|safe }}
|
||||||
</div>
|
</div>
|
||||||
<div class="post_footer">
|
<div class="post_footer">
|
||||||
<a href="{{ post.permalink }}" class="post_comments" title="{{ post.comments.1 }} comments">{{ post.comments.0 }} comments</a>
|
<a href="{{ post.permalink }}" class="post_comments" title="{{ post.comments.1 }} {% if post.comments.1 == "1" %}comment{% else %}comments{% endif %}">{{ post.comments.0 }} {% if post.comments.1 == "1" %}comment{% else %}comments{% endif %}</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{%- endmacro %}
|
{%- endmacro %}
|
||||||
|
@ -22,7 +22,7 @@
|
|||||||
<div>Wiki</div>
|
<div>Wiki</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="wiki">
|
<div id="wiki">
|
||||||
{{ wiki }}
|
{{ wiki|safe }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
Reference in New Issue
Block a user