Compare commits
55 Commits
Author | SHA1 | Date | |
---|---|---|---|
b47cfd1ba5 | |||
28ca3589ed | |||
3cf787cf98 | |||
46e22cf74e | |||
5c2e134924 | |||
c6244585fa | |||
9f1ba274eb | |||
93ed1c6f0c | |||
6ce82c36fb | |||
2974d92e30 | |||
34dfcb2512 | |||
6b42e97bda | |||
49bfe4d27c | |||
c8965ae51b | |||
0b64a52a63 | |||
a18db1e2b7 | |||
3b53e5be4c | |||
42e8351285 | |||
b3e4b7bfae | |||
4a42a25ed3 | |||
2bacaa163f | |||
48c3a8c0d0 | |||
c23d2dc50b | |||
46dbd88d91 | |||
f0f484288e | |||
90d39b121f | |||
44dee302c9 | |||
c7f9386c01 | |||
66ac72beab | |||
14f9ac4ca7 | |||
6a7f725c12 | |||
2533e8cef5 | |||
772d20615b | |||
0bb1677520 | |||
da4883db29 | |||
d50b6ca4b3 | |||
4c66e75f6b | |||
966e0ce921 | |||
ab886d1e67 | |||
dc7e087ed0 | |||
0d6e18d97d | |||
f872baa1fe | |||
9b5176f7b9 | |||
60c89197e5 | |||
7d94876d90 | |||
467342edf4 | |||
3c5b4037e2 | |||
a81502dde1 | |||
0ce2d9054e | |||
a5203fe8dd | |||
038fafa378 | |||
e15c15c390 | |||
07363e47a9 | |||
fb7faf6477 | |||
b14b4ff551 |
27
.github/ISSUE_TEMPLATE/bug_report.md
vendored
27
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@ -1,24 +1,33 @@
|
|||||||
---
|
---
|
||||||
name: Bug report
|
name: 🐛 Bug report
|
||||||
about: Create a report to help us improve
|
about: Create a report to help us improve
|
||||||
title: Bug Report | [title]
|
title: ''
|
||||||
labels: bug
|
labels: bug
|
||||||
assignees: ''
|
assignees: ''
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Describe the bug**
|
## Describe the bug
|
||||||
A clear and concise description of what the bug is.
|
<!--
|
||||||
|
A clear and concise description of what the bug is.
|
||||||
|
-->
|
||||||
|
|
||||||
**To reproduce**
|
## To reproduce
|
||||||
|
|
||||||
|
<!--
|
||||||
Steps to reproduce the behavior:
|
Steps to reproduce the behavior:
|
||||||
1. Go to '...'
|
1. Go to '...'
|
||||||
2. Click on '....'
|
2. Click on '....'
|
||||||
3. Scroll down to '....'
|
3. Scroll down to '....'
|
||||||
4. See error
|
4. See error
|
||||||
|
-->
|
||||||
|
|
||||||
**Expected behavior**
|
## Expected behavior
|
||||||
A clear and concise description of what you expected to happen.
|
<!--
|
||||||
|
A clear and concise description of what you expected to happen.
|
||||||
|
-->
|
||||||
|
|
||||||
**Additional context**
|
## Additional context
|
||||||
Add any other context about the problem here.
|
<!--
|
||||||
|
Add any other context about the problem here.
|
||||||
|
-->
|
||||||
|
28
.github/ISSUE_TEMPLATE/feature_request.md
vendored
28
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@ -1,20 +1,28 @@
|
|||||||
---
|
---
|
||||||
name: Feature request
|
name: 💡 Feature request
|
||||||
about: Suggest an idea for this project
|
about: Suggest an idea for this project
|
||||||
title: Feature Request | [title]
|
title: ''
|
||||||
labels: enhancement
|
labels: enhancement
|
||||||
assignees: ''
|
assignees: ''
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Is your feature request related to a problem? Please describe.**
|
## Is your feature request related to a problem? Please describe.
|
||||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
<!--
|
||||||
|
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||||
|
-->
|
||||||
|
|
||||||
**Describe the solution you'd like**
|
## Describe the solution you'd like
|
||||||
A clear and concise description of what you want to happen.
|
<!--
|
||||||
|
A clear and concise description of what you want to happen.
|
||||||
|
-->
|
||||||
|
|
||||||
**Describe alternatives you've considered**
|
## Describe alternatives you've considered
|
||||||
A clear and concise description of any alternative solutions or features you've considered.
|
<!--
|
||||||
|
A clear and concise description of any alternative solutions or features you've considered.
|
||||||
|
-->
|
||||||
|
|
||||||
**Additional context**
|
## Additional context
|
||||||
Add any other context or screenshots about the feature request here.
|
<!--
|
||||||
|
Add any other context or screenshots about the feature request here.
|
||||||
|
-->
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
name: Docker Multi-Architecture Build
|
name: Docker ARM Build
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@ -30,7 +30,7 @@ jobs:
|
|||||||
uses: docker/build-push-action@v2
|
uses: docker/build-push-action@v2
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile.arm64
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/arm64
|
||||||
push: true
|
push: true
|
||||||
tags: spikecodes/libreddit:latest
|
tags: spikecodes/libreddit:arm
|
27
.github/workflows/rust.yml
vendored
27
.github/workflows/rust.yml
vendored
@ -21,9 +21,34 @@ jobs:
|
|||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: cargo build --release
|
run: cargo build --release
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v2.2.1
|
- uses: actions/upload-artifact@v2.2.1
|
||||||
name: Upload a Build Artifact
|
name: Upload a Build Artifact
|
||||||
with:
|
with:
|
||||||
name: libreddit
|
name: libreddit
|
||||||
path: target/release/libreddit
|
path: target/release/libreddit
|
||||||
|
|
||||||
|
- name: Versions
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
echo "::set-output name=version::$(cargo metadata --format-version 1 --no-deps | jq .packages[0].version -r | sed 's/^/v/')"
|
||||||
|
echo "::set-output name=tag::$(git describe --tags)"
|
||||||
|
|
||||||
|
- name: Calculate SHA512 checksum
|
||||||
|
run: sha512sum target/release/libreddit > libreddit.sha512
|
||||||
|
|
||||||
|
- name: Release
|
||||||
|
uses: softprops/action-gh-release@v1
|
||||||
|
with:
|
||||||
|
tag_name: ${{ steps.version.outputs.version }}
|
||||||
|
name: ${{ steps.version.outputs.version }} - NAME
|
||||||
|
draft: true
|
||||||
|
files: |
|
||||||
|
target/release/libreddit
|
||||||
|
libreddit.sha512
|
||||||
|
body: |
|
||||||
|
- ${{ github.event.head_commit.message }} ${{ github.sha }}
|
||||||
|
|
||||||
|
See full list of changes [here](https://github.com/spikecodes/libreddit/compare/${{ steps.version.outputs.tag }}...${{ steps.version.outputs.version }}).
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -1,2 +1 @@
|
|||||||
/target
|
/target
|
||||||
Cargo.lock
|
|
1460
Cargo.lock
generated
Normal file
1460
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
Cargo.toml
18
Cargo.toml
@ -3,19 +3,23 @@ name = "libreddit"
|
|||||||
description = " Alternative private front-end to Reddit"
|
description = " Alternative private front-end to Reddit"
|
||||||
license = "AGPL-3.0"
|
license = "AGPL-3.0"
|
||||||
repository = "https://github.com/spikecodes/libreddit"
|
repository = "https://github.com/spikecodes/libreddit"
|
||||||
version = "0.4.2"
|
version = "0.10.5"
|
||||||
authors = ["spikecodes <19519553+spikecodes@users.noreply.github.com>"]
|
authors = ["spikecodes <19519553+spikecodes@users.noreply.github.com>"]
|
||||||
edition = "2018"
|
edition = "2018"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
askama = { version = "0.10.5", default-features = false }
|
askama = { version = "0.10.5", default-features = false }
|
||||||
async-recursion = "0.3.2"
|
async-recursion = "0.3.2"
|
||||||
async-std = { version = "1.9.0", features = ["attributes"] }
|
|
||||||
async-tls = { version = "0.11.0", default-features = false, features = ["client"] }
|
|
||||||
cached = "0.23.0"
|
cached = "0.23.0"
|
||||||
clap = { version = "2.33.3", default-features = false }
|
clap = { version = "2.33.3", default-features = false }
|
||||||
regex = "1.4.4"
|
regex = "1.4.5"
|
||||||
serde = { version = "1.0.124", features = ["derive"] }
|
serde = { version = "1.0.125", features = ["derive"] }
|
||||||
|
cookie = "0.15.0"
|
||||||
|
futures-lite = "1.11.3"
|
||||||
|
hyper = { version = "0.14.5", features = ["full"] }
|
||||||
|
hyper-rustls = "0.22.1"
|
||||||
|
route-recognizer = "0.3.0"
|
||||||
serde_json = "1.0.64"
|
serde_json = "1.0.64"
|
||||||
tide = { version = "0.16.0", default-features = false, features = ["h1-server", "cookies"] }
|
tokio = { version = "1.5.0", features = ["full"] }
|
||||||
time = "0.2.25"
|
time = "0.2.26"
|
||||||
|
url = "2.2.1"
|
||||||
|
35
Dockerfile
35
Dockerfile
@ -1,17 +1,36 @@
|
|||||||
FROM rust:latest as builder
|
####################################################################################################
|
||||||
|
## Builder
|
||||||
|
####################################################################################################
|
||||||
|
FROM rust:alpine AS builder
|
||||||
|
|
||||||
|
RUN apk add --no-cache musl-dev
|
||||||
|
|
||||||
|
WORKDIR /libreddit
|
||||||
|
|
||||||
WORKDIR /usr/src/libreddit
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN cargo install --path .
|
|
||||||
|
|
||||||
|
RUN cargo build --target x86_64-unknown-linux-musl --release
|
||||||
|
|
||||||
FROM debian:buster-slim
|
####################################################################################################
|
||||||
|
## Final image
|
||||||
|
####################################################################################################
|
||||||
|
FROM alpine:latest
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y libcurl4 && rm -rf /var/lib/apt/lists/*
|
# Import ca-certificates from builder
|
||||||
COPY --from=builder /usr/local/cargo/bin/libreddit /usr/local/bin/libreddit
|
COPY --from=builder /usr/share/ca-certificates /usr/share/ca-certificates
|
||||||
RUN useradd --system --user-group --home-dir /nonexistent --no-create-home --shell /usr/sbin/nologin libreddit
|
COPY --from=builder /etc/ssl/certs /etc/ssl/certs
|
||||||
|
|
||||||
|
# Copy our build
|
||||||
|
COPY --from=builder /libreddit/target/x86_64-unknown-linux-musl/release/libreddit /usr/local/bin/libreddit
|
||||||
|
|
||||||
|
# Use an unprivileged user.
|
||||||
|
RUN adduser --home /nonexistent --no-create-home --disabled-password libreddit
|
||||||
USER libreddit
|
USER libreddit
|
||||||
|
|
||||||
|
# Tell Docker to expose port 8080
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
CMD ["libreddit"]
|
# Run a healthcheck every minute to make sure Libreddit is functional
|
||||||
|
HEALTHCHECK --interval=1m --timeout=3s CMD wget --spider --q http://localhost:8080/settings || exit 1
|
||||||
|
|
||||||
|
CMD ["libreddit"]
|
36
Dockerfile.arm64
Normal file
36
Dockerfile.arm64
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
####################################################################################################
|
||||||
|
## Builder
|
||||||
|
####################################################################################################
|
||||||
|
FROM rust:alpine AS builder
|
||||||
|
|
||||||
|
RUN apk add --no-cache g++
|
||||||
|
|
||||||
|
WORKDIR /usr/src/libreddit
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN cargo install --path .
|
||||||
|
|
||||||
|
####################################################################################################
|
||||||
|
## Final image
|
||||||
|
####################################################################################################
|
||||||
|
FROM alpine:latest
|
||||||
|
|
||||||
|
# Import ca-certificates from builder
|
||||||
|
COPY --from=builder /usr/share/ca-certificates /usr/share/ca-certificates
|
||||||
|
COPY --from=builder /etc/ssl/certs /etc/ssl/certs
|
||||||
|
|
||||||
|
# Copy our build
|
||||||
|
COPY --from=builder /usr/local/cargo/bin/libreddit /usr/local/bin/libreddit
|
||||||
|
|
||||||
|
# Use an unprivileged user.
|
||||||
|
RUN adduser --home /nonexistent --no-create-home --disabled-password libreddit
|
||||||
|
USER libreddit
|
||||||
|
|
||||||
|
# Tell Docker to expose port 8080
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# Run a healthcheck every minute to make sure Libreddit is functional
|
||||||
|
HEALTHCHECK --interval=1m --timeout=3s CMD wget --spider --q http://localhost:8080/settings || exit 1
|
||||||
|
|
||||||
|
CMD ["libreddit"]
|
29
README.md
29
README.md
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> An alternative private front-end to Reddit
|
> An alternative private front-end to Reddit
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -21,20 +21,6 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Jump to...
|
|
||||||
- [About](#about)
|
|
||||||
- [Teddit Comparison](#how-does-it-compare-to-teddit)
|
|
||||||
- [Comparison](#comparison)
|
|
||||||
- [Installation](#installation)
|
|
||||||
- [Cargo](#1-cargo)
|
|
||||||
- [Docker](#2-docker)
|
|
||||||
- [AUR](#3-aur)
|
|
||||||
- [GitHub Releases](#4-github-releases)
|
|
||||||
- [Replit](#5-replit)
|
|
||||||
- [Deployment](#deployment)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Instances
|
# Instances
|
||||||
|
|
||||||
Feel free to [open an issue](https://github.com/spikecodes/libreddit/issues/new) to have your [selfhosted instance](#deployment) listed here!
|
Feel free to [open an issue](https://github.com/spikecodes/libreddit/issues/new) to have your [selfhosted instance](#deployment) listed here!
|
||||||
@ -47,6 +33,11 @@ Feel free to [open an issue](https://github.com/spikecodes/libreddit/issues/new)
|
|||||||
| [libreddit.kavin.rocks](https://libreddit.kavin.rocks) | 🇮🇳 IN | ✅ |
|
| [libreddit.kavin.rocks](https://libreddit.kavin.rocks) | 🇮🇳 IN | ✅ |
|
||||||
| [libreddit.himiko.cloud](https://libreddit.himiko.cloud) | 🇫🇮 FI | |
|
| [libreddit.himiko.cloud](https://libreddit.himiko.cloud) | 🇫🇮 FI | |
|
||||||
| [libreddit.bcow.xyz](https://libreddit.bcow.xyz) | 🇺🇸 US | |
|
| [libreddit.bcow.xyz](https://libreddit.bcow.xyz) | 🇺🇸 US | |
|
||||||
|
| [libreddit.40two.app](https://libreddit.40two.app) | 🇳🇱 NL | |
|
||||||
|
| [reddit.invak.id](https://reddit.invak.id) | 🇧🇬 BG | |
|
||||||
|
| [reddit.phii.me](https://reddit.phii.me) | 🇺🇸 US | |
|
||||||
|
| [libreddit.silkky.cloud](https://libreddit.silkky.cloud) | 🇫🇮 FI | |
|
||||||
|
| [libreddit.database.red](https://libreddit.database.red) | 🇺🇸 US | ✅ |
|
||||||
| [spjmllawtheisznfs7uryhxumin26ssv2draj7oope3ok3wuhy43eoyd.onion](http://spjmllawtheisznfs7uryhxumin26ssv2draj7oope3ok3wuhy43eoyd.onion) | 🇮🇳 IN | |
|
| [spjmllawtheisznfs7uryhxumin26ssv2draj7oope3ok3wuhy43eoyd.onion](http://spjmllawtheisznfs7uryhxumin26ssv2draj7oope3ok3wuhy43eoyd.onion) | 🇮🇳 IN | |
|
||||||
| [fwhhsbrbltmrct5hshrnqlqygqvcgmnek3cnka55zj4y7nuus5muwyyd.onion](http://fwhhsbrbltmrct5hshrnqlqygqvcgmnek3cnka55zj4y7nuus5muwyyd.onion) | 🇩🇪 DE | |
|
| [fwhhsbrbltmrct5hshrnqlqygqvcgmnek3cnka55zj4y7nuus5muwyyd.onion](http://fwhhsbrbltmrct5hshrnqlqygqvcgmnek3cnka55zj4y7nuus5muwyyd.onion) | 🇩🇪 DE | |
|
||||||
| [libreddit.himiko7xl2skojc6odi7hykl626gt4qki3vxdbv33u2u3af76d6k32ad.onion](http://libreddit.himiko7xl2skojc6odi7hykl626gt4qki3vxdbv33u2u3af76d6k32ad.onion) | 🇫🇮 FI | |
|
| [libreddit.himiko7xl2skojc6odi7hykl626gt4qki3vxdbv33u2u3af76d6k32ad.onion](http://libreddit.himiko7xl2skojc6odi7hykl626gt4qki3vxdbv33u2u3af76d6k32ad.onion) | 🇫🇮 FI | |
|
||||||
@ -63,9 +54,9 @@ Find Libreddit on 💬 [Matrix](https://matrix.to/#/#libreddit:kde.org), 🐋 [D
|
|||||||
## Built with
|
## Built with
|
||||||
|
|
||||||
- [Rust](https://www.rust-lang.org/) - Programming language
|
- [Rust](https://www.rust-lang.org/) - Programming language
|
||||||
- [Tide](https://github.com/http-rs/tide) - Web server
|
- [Hyper](https://github.com/hyperium/hyper) - HTTP server and client
|
||||||
- [Askama](https://github.com/djc/askama) - Templating engine
|
- [Askama](https://github.com/djc/askama) - Templating engine
|
||||||
- [Surf](https://github.com/http-rs/surf) - HTTP client
|
- [Rustls](https://github.com/ctz/rustls) - TLS library
|
||||||
|
|
||||||
## Info
|
## Info
|
||||||
Libreddit hopes to provide an easier way to browse Reddit, without the ads, trackers, and bloat. Libreddit was inspired by other alternative front-ends to popular services such as [Invidious](https://github.com/iv-org/invidious) for YouTube, [Nitter](https://github.com/zedeus/nitter) for Twitter, and [Bibliogram](https://sr.ht/~cadence/bibliogram/) for Instagram.
|
Libreddit hopes to provide an easier way to browse Reddit, without the ads, trackers, and bloat. Libreddit was inspired by other alternative front-ends to popular services such as [Invidious](https://github.com/iv-org/invidious) for YouTube, [Nitter](https://github.com/zedeus/nitter) for Twitter, and [Bibliogram](https://sr.ht/~cadence/bibliogram/) for Instagram.
|
||||||
@ -157,14 +148,18 @@ cargo install libreddit
|
|||||||
|
|
||||||
Deploy the [Docker image](https://hub.docker.com/r/spikecodes/libreddit) of Libreddit:
|
Deploy the [Docker image](https://hub.docker.com/r/spikecodes/libreddit) of Libreddit:
|
||||||
```
|
```
|
||||||
|
docker pull spikecodes/libreddit
|
||||||
docker run -d --name libreddit -p 8080:8080 spikecodes/libreddit
|
docker run -d --name libreddit -p 8080:8080 spikecodes/libreddit
|
||||||
```
|
```
|
||||||
|
|
||||||
Deploy using a different port (in this case, port 80):
|
Deploy using a different port (in this case, port 80):
|
||||||
```
|
```
|
||||||
|
docker pull spikecodes/libreddit
|
||||||
docker run -d --name libreddit -p 80:8080 spikecodes/libreddit
|
docker run -d --name libreddit -p 80:8080 spikecodes/libreddit
|
||||||
```
|
```
|
||||||
|
|
||||||
|
To deploy on `arm64` platforms, simply replace `spikecodes/libreddit` in the commands above with `spikecodes/libreddit:arm`.
|
||||||
|
|
||||||
## 3) AUR
|
## 3) AUR
|
||||||
|
|
||||||
For ArchLinux users, Libreddit is available from the AUR as [`libreddit-git`](https://aur.archlinux.org/packages/libreddit-git).
|
For ArchLinux users, Libreddit is available from the AUR as [`libreddit-git`](https://aur.archlinux.org/packages/libreddit-git).
|
||||||
|
13
docker-compose.yml
Normal file
13
docker-compose.yml
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
version: "3.8"
|
||||||
|
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
build: .
|
||||||
|
restart: always
|
||||||
|
container_name: "libreddit"
|
||||||
|
ports:
|
||||||
|
- 8080:8080
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8080/settings"]
|
||||||
|
interval: 5m
|
||||||
|
timeout: 3s
|
139
src/client.rs
Normal file
139
src/client.rs
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
use cached::proc_macro::cached;
|
||||||
|
use futures_lite::{future::Boxed, FutureExt};
|
||||||
|
use hyper::{body::Buf, client, Body, Request, Response, Uri};
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::{result::Result, str::FromStr};
|
||||||
|
|
||||||
|
use crate::server::RequestExt;
|
||||||
|
|
||||||
|
pub async fn proxy(req: Request<Body>, format: &str) -> Result<Response<Body>, String> {
|
||||||
|
let mut url = format!("{}?{}", format, req.uri().query().unwrap_or_default());
|
||||||
|
|
||||||
|
for (name, value) in req.params().iter() {
|
||||||
|
url = url.replace(&format!("{{{}}}", name), value);
|
||||||
|
}
|
||||||
|
|
||||||
|
stream(&url).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stream(url: &str) -> Result<Response<Body>, String> {
|
||||||
|
// First parameter is target URL (mandatory).
|
||||||
|
let url = Uri::from_str(url).map_err(|_| "Couldn't parse URL".to_string())?;
|
||||||
|
|
||||||
|
// Prepare the HTTPS connector.
|
||||||
|
let https = hyper_rustls::HttpsConnector::with_native_roots();
|
||||||
|
|
||||||
|
// Build the hyper client from the HTTPS connector.
|
||||||
|
let client: client::Client<_, hyper::Body> = client::Client::builder().build(https);
|
||||||
|
|
||||||
|
client
|
||||||
|
.get(url)
|
||||||
|
.await
|
||||||
|
.map(|mut res| {
|
||||||
|
let mut rm = |key: &str| res.headers_mut().remove(key);
|
||||||
|
|
||||||
|
rm("access-control-expose-headers");
|
||||||
|
rm("server");
|
||||||
|
rm("vary");
|
||||||
|
rm("etag");
|
||||||
|
rm("x-cdn");
|
||||||
|
rm("x-cdn-client-region");
|
||||||
|
rm("x-cdn-name");
|
||||||
|
rm("x-cdn-server-region");
|
||||||
|
|
||||||
|
res
|
||||||
|
})
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request(url: String) -> Boxed<Result<Response<Body>, String>> {
|
||||||
|
// Prepare the HTTPS connector.
|
||||||
|
let https = hyper_rustls::HttpsConnector::with_native_roots();
|
||||||
|
|
||||||
|
// Build the hyper client from the HTTPS connector.
|
||||||
|
let client: client::Client<_, hyper::Body> = client::Client::builder().build(https);
|
||||||
|
|
||||||
|
let builder = Request::builder()
|
||||||
|
.method("GET")
|
||||||
|
.uri(&url)
|
||||||
|
.header("User-Agent", format!("web:libreddit:{}", env!("CARGO_PKG_VERSION")))
|
||||||
|
.header("Host", "www.reddit.com")
|
||||||
|
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8")
|
||||||
|
.header("Accept-Language", "en-US,en;q=0.5")
|
||||||
|
.header("Connection", "keep-alive")
|
||||||
|
.body(Body::empty());
|
||||||
|
|
||||||
|
async move {
|
||||||
|
match builder {
|
||||||
|
Ok(req) => match client.request(req).await {
|
||||||
|
Ok(response) => {
|
||||||
|
if response.status().to_string().starts_with('3') {
|
||||||
|
request(
|
||||||
|
response
|
||||||
|
.headers()
|
||||||
|
.get("Location")
|
||||||
|
.map(|val| val.to_str().unwrap_or_default())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
} else {
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => Err(e.to_string()),
|
||||||
|
},
|
||||||
|
Err(_) => Err("Post url contains non-ASCII characters".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.boxed()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make a request to a Reddit API and parse the JSON response
|
||||||
|
#[cached(size = 100, time = 30, result = true)]
|
||||||
|
pub async fn json(path: String) -> Result<Value, String> {
|
||||||
|
// Build Reddit url from path
|
||||||
|
let url = format!("https://www.reddit.com{}", path);
|
||||||
|
|
||||||
|
// Closure to quickly build errors
|
||||||
|
let err = |msg: &str, e: String| -> Result<Value, String> {
|
||||||
|
// eprintln!("{} - {}: {}", url, msg, e);
|
||||||
|
Err(format!("{}: {}", msg, e))
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch the url...
|
||||||
|
match request(url.clone()).await {
|
||||||
|
Ok(response) => {
|
||||||
|
// asynchronously aggregate the chunks of the body
|
||||||
|
match hyper::body::aggregate(response).await {
|
||||||
|
Ok(body) => {
|
||||||
|
// Parse the response from Reddit as JSON
|
||||||
|
match serde_json::from_reader(body.reader()) {
|
||||||
|
Ok(value) => {
|
||||||
|
let json: Value = value;
|
||||||
|
// If Reddit returned an error
|
||||||
|
if json["error"].is_i64() {
|
||||||
|
Err(
|
||||||
|
json["reason"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
json["message"].as_str().unwrap_or_else(|| {
|
||||||
|
eprintln!("{} - Error parsing reddit error", url);
|
||||||
|
"Error parsing reddit error"
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Ok(json)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => err("Failed to parse page JSON data", e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => err("Failed receiving body from Reddit", e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => err("Couldn't send request to Reddit", e),
|
||||||
|
}
|
||||||
|
}
|
300
src/main.rs
300
src/main.rs
@ -6,12 +6,12 @@
|
|||||||
clippy::match_wildcard_for_single_variants,
|
clippy::match_wildcard_for_single_variants,
|
||||||
clippy::cast_possible_truncation,
|
clippy::cast_possible_truncation,
|
||||||
clippy::similar_names,
|
clippy::similar_names,
|
||||||
clippy::cast_possible_wrap
|
clippy::cast_possible_wrap,
|
||||||
|
clippy::find_map
|
||||||
)]
|
)]
|
||||||
|
|
||||||
// Reference local files
|
// Reference local files
|
||||||
mod post;
|
mod post;
|
||||||
mod proxy;
|
|
||||||
mod search;
|
mod search;
|
||||||
mod settings;
|
mod settings;
|
||||||
mod subreddit;
|
mod subreddit;
|
||||||
@ -19,99 +19,72 @@ mod user;
|
|||||||
mod utils;
|
mod utils;
|
||||||
|
|
||||||
// Import Crates
|
// Import Crates
|
||||||
use clap::{App, Arg};
|
use clap::{App as cli, Arg};
|
||||||
use proxy::handler;
|
|
||||||
use tide::{
|
use futures_lite::FutureExt;
|
||||||
utils::{async_trait, After},
|
use hyper::{header::HeaderValue, Body, Request, Response};
|
||||||
Middleware, Next, Request, Response,
|
|
||||||
};
|
mod client;
|
||||||
|
use client::proxy;
|
||||||
|
use server::RequestExt;
|
||||||
use utils::{error, redirect};
|
use utils::{error, redirect};
|
||||||
|
|
||||||
// Build middleware
|
mod server;
|
||||||
struct HttpsRedirect<HttpsOnly>(HttpsOnly);
|
|
||||||
struct NormalizePath;
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl<State, HttpsOnly> Middleware<State> for HttpsRedirect<HttpsOnly>
|
|
||||||
where
|
|
||||||
State: Clone + Send + Sync + 'static,
|
|
||||||
HttpsOnly: Into<bool> + Copy + Send + Sync + 'static,
|
|
||||||
{
|
|
||||||
async fn handle(&self, request: Request<State>, next: Next<'_, State>) -> tide::Result {
|
|
||||||
let secure = request.url().scheme() == "https";
|
|
||||||
|
|
||||||
if self.0.into() && !secure {
|
|
||||||
let mut secured = request.url().to_owned();
|
|
||||||
secured.set_scheme("https").unwrap_or_default();
|
|
||||||
|
|
||||||
Ok(redirect(secured.to_string()))
|
|
||||||
} else {
|
|
||||||
Ok(next.run(request).await)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl<State: Clone + Send + Sync + 'static> Middleware<State> for NormalizePath {
|
|
||||||
async fn handle(&self, request: Request<State>, next: Next<'_, State>) -> tide::Result {
|
|
||||||
let path = request.url().path();
|
|
||||||
let query = request.url().query().unwrap_or_default();
|
|
||||||
if path.ends_with('/') {
|
|
||||||
Ok(next.run(request).await)
|
|
||||||
} else {
|
|
||||||
let normalized = if query.is_empty() {
|
|
||||||
format!("{}/", path.replace("//", "/"))
|
|
||||||
} else {
|
|
||||||
format!("{}/?{}", path.replace("//", "/"), query)
|
|
||||||
};
|
|
||||||
Ok(redirect(normalized))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create Services
|
// Create Services
|
||||||
|
|
||||||
// Required for the manifest to be valid
|
// Required for the manifest to be valid
|
||||||
async fn pwa_logo(_req: Request<()>) -> tide::Result {
|
async fn pwa_logo() -> Result<Response<Body>, String> {
|
||||||
Ok(Response::builder(200).content_type("image/png").body(include_bytes!("../static/logo.png").as_ref()).build())
|
Ok(
|
||||||
|
Response::builder()
|
||||||
|
.status(200)
|
||||||
|
.header("content-type", "image/png")
|
||||||
|
.body(include_bytes!("../static/logo.png").as_ref().into())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Required for iOS App Icons
|
// Required for iOS App Icons
|
||||||
async fn iphone_logo(_req: Request<()>) -> tide::Result {
|
async fn iphone_logo() -> Result<Response<Body>, String> {
|
||||||
Ok(
|
Ok(
|
||||||
Response::builder(200)
|
Response::builder()
|
||||||
.content_type("image/png")
|
.status(200)
|
||||||
.body(include_bytes!("../static/apple-touch-icon.png").as_ref())
|
.header("content-type", "image/png")
|
||||||
.build(),
|
.body(include_bytes!("../static/apple-touch-icon.png").as_ref().into())
|
||||||
|
.unwrap_or_default(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn favicon(_req: Request<()>) -> tide::Result {
|
async fn favicon() -> Result<Response<Body>, String> {
|
||||||
Ok(
|
Ok(
|
||||||
Response::builder(200)
|
Response::builder()
|
||||||
.content_type("image/vnd.microsoft.icon")
|
.status(200)
|
||||||
|
.header("content-type", "image/vnd.microsoft.icon")
|
||||||
.header("Cache-Control", "public, max-age=1209600, s-maxage=86400")
|
.header("Cache-Control", "public, max-age=1209600, s-maxage=86400")
|
||||||
.body(include_bytes!("../static/favicon.ico").as_ref())
|
.body(include_bytes!("../static/favicon.ico").as_ref().into())
|
||||||
.build(),
|
.unwrap_or_default(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn resource(body: &str, content_type: &str, cache: bool) -> tide::Result {
|
async fn resource(body: &str, content_type: &str, cache: bool) -> Result<Response<Body>, String> {
|
||||||
let mut res = Response::new(200);
|
let mut res = Response::builder()
|
||||||
|
.status(200)
|
||||||
|
.header("content-type", content_type)
|
||||||
|
.body(body.to_string().into())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
if cache {
|
if cache {
|
||||||
res.insert_header("Cache-Control", "public, max-age=1209600, s-maxage=86400");
|
if let Ok(val) = HeaderValue::from_str("public, max-age=1209600, s-maxage=86400") {
|
||||||
|
res.headers_mut().insert("Cache-Control", val);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
res.set_content_type(content_type);
|
|
||||||
res.set_body(body);
|
|
||||||
|
|
||||||
Ok(res)
|
Ok(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_std::main]
|
#[tokio::main]
|
||||||
async fn main() -> tide::Result<()> {
|
async fn main() {
|
||||||
let matches = App::new("Libreddit")
|
let matches = cli::new("Libreddit")
|
||||||
.version(env!("CARGO_PKG_VERSION"))
|
.version(env!("CARGO_PKG_VERSION"))
|
||||||
.about("Private front-end for Reddit written in Rust ")
|
.about("Private front-end for Reddit written in Rust ")
|
||||||
.arg(
|
.arg(
|
||||||
@ -136,142 +109,149 @@ async fn main() -> tide::Result<()> {
|
|||||||
Arg::with_name("redirect-https")
|
Arg::with_name("redirect-https")
|
||||||
.short("r")
|
.short("r")
|
||||||
.long("redirect-https")
|
.long("redirect-https")
|
||||||
.help("Redirect all HTTP requests to HTTPS")
|
.help("Redirect all HTTP requests to HTTPS (no longer functional)")
|
||||||
.takes_value(false),
|
.takes_value(false),
|
||||||
)
|
)
|
||||||
|
.arg(
|
||||||
|
Arg::with_name("hsts")
|
||||||
|
.short("H")
|
||||||
|
.long("hsts")
|
||||||
|
.value_name("EXPIRE_TIME")
|
||||||
|
.help("HSTS header to tell browsers that this site should only be accessed over HTTPS")
|
||||||
|
.default_value("604800")
|
||||||
|
.takes_value(true),
|
||||||
|
)
|
||||||
.get_matches();
|
.get_matches();
|
||||||
|
|
||||||
let address = matches.value_of("address").unwrap_or("0.0.0.0");
|
let address = matches.value_of("address").unwrap_or("0.0.0.0");
|
||||||
let port = matches.value_of("port").unwrap_or("8080");
|
let port = matches.value_of("port").unwrap_or("8080");
|
||||||
let force_https = matches.is_present("redirect-https");
|
let hsts = matches.value_of("hsts");
|
||||||
|
|
||||||
let listener = format!("{}:{}", address, port);
|
let listener = format!("{}:{}", address, port);
|
||||||
|
|
||||||
println!("Starting Libreddit...");
|
println!("Starting Libreddit...");
|
||||||
|
|
||||||
// Start HTTP server
|
// Begin constructing a server
|
||||||
let mut app = tide::new();
|
let mut app = server::Server::new();
|
||||||
|
|
||||||
// Redirect to HTTPS if "--redirect-https" enabled
|
// Define default headers (added to all responses)
|
||||||
app.with(HttpsRedirect(force_https));
|
app.default_headers = headers! {
|
||||||
|
"Referrer-Policy" => "no-referrer",
|
||||||
|
"X-Content-Type-Options" => "nosniff",
|
||||||
|
"X-Frame-Options" => "DENY",
|
||||||
|
"Content-Security-Policy" => "default-src 'none'; manifest-src 'self'; media-src 'self'; style-src 'self' 'unsafe-inline'; base-uri 'none'; img-src 'self' data:; form-action 'self'; frame-ancestors 'none';"
|
||||||
|
};
|
||||||
|
|
||||||
// Append trailing slash and remove double slashes
|
if let Some(expire_time) = hsts {
|
||||||
app.with(NormalizePath);
|
if let Ok(val) = HeaderValue::from_str(&format!("max-age={}", expire_time)) {
|
||||||
|
app.default_headers.insert("Strict-Transport-Security", val);
|
||||||
// Apply default headers for security
|
}
|
||||||
app.with(After(|mut res: Response| async move {
|
}
|
||||||
res.insert_header("Referrer-Policy", "no-referrer");
|
|
||||||
res.insert_header("X-Content-Type-Options", "nosniff");
|
|
||||||
res.insert_header("X-Frame-Options", "DENY");
|
|
||||||
res.insert_header(
|
|
||||||
"Content-Security-Policy",
|
|
||||||
"default-src 'none'; manifest-src 'self'; media-src 'self'; style-src 'self' 'unsafe-inline'; base-uri 'none'; img-src 'self' data:; form-action 'self'; frame-ancestors 'none';",
|
|
||||||
);
|
|
||||||
Ok(res)
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Read static files
|
// Read static files
|
||||||
app.at("/style.css/").get(|_| resource(include_str!("../static/style.css"), "text/css", false));
|
app.at("/style.css").get(|_| resource(include_str!("../static/style.css"), "text/css", false).boxed());
|
||||||
app
|
app
|
||||||
.at("/manifest.json/")
|
.at("/manifest.json")
|
||||||
.get(|_| resource(include_str!("../static/manifest.json"), "application/json", false));
|
.get(|_| resource(include_str!("../static/manifest.json"), "application/json", false).boxed());
|
||||||
app.at("/robots.txt/").get(|_| resource("User-agent: *\nAllow: /", "text/plain", true));
|
app.at("/robots.txt").get(|_| resource("User-agent: *\nAllow: /", "text/plain", true).boxed());
|
||||||
app.at("/favicon.ico/").get(favicon);
|
app.at("/favicon.ico").get(|_| favicon().boxed());
|
||||||
app.at("/logo.png/").get(pwa_logo);
|
app.at("/logo.png").get(|_| pwa_logo().boxed());
|
||||||
app.at("/touch-icon-iphone.png/").get(iphone_logo);
|
app.at("/touch-icon-iphone.png").get(|_| iphone_logo().boxed());
|
||||||
app.at("/apple-touch-icon.png/").get(iphone_logo);
|
app.at("/apple-touch-icon.png").get(|_| iphone_logo().boxed());
|
||||||
|
|
||||||
// Proxy media through Libreddit
|
// Proxy media through Libreddit
|
||||||
app
|
app.at("/vid/:id/:size").get(|r| proxy(r, "https://v.redd.it/{id}/DASH_{size}").boxed());
|
||||||
.at("/vid/:id/:size/") /* */
|
app.at("/img/:id").get(|r| proxy(r, "https://i.redd.it/{id}").boxed());
|
||||||
.get(|req| handler(req, "https://v.redd.it/{}/DASH_{}", vec!["id", "size"]));
|
app.at("/thumb/:point/:id").get(|r| proxy(r, "https://{point}.thumbs.redditmedia.com/{id}").boxed());
|
||||||
app
|
app.at("/emoji/:id/:name").get(|r| proxy(r, "https://emoji.redditmedia.com/{id}/{name}").boxed());
|
||||||
.at("/img/:id/") /* */
|
app.at("/preview/:loc/:id").get(|r| proxy(r, "https://{loc}view.redd.it/{id}").boxed());
|
||||||
.get(|req| handler(req, "https://i.redd.it/{}", vec!["id"]));
|
app.at("/style/*path").get(|r| proxy(r, "https://styles.redditmedia.com/{path}").boxed());
|
||||||
app
|
app.at("/static/*path").get(|r| proxy(r, "https://www.redditstatic.com/{path}").boxed());
|
||||||
.at("/thumb/:point/:id/") /* */
|
|
||||||
.get(|req| handler(req, "https://{}.thumbs.redditmedia.com/{}", vec!["point", "id"]));
|
|
||||||
app
|
|
||||||
.at("/emoji/:id/:name/") /* */
|
|
||||||
.get(|req| handler(req, "https://emoji.redditmedia.com/{}/{}", vec!["id", "name"]));
|
|
||||||
app
|
|
||||||
.at("/preview/:loc/:id/:query/")
|
|
||||||
.get(|req| handler(req, "https://{}view.redd.it/{}?{}", vec!["loc", "id", "query"]));
|
|
||||||
app
|
|
||||||
.at("/style/*path/") /* */
|
|
||||||
.get(|req| handler(req, "https://styles.redditmedia.com/{}", vec!["path"]));
|
|
||||||
app
|
|
||||||
.at("/static/*path/") /* */
|
|
||||||
.get(|req| handler(req, "https://www.redditstatic.com/{}", vec!["path"]));
|
|
||||||
|
|
||||||
// Browse user profile
|
// Browse user profile
|
||||||
app.at("/u/:name/").get(user::profile);
|
app
|
||||||
app.at("/u/:name/comments/:id/:title/").get(post::item);
|
.at("/u/:name")
|
||||||
app.at("/u/:name/comments/:id/:title/:comment_id/").get(post::item);
|
.get(|r| async move { Ok(redirect(format!("/user/{}", r.param("name").unwrap_or_default()))) }.boxed());
|
||||||
|
app.at("/u/:name/comments/:id/:title").get(|r| post::item(r).boxed());
|
||||||
|
app.at("/u/:name/comments/:id/:title/:comment_id").get(|r| post::item(r).boxed());
|
||||||
|
|
||||||
app.at("/user/:name/").get(user::profile);
|
app.at("/user/[deleted]").get(|req| error(req, "User has deleted their account".to_string()).boxed());
|
||||||
app.at("/user/:name/comments/:id/").get(post::item);
|
app.at("/user/:name").get(|r| user::profile(r).boxed());
|
||||||
app.at("/user/:name/comments/:id/:title/").get(post::item);
|
app.at("/user/:name/comments/:id").get(|r| post::item(r).boxed());
|
||||||
app.at("/user/:name/comments/:id/:title/:comment_id/").get(post::item);
|
app.at("/user/:name/comments/:id/:title").get(|r| post::item(r).boxed());
|
||||||
|
app.at("/user/:name/comments/:id/:title/:comment_id").get(|r| post::item(r).boxed());
|
||||||
|
|
||||||
// Configure settings
|
// Configure settings
|
||||||
app.at("/settings/").get(settings::get).post(settings::set);
|
app.at("/settings").get(|r| settings::get(r).boxed()).post(|r| settings::set(r).boxed());
|
||||||
app.at("/settings/restore/").get(settings::restore);
|
app.at("/settings/restore").get(|r| settings::restore(r).boxed());
|
||||||
|
|
||||||
// Subreddit services
|
// Subreddit services
|
||||||
app.at("/r/:sub/").get(subreddit::community);
|
app.at("/r/:sub").get(|r| subreddit::community(r).boxed());
|
||||||
|
|
||||||
app.at("/r/:sub/subscribe/").post(subreddit::subscriptions);
|
app
|
||||||
app.at("/r/:sub/unsubscribe/").post(subreddit::subscriptions);
|
.at("/r/u_:name")
|
||||||
|
.get(|r| async move { Ok(redirect(format!("/user/{}", r.param("name").unwrap_or_default()))) }.boxed());
|
||||||
|
|
||||||
app.at("/r/:sub/comments/:id/").get(post::item);
|
app.at("/r/:sub/subscribe").post(|r| subreddit::subscriptions(r).boxed());
|
||||||
app.at("/r/:sub/comments/:id/:title/").get(post::item);
|
app.at("/r/:sub/unsubscribe").post(|r| subreddit::subscriptions(r).boxed());
|
||||||
app.at("/r/:sub/comments/:id/:title/:comment_id/").get(post::item);
|
|
||||||
|
|
||||||
app.at("/r/:sub/search/").get(search::find);
|
app.at("/r/:sub/comments/:id").get(|r| post::item(r).boxed());
|
||||||
|
app.at("/r/:sub/comments/:id/:title").get(|r| post::item(r).boxed());
|
||||||
|
app.at("/r/:sub/comments/:id/:title/:comment_id").get(|r| post::item(r).boxed());
|
||||||
|
|
||||||
app.at("/r/:sub/wiki/").get(subreddit::wiki);
|
app.at("/r/:sub/search").get(|r| search::find(r).boxed());
|
||||||
app.at("/r/:sub/wiki/:page/").get(subreddit::wiki);
|
|
||||||
app.at("/r/:sub/w/").get(subreddit::wiki);
|
|
||||||
app.at("/r/:sub/w/:page/").get(subreddit::wiki);
|
|
||||||
|
|
||||||
app.at("/r/:sub/:sort/").get(subreddit::community);
|
app
|
||||||
|
.at("/r/:sub/w")
|
||||||
|
.get(|r| async move { Ok(redirect(format!("/r/{}/wiki", r.param("sub").unwrap_or_default()))) }.boxed());
|
||||||
|
app
|
||||||
|
.at("/r/:sub/w/*page")
|
||||||
|
.get(|r| async move { Ok(redirect(format!("/r/{}/wiki/{}", r.param("sub").unwrap_or_default(), r.param("wiki").unwrap_or_default()))) }.boxed());
|
||||||
|
app.at("/r/:sub/wiki").get(|r| subreddit::wiki(r).boxed());
|
||||||
|
app.at("/r/:sub/wiki/*page").get(|r| subreddit::wiki(r).boxed());
|
||||||
|
|
||||||
|
app.at("/r/:sub/about/sidebar").get(|r| subreddit::sidebar(r).boxed());
|
||||||
|
|
||||||
|
app.at("/r/:sub/:sort").get(|r| subreddit::community(r).boxed());
|
||||||
|
|
||||||
// Comments handler
|
// Comments handler
|
||||||
app.at("/comments/:id/").get(post::item);
|
app.at("/comments/:id").get(|r| post::item(r).boxed());
|
||||||
|
|
||||||
// Front page
|
// Front page
|
||||||
app.at("/").get(subreddit::community);
|
app.at("/").get(|r| subreddit::community(r).boxed());
|
||||||
|
|
||||||
// View Reddit wiki
|
// View Reddit wiki
|
||||||
app.at("/w/").get(subreddit::wiki);
|
app.at("/w").get(|_| async { Ok(redirect("/wiki".to_string())) }.boxed());
|
||||||
app.at("/w/:page/").get(subreddit::wiki);
|
app
|
||||||
app.at("/wiki/").get(subreddit::wiki);
|
.at("/w/*page")
|
||||||
app.at("/wiki/:page/").get(subreddit::wiki);
|
.get(|r| async move { Ok(redirect(format!("/wiki/{}", r.param("page").unwrap_or_default()))) }.boxed());
|
||||||
|
app.at("/wiki").get(|r| subreddit::wiki(r).boxed());
|
||||||
|
app.at("/wiki/*page").get(|r| subreddit::wiki(r).boxed());
|
||||||
|
|
||||||
// Search all of Reddit
|
// Search all of Reddit
|
||||||
app.at("/search/").get(search::find);
|
app.at("/search").get(|r| search::find(r).boxed());
|
||||||
|
|
||||||
// Handle about pages
|
// Handle about pages
|
||||||
app.at("/about/").get(|req| error(req, "About pages aren't here yet".to_string()));
|
app.at("/about").get(|req| error(req, "About pages aren't added yet".to_string()).boxed());
|
||||||
|
|
||||||
app.at("/:id/").get(|req: Request<()>| async {
|
app.at("/:id").get(|req: Request<Body>| match req.param("id").as_deref() {
|
||||||
match req.param("id") {
|
// Sort front page
|
||||||
// Sort front page
|
Some("best") | Some("hot") | Some("new") | Some("top") | Some("rising") | Some("controversial") => subreddit::community(req).boxed(),
|
||||||
Ok("best") | Ok("hot") | Ok("new") | Ok("top") | Ok("rising") | Ok("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(),
|
||||||
Ok(id) if id.len() > 4 && id.len() < 7 => post::item(req).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
|
||||||
app.at("*").get(|req| error(req, "Nothing here".to_string()));
|
app.at("/*").get(|req| error(req, "Nothing here".to_string()).boxed());
|
||||||
|
|
||||||
println!("Running Libreddit v{} on {}!", env!("CARGO_PKG_VERSION"), listener);
|
println!("Running Libreddit v{} on {}!", env!("CARGO_PKG_VERSION"), listener);
|
||||||
|
|
||||||
app.listen(&listener).await?;
|
let server = app.listen(listener);
|
||||||
|
|
||||||
Ok(())
|
// Run this server for... forever!
|
||||||
|
if let Err(e) = server.await {
|
||||||
|
eprintln!("Server error: {}", e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
26
src/post.rs
26
src/post.rs
@ -1,9 +1,9 @@
|
|||||||
// CRATES
|
// CRATES
|
||||||
|
use crate::client::json;
|
||||||
use crate::esc;
|
use crate::esc;
|
||||||
use crate::utils::{
|
use crate::server::RequestExt;
|
||||||
cookie, error, format_num, format_url, param, request, rewrite_urls, template, time, val, Author, Comment, Flags, Flair, FlairPart, Media, Post, Preferences,
|
use crate::utils::{cookie, error, format_num, format_url, param, rewrite_urls, template, time, val, Author, Comment, Flags, Flair, FlairPart, Media, Post, Preferences};
|
||||||
};
|
use hyper::{Body, Request, Response};
|
||||||
use tide::Request;
|
|
||||||
|
|
||||||
use async_recursion::async_recursion;
|
use async_recursion::async_recursion;
|
||||||
|
|
||||||
@ -20,9 +20,9 @@ struct PostTemplate {
|
|||||||
single_thread: bool,
|
single_thread: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn item(req: Request<()>) -> tide::Result {
|
pub async fn item(req: Request<Body>) -> Result<Response<Body>, String> {
|
||||||
// Build Reddit API path
|
// Build Reddit API path
|
||||||
let mut path: String = format!("{}.json?{}&raw_json=1", req.url().path(), req.url().query().unwrap_or_default());
|
let mut path: String = format!("{}.json?{}&raw_json=1", req.uri().path(), req.uri().query().unwrap_or_default());
|
||||||
|
|
||||||
// Set sort to sort query parameter
|
// Set sort to sort query parameter
|
||||||
let mut sort: String = param(&path, "sort");
|
let mut sort: String = param(&path, "sort");
|
||||||
@ -33,23 +33,23 @@ pub async fn item(req: Request<()>) -> tide::Result {
|
|||||||
// If there's no sort query but there's a default sort, set sort to default_sort
|
// If there's no sort query but there's a default sort, set sort to default_sort
|
||||||
if sort.is_empty() && !default_sort.is_empty() {
|
if sort.is_empty() && !default_sort.is_empty() {
|
||||||
sort = default_sort;
|
sort = default_sort;
|
||||||
path = format!("{}.json?{}&sort={}&raw_json=1", req.url().path(), req.url().query().unwrap_or_default(), sort);
|
path = format!("{}.json?{}&sort={}&raw_json=1", req.uri().path(), req.uri().query().unwrap_or_default(), sort);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log the post ID being fetched in debug mode
|
// Log the post ID being fetched in debug mode
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
dbg!(req.param("id").unwrap_or(""));
|
dbg!(req.param("id").unwrap_or_default());
|
||||||
|
|
||||||
let single_thread = &req.param("comment_id").is_ok();
|
let single_thread = req.param("comment_id").is_some();
|
||||||
let highlighted_comment = &req.param("comment_id").unwrap_or_default();
|
let highlighted_comment = &req.param("comment_id").unwrap_or_default();
|
||||||
|
|
||||||
// Send a request to the url, receive JSON in response
|
// Send a request to the url, receive JSON in response
|
||||||
match request(path).await {
|
match json(path).await {
|
||||||
// Otherwise, grab the JSON output from the request
|
// Otherwise, grab the JSON output from the request
|
||||||
Ok(res) => {
|
Ok(res) => {
|
||||||
// Parse the JSON into Post and Comment structs
|
// Parse the JSON into Post and Comment structs
|
||||||
let post = parse_post(&res[0]).await;
|
let post = parse_post(&res[0]).await;
|
||||||
let comments = parse_comments(&res[1], &post.permalink, &post.author.name, *highlighted_comment).await;
|
let comments = parse_comments(&res[1], &post.permalink, &post.author.name, highlighted_comment).await;
|
||||||
|
|
||||||
// Use the Post and Comment structs to generate a website to show users
|
// Use the Post and Comment structs to generate a website to show users
|
||||||
template(PostTemplate {
|
template(PostTemplate {
|
||||||
@ -57,7 +57,7 @@ pub async fn item(req: Request<()>) -> tide::Result {
|
|||||||
post,
|
post,
|
||||||
sort,
|
sort,
|
||||||
prefs: Preferences::new(req),
|
prefs: Preferences::new(req),
|
||||||
single_thread: *single_thread,
|
single_thread,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// If the Reddit API returns an error, exit and send error page to user
|
// If the Reddit API returns an error, exit and send error page to user
|
||||||
@ -199,7 +199,7 @@ async fn parse_comments(json: &serde_json::Value, post_link: &str, post_author:
|
|||||||
distinguished: val(&comment, "distinguished"),
|
distinguished: val(&comment, "distinguished"),
|
||||||
},
|
},
|
||||||
score: if data["score_hidden"].as_bool().unwrap_or_default() {
|
score: if data["score_hidden"].as_bool().unwrap_or_default() {
|
||||||
"\u{2022}".to_string()
|
("\u{2022}".to_string(), "Hidden".to_string())
|
||||||
} else {
|
} else {
|
||||||
format_num(score)
|
format_num(score)
|
||||||
},
|
},
|
||||||
|
88
src/proxy.rs
88
src/proxy.rs
@ -1,88 +0,0 @@
|
|||||||
use async_std::{io, net::TcpStream, prelude::*};
|
|
||||||
use async_tls::TlsConnector;
|
|
||||||
use tide::{http::url::Url, Request, Response};
|
|
||||||
|
|
||||||
/// Handle tide routes to proxy by parsing `params` from `req`uest.
|
|
||||||
pub async fn handler(req: Request<()>, format: &str, params: Vec<&str>) -> tide::Result {
|
|
||||||
let mut url = format.to_string();
|
|
||||||
|
|
||||||
for name in params {
|
|
||||||
let param = req.param(name).unwrap_or_default();
|
|
||||||
url = url.replacen("{}", param, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
request(url).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sends a request to a Reddit media domain and proxy the response.
|
|
||||||
///
|
|
||||||
/// Relays the `Content-Length` and `Content-Type` header.
|
|
||||||
async fn request(url: String) -> tide::Result {
|
|
||||||
// Parse url into parts
|
|
||||||
let parts = Url::parse(&url)?;
|
|
||||||
let host = parts.host().map(|host| host.to_string()).unwrap_or_default();
|
|
||||||
let domain = parts.domain().unwrap_or_default();
|
|
||||||
let path = format!("{}?{}", parts.path(), parts.query().unwrap_or_default());
|
|
||||||
// Build reddit-compliant user agent for Libreddit
|
|
||||||
let user_agent = format!("web:libreddit:{}", env!("CARGO_PKG_VERSION"));
|
|
||||||
|
|
||||||
// Construct a request body
|
|
||||||
let req = format!(
|
|
||||||
"GET {} HTTP/1.1\r\nHost: {}\r\nAccept: */*\r\nConnection: close\r\nUser-Agent: {}\r\n\r\n",
|
|
||||||
path, host, user_agent
|
|
||||||
);
|
|
||||||
|
|
||||||
// Initialize TLS connector for requests
|
|
||||||
let connector = TlsConnector::default();
|
|
||||||
|
|
||||||
// Open a TCP connection
|
|
||||||
let tcp_stream = TcpStream::connect(format!("{}:443", domain)).await?;
|
|
||||||
|
|
||||||
// Use the connector to start the handshake process
|
|
||||||
let mut tls_stream = connector.connect(domain, tcp_stream).await?;
|
|
||||||
|
|
||||||
// Write the aforementioned HTTP request to the stream
|
|
||||||
tls_stream.write_all(req.as_bytes()).await?;
|
|
||||||
|
|
||||||
// And read the response
|
|
||||||
let mut writer = Vec::new();
|
|
||||||
io::copy(&mut tls_stream, &mut writer).await?;
|
|
||||||
|
|
||||||
// Find the delimiter which separates the body and headers
|
|
||||||
match (0..writer.len()).find(|i| writer[i.to_owned()] == 10_u8 && writer[i - 2] == 10_u8) {
|
|
||||||
Some(delim) => {
|
|
||||||
// Split the response into the body and headers
|
|
||||||
let split = writer.split_at(delim);
|
|
||||||
let headers_str = String::from_utf8_lossy(split.0);
|
|
||||||
let headers = headers_str.split("\r\n").collect::<Vec<&str>>();
|
|
||||||
let body = split.1[1..split.1.len()].to_vec();
|
|
||||||
|
|
||||||
// Parse the status code from the first header line
|
|
||||||
let status: u16 = headers[0].split(' ').collect::<Vec<&str>>()[1].parse().unwrap_or_default();
|
|
||||||
|
|
||||||
// Define a closure for easier header fetching
|
|
||||||
let header = |name: &str| {
|
|
||||||
headers
|
|
||||||
.iter()
|
|
||||||
.find(|x| x.starts_with(name))
|
|
||||||
.map(|f| f.split(": ").collect::<Vec<&str>>()[1])
|
|
||||||
.unwrap_or_default()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Parse Content-Length and Content-Type from headers
|
|
||||||
let content_length = header("Content-Length");
|
|
||||||
let content_type = header("Content-Type");
|
|
||||||
|
|
||||||
// Build response
|
|
||||||
Ok(
|
|
||||||
Response::builder(status)
|
|
||||||
.body(tide::http::Body::from_bytes(body))
|
|
||||||
.header("Cache-Control", "public, max-age=1209600, s-maxage=86400")
|
|
||||||
.header("Content-Length", content_length)
|
|
||||||
.header("Content-Type", content_type)
|
|
||||||
.build(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
None => Ok(Response::builder(503).body("Couldn't parse media".to_string()).build()),
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,7 +1,8 @@
|
|||||||
// CRATES
|
// CRATES
|
||||||
use crate::utils::{cookie, error, param, request, template, val, Post, Preferences};
|
use crate::utils::{cookie, error, format_num, format_url, param, template, val, Post, Preferences};
|
||||||
|
use crate::{client::json, RequestExt};
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
use tide::Request;
|
use hyper::{Body, Request, Response};
|
||||||
|
|
||||||
// STRUCTS
|
// STRUCTS
|
||||||
struct SearchParams {
|
struct SearchParams {
|
||||||
@ -17,8 +18,9 @@ struct SearchParams {
|
|||||||
struct Subreddit {
|
struct Subreddit {
|
||||||
name: String,
|
name: String,
|
||||||
url: String,
|
url: String,
|
||||||
|
icon: String,
|
||||||
description: String,
|
description: String,
|
||||||
subscribers: i64,
|
subscribers: (String, String),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
@ -32,10 +34,10 @@ struct SearchTemplate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SERVICES
|
// SERVICES
|
||||||
pub async fn find(req: Request<()>) -> tide::Result {
|
pub async fn find(req: Request<Body>) -> Result<Response<Body>, String> {
|
||||||
let nsfw_results = if cookie(&req, "show_nsfw") == "on" { "&include_over_18=on" } else { "" };
|
let nsfw_results = if cookie(&req, "show_nsfw") == "on" { "&include_over_18=on" } else { "" };
|
||||||
let path = format!("{}.json?{}{}", req.url().path(), req.url().query().unwrap_or_default(), nsfw_results);
|
let path = format!("{}.json?{}{}", req.uri().path(), req.uri().query().unwrap_or_default(), nsfw_results);
|
||||||
let sub = req.param("sub").unwrap_or("").to_string();
|
let sub = req.param("sub").unwrap_or_default();
|
||||||
let query = param(&path, "q");
|
let query = param(&path, "q");
|
||||||
|
|
||||||
let sort = if param(&path, "sort").is_empty() {
|
let sort = if param(&path, "sort").is_empty() {
|
||||||
@ -73,18 +75,29 @@ async fn search_subreddits(q: &str) -> Vec<Subreddit> {
|
|||||||
let subreddit_search_path = format!("/subreddits/search.json?q={}&limit=3", q.replace(' ', "+"));
|
let subreddit_search_path = format!("/subreddits/search.json?q={}&limit=3", q.replace(' ', "+"));
|
||||||
|
|
||||||
// Send a request to the url
|
// Send a request to the url
|
||||||
match request(subreddit_search_path).await {
|
match json(subreddit_search_path).await {
|
||||||
// If success, receive JSON in response
|
// If success, receive JSON in response
|
||||||
Ok(response) => {
|
Ok(response) => {
|
||||||
match response["data"]["children"].as_array() {
|
match response["data"]["children"].as_array() {
|
||||||
// For each subreddit from subreddit list
|
// For each subreddit from subreddit list
|
||||||
Some(list) => list
|
Some(list) => list
|
||||||
.iter()
|
.iter()
|
||||||
.map(|subreddit| Subreddit {
|
.map(|subreddit| {
|
||||||
name: val(subreddit, "display_name_prefixed"),
|
// Fetch subreddit icon either from the community_icon or icon_img value
|
||||||
url: val(subreddit, "url"),
|
let community_icon: &str = subreddit["data"]["community_icon"].as_str().map_or("", |s| s.split('?').collect::<Vec<&str>>()[0]);
|
||||||
description: val(subreddit, "public_description"),
|
let icon = if community_icon.is_empty() {
|
||||||
subscribers: subreddit["data"]["subscribers"].as_f64().unwrap_or_default() as i64,
|
val(&subreddit, "icon_img")
|
||||||
|
} else {
|
||||||
|
community_icon.to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
Subreddit {
|
||||||
|
name: val(subreddit, "display_name_prefixed"),
|
||||||
|
url: val(subreddit, "url"),
|
||||||
|
icon: format_url(&icon),
|
||||||
|
description: val(subreddit, "public_description"),
|
||||||
|
subscribers: format_num(subreddit["data"]["subscribers"].as_f64().unwrap_or_default() as i64),
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.collect::<Vec<Subreddit>>(),
|
.collect::<Vec<Subreddit>>(),
|
||||||
_ => Vec::new(),
|
_ => Vec::new(),
|
||||||
|
214
src/server.rs
Normal file
214
src/server.rs
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
use cookie::Cookie;
|
||||||
|
use futures_lite::{future::Boxed, Future, FutureExt};
|
||||||
|
use hyper::{
|
||||||
|
header::HeaderValue,
|
||||||
|
service::{make_service_fn, service_fn},
|
||||||
|
HeaderMap,
|
||||||
|
};
|
||||||
|
use hyper::{Body, Method, Request, Response, Server as HyperServer};
|
||||||
|
use route_recognizer::{Params, Router};
|
||||||
|
use std::{pin::Pin, result::Result};
|
||||||
|
use time::Duration;
|
||||||
|
|
||||||
|
type BoxResponse = Pin<Box<dyn Future<Output = Result<Response<Body>, String>> + Send>>;
|
||||||
|
|
||||||
|
pub struct Route<'a> {
|
||||||
|
router: &'a mut Router<fn(Request<Body>) -> BoxResponse>,
|
||||||
|
path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Server {
|
||||||
|
pub default_headers: HeaderMap,
|
||||||
|
router: Router<fn(Request<Body>) -> BoxResponse>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! headers(
|
||||||
|
{ $($key:expr => $value:expr),+ } => {
|
||||||
|
{
|
||||||
|
let mut m = hyper::HeaderMap::new();
|
||||||
|
$(
|
||||||
|
if let Ok(val) = hyper::header::HeaderValue::from_str($value) {
|
||||||
|
m.insert($key, val);
|
||||||
|
}
|
||||||
|
)+
|
||||||
|
m
|
||||||
|
}
|
||||||
|
};
|
||||||
|
);
|
||||||
|
|
||||||
|
pub trait RequestExt {
|
||||||
|
fn params(&self) -> Params;
|
||||||
|
fn param(&self, name: &str) -> Option<String>;
|
||||||
|
fn set_params(&mut self, params: Params) -> Option<Params>;
|
||||||
|
fn cookies(&self) -> Vec<Cookie>;
|
||||||
|
fn cookie(&self, name: &str) -> Option<Cookie>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait ResponseExt {
|
||||||
|
fn cookies(&self) -> Vec<Cookie>;
|
||||||
|
fn insert_cookie(&mut self, cookie: Cookie);
|
||||||
|
fn remove_cookie(&mut self, name: String);
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RequestExt for Request<Body> {
|
||||||
|
fn params(&self) -> Params {
|
||||||
|
self.extensions().get::<Params>().unwrap_or(&Params::new()).to_owned()
|
||||||
|
// self.extensions()
|
||||||
|
// .get::<RequestMeta>()
|
||||||
|
// .and_then(|meta| meta.route_params())
|
||||||
|
// .expect("Routerify: No RouteParams added while processing request")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn param(&self, name: &str) -> Option<String> {
|
||||||
|
self.params().find(name).map(std::borrow::ToOwned::to_owned)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_params(&mut self, params: Params) -> Option<Params> {
|
||||||
|
self.extensions_mut().insert(params)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cookies(&self) -> Vec<Cookie> {
|
||||||
|
let mut cookies = Vec::new();
|
||||||
|
if let Some(header) = self.headers().get("Cookie") {
|
||||||
|
for cookie in header.to_str().unwrap_or_default().split("; ") {
|
||||||
|
cookies.push(Cookie::parse(cookie).unwrap_or_else(|_| Cookie::named("")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cookies
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cookie(&self, name: &str) -> Option<Cookie> {
|
||||||
|
self.cookies().iter().find(|c| c.name() == name).map(std::borrow::ToOwned::to_owned)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResponseExt for Response<Body> {
|
||||||
|
fn cookies(&self) -> Vec<Cookie> {
|
||||||
|
let mut cookies = Vec::new();
|
||||||
|
for header in self.headers().get_all("Cookie") {
|
||||||
|
if let Ok(cookie) = Cookie::parse(header.to_str().unwrap_or_default()) {
|
||||||
|
cookies.push(cookie);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cookies
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert_cookie(&mut self, cookie: Cookie) {
|
||||||
|
if let Ok(val) = HeaderValue::from_str(&cookie.to_string()) {
|
||||||
|
self.headers_mut().append("Set-Cookie", val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_cookie(&mut self, name: String) {
|
||||||
|
let mut cookie = Cookie::named(name);
|
||||||
|
cookie.set_path("/");
|
||||||
|
cookie.set_max_age(Duration::second());
|
||||||
|
if let Ok(val) = HeaderValue::from_str(&cookie.to_string()) {
|
||||||
|
self.headers_mut().append("Set-Cookie", val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Route<'_> {
|
||||||
|
fn method(&mut self, method: Method, dest: fn(Request<Body>) -> BoxResponse) -> &mut Self {
|
||||||
|
self.router.add(&format!("/{}{}", method.as_str(), self.path), dest);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add an endpoint for `GET` requests
|
||||||
|
pub fn get(&mut self, dest: fn(Request<Body>) -> BoxResponse) -> &mut Self {
|
||||||
|
self.method(Method::GET, dest)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add an endpoint for `POST` requests
|
||||||
|
pub fn post(&mut self, dest: fn(Request<Body>) -> BoxResponse) -> &mut Self {
|
||||||
|
self.method(Method::POST, dest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Server {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Server {
|
||||||
|
default_headers: HeaderMap::new(),
|
||||||
|
router: Router::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn at(&mut self, path: &str) -> Route {
|
||||||
|
Route {
|
||||||
|
path: path.to_owned(),
|
||||||
|
router: &mut self.router,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn listen(self, addr: String) -> Boxed<Result<(), hyper::Error>> {
|
||||||
|
let make_svc = make_service_fn(move |_conn| {
|
||||||
|
let router = self.router.clone();
|
||||||
|
let default_headers = self.default_headers.clone();
|
||||||
|
|
||||||
|
// This is the `Service` that will handle the connection.
|
||||||
|
// `service_fn` is a helper to convert a function that
|
||||||
|
// returns a Response into a `Service`.
|
||||||
|
// let shared_router = router.clone();
|
||||||
|
async move {
|
||||||
|
Ok::<_, String>(service_fn(move |req: Request<Body>| {
|
||||||
|
let headers = default_headers.clone();
|
||||||
|
|
||||||
|
// Remove double slashes
|
||||||
|
let mut path = req.uri().path().replace("//", "/");
|
||||||
|
|
||||||
|
// Remove trailing slashes
|
||||||
|
if path.ends_with('/') && path != "/" {
|
||||||
|
path.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match the visited path with an added route
|
||||||
|
match router.recognize(&format!("/{}{}", req.method().as_str(), path)) {
|
||||||
|
// If a route was configured for this path
|
||||||
|
Ok(found) => {
|
||||||
|
let mut parammed = req;
|
||||||
|
parammed.set_params(found.params().to_owned());
|
||||||
|
|
||||||
|
// Run the route's function
|
||||||
|
let func = (found.handler().to_owned().to_owned())(parammed);
|
||||||
|
async move {
|
||||||
|
let res: Result<Response<Body>, String> = func.await;
|
||||||
|
// Add default headers to response
|
||||||
|
res.map(|mut response| {
|
||||||
|
response.headers_mut().extend(headers);
|
||||||
|
response
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.boxed()
|
||||||
|
}
|
||||||
|
// If there was a routing error
|
||||||
|
Err(e) => async move {
|
||||||
|
// Return a 404 error
|
||||||
|
let res: Result<Response<Body>, String> = Ok(Response::builder().status(404).body(e.into()).unwrap_or_default());
|
||||||
|
// Add default headers to response
|
||||||
|
res.map(|mut response| {
|
||||||
|
response.headers_mut().extend(headers);
|
||||||
|
response
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.boxed(),
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let address = &addr.parse().unwrap_or_else(|_| panic!("Cannot parse {} as address (example format: 0.0.0.0:8080)", addr));
|
||||||
|
|
||||||
|
let server = HyperServer::bind(address).serve(make_svc);
|
||||||
|
|
||||||
|
let graceful = server.with_graceful_shutdown(shutdown_signal());
|
||||||
|
|
||||||
|
graceful.boxed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn shutdown_signal() {
|
||||||
|
// Wait for the CTRL+C signal
|
||||||
|
tokio::signal::ctrl_c().await.expect("Failed to install CTRL+C signal handler");
|
||||||
|
}
|
@ -1,7 +1,12 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
// CRATES
|
// CRATES
|
||||||
|
use crate::server::ResponseExt;
|
||||||
use crate::utils::{redirect, template, Preferences};
|
use crate::utils::{redirect, template, Preferences};
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
use tide::{http::Cookie, Request};
|
use cookie::Cookie;
|
||||||
|
use futures_lite::StreamExt;
|
||||||
|
use hyper::{Body, Request, Response};
|
||||||
use time::{Duration, OffsetDateTime};
|
use time::{Duration, OffsetDateTime};
|
||||||
|
|
||||||
// STRUCTS
|
// STRUCTS
|
||||||
@ -11,45 +16,52 @@ struct SettingsTemplate {
|
|||||||
prefs: Preferences,
|
prefs: Preferences,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(serde::Deserialize, Default)]
|
|
||||||
#[serde(default)]
|
|
||||||
pub struct Form {
|
|
||||||
theme: Option<String>,
|
|
||||||
front_page: Option<String>,
|
|
||||||
layout: Option<String>,
|
|
||||||
wide: Option<String>,
|
|
||||||
comment_sort: Option<String>,
|
|
||||||
show_nsfw: Option<String>,
|
|
||||||
redirect: Option<String>,
|
|
||||||
subscriptions: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
// FUNCTIONS
|
// FUNCTIONS
|
||||||
|
|
||||||
// Retrieve cookies from request "Cookie" header
|
// Retrieve cookies from request "Cookie" header
|
||||||
pub async fn get(req: Request<()>) -> tide::Result {
|
pub async fn get(req: Request<Body>) -> Result<Response<Body>, String> {
|
||||||
template(SettingsTemplate { prefs: Preferences::new(req) })
|
template(SettingsTemplate { prefs: Preferences::new(req) })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set cookies using response "Set-Cookie" header
|
// Set cookies using response "Set-Cookie" header
|
||||||
pub async fn set(mut req: Request<()>) -> tide::Result {
|
pub async fn set(req: Request<Body>) -> Result<Response<Body>, String> {
|
||||||
let form: Form = req.body_form().await.unwrap_or_default();
|
// Split the body into parts
|
||||||
|
let (parts, mut body) = req.into_parts();
|
||||||
|
|
||||||
|
// Grab existing cookies
|
||||||
|
let mut cookies = Vec::new();
|
||||||
|
for header in parts.headers.get_all("Cookie") {
|
||||||
|
if let Ok(cookie) = Cookie::parse(header.to_str().unwrap_or_default()) {
|
||||||
|
cookies.push(cookie);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggregate the body...
|
||||||
|
// let whole_body = hyper::body::aggregate(req).await.map_err(|e| e.to_string())?;
|
||||||
|
let body_bytes = body
|
||||||
|
.try_fold(Vec::new(), |mut data, chunk| {
|
||||||
|
data.extend_from_slice(&chunk);
|
||||||
|
Ok(data)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let form = url::form_urlencoded::parse(&body_bytes).collect::<HashMap<_, _>>();
|
||||||
|
|
||||||
let mut res = redirect("/settings".to_string());
|
let mut res = redirect("/settings".to_string());
|
||||||
|
|
||||||
let names = vec!["theme", "front_page", "layout", "wide", "comment_sort", "show_nsfw"];
|
let names = vec!["theme", "front_page", "layout", "wide", "comment_sort", "post_sort", "show_nsfw"];
|
||||||
let values = vec![form.theme, form.front_page, form.layout, form.wide, form.comment_sort, form.show_nsfw];
|
|
||||||
|
|
||||||
for (i, name) in names.iter().enumerate() {
|
for name in names {
|
||||||
match values.get(i) {
|
match form.get(name) {
|
||||||
Some(value) => res.insert_cookie(
|
Some(value) => res.insert_cookie(
|
||||||
Cookie::build(name.to_owned(), value.to_owned().unwrap_or_default())
|
Cookie::build(name.to_owned(), value.to_owned())
|
||||||
.path("/")
|
.path("/")
|
||||||
.http_only(true)
|
.http_only(true)
|
||||||
.expires(OffsetDateTime::now_utc() + Duration::weeks(52))
|
.expires(OffsetDateTime::now_utc() + Duration::weeks(52))
|
||||||
.finish(),
|
.finish(),
|
||||||
),
|
),
|
||||||
None => res.remove_cookie(Cookie::named(name.to_owned())),
|
None => res.remove_cookie(name.to_string()),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -57,29 +69,41 @@ pub async fn set(mut req: Request<()>) -> tide::Result {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set cookies using response "Set-Cookie" header
|
// Set cookies using response "Set-Cookie" header
|
||||||
pub async fn restore(req: Request<()>) -> tide::Result {
|
pub async fn restore(req: Request<Body>) -> Result<Response<Body>, String> {
|
||||||
let form: Form = req.query()?;
|
// Split the body into parts
|
||||||
|
let (parts, _) = req.into_parts();
|
||||||
|
|
||||||
let path = match form.redirect {
|
// Grab existing cookies
|
||||||
|
let mut cookies = Vec::new();
|
||||||
|
for header in parts.headers.get_all("Cookie") {
|
||||||
|
if let Ok(cookie) = Cookie::parse(header.to_str().unwrap_or_default()) {
|
||||||
|
cookies.push(cookie);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let query = parts.uri.query().unwrap_or_default().as_bytes();
|
||||||
|
|
||||||
|
let form = url::form_urlencoded::parse(query).collect::<HashMap<_, _>>();
|
||||||
|
|
||||||
|
let names = vec!["theme", "front_page", "layout", "wide", "comment_sort", "post_sort", "show_nsfw", "subscriptions"];
|
||||||
|
|
||||||
|
let path = match form.get("redirect") {
|
||||||
Some(value) => format!("/{}/", value),
|
Some(value) => format!("/{}/", value),
|
||||||
None => "/".to_string(),
|
None => "/".to_string(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut res = redirect(path);
|
let mut res = redirect(path);
|
||||||
|
|
||||||
let names = vec!["theme", "front_page", "layout", "wide", "comment_sort", "show_nsfw", "subscriptions"];
|
for name in names {
|
||||||
let values = vec![form.theme, form.front_page, form.layout, form.wide, form.comment_sort, form.show_nsfw, form.subscriptions];
|
match form.get(name) {
|
||||||
|
|
||||||
for (i, name) in names.iter().enumerate() {
|
|
||||||
match values.get(i) {
|
|
||||||
Some(value) => res.insert_cookie(
|
Some(value) => res.insert_cookie(
|
||||||
Cookie::build(name.to_owned(), value.to_owned().unwrap_or_default())
|
Cookie::build(name.to_owned(), value.to_owned())
|
||||||
.path("/")
|
.path("/")
|
||||||
.http_only(true)
|
.http_only(true)
|
||||||
.expires(OffsetDateTime::now_utc() + Duration::weeks(52))
|
.expires(OffsetDateTime::now_utc() + Duration::weeks(52))
|
||||||
.finish(),
|
.finish(),
|
||||||
),
|
),
|
||||||
None => res.remove_cookie(Cookie::named(name.to_owned())),
|
None => res.remove_cookie(name.to_string()),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,8 +1,10 @@
|
|||||||
// CRATES
|
// CRATES
|
||||||
use crate::esc;
|
use crate::esc;
|
||||||
use crate::utils::{cookie, error, format_num, format_url, param, redirect, request, rewrite_urls, template, val, Post, Preferences, Subreddit};
|
use crate::utils::{cookie, error, format_num, format_url, param, redirect, rewrite_urls, template, val, Post, Preferences, Subreddit};
|
||||||
|
use crate::{client::json, server::ResponseExt, RequestExt};
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
use tide::{http::Cookie, Request};
|
use cookie::Cookie;
|
||||||
|
use hyper::{Body, Request, Response};
|
||||||
use time::{Duration, OffsetDateTime};
|
use time::{Duration, OffsetDateTime};
|
||||||
|
|
||||||
// STRUCTS
|
// STRUCTS
|
||||||
@ -26,13 +28,14 @@ struct WikiTemplate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SERVICES
|
// SERVICES
|
||||||
pub async fn community(req: Request<()>) -> tide::Result {
|
pub async fn community(req: Request<Body>) -> Result<Response<Body>, String> {
|
||||||
// Build Reddit API path
|
// Build Reddit API path
|
||||||
let subscribed = cookie(&req, "subscriptions");
|
let subscribed = cookie(&req, "subscriptions");
|
||||||
let front_page = cookie(&req, "front_page");
|
let front_page = cookie(&req, "front_page");
|
||||||
let sort = req.param("sort").unwrap_or_else(|_| req.param("id").unwrap_or("hot")).to_string();
|
let post_sort = req.cookie("post_sort").map_or_else(|| "hot".to_string(), |c| c.value().to_string());
|
||||||
|
let sort = req.param("sort").unwrap_or_else(|| req.param("id").unwrap_or(post_sort));
|
||||||
|
|
||||||
let sub = req.param("sub").map(String::from).unwrap_or(if front_page == "default" || front_page.is_empty() {
|
let sub = req.param("sub").unwrap_or(if front_page == "default" || front_page.is_empty() {
|
||||||
if subscribed.is_empty() {
|
if subscribed.is_empty() {
|
||||||
"popular".to_string()
|
"popular".to_string()
|
||||||
} else {
|
} else {
|
||||||
@ -42,7 +45,11 @@ pub async fn community(req: Request<()>) -> tide::Result {
|
|||||||
front_page.to_owned()
|
front_page.to_owned()
|
||||||
});
|
});
|
||||||
|
|
||||||
let path = format!("/r/{}/{}.json?{}&raw_json=1", sub, sort, req.url().query().unwrap_or_default());
|
if req.param("sub").is_some() && sub.starts_with("u_") {
|
||||||
|
return Ok(redirect(["/user/", &sub[2..]].concat()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = format!("/r/{}/{}.json?{}&raw_json=1", sub, sort, req.uri().query().unwrap_or_default());
|
||||||
|
|
||||||
match Post::fetch(&path, String::new()).await {
|
match Post::fetch(&path, String::new()).await {
|
||||||
Ok((posts, after)) => {
|
Ok((posts, after)) => {
|
||||||
@ -52,7 +59,7 @@ pub async fn community(req: Request<()>) -> tide::Result {
|
|||||||
subreddit(&sub).await.unwrap_or_default()
|
subreddit(&sub).await.unwrap_or_default()
|
||||||
} else if sub == subscribed {
|
} else if sub == subscribed {
|
||||||
// Subscription feed
|
// Subscription feed
|
||||||
if req.url().path().starts_with("/r/") {
|
if req.uri().path().starts_with("/r/") {
|
||||||
subreddit(&sub).await.unwrap_or_default()
|
subreddit(&sub).await.unwrap_or_default()
|
||||||
} else {
|
} else {
|
||||||
Subreddit::default()
|
Subreddit::default()
|
||||||
@ -85,10 +92,10 @@ pub async fn community(req: Request<()>) -> tide::Result {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Sub or unsub by setting subscription cookie using response "Set-Cookie" header
|
// Sub or unsub by setting subscription cookie using response "Set-Cookie" header
|
||||||
pub async fn subscriptions(req: Request<()>) -> tide::Result {
|
pub async fn subscriptions(req: Request<Body>) -> Result<Response<Body>, String> {
|
||||||
let sub = req.param("sub").unwrap_or_default().to_string();
|
let sub = req.param("sub").unwrap_or_default();
|
||||||
let query = req.url().query().unwrap_or_default().to_string();
|
let query = req.uri().query().unwrap_or_default().to_string();
|
||||||
let action: Vec<String> = req.url().path().split('/').map(String::from).collect();
|
let action: Vec<String> = req.uri().path().split('/').map(String::from).collect();
|
||||||
|
|
||||||
let mut sub_list = Preferences::new(req).subscriptions;
|
let mut sub_list = Preferences::new(req).subscriptions;
|
||||||
|
|
||||||
@ -119,8 +126,7 @@ pub async fn subscriptions(req: Request<()>) -> tide::Result {
|
|||||||
|
|
||||||
// Delete cookie if empty, else set
|
// Delete cookie if empty, else set
|
||||||
if sub_list.is_empty() {
|
if sub_list.is_empty() {
|
||||||
// res.del_cookie(&Cookie::build("subscriptions", "").path("/").finish());
|
res.remove_cookie("subscriptions".to_string());
|
||||||
res.remove_cookie(Cookie::build("subscriptions", "").path("/").finish());
|
|
||||||
} else {
|
} else {
|
||||||
res.insert_cookie(
|
res.insert_cookie(
|
||||||
Cookie::build("subscriptions", sub_list.join("+"))
|
Cookie::build("subscriptions", sub_list.join("+"))
|
||||||
@ -134,12 +140,12 @@ pub async fn subscriptions(req: Request<()>) -> tide::Result {
|
|||||||
Ok(res)
|
Ok(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn wiki(req: Request<()>) -> tide::Result {
|
pub async fn wiki(req: Request<Body>) -> Result<Response<Body>, String> {
|
||||||
let sub = req.param("sub").unwrap_or("reddit.com").to_string();
|
let sub = req.param("sub").unwrap_or_else(|| "reddit.com".to_string());
|
||||||
let page = req.param("page").unwrap_or("index").to_string();
|
let page = req.param("page").unwrap_or_else(|| "index".to_string());
|
||||||
let path: String = format!("/r/{}/wiki/{}.json?raw_json=1", sub, page);
|
let path: String = format!("/r/{}/wiki/{}.json?raw_json=1", sub, page);
|
||||||
|
|
||||||
match request(path).await {
|
match json(path).await {
|
||||||
Ok(response) => template(WikiTemplate {
|
Ok(response) => template(WikiTemplate {
|
||||||
sub,
|
sub,
|
||||||
wiki: rewrite_urls(response["data"]["content_html"].as_str().unwrap_or_default()),
|
wiki: rewrite_urls(response["data"]["content_html"].as_str().unwrap_or_default()),
|
||||||
@ -150,13 +156,32 @@ pub async fn wiki(req: Request<()>) -> tide::Result {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn sidebar(req: Request<Body>) -> Result<Response<Body>, String> {
|
||||||
|
let sub = req.param("sub").unwrap_or_else(|| "reddit.com".to_string());
|
||||||
|
|
||||||
|
// Build the Reddit JSON API url
|
||||||
|
let path: String = format!("/r/{}/about.json?raw_json=1", sub);
|
||||||
|
|
||||||
|
// Send a request to the url
|
||||||
|
match json(path).await {
|
||||||
|
// If success, receive JSON in response
|
||||||
|
Ok(response) => template(WikiTemplate {
|
||||||
|
sub,
|
||||||
|
wiki: rewrite_urls(&val(&response, "description_html").replace("\\", "")),
|
||||||
|
page: "Sidebar".to_string(),
|
||||||
|
prefs: Preferences::new(req),
|
||||||
|
}),
|
||||||
|
Err(msg) => error(req, msg).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// SUBREDDIT
|
// SUBREDDIT
|
||||||
async fn subreddit(sub: &str) -> Result<Subreddit, String> {
|
async fn subreddit(sub: &str) -> Result<Subreddit, String> {
|
||||||
// Build the Reddit JSON API url
|
// Build the Reddit JSON API url
|
||||||
let path: String = format!("/r/{}/about.json?raw_json=1", sub);
|
let path: String = format!("/r/{}/about.json?raw_json=1", sub);
|
||||||
|
|
||||||
// Send a request to the url
|
// Send a request to the url
|
||||||
match request(path).await {
|
match json(path).await {
|
||||||
// If success, receive JSON in response
|
// If success, receive JSON in response
|
||||||
Ok(res) => {
|
Ok(res) => {
|
||||||
// Metadata regarding the subreddit
|
// Metadata regarding the subreddit
|
||||||
|
18
src/user.rs
18
src/user.rs
@ -1,8 +1,10 @@
|
|||||||
// CRATES
|
// CRATES
|
||||||
|
use crate::client::json;
|
||||||
use crate::esc;
|
use crate::esc;
|
||||||
use crate::utils::{error, format_url, param, request, template, Post, Preferences, User};
|
use crate::server::RequestExt;
|
||||||
|
use crate::utils::{error, format_url, param, template, Post, Preferences, User};
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
use tide::Request;
|
use hyper::{Body, Request, Response};
|
||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
|
|
||||||
// STRUCTS
|
// STRUCTS
|
||||||
@ -17,13 +19,17 @@ struct UserTemplate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// FUNCTIONS
|
// FUNCTIONS
|
||||||
pub async fn profile(req: Request<()>) -> tide::Result {
|
pub async fn profile(req: Request<Body>) -> Result<Response<Body>, String> {
|
||||||
// Build the Reddit JSON API path
|
// Build the Reddit JSON API path
|
||||||
let path = format!("{}.json?{}&raw_json=1", req.url().path(), req.url().query().unwrap_or_default());
|
let path = format!(
|
||||||
|
"/user/{}.json?{}&raw_json=1",
|
||||||
|
req.param("name").unwrap_or_else(|| "reddit".to_string()),
|
||||||
|
req.uri().query().unwrap_or_default()
|
||||||
|
);
|
||||||
|
|
||||||
// Retrieve other variables from Libreddit request
|
// Retrieve other variables from Libreddit request
|
||||||
let sort = param(&path, "sort");
|
let sort = param(&path, "sort");
|
||||||
let username = req.param("name").unwrap_or("").to_string();
|
let username = req.param("name").unwrap_or_default();
|
||||||
|
|
||||||
// Request user posts/comments from Reddit
|
// Request user posts/comments from Reddit
|
||||||
let posts = Post::fetch(&path, "Comment".to_string()).await;
|
let posts = Post::fetch(&path, "Comment".to_string()).await;
|
||||||
@ -52,7 +58,7 @@ async fn user(name: &str) -> Result<User, String> {
|
|||||||
let path: String = format!("/user/{}/about.json?raw_json=1", name);
|
let path: String = format!("/user/{}/about.json?raw_json=1", name);
|
||||||
|
|
||||||
// Send a request to the url
|
// Send a request to the url
|
||||||
match request(path).await {
|
match json(path).await {
|
||||||
// If success, receive JSON in response
|
// If success, receive JSON in response
|
||||||
Ok(res) => {
|
Ok(res) => {
|
||||||
// Grab creation date as unix timestamp
|
// Grab creation date as unix timestamp
|
||||||
|
165
src/utils.rs
165
src/utils.rs
@ -1,17 +1,15 @@
|
|||||||
//
|
//
|
||||||
// CRATES
|
// CRATES
|
||||||
//
|
//
|
||||||
use crate::esc;
|
use crate::{client::json, esc, server::RequestExt};
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
use async_recursion::async_recursion;
|
use cookie::Cookie;
|
||||||
use async_std::{io, net::TcpStream, prelude::*};
|
use hyper::{Body, Request, Response};
|
||||||
use async_tls::TlsConnector;
|
|
||||||
use cached::proc_macro::cached;
|
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use serde_json::{from_str, Error, Value};
|
use serde_json::Value;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use tide::{http::url::Url, http::Cookie, Request, Response};
|
|
||||||
use time::{Duration, OffsetDateTime};
|
use time::{Duration, OffsetDateTime};
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
// Post flair with content, background color and foreground color
|
// Post flair with content, background color and foreground color
|
||||||
pub struct Flair {
|
pub struct Flair {
|
||||||
@ -183,7 +181,7 @@ pub struct Post {
|
|||||||
pub body: String,
|
pub body: String,
|
||||||
pub author: Author,
|
pub author: Author,
|
||||||
pub permalink: String,
|
pub permalink: String,
|
||||||
pub score: String,
|
pub score: (String, String),
|
||||||
pub upvote_ratio: i64,
|
pub upvote_ratio: i64,
|
||||||
pub post_type: String,
|
pub post_type: String,
|
||||||
pub flair: Flair,
|
pub flair: Flair,
|
||||||
@ -193,7 +191,7 @@ pub struct Post {
|
|||||||
pub domain: String,
|
pub domain: String,
|
||||||
pub rel_time: String,
|
pub rel_time: String,
|
||||||
pub created: String,
|
pub created: String,
|
||||||
pub comments: String,
|
pub comments: (String, String),
|
||||||
pub gallery: Vec<GalleryMedia>,
|
pub gallery: Vec<GalleryMedia>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -204,7 +202,7 @@ impl Post {
|
|||||||
let post_list;
|
let post_list;
|
||||||
|
|
||||||
// Send a request to the url
|
// Send a request to the url
|
||||||
match request(path.to_string()).await {
|
match json(path.to_string()).await {
|
||||||
// If success, receive JSON in response
|
// If success, receive JSON in response
|
||||||
Ok(response) => {
|
Ok(response) => {
|
||||||
res = response;
|
res = response;
|
||||||
@ -253,7 +251,7 @@ impl Post {
|
|||||||
distinguished: val(post, "distinguished"),
|
distinguished: val(post, "distinguished"),
|
||||||
},
|
},
|
||||||
score: if data["hide_score"].as_bool().unwrap_or_default() {
|
score: if data["hide_score"].as_bool().unwrap_or_default() {
|
||||||
"\u{2022}".to_string()
|
("\u{2022}".to_string(), "Hidden".to_string())
|
||||||
} else {
|
} else {
|
||||||
format_num(score)
|
format_num(score)
|
||||||
},
|
},
|
||||||
@ -309,7 +307,7 @@ pub struct Comment {
|
|||||||
pub post_author: String,
|
pub post_author: String,
|
||||||
pub body: String,
|
pub body: String,
|
||||||
pub author: Author,
|
pub author: Author,
|
||||||
pub score: String,
|
pub score: (String, String),
|
||||||
pub rel_time: String,
|
pub rel_time: String,
|
||||||
pub created: String,
|
pub created: String,
|
||||||
pub edited: (String, String),
|
pub edited: (String, String),
|
||||||
@ -344,8 +342,8 @@ pub struct Subreddit {
|
|||||||
pub description: String,
|
pub description: String,
|
||||||
pub info: String,
|
pub info: String,
|
||||||
pub icon: String,
|
pub icon: String,
|
||||||
pub members: String,
|
pub members: (String, String),
|
||||||
pub active: String,
|
pub active: (String, String),
|
||||||
pub wiki: bool,
|
pub wiki: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -367,12 +365,13 @@ pub struct Preferences {
|
|||||||
pub wide: String,
|
pub wide: String,
|
||||||
pub show_nsfw: String,
|
pub show_nsfw: String,
|
||||||
pub comment_sort: String,
|
pub comment_sort: String,
|
||||||
|
pub post_sort: String,
|
||||||
pub subscriptions: Vec<String>,
|
pub subscriptions: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Preferences {
|
impl Preferences {
|
||||||
// Build preferences from cookies
|
// Build preferences from cookies
|
||||||
pub fn new(req: Request<()>) -> Self {
|
pub fn new(req: Request<Body>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
theme: cookie(&req, "theme"),
|
theme: cookie(&req, "theme"),
|
||||||
front_page: cookie(&req, "front_page"),
|
front_page: cookie(&req, "front_page"),
|
||||||
@ -380,6 +379,7 @@ impl Preferences {
|
|||||||
wide: cookie(&req, "wide"),
|
wide: cookie(&req, "wide"),
|
||||||
show_nsfw: cookie(&req, "show_nsfw"),
|
show_nsfw: cookie(&req, "show_nsfw"),
|
||||||
comment_sort: cookie(&req, "comment_sort"),
|
comment_sort: cookie(&req, "comment_sort"),
|
||||||
|
post_sort: cookie(&req, "post_sort"),
|
||||||
subscriptions: cookie(&req, "subscriptions").split('+').map(String::from).filter(|s| !s.is_empty()).collect(),
|
subscriptions: cookie(&req, "subscriptions").split('+').map(String::from).filter(|s| !s.is_empty()).collect(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -398,7 +398,7 @@ pub fn param(path: &str, value: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Parse a cookie value from request
|
// Parse a cookie value from request
|
||||||
pub fn cookie(req: &Request<()>, name: &str) -> String {
|
pub fn cookie(req: &Request<Body>, name: &str) -> String {
|
||||||
let cookie = req.cookie(name).unwrap_or_else(|| Cookie::named(name));
|
let cookie = req.cookie(name).unwrap_or_else(|| Cookie::named(name));
|
||||||
cookie.value().to_string()
|
cookie.value().to_string()
|
||||||
}
|
}
|
||||||
@ -416,8 +416,8 @@ pub fn format_url(url: &str) -> String {
|
|||||||
Regex::new(regex)
|
Regex::new(regex)
|
||||||
.map(|re| match re.captures(url) {
|
.map(|re| match re.captures(url) {
|
||||||
Some(caps) => match segments {
|
Some(caps) => match segments {
|
||||||
1 => [format, &caps[1], "/"].join(""),
|
1 => [format, &caps[1]].join(""),
|
||||||
2 => [format, &caps[1], "/", &caps[2], "/"].join(""),
|
2 => [format, &caps[1], "/", &caps[2]].join(""),
|
||||||
_ => String::new(),
|
_ => String::new(),
|
||||||
},
|
},
|
||||||
None => String::new(),
|
None => String::new(),
|
||||||
@ -431,8 +431,8 @@ pub fn format_url(url: &str) -> String {
|
|||||||
"a.thumbs.redditmedia.com" => capture(r"https://a\.thumbs\.redditmedia\.com/(.*)", "/thumb/a/", 1),
|
"a.thumbs.redditmedia.com" => capture(r"https://a\.thumbs\.redditmedia\.com/(.*)", "/thumb/a/", 1),
|
||||||
"b.thumbs.redditmedia.com" => capture(r"https://b\.thumbs\.redditmedia\.com/(.*)", "/thumb/b/", 1),
|
"b.thumbs.redditmedia.com" => capture(r"https://b\.thumbs\.redditmedia\.com/(.*)", "/thumb/b/", 1),
|
||||||
"emoji.redditmedia.com" => capture(r"https://emoji\.redditmedia\.com/(.*)/(.*)", "/emoji/", 2),
|
"emoji.redditmedia.com" => capture(r"https://emoji\.redditmedia\.com/(.*)/(.*)", "/emoji/", 2),
|
||||||
"preview.redd.it" => capture(r"https://preview\.redd\.it/(.*)\?(.*)", "/preview/pre/", 2),
|
"preview.redd.it" => capture(r"https://preview\.redd\.it/(.*)", "/preview/pre/", 1),
|
||||||
"external-preview.redd.it" => capture(r"https://external\-preview\.redd\.it/(.*)\?(.*)", "/preview/external-pre/", 2),
|
"external-preview.redd.it" => capture(r"https://external\-preview\.redd\.it/(.*)", "/preview/external-pre/", 1),
|
||||||
"styles.redditmedia.com" => capture(r"https://styles\.redditmedia\.com/(.*)", "/style/", 1),
|
"styles.redditmedia.com" => capture(r"https://styles\.redditmedia\.com/(.*)", "/style/", 1),
|
||||||
"www.redditstatic.com" => capture(r"https://www\.redditstatic\.com/(.*)", "/static/", 1),
|
"www.redditstatic.com" => capture(r"https://www\.redditstatic\.com/(.*)", "/static/", 1),
|
||||||
_ => String::new(),
|
_ => String::new(),
|
||||||
@ -445,21 +445,23 @@ 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(text: &str) -> String {
|
pub fn rewrite_urls(text: &str) -> String {
|
||||||
match Regex::new(r#"href="(https|http|)://(www.|old.|np.|)(reddit).(com)/"#) {
|
match Regex::new(r#"href="(https|http|)://(www.|old.|np.|amp.|)(reddit).(com)/"#) {
|
||||||
Ok(re) => re.replace_all(text, r#"href="/"#).to_string(),
|
Ok(re) => re.replace_all(text, r#"href="/"#).to_string(),
|
||||||
Err(_) => String::new(),
|
Err(_) => String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Append `m` and `k` for millions and thousands respectively
|
// Append `m` and `k` for millions and thousands respectively
|
||||||
pub fn format_num(num: i64) -> String {
|
pub fn format_num(num: i64) -> (String, String) {
|
||||||
if num >= 1_000_000 {
|
let truncated = if num >= 1_000_000 || num <= -1_000_000 {
|
||||||
format!("{}m", num / 1_000_000)
|
format!("{}m", num / 1_000_000)
|
||||||
} else if num >= 1000 {
|
} else if num >= 1000 || num <= -1000 {
|
||||||
format!("{}k", num / 1_000)
|
format!("{}k", num / 1_000)
|
||||||
} else {
|
} else {
|
||||||
num.to_string()
|
num.to_string()
|
||||||
}
|
};
|
||||||
|
|
||||||
|
(truncated, num.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse a relative and absolute time from a UNIX timestamp
|
// Parse a relative and absolute time from a UNIX timestamp
|
||||||
@ -512,19 +514,26 @@ macro_rules! esc {
|
|||||||
// NETWORKING
|
// NETWORKING
|
||||||
//
|
//
|
||||||
|
|
||||||
pub fn template(t: impl Template) -> tide::Result {
|
pub fn template(t: impl Template) -> Result<Response<Body>, String> {
|
||||||
Ok(Response::builder(200).content_type("text/html").body(t.render().unwrap_or_default()).build())
|
Ok(
|
||||||
|
Response::builder()
|
||||||
|
.status(200)
|
||||||
|
.header("content-type", "text/html")
|
||||||
|
.body(t.render().unwrap_or_default().into())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn redirect(path: String) -> Response {
|
pub fn redirect(path: String) -> Response<Body> {
|
||||||
Response::builder(302)
|
Response::builder()
|
||||||
.content_type("text/html")
|
.status(302)
|
||||||
|
.header("content-type", "text/html")
|
||||||
.header("Location", &path)
|
.header("Location", &path)
|
||||||
.body(format!("Redirecting to <a href=\"{0}\">{0}</a>...", path))
|
.body(format!("Redirecting to <a href=\"{0}\">{0}</a>...", path).into())
|
||||||
.build()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn error(req: Request<()>, msg: String) -> tide::Result {
|
pub async fn error(req: Request<Body>, msg: String) -> Result<Response<Body>, String> {
|
||||||
let body = ErrorTemplate {
|
let body = ErrorTemplate {
|
||||||
msg,
|
msg,
|
||||||
prefs: Preferences::new(req),
|
prefs: Preferences::new(req),
|
||||||
@ -532,93 +541,5 @@ pub async fn error(req: Request<()>, msg: String) -> tide::Result {
|
|||||||
.render()
|
.render()
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
Ok(Response::builder(404).content_type("text/html").body(body).build())
|
Ok(Response::builder().status(404).header("content-type", "text/html").body(body.into()).unwrap_or_default())
|
||||||
}
|
|
||||||
|
|
||||||
#[async_recursion]
|
|
||||||
async fn connect(path: String) -> io::Result<String> {
|
|
||||||
// Build reddit-compliant user agent for Libreddit
|
|
||||||
let user_agent = format!("web:libreddit:{}", env!("CARGO_PKG_VERSION"));
|
|
||||||
|
|
||||||
// Construct an HTTP request body
|
|
||||||
let req = format!(
|
|
||||||
"GET {} HTTP/1.1\r\nHost: www.reddit.com\r\nAccept: */*\r\nConnection: close\r\nUser-Agent: {}\r\n\r\n",
|
|
||||||
path, user_agent
|
|
||||||
);
|
|
||||||
|
|
||||||
// Open a TCP connection
|
|
||||||
let tcp_stream = TcpStream::connect("www.reddit.com:443").await?;
|
|
||||||
|
|
||||||
// Initialize TLS connector for requests
|
|
||||||
let connector = TlsConnector::default();
|
|
||||||
|
|
||||||
// Use the connector to start the handshake process
|
|
||||||
let mut tls_stream = connector.connect("www.reddit.com", tcp_stream).await?;
|
|
||||||
|
|
||||||
// Write the crafted HTTP request to the stream
|
|
||||||
tls_stream.write_all(req.as_bytes()).await?;
|
|
||||||
|
|
||||||
// And read the response
|
|
||||||
let mut writer = Vec::new();
|
|
||||||
io::copy(&mut tls_stream, &mut writer).await?;
|
|
||||||
let response = String::from_utf8_lossy(&writer).to_string();
|
|
||||||
|
|
||||||
let split = response.split("\r\n\r\n").collect::<Vec<&str>>();
|
|
||||||
|
|
||||||
let headers = split[0].split("\r\n").collect::<Vec<&str>>();
|
|
||||||
let status: i16 = headers[0].split(' ').collect::<Vec<&str>>()[1].parse().unwrap_or(200);
|
|
||||||
let body = split[1].to_string();
|
|
||||||
|
|
||||||
if (300..400).contains(&status) {
|
|
||||||
let location = headers
|
|
||||||
.iter()
|
|
||||||
.find(|header| header.starts_with("location:"))
|
|
||||||
.map(|f| f.to_owned())
|
|
||||||
.unwrap_or_default()
|
|
||||||
.split(": ")
|
|
||||||
.collect::<Vec<&str>>()[1];
|
|
||||||
connect(location.replace("https://www.reddit.com", "")).await
|
|
||||||
} else {
|
|
||||||
Ok(body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make a request to a Reddit API and parse the JSON response
|
|
||||||
#[cached(size = 100, time = 30, result = true)]
|
|
||||||
pub async fn request(path: String) -> Result<Value, String> {
|
|
||||||
let url = format!("https://www.reddit.com{}", path);
|
|
||||||
|
|
||||||
let err = |msg: &str, e: String| -> Result<Value, String> {
|
|
||||||
eprintln!("{} - {}: {}", url, msg, e);
|
|
||||||
Err(msg.to_string())
|
|
||||||
};
|
|
||||||
|
|
||||||
match connect(path).await {
|
|
||||||
Ok(body) => {
|
|
||||||
// Parse the response from Reddit as JSON
|
|
||||||
let parsed: Result<Value, Error> = from_str(&body);
|
|
||||||
match parsed {
|
|
||||||
Ok(json) => {
|
|
||||||
// If Reddit returned an error
|
|
||||||
if json["error"].is_i64() {
|
|
||||||
Err(
|
|
||||||
json["reason"]
|
|
||||||
.as_str()
|
|
||||||
.unwrap_or_else(|| {
|
|
||||||
json["message"].as_str().unwrap_or_else(|| {
|
|
||||||
eprintln!("{} - Error parsing reddit error", url);
|
|
||||||
"Error parsing reddit error"
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.to_string(),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Ok(json)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => err("Failed to parse page JSON data", e.to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => err("Couldn't send request to Reddit", e.to_string()),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
Binary file not shown.
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 8.0 KiB |
@ -65,7 +65,33 @@
|
|||||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dracula theme setting */
|
||||||
|
.dracula {
|
||||||
|
--accent: #bd93f9;
|
||||||
|
--green: #50fa7b;
|
||||||
|
--text: #f8f8f2;
|
||||||
|
--foreground: #3d4051;
|
||||||
|
--background: #282a36;
|
||||||
|
--outside: #44475a;
|
||||||
|
--post: #44475a;
|
||||||
|
--panel-border: 2px solid #44475a;
|
||||||
|
--highlighted: #4e5267;
|
||||||
|
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nord theme setting */
|
||||||
|
.nord {
|
||||||
|
--accent: #8fbcbb;
|
||||||
|
--green: #a3be8c;
|
||||||
|
--text: #eceff4;
|
||||||
|
--foreground: #3b4252;
|
||||||
|
--background: #2e3440;
|
||||||
|
--outside: #434c5e;
|
||||||
|
--post: #434c5e;
|
||||||
|
--panel-border: 2px solid #4c566a;
|
||||||
|
--highlighted: #3b4252;
|
||||||
|
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
/* General */
|
/* General */
|
||||||
|
|
||||||
::selection {
|
::selection {
|
||||||
@ -281,7 +307,7 @@ aside {
|
|||||||
|
|
||||||
/* Subscriptions */
|
/* Subscriptions */
|
||||||
|
|
||||||
#sub_subscription {
|
#sub_subscription, #user_subscription {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -522,7 +548,26 @@ button.submit:hover > svg { stroke: var(--accent); }
|
|||||||
|
|
||||||
.search_subreddit {
|
.search_subreddit {
|
||||||
padding: 16px 20px;
|
padding: 16px 20px;
|
||||||
display: block;
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search_subreddit_left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search_subreddit_left:not(:empty) {
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search_subreddit_left img {
|
||||||
|
width: 35px;
|
||||||
|
height: 35px;
|
||||||
|
border-radius: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search_subreddit_right {
|
||||||
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
a.search_subreddit:hover {
|
a.search_subreddit:hover {
|
||||||
@ -628,13 +673,13 @@ a.search_subreddit:hover {
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.author_flair, .post_flair:empty {
|
.author_flair:empty, .post_flair:empty {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.emoji {
|
.emoji {
|
||||||
width: 1em;
|
width: 1.25em;
|
||||||
height: 1em;
|
height: 1.25em;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
background-size: contain;
|
background-size: contain;
|
||||||
background-position: 50% 50%;
|
background-position: 50% 50%;
|
||||||
@ -1084,6 +1129,8 @@ input[type="submit"] {
|
|||||||
|
|
||||||
.md table {
|
.md table {
|
||||||
margin: 5px;
|
margin: 5px;
|
||||||
|
display: block;
|
||||||
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.md code {
|
.md code {
|
||||||
@ -1200,9 +1247,9 @@ td, th {
|
|||||||
|
|
||||||
/* .thread { margin-left: -5px; } */
|
/* .thread { margin-left: -5px; } */
|
||||||
.comment_right { padding: 5px 0 10px 2px; }
|
.comment_right { padding: 5px 0 10px 2px; }
|
||||||
.comment_author { margin-left: 5px; }
|
.comment_author { margin-left: 12px; }
|
||||||
.comment_data { margin-left: 12px; }
|
.comment_data { margin-left: 12px; }
|
||||||
.comment_data::marker { font-size: 22px; }
|
.comment_data::marker { font-size: 25px; }
|
||||||
.created { width: 100%; }
|
.created { width: 100%; }
|
||||||
|
|
||||||
.comment_score {
|
.comment_score {
|
||||||
@ -1212,4 +1259,4 @@ td, th {
|
|||||||
padding: 7px 0px;
|
padding: 7px 0px;
|
||||||
margin-right: -5px;
|
margin-right: -5px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -15,11 +15,11 @@
|
|||||||
<!-- Android -->
|
<!-- Android -->
|
||||||
<meta name="mobile-web-app-capable" content="yes">
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
<!-- iOS Logo -->
|
<!-- iOS Logo -->
|
||||||
<link href="/touch-icon-iphone.png/" rel="apple-touch-icon">
|
<link href="/touch-icon-iphone.png" rel="apple-touch-icon">
|
||||||
<!-- 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">
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body class="
|
<body class="
|
||||||
@ -35,7 +35,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% block search %}{% endblock %}
|
{% block search %}{% endblock %}
|
||||||
<div id="links">
|
<div id="links">
|
||||||
<a id="settings_link" href="/settings/">
|
<a id="settings_link" href="/settings">
|
||||||
<span>settings</span>
|
<span>settings</span>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<title>settings</title>
|
<title>settings</title>
|
||||||
|
@ -5,12 +5,12 @@
|
|||||||
{% else if kind == "t1" %}
|
{% else if kind == "t1" %}
|
||||||
<div id="{{ id }}" class="comment">
|
<div id="{{ id }}" class="comment">
|
||||||
<div class="comment_left">
|
<div class="comment_left">
|
||||||
<p class="comment_score">{{ score }}</p>
|
<p class="comment_score" title="{{ score.1 }}">{{ score.0 }}</p>
|
||||||
<div class="line"></div>
|
<div class="line"></div>
|
||||||
</div>
|
</div>
|
||||||
<details class="comment_right" open>
|
<details class="comment_right" open>
|
||||||
<summary class="comment_data">
|
<summary class="comment_data">
|
||||||
<a class="comment_author {{ author.distinguished }} {% if author.name == post_author %}op{% endif %}" href="/u/{{ author.name }}">u/{{ author.name }}</a>
|
<a class="comment_author {{ author.distinguished }} {% if author.name == post_author %}op{% endif %}" href="/user/{{ author.name }}">u/{{ author.name }}</a>
|
||||||
{% if author.flair.flair_parts.len() > 0 %}
|
{% if author.flair.flair_parts.len() > 0 %}
|
||||||
<small class="author_flair">{% call utils::render_flair(author.flair.flair_parts) %}</small>
|
<small class="author_flair">{% call utils::render_flair(author.flair.flair_parts) %}</small>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -37,7 +37,7 @@
|
|||||||
<p class="post_header">
|
<p class="post_header">
|
||||||
<a class="post_subreddit" href="/r/{{ post.community }}">r/{{ post.community }}</a>
|
<a class="post_subreddit" href="/r/{{ post.community }}">r/{{ post.community }}</a>
|
||||||
<span class="dot">•</span>
|
<span class="dot">•</span>
|
||||||
<a class="post_author" href="/u/{{ post.author.name }}">u/{{ post.author.name }}</a>
|
<a class="post_author" href="/user/{{ post.author.name }}">u/{{ post.author.name }}</a>
|
||||||
{% if post.author.flair.flair_parts.len() > 0 %}
|
{% if post.author.flair.flair_parts.len() > 0 %}
|
||||||
<small class="author_flair">{% call utils::render_flair(post.author.flair.flair_parts) %}</small>
|
<small class="author_flair">{% call utils::render_flair(post.author.flair.flair_parts) %}</small>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -47,7 +47,7 @@
|
|||||||
<p class="post_title">
|
<p class="post_title">
|
||||||
<a href="{{ post.permalink }}">{{ post.title }}</a>
|
<a href="{{ post.permalink }}">{{ post.title }}</a>
|
||||||
{% if post.flair.flair_parts.len() > 0 %}
|
{% if post.flair.flair_parts.len() > 0 %}
|
||||||
<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"
|
||||||
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 %}
|
||||||
@ -89,7 +89,7 @@
|
|||||||
|
|
||||||
<!-- POST BODY -->
|
<!-- POST BODY -->
|
||||||
<div class="post_body">{{ post.body }}</div>
|
<div class="post_body">{{ post.body }}</div>
|
||||||
<div class="post_score">{{ post.score }}<span class="label"> Upvotes</span></div>
|
<div class="post_score" title="{{ post.score.1 }}">{{ post.score.0 }}<span class="label"> Upvotes</span></div>
|
||||||
<div class="post_footer">
|
<div class="post_footer">
|
||||||
<ul id="post_links">
|
<ul id="post_links">
|
||||||
<li><a href="/{{ post.id }}">permalink</a></li>
|
<li><a href="/{{ post.id }}">permalink</a></li>
|
||||||
|
@ -34,12 +34,15 @@
|
|||||||
<div id="search_subreddits">
|
<div id="search_subreddits">
|
||||||
{% for subreddit in subreddits %}
|
{% for subreddit in subreddits %}
|
||||||
<a href="{{ subreddit.url }}" class="search_subreddit">
|
<a href="{{ subreddit.url }}" class="search_subreddit">
|
||||||
<p class="search_subreddit_header">
|
<div class="search_subreddit_left">{% if subreddit.icon != "" %}<img src="{{ subreddit.icon }}" alt="r/{{ subreddit.name }} icon">{% endif %}</div>
|
||||||
<span class="search_subreddit_name">{{ subreddit.name }}</span>
|
<div class="search_subreddit_right">
|
||||||
<span class="dot">•</span>
|
<p class="search_subreddit_header">
|
||||||
<span class="search_subreddit_members">{{ subreddit.subscribers }} Members</span>
|
<span class="search_subreddit_name">{{ subreddit.name }}</span>
|
||||||
</p>
|
<span class="dot">•</span>
|
||||||
<p class="search_subreddit_description">{{ subreddit.description }}</p>
|
<span class="search_subreddit_members" title="{{ subreddit.subscribers.1 }} Members">{{ subreddit.subscribers.0 }} Members</span>
|
||||||
|
</p>
|
||||||
|
<p class="search_subreddit_description">{{ subreddit.description }}</p>
|
||||||
|
</div>
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
@ -52,7 +55,7 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<div class="comment">
|
<div class="comment">
|
||||||
<div class="comment_left">
|
<div class="comment_left">
|
||||||
<p class="comment_score">{{ post.score }}</p>
|
<p class="comment_score" title="{{ post.score.1 }}">{{ post.score.0 }}</p>
|
||||||
<div class="line"></div>
|
<div class="line"></div>
|
||||||
</div>
|
</div>
|
||||||
<details class="comment_right" open>
|
<details class="comment_right" open>
|
||||||
|
@ -9,13 +9,13 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div id="settings">
|
<div id="settings">
|
||||||
<form action="/settings/" method="POST">
|
<form action="/settings" method="POST">
|
||||||
<div class="prefs">
|
<div class="prefs">
|
||||||
<p>Appearance</p>
|
<p>Appearance</p>
|
||||||
<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"], "system") %}
|
{% call utils::options(prefs.theme, ["system", "light", "dark", "black", "dracula", "nord"], "system") %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<p>Interface</p>
|
<p>Interface</p>
|
||||||
@ -36,6 +36,12 @@
|
|||||||
<input type="checkbox" name="wide" {% if prefs.wide == "on" %}checked{% endif %}>
|
<input type="checkbox" name="wide" {% if prefs.wide == "on" %}checked{% endif %}>
|
||||||
</div>
|
</div>
|
||||||
<p>Content</p>
|
<p>Content</p>
|
||||||
|
<div id="post_sort">
|
||||||
|
<label for="post_sort" title="Applies only to subreddit feeds">Default subreddit post sort:</label>
|
||||||
|
<select name="post_sort">
|
||||||
|
{% call utils::options(prefs.post_sort, ["hot", "new", "top", "rising", "controversial"], "hot") %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div id="comment_sort">
|
<div id="comment_sort">
|
||||||
<label for="comment_sort">Default comment sort:</label>
|
<label for="comment_sort">Default comment sort:</label>
|
||||||
<select name="comment_sort">
|
<select name="comment_sort">
|
||||||
@ -51,10 +57,10 @@
|
|||||||
</form>
|
</form>
|
||||||
{% if prefs.subscriptions.len() > 0 %}
|
{% if prefs.subscriptions.len() > 0 %}
|
||||||
<div class="prefs" id="settings_subs">
|
<div class="prefs" id="settings_subs">
|
||||||
<p>Subscribed Subreddits</p>
|
<p>Subscribed Feeds</p>
|
||||||
{% for sub in prefs.subscriptions %}
|
{% for sub in prefs.subscriptions %}
|
||||||
<div>
|
<div>
|
||||||
<span>{{ sub }}</span>
|
<span>{% if sub.starts_with("u_") -%}{{ format!("u/{}", &sub[2..]) }}{% else -%}{{ format!("r/{}", sub) }}{% endif -%}</span>
|
||||||
<form action="/r/{{ sub }}/unsubscribe/?redirect=settings" method="POST">
|
<form action="/r/{{ sub }}/unsubscribe/?redirect=settings" method="POST">
|
||||||
<button class="unsubscribe">Unsubscribe</button>
|
<button class="unsubscribe">Unsubscribe</button>
|
||||||
</form>
|
</form>
|
||||||
|
@ -40,7 +40,7 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
{% if sub.name.contains("+") %}
|
{% if sub.name.contains("+") %}
|
||||||
<form action="/r/{{ sub.name }}/subscribe/" method="POST">
|
<form action="/r/{{ sub.name }}/subscribe" method="POST">
|
||||||
<button id="multisub" class="subscribe" title="Subscribe to each sub in this multireddit">Subscribe to Multireddit</button>
|
<button id="multisub" class="subscribe" title="Subscribe to each sub in this multireddit">Subscribe to Multireddit</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -81,16 +81,16 @@
|
|||||||
<div id="sub_details">
|
<div id="sub_details">
|
||||||
<label>Members</label>
|
<label>Members</label>
|
||||||
<label>Active</label>
|
<label>Active</label>
|
||||||
<div>{{ sub.members }}</div>
|
<div title="{{ sub.members.1 }}">{{ sub.members.0 }}</div>
|
||||||
<div>{{ sub.active }}</div>
|
<div title="{{ sub.active.1 }}">{{ sub.active.0 }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="sub_subscription">
|
<div id="sub_subscription">
|
||||||
{% if prefs.subscriptions.contains(sub.name) %}
|
{% if prefs.subscriptions.contains(sub.name) %}
|
||||||
<form action="/r/{{ sub.name }}/unsubscribe/" method="POST">
|
<form action="/r/{{ sub.name }}/unsubscribe" method="POST">
|
||||||
<button class="unsubscribe">Unsubscribe</button>
|
<button class="unsubscribe">Unsubscribe</button>
|
||||||
</form>
|
</form>
|
||||||
{% else %}
|
{% else %}
|
||||||
<form action="/r/{{ sub.name }}/subscribe/" method="POST">
|
<form action="/r/{{ sub.name }}/subscribe" method="POST">
|
||||||
<button class="subscribe">Subscribe</button>
|
<button class="subscribe">Subscribe</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -37,7 +37,7 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<div class="comment">
|
<div class="comment">
|
||||||
<div class="comment_left">
|
<div class="comment_left">
|
||||||
<p class="comment_score">{{ post.score }}</p>
|
<p class="comment_score" title="{{ post.score.1 }}">{{ post.score.0 }}</p>
|
||||||
<div class="line"></div>
|
<div class="line"></div>
|
||||||
</div>
|
</div>
|
||||||
<details class="comment_right" open>
|
<details class="comment_right" open>
|
||||||
@ -75,6 +75,18 @@
|
|||||||
<div>{{ user.karma }}</div>
|
<div>{{ user.karma }}</div>
|
||||||
<div>{{ user.created }}</div>
|
<div>{{ user.created }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="user_subscription">
|
||||||
|
{% let name = ["u_", user.name.as_str()].join("") %}
|
||||||
|
{% if prefs.subscriptions.contains(name) %}
|
||||||
|
<form action="/r/u_{{ user.name }}/unsubscribe" method="POST">
|
||||||
|
<button class="unsubscribe">Unfollow</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<form action="/r/u_{{ user.name }}/subscribe" method="POST">
|
||||||
|
<button class="subscribe">Follow</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</main>
|
</main>
|
||||||
|
@ -15,7 +15,7 @@
|
|||||||
{%- endmacro %}
|
{%- endmacro %}
|
||||||
|
|
||||||
{% macro search(root, search) -%}
|
{% macro search(root, search) -%}
|
||||||
<form action="{% if root != "/r/" && !root.is_empty() %}{{ root }}{% endif %}/search/" id="searchbox">
|
<form action="{% if root != "/r/" && !root.is_empty() %}{{ root }}{% endif %}/search" id="searchbox">
|
||||||
<input id="search" type="text" name="q" placeholder="Search" title="Search libreddit" value="{{ search }}">
|
<input id="search" type="text" name="q" placeholder="Search" title="Search libreddit" value="{{ search }}">
|
||||||
{% if root != "/r/" && !root.is_empty() %}
|
{% if root != "/r/" && !root.is_empty() %}
|
||||||
<div id="inside">
|
<div id="inside">
|
||||||
@ -58,7 +58,13 @@
|
|||||||
{% macro post_in_list(post) -%}
|
{% macro post_in_list(post) -%}
|
||||||
<div class="post {% if post.flags.stickied %}stickied{% endif %}">
|
<div class="post {% if post.flags.stickied %}stickied{% endif %}">
|
||||||
<p class="post_header">
|
<p class="post_header">
|
||||||
<a class="post_subreddit" href="/r/{{ post.community }}">r/{{ post.community }}</a>
|
{% let community -%}
|
||||||
|
{% if post.community.starts_with("u_") -%}
|
||||||
|
{% let community = format!("u/{}", &post.community[2..]) -%}
|
||||||
|
{% else -%}
|
||||||
|
{% let community = format!("r/{}", post.community) -%}
|
||||||
|
{% endif -%}
|
||||||
|
<a class="post_subreddit" href="/{{ community }}">{{ community }}</a>
|
||||||
<span class="dot">•</span>
|
<span class="dot">•</span>
|
||||||
<a class="post_author" href="/u/{{ post.author.name }}">u/{{ post.author.name }}</a>
|
<a class="post_author" href="/u/{{ post.author.name }}">u/{{ post.author.name }}</a>
|
||||||
<span class="dot">•</span>
|
<span class="dot">•</span>
|
||||||
@ -66,7 +72,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<p class="post_title">
|
<p 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"
|
||||||
style="color:{{ post.flair.foreground_color }}; background:{{ post.flair.background_color }};">{% call render_flair(post.flair.flair_parts) %}</a>
|
style="color:{{ post.flair.foreground_color }}; background:{{ post.flair.background_color }};">{% call render_flair(post.flair.flair_parts) %}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -108,9 +114,9 @@
|
|||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="post_score">{{ post.score }}<span class="label"> Upvotes</span></div>
|
<div class="post_score" title="{{ post.score.1 }}">{{ post.score.0 }}<span class="label"> Upvotes</span></div>
|
||||||
<div class="post_footer">
|
<div class="post_footer">
|
||||||
<a href="{{ post.permalink }}" class="post_comments">{{ post.comments }} comments</a>
|
<a href="{{ post.permalink }}" class="post_comments" title="{{ post.comments.1 }} comments">{{ post.comments.0 }} comments</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{%- endmacro %}
|
{%- endmacro %}
|
||||||
|
Reference in New Issue
Block a user