Compare commits

..

61 Commits

Author SHA1 Message Date
c23d2dc50b Re-add unprivileged user to Dockerfile 2021-04-01 12:09:08 -07:00
46dbd88d91 New alpine-based Dockerfile with healthcheck 2021-03-31 13:05:22 -07:00
f0f484288e Fix server.rs function name 2021-03-31 13:03:44 -07:00
90d39b121f Example docker-compose 2021-03-31 13:03:18 -07:00
44dee302c9 Publish SHA512 checksums for future releases 2021-03-27 20:11:05 +00:00
c7f9386c01 Fix #169 2021-03-27 13:03:13 -07:00
66ac72beab Fix clippy errors 2021-03-26 20:00:47 -07:00
14f9ac4ca7 Automatically draft a release on push 2021-03-26 21:48:00 +00:00
6a7f725c12 Default subreddit post sorting. Closes #166 2021-03-25 21:41:58 -07:00
2533e8cef5 Add phii.me instance. Closes #163 2021-03-23 21:08:00 +00:00
772d20615b Sidebar about page. Closes #162 2021-03-21 19:28:05 -07:00
0bb1677520 Revert client to HTTP/1.1 2021-03-21 13:56:05 -07:00
da4883db29 Upgrade client to HTTP/2 2021-03-21 11:37:03 -07:00
d50b6ca4b3 Add reddit.invak.id instance. Closes #157 2021-03-21 18:23:05 +00:00
4c66e75f6b Add HSTS command line flag 2021-03-20 22:10:31 -07:00
966e0ce921 Expand truncated numbers on mouseover. Close #156 2021-03-20 15:42:47 -07:00
ab886d1e67 Fix #155 2021-03-20 13:03:05 -07:00
dc7e087ed0 Truncate negative scores 2021-03-19 22:04:44 -07:00
0d6e18d97d Fill background of Apple Touch Icon 2021-03-18 21:36:39 -07:00
f872baa1fe Update 0.5.2 2021-03-18 21:35:14 -07:00
9b5176f7b9 Sub icons and truncated subscribers in search results 2021-03-18 21:32:54 -07:00
60c89197e5 Update "Built with" section of Readme 2021-03-18 16:53:03 +00:00
7d94876d90 Update screenshot 2021-03-18 16:47:10 +00:00
467342edf4 Patch broken homepage sorting 2021-03-18 08:51:31 -07:00
3c5b4037e2 Add libreddit.40two.app to instance list 2021-03-18 04:47:16 +00:00
a81502dde1 Fix wiki routing 2021-03-17 21:40:55 -07:00
0ce2d9054e Handle non-ASCII post headers 2021-03-17 21:26:06 -07:00
a5203fe8dd Include docker pull in Docker instructions (#153) 2021-03-18 02:48:38 +00:00
038fafa378 Remove unnecessary trailing slashes 2021-03-17 17:28:05 -07:00
e15c15c390 Fix author flairs not showing 2021-03-17 17:08:43 -07:00
07363e47a9 Request building error handler 2021-03-17 16:53:49 -07:00
fb7faf6477 Handle HeaderValue and Uri parsing errors 2021-03-17 16:32:28 -07:00
b14b4ff551 Rewrite server and client using Hyper 2021-03-17 15:30:33 -07:00
4b1195f221 Update to v0.4.2 2021-03-12 13:48:43 -08:00
a472461ee8 Switch some links in Readme to spike.codes instance 2021-03-12 12:59:50 -08:00
baf5e3d7ee Fix Replit links 2021-03-12 12:55:02 -08:00
f209757ed6 Handle proxy unwraps 2021-03-12 12:21:02 -08:00
4173362ce1 Fix #148 2021-03-11 20:15:26 -08:00
b2ae5e486f Rename subreddit::page to subreddit::community 2021-03-10 21:43:06 -08:00
cda19a1912 Remove duplicate "description" meta tag for posts 2021-03-10 21:41:39 -08:00
f0b69f8a4a Update to v0.4 2021-03-10 20:51:08 -08:00
118ff9485c Document proxy.rs 2021-03-10 19:02:03 -08:00
4a51b7cfb0 Horizontally squish comments 2021-03-10 15:10:59 -08:00
f877face80 Update README.md 2021-03-10 20:56:33 +00:00
f0e8deb000 Add alt attribute to user icon 2021-03-10 11:29:36 -08:00
e70dfe2c0b Fix <video> size attributes 2021-03-10 10:49:18 -08:00
2e89a85858 Handle alternative status codes 2021-03-09 22:23:26 -08:00
e59b2b1346 Custom HTTP client with Rustls 2021-03-09 22:13:46 -08:00
1c36549134 Fix #146 2021-03-09 07:22:17 -08:00
5fb88d4744 Allow certain clippy lints 2021-03-08 19:22:10 -08:00
6c7188a1b9 Prevent pushing of Cargo.lock 2021-03-08 18:50:03 -08:00
84009fbb8e Remove Cargo.lock 2021-03-08 18:49:35 -08:00
bf783c2f3a Optimize type casting 2021-03-08 18:49:06 -08:00
213babb057 Update dependencies 2021-03-08 16:30:34 -08:00
7dbc02d930 Update himiko instances' location 2021-03-05 18:39:31 +00:00
10873dd0c6 Fix #144 2021-03-05 06:24:40 -08:00
c0d1519341 Update screenshot in README.md (#143)
* Update screenshot in README.md

New screenshot for v0.3.1. Also 35% lighter!

* Update screenshot

Co-authored-by: Spike <19519553+spikecodes@users.noreply.github.com>
2021-03-05 04:30:32 +00:00
8709c49f39 NGINX reverse proxy notice 2021-03-04 03:53:49 +00:00
56cfeba9e5 Update to v0.3.1 2021-03-03 09:35:40 -08:00
890d5ae625 Revert to curl-client 2021-03-03 09:24:31 -08:00
caa8f1d49e Test h1-client 2021-03-03 09:15:19 -08:00
27 changed files with 955 additions and 2603 deletions

View File

@ -21,9 +21,34 @@ jobs:
- name: Build
run: cargo build --release
- uses: actions/upload-artifact@v2.2.1
name: Upload a Build Artifact
with:
name: 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::${GITHUB_REF#refs/*/}"
- 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: |
- CHANGES
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 }}

1
.gitignore vendored
View File

@ -1 +1,2 @@
/target
Cargo.lock

2101
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -3,19 +3,23 @@ name = "libreddit"
description = " Alternative private front-end to Reddit"
license = "AGPL-3.0"
repository = "https://github.com/spikecodes/libreddit"
version = "0.3.0"
version = "0.8.4"
authors = ["spikecodes <19519553+spikecodes@users.noreply.github.com>"]
edition = "2018"
[dependencies]
tide = { version = "0.16.0", default-features = false, features = ["h1-server", "cookies"] }
async-std = { version = "1.9.0", features = ["attributes"] }
surf = { version = "2.2.0", default-features = false, features = ["h1-client-rustls", "encoding"] }
cached = "0.23.0"
askama = { version = "0.10.5", default-features = false }
serde = { version = "1.0.123", features = ["derive"] }
serde_json = "1.0.64"
async-recursion = "0.3.2"
regex = "1.4.3"
cached = "0.23.0"
clap = { version = "2.33.3", default-features = false }
time = "0.2.25"
regex = "1.4.5"
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"
tokio = { version = "1.4.0", features = ["full"] }
time = "0.2.26"
url = "2.2.1"

View File

@ -1,17 +1,15 @@
FROM rust:latest as builder
FROM rust:alpine as builder
WORKDIR /usr/src/libreddit
COPY . .
RUN apk add --no-cache g++
RUN cargo install --path .
FROM debian:buster-slim
RUN apt-get update && apt-get install -y libcurl4 && rm -rf /var/lib/apt/lists/*
FROM alpine:latest
RUN apk add --no-cache curl
COPY --from=builder /usr/local/cargo/bin/libreddit /usr/local/bin/libreddit
RUN useradd --system --user-group --home-dir /nonexistent --no-create-home --shell /usr/sbin/nologin libreddit
RUN adduser --system --home /nonexistent --no-create-home --disabled-password libreddit
USER libreddit
EXPOSE 8080
HEALTHCHECK --interval=5m --timeout=3s CMD curl -f http://localhost:8080/settings || exit 1
CMD ["libreddit"]
CMD ["libreddit"]

View File

@ -2,11 +2,11 @@
> An alternative private front-end to Reddit
![screenshot](https://i.ibb.co/F0JsY5K/image.png)
![screenshot](https://i.ibb.co/QYbqTQt/libreddit-rust.png)
---
**10 second pitch:** Libreddit is a portmanteau of "libre" (meaning freedom) and "Reddit". It is a private front-end like [Invidious](https://github.com/iv-org/invidious) but for Reddit. Browse the coldest takes of [r/unpopularopinion](https://libredd.it/r/unpopularopinion) without being [tracked](#reddit).
**10 second pitch:** Libreddit is a portmanteau of "libre" (meaning freedom) and "Reddit". It is a private front-end like [Invidious](https://github.com/iv-org/invidious) but for Reddit. Browse the coldest takes of [r/unpopularopinion](https://libreddit.spike.codes/r/unpopularopinion) without being [tracked](#reddit).
- 🚀 Fast: written in Rust for blazing fast speeds and memory safety
- ☁️ Light: no JavaScript, no ads, no tracking, no bloat
@ -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)
- [Repl.it](#5-replit)
- [Deployment](#deployment)
---
# Instances
Feel free to [open an issue](https://github.com/spikecodes/libreddit/issues/new) to have your [selfhosted instance](#deployment) listed here!
@ -43,13 +29,16 @@ Feel free to [open an issue](https://github.com/spikecodes/libreddit/issues/new)
|-|-|-|
| [libredd.it](https://libredd.it) (official) | 🇺🇸 US | |
| [libreddit.spike.codes](https://libreddit.spike.codes) (official) | 🇺🇸 US | |
| [libreddit.dothq.co](https://libreddit.dothq.co) | 🇺🇸 US | |
| [libreddit.dothq.co](https://libreddit.dothq.co) | 🇺🇸 US | |
| [libreddit.kavin.rocks](https://libreddit.kavin.rocks) | 🇮🇳 IN | ✅ |
| [libreddit.himiko.cloud](https://libreddit.himiko.cloud) | 🇧🇬 BG | |
| [libreddit.himiko.cloud](https://libreddit.himiko.cloud) | 🇫🇮 FI | |
| [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 | |
| [spjmllawtheisznfs7uryhxumin26ssv2draj7oope3ok3wuhy43eoyd.onion](http://spjmllawtheisznfs7uryhxumin26ssv2draj7oope3ok3wuhy43eoyd.onion) | 🇮🇳 IN | |
| [fwhhsbrbltmrct5hshrnqlqygqvcgmnek3cnka55zj4y7nuus5muwyyd.onion](http://fwhhsbrbltmrct5hshrnqlqygqvcgmnek3cnka55zj4y7nuus5muwyyd.onion) | 🇩🇪 DE | |
| [libreddit.himiko7xl2skojc6odi7hykl626gt4qki3vxdbv33u2u3af76d6k32ad.onion](http://libreddit.himiko7xl2skojc6odi7hykl626gt4qki3vxdbv33u2u3af76d6k32ad.onion) | 🇧🇬 BG | |
| [libreddit.himiko7xl2skojc6odi7hykl626gt4qki3vxdbv33u2u3af76d6k32ad.onion](http://libreddit.himiko7xl2skojc6odi7hykl626gt4qki3vxdbv33u2u3af76d6k32ad.onion) | 🇫🇮 FI | |
| [dflv6yjt7il3n3tggf4qhcmkzbti2ppytqx3o7pjrzwgntutpewscyid.onion](http://dflv6yjt7il3n3tggf4qhcmkzbti2ppytqx3o7pjrzwgntutpewscyid.onion/) | 🇺🇸 US | |
A checkmark in the "Cloudflare" category here refers to the use of the reverse proxy, [Cloudflare](https://cloudflare). The checkmark will not be listed for a site which uses Cloudflare DNS but rather the proxying service which grants Cloudflare the ability to monitor traffic to the website.
@ -63,9 +52,9 @@ Find Libreddit on 💬 [Matrix](https://matrix.to/#/#libreddit:kde.org), 🐋 [D
## Built with
- [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
- [Surf](https://github.com/http-rs/surf) - HTTP client
- [Rustls](https://github.com/ctz/rustls) - TLS library
## 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.
@ -137,9 +126,9 @@ For transparency, I hope to describe all the ways Libreddit handles user privacy
**DNS:** Both official domains (`libredd.it` and `libreddit.spike.codes`) use Cloudflare as the DNS resolver. Though, the sites are not proxied through Cloudflare meaning Cloudflare doesn't have access to user traffic.
**Cookies:** Libreddit uses optional cookies to store any configured settings in [the settings menu](https://libredd.it/settings). This is not a cross-site cookie and the cookie holds no personal data, only a value of the possible layout.
**Cookies:** Libreddit uses optional cookies to store any configured settings in [the settings menu](https://libreddit.spike.codes/settings). This is not a cross-site cookie and the cookie holds no personal data, only a value of the possible layout.
**Hosting:** The official instances are hosted on [Repl.it](https://repl.it/) which monitors usage to prevent abuse. I can understand if this invalidates certain users' threat models and therefore, selfhosting and browsing through Tor are welcomed.
**Hosting:** The official instances are hosted on [Replit](https://replit.com/) which monitors usage to prevent abuse. I can understand if this invalidates certain users' threat models and therefore, selfhosting and browsing through Tor are welcomed.
---
@ -157,11 +146,13 @@ cargo install 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
```
Deploy using a different port (in this case, port 80):
```
docker pull spikecodes/libreddit
docker run -d --name libreddit -p 80:8080 spikecodes/libreddit
```
@ -177,15 +168,15 @@ yay -S libreddit-git
If you're on Linux and none of these methods work for you, you can grab a Linux binary from [the newest release](https://github.com/spikecodes/libreddit/releases/latest).
## 5) Repl.it
## 5) Replit
**Note:** Repl.it is a free option but they are *not* private and will monitor server usage to prevent abuse. If you need a free and easy setup, this method may work best for you.
**Note:** Replit is a free option but they are *not* private and will monitor server usage to prevent abuse. If you need a free and easy setup, this method may work best for you.
1. Create a Repl.it account (see note above)
2. Visit [the official Repl](https://repl.it/@spikethecoder/libreddit) and fork it
1. Create a Replit account (see note above)
2. Visit [the official Repl](https://replit.com/@spikethecoder/libreddit) and fork it
3. Hit the run button to download the latest Libreddit version and start it
In the web preview (defaults to top right), you should see your instance hosted where you can assign a [custom domain](https://docs.repl.it/repls/web-hosting#custom-domains).
In the web preview (defaults to top right), you should see your instance hosted where you can assign a [custom domain](https://docs.replit.com/repls/web-hosting#custom-domains).
---
@ -197,6 +188,14 @@ Once installed, deploy Libreddit to `0.0.0.0:8080` by running:
libreddit
```
## Proxying using NGINX
**NOTE** If you're [proxying Libreddit through a NGINX Reverse Proxy](https://github.com/spikecodes/libreddit/issues/122#issuecomment-782226853), add
```nginx
proxy_http_version 1.1;
```
to your NGINX configuration file above your `proxy_pass` line.
## Building
```

13
docker-compose.yml Normal file
View 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
View 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.to_string();
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),
}
}

View File

@ -1,6 +1,17 @@
// Global specifiers
#![forbid(unsafe_code)]
#![warn(clippy::pedantic, clippy::all)]
#![allow(
clippy::needless_pass_by_value,
clippy::match_wildcard_for_single_variants,
clippy::cast_possible_truncation,
clippy::similar_names,
clippy::cast_possible_wrap,
clippy::find_map
)]
// Reference local files
mod post;
mod proxy;
mod search;
mod settings;
mod subreddit;
@ -8,99 +19,72 @@ mod user;
mod utils;
// Import Crates
use clap::{App, Arg};
use proxy::handler;
use tide::{
utils::{async_trait, After},
Middleware, Next, Request, Response,
};
use clap::{App as cli, Arg};
use futures_lite::FutureExt;
use hyper::{header::HeaderValue, Body, Request, Response};
mod client;
use client::proxy;
use server::RequestExt;
use utils::{error, redirect};
// Build middleware
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 != "" {
format!("{}/?{}", path.replace("//", "/"), query)
} else {
format!("{}/", path.replace("//", "/"))
};
Ok(redirect(normalized))
}
}
}
mod server;
// Create Services
// Required for the manifest to be valid
async fn pwa_logo(_req: Request<()>) -> tide::Result {
Ok(Response::builder(200).content_type("image/png").body(include_bytes!("../static/logo.png").as_ref()).build())
async fn pwa_logo() -> Result<Response<Body>, String> {
Ok(
Response::builder()
.status(200)
.header("content-type", "image/png")
.body(include_bytes!("../static/logo.png").as_ref().into())
.unwrap_or_default(),
)
}
// Required for iOS App Icons
async fn iphone_logo(_req: Request<()>) -> tide::Result {
async fn iphone_logo() -> Result<Response<Body>, String> {
Ok(
Response::builder(200)
.content_type("image/png")
.body(include_bytes!("../static/apple-touch-icon.png").as_ref())
.build(),
Response::builder()
.status(200)
.header("content-type", "image/png")
.body(include_bytes!("../static/apple-touch-icon.png").as_ref().into())
.unwrap_or_default(),
)
}
async fn favicon(_req: Request<()>) -> tide::Result {
async fn favicon() -> Result<Response<Body>, String> {
Ok(
Response::builder(200)
.content_type("image/vnd.microsoft.icon")
Response::builder()
.status(200)
.header("content-type", "image/vnd.microsoft.icon")
.header("Cache-Control", "public, max-age=1209600, s-maxage=86400")
.body(include_bytes!("../static/favicon.ico").as_ref())
.build(),
.body(include_bytes!("../static/favicon.ico").as_ref().into())
.unwrap_or_default(),
)
}
async fn resource(body: &str, content_type: &str, cache: bool) -> tide::Result {
let mut res = Response::new(200);
async fn resource(body: &str, content_type: &str, cache: bool) -> Result<Response<Body>, String> {
let mut res = Response::builder()
.status(200)
.header("content-type", content_type)
.body(body.to_string().into())
.unwrap_or_default();
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)
}
#[async_std::main]
async fn main() -> tide::Result<()> {
let matches = App::new("Libreddit")
#[tokio::main]
async fn main() {
let matches = cli::new("Libreddit")
.version(env!("CARGO_PKG_VERSION"))
.about("Private front-end for Reddit written in Rust ")
.arg(
@ -125,139 +109,149 @@ async fn main() -> tide::Result<()> {
Arg::with_name("redirect-https")
.short("r")
.long("redirect-https")
.help("Redirect all HTTP requests to HTTPS")
.help("Redirect all HTTP requests to HTTPS (no longer functional)")
.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();
let address = matches.value_of("address").unwrap_or("0.0.0.0");
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);
println!("Starting Libreddit...");
// Start HTTP server
let mut app = tide::new();
// Begin constructing a server
let mut app = server::Server::new();
// Redirect to HTTPS if "--redirect-https" enabled
app.with(HttpsRedirect(force_https));
// Define default headers (added to all responses)
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
app.with(NormalizePath);
// 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)
}));
if let Some(expire_time) = hsts {
if let Ok(val) = HeaderValue::from_str(&format!("max-age={}", expire_time)) {
app.default_headers.insert("Strict-Transport-Security", val);
}
}
// 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
.at("/manifest.json/")
.get(|_| resource(include_str!("../static/manifest.json"), "application/json", false));
app.at("/robots.txt/").get(|_| resource("User-agent: *\nAllow: /", "text/plain", true));
app.at("/favicon.ico/").get(favicon);
app.at("/logo.png/").get(pwa_logo);
app.at("/touch-icon-iphone.png/").get(iphone_logo);
app.at("/apple-touch-icon.png/").get(iphone_logo);
.at("/manifest.json")
.get(|_| resource(include_str!("../static/manifest.json"), "application/json", false).boxed());
app.at("/robots.txt").get(|_| resource("User-agent: *\nAllow: /", "text/plain", true).boxed());
app.at("/favicon.ico").get(|_| favicon().boxed());
app.at("/logo.png").get(|_| pwa_logo().boxed());
app.at("/touch-icon-iphone.png").get(|_| iphone_logo().boxed());
app.at("/apple-touch-icon.png").get(|_| iphone_logo().boxed());
// Proxy media through Libreddit
app
.at("/vid/:id/:size/") /* */
.get(|req| handler(req, "https://v.redd.it/{}/DASH_{}", vec!["id", "size"]));
app
.at("/img/:id/") /* */
.get(|req| handler(req, "https://i.redd.it/{}", vec!["id"]));
app
.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"]));
app.at("/vid/:id/:size").get(|r| proxy(r, "https://v.redd.it/{id}/DASH_{size}").boxed());
app.at("/img/:id").get(|r| proxy(r, "https://i.redd.it/{id}").boxed());
app.at("/thumb/:point/:id").get(|r| proxy(r, "https://{point}.thumbs.redditmedia.com/{id}").boxed());
app.at("/emoji/:id/:name").get(|r| proxy(r, "https://emoji.redditmedia.com/{id}/{name}").boxed());
app.at("/preview/:loc/:id/:query").get(|r| proxy(r, "https://{loc}view.redd.it/{id}?{query}").boxed());
app.at("/style/*path").get(|r| proxy(r, "https://styles.redditmedia.com/{path}").boxed());
app.at("/static/*path").get(|r| proxy(r, "https://www.redditstatic.com/{path}").boxed());
// Browse user profile
app.at("/u/:name/").get(user::profile);
app.at("/u/:name/comments/:id/:title/").get(post::item);
app.at("/u/:name/comments/:id/:title/:comment_id/").get(post::item);
app
.at("/u/:name")
.get(|r| async move { Ok(redirect(format!("/user/{}", r.param("name").unwrap_or_default()))) }.boxed());
app.at("/u/:name/comments/:id/:title").get(|r| post::item(r).boxed());
app.at("/u/:name/comments/:id/:title/:comment_id").get(|r| post::item(r).boxed());
app.at("/user/:name/").get(user::profile);
app.at("/user/:name/comments/:id/").get(post::item);
app.at("/user/:name/comments/:id/:title/").get(post::item);
app.at("/user/:name/comments/:id/:title/:comment_id/").get(post::item);
app.at("/user/[deleted]").get(|req| error(req, "User has deleted their account".to_string()).boxed());
app.at("/user/:name").get(|r| user::profile(r).boxed());
app.at("/user/:name/comments/:id").get(|r| post::item(r).boxed());
app.at("/user/:name/comments/:id/:title").get(|r| post::item(r).boxed());
app.at("/user/:name/comments/:id/:title/:comment_id").get(|r| post::item(r).boxed());
// Configure settings
app.at("/settings/").get(settings::get).post(settings::set);
app.at("/settings/restore/").get(settings::restore);
app.at("/settings").get(|r| settings::get(r).boxed()).post(|r| settings::set(r).boxed());
app.at("/settings/restore").get(|r| settings::restore(r).boxed());
// Subreddit services
app.at("/r/:sub/").get(subreddit::page);
app.at("/r/:sub").get(|r| subreddit::community(r).boxed());
app.at("/r/:sub/subscribe/").post(subreddit::subscriptions);
app.at("/r/:sub/unsubscribe/").post(subreddit::subscriptions);
app
.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/comments/:id/:title/").get(post::item);
app.at("/r/:sub/comments/:id/:title/:comment_id/").get(post::item);
app.at("/r/:sub/subscribe").post(|r| subreddit::subscriptions(r).boxed());
app.at("/r/:sub/unsubscribe").post(|r| subreddit::subscriptions(r).boxed());
app.at("/r/:sub/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/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/search").get(|r| search::find(r).boxed());
app.at("/r/:sub/:sort/").get(subreddit::page);
app
.at("/r/:sub/w")
.get(|r| async move { Ok(redirect(format!("/r/{}/wiki", r.param("sub").unwrap_or_default()))) }.boxed());
app
.at("/r/:sub/w/:page")
.get(|r| async move { Ok(redirect(format!("/r/{}/wiki/{}", r.param("sub").unwrap_or_default(), r.param("wiki").unwrap_or_default()))) }.boxed());
app.at("/r/:sub/wiki").get(|r| subreddit::wiki(r).boxed());
app.at("/r/:sub/wiki/:page").get(|r| subreddit::wiki(r).boxed());
app.at("/r/:sub/about/sidebar").get(|r| subreddit::sidebar(r).boxed());
app.at("/r/:sub/:sort").get(|r| subreddit::community(r).boxed());
// Comments handler
app.at("/comments/:id").get(|r| post::item(r).boxed());
// Front page
app.at("/").get(subreddit::page);
app.at("/").get(|r| subreddit::community(r).boxed());
// View Reddit wiki
app.at("/w/").get(subreddit::wiki);
app.at("/w/:page/").get(subreddit::wiki);
app.at("/wiki/").get(subreddit::wiki);
app.at("/wiki/:page/").get(subreddit::wiki);
app.at("/w").get(|_| async { Ok(redirect("/wiki".to_string())) }.boxed());
app
.at("/w/:page")
.get(|r| async move { Ok(redirect(format!("/wiki/{}", r.param("page").unwrap_or_default()))) }.boxed());
app.at("/wiki").get(|r| subreddit::wiki(r).boxed());
app.at("/wiki/:page").get(|r| subreddit::wiki(r).boxed());
// Search all of Reddit
app.at("/search/").get(search::find);
app.at("/search").get(|r| search::find(r).boxed());
// 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 {
match req.param("id") {
// Sort front page
Ok("best") | Ok("hot") | Ok("new") | Ok("top") | Ok("rising") | Ok("controversial") => subreddit::page(req).await,
// Short link for post
Ok(id) if id.len() > 4 && id.len() < 7 => post::item(req).await,
// Error message for unknown pages
_ => error(req, "Nothing here".to_string()).await,
}
app.at("/:id").get(|req: Request<Body>| match req.param("id").as_deref() {
// Sort front page
Some("best") | Some("hot") | Some("new") | Some("top") | Some("rising") | Some("controversial") => subreddit::community(req).boxed(),
// Short link for post
Some(id) if id.len() > 4 && id.len() < 7 => post::item(req).boxed(),
// Error message for unknown pages
_ => error(req, "Nothing here".to_string()).boxed(),
});
// Default service in case no routes match
app.at("*").get(|req| error(req, "Nothing here".to_string()));
app.at("/*").get(|req| error(req, "Nothing here".to_string()).boxed());
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);
}
}

View File

@ -1,6 +1,9 @@
// CRATES
use crate::utils::*;
use tide::Request;
use crate::client::json;
use crate::esc;
use crate::server::RequestExt;
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 async_recursion::async_recursion;
@ -17,9 +20,9 @@ struct PostTemplate {
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
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
let mut sort: String = param(&path, "sort");
@ -30,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 sort.is_empty() && !default_sort.is_empty() {
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
#[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();
// 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
Ok(res) => {
// Parse the JSON into Post and Comment structs
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
template(PostTemplate {
@ -54,7 +57,7 @@ pub async fn item(req: Request<()>) -> tide::Result {
post,
sort,
prefs: Preferences::new(req),
single_thread: *single_thread,
single_thread,
})
}
// If the Reddit API returns an error, exit and send error page to user
@ -79,7 +82,7 @@ async fn parse_post(json: &serde_json::Value) -> Post {
// Build a post using data parsed from Reddit post API
Post {
id: val(post, "id"),
title: val(post, "title"),
title: esc!(post, "title"),
community: val(post, "subreddit"),
body: rewrite_urls(&val(post, "selftext_html")).replace("\\", ""),
author: Author {
@ -90,7 +93,7 @@ async fn parse_post(json: &serde_json::Value) -> Post {
post["data"]["author_flair_richtext"].as_array(),
post["data"]["author_flair_text"].as_str(),
),
text: val(post, "link_flair_text"),
text: esc!(post, "link_flair_text"),
background_color: val(post, "author_flair_background_color"),
foreground_color: val(post, "author_flair_text_color"),
},
@ -113,7 +116,7 @@ async fn parse_post(json: &serde_json::Value) -> Post {
post["data"]["link_flair_richtext"].as_array(),
post["data"]["link_flair_text"].as_str(),
),
text: val(post, "link_flair_text"),
text: esc!(post, "link_flair_text"),
background_color: val(post, "link_flair_background_color"),
foreground_color: if val(post, "link_flair_text_color") == "dark" {
"black".to_string()
@ -189,14 +192,14 @@ async fn parse_comments(json: &serde_json::Value, post_link: &str, post_author:
data["author_flair_richtext"].as_array(),
data["author_flair_text"].as_str(),
),
text: val(&comment, "link_flair_text"),
text: esc!(&comment, "link_flair_text"),
background_color: val(&comment, "author_flair_background_color"),
foreground_color: val(&comment, "author_flair_text_color"),
},
distinguished: val(&comment, "distinguished"),
},
score: if data["score_hidden"].as_bool().unwrap_or_default() {
"".to_string()
("\u{2022}".to_string(), "Hidden".to_string())
} else {
format_num(score)
},

View File

@ -1,32 +0,0 @@
use surf::Body;
use tide::{Request, Response};
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
}
async fn request(url: String) -> tide::Result {
match surf::get(url).await {
Ok(res) => {
let content_length = res.header("Content-Length").map(|v| v.to_string()).unwrap_or_default();
let content_type = res.content_type().map(|m| m.to_string()).unwrap_or_default();
Ok(
Response::builder(res.status())
.body(Body::from_reader(res, None))
.header("Cache-Control", "public, max-age=1209600, s-maxage=86400")
.header("Content-Length", content_length)
.header("Content-Type", content_type)
.build(),
)
}
Err(e) => Ok(Response::builder(503).body(e.to_string()).build()),
}
}

View File

@ -1,7 +1,8 @@
// 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 tide::Request;
use hyper::{Body, Request, Response};
// STRUCTS
struct SearchParams {
@ -17,8 +18,9 @@ struct SearchParams {
struct Subreddit {
name: String,
url: String,
icon: String,
description: String,
subscribers: i64,
subscribers: (String, String),
}
#[derive(Template)]
@ -32,10 +34,10 @@ struct SearchTemplate {
}
// 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 path = format!("{}.json?{}{}", req.url().path(), req.url().query().unwrap_or_default(), nsfw_results);
let sub = req.param("sub").unwrap_or("").to_string();
let path = format!("{}.json?{}{}", req.uri().path(), req.uri().query().unwrap_or_default(), nsfw_results);
let sub = req.param("sub").unwrap_or_default();
let query = param(&path, "q");
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(' ', "+"));
// Send a request to the url
match request(subreddit_search_path).await {
match json(subreddit_search_path).await {
// If success, receive JSON in response
Ok(response) => {
match response["data"]["children"].as_array() {
// For each subreddit from subreddit list
Some(list) => list
.iter()
.map(|subreddit| Subreddit {
name: val(subreddit, "display_name_prefixed"),
url: val(subreddit, "url"),
description: val(subreddit, "public_description"),
subscribers: subreddit["data"]["subscribers"].as_u64().unwrap_or_default() as i64,
.map(|subreddit| {
// Fetch subreddit icon either from the community_icon or icon_img value
let community_icon: &str = subreddit["data"]["community_icon"].as_str().map_or("", |s| s.split('?').collect::<Vec<&str>>()[0]);
let icon = if community_icon.is_empty() {
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>>(),
_ => Vec::new(),

214
src/server.rs Normal file
View 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");
}

View File

@ -1,7 +1,12 @@
use std::collections::HashMap;
// CRATES
use crate::server::ResponseExt;
use crate::utils::{redirect, template, Preferences};
use askama::Template;
use tide::{http::Cookie, Request};
use cookie::Cookie;
use futures_lite::StreamExt;
use hyper::{Body, Request, Response};
use time::{Duration, OffsetDateTime};
// STRUCTS
@ -11,45 +16,52 @@ struct SettingsTemplate {
prefs: Preferences,
}
#[derive(serde::Deserialize, Default)]
#[serde(default)]
pub struct SettingsForm {
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
// 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) })
}
// Set cookies using response "Set-Cookie" header
pub async fn set(mut req: Request<()>) -> tide::Result {
let form: SettingsForm = req.body_form().await.unwrap_or_default();
pub async fn set(req: Request<Body>) -> Result<Response<Body>, String> {
// 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 names = vec!["theme", "front_page", "layout", "wide", "comment_sort", "show_nsfw"];
let values = vec![form.theme, form.front_page, form.layout, form.wide, form.comment_sort, form.show_nsfw];
let names = vec!["theme", "front_page", "layout", "wide", "comment_sort", "post_sort", "show_nsfw"];
for (i, name) in names.iter().enumerate() {
match values.get(i) {
for name in names {
match form.get(name) {
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("/")
.http_only(true)
.expires(OffsetDateTime::now_utc() + Duration::weeks(52))
.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
pub async fn restore(req: Request<()>) -> tide::Result {
let form: SettingsForm = req.query()?;
pub async fn restore(req: Request<Body>) -> Result<Response<Body>, String> {
// 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),
None => "/".to_string(),
};
let mut res = redirect(path);
let names = vec!["theme", "front_page", "layout", "wide", "comment_sort", "show_nsfw", "subscriptions"];
let values = vec![form.theme, form.front_page, form.layout, form.wide, form.comment_sort, form.show_nsfw, form.subscriptions];
for (i, name) in names.iter().enumerate() {
match values.get(i) {
for name in names {
match form.get(name) {
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("/")
.http_only(true)
.expires(OffsetDateTime::now_utc() + Duration::weeks(52))
.finish(),
),
None => res.remove_cookie(Cookie::named(name.to_owned())),
None => res.remove_cookie(name.to_string()),
};
}

View File

@ -1,7 +1,10 @@
// CRATES
use crate::utils::*;
use crate::esc;
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 tide::{http::Cookie, Request};
use cookie::Cookie;
use hyper::{Body, Request, Response};
use time::{Duration, OffsetDateTime};
// STRUCTS
@ -25,23 +28,27 @@ struct WikiTemplate {
}
// SERVICES
pub async fn page(req: Request<()>) -> tide::Result {
pub async fn community(req: Request<Body>) -> Result<Response<Body>, String> {
// Build Reddit API path
let subscribed = cookie(&req, "subscriptions");
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() {
if subscribed.is_empty() {
"popular".to_string()
let sub = req.param("sub").map_or(
if front_page == "default" || front_page.is_empty() {
if subscribed.is_empty() {
"popular".to_string()
} else {
subscribed.to_owned()
}
} else {
subscribed.to_owned()
}
} else {
front_page.to_owned()
});
front_page.to_owned()
},
String::from,
);
let path = format!("/r/{}/{}.json?{}&raw_json=1", sub, sort, req.url().query().unwrap_or_default());
let path = format!("/r/{}/{}.json?{}&raw_json=1", sub, sort, req.uri().query().unwrap_or_default());
match Post::fetch(&path, String::new()).await {
Ok((posts, after)) => {
@ -51,7 +58,7 @@ pub async fn page(req: Request<()>) -> tide::Result {
subreddit(&sub).await.unwrap_or_default()
} else if sub == subscribed {
// Subscription feed
if req.url().path().starts_with("/r/") {
if req.uri().path().starts_with("/r/") {
subreddit(&sub).await.unwrap_or_default()
} else {
Subreddit::default()
@ -84,10 +91,10 @@ pub async fn page(req: Request<()>) -> tide::Result {
}
// Sub or unsub by setting subscription cookie using response "Set-Cookie" header
pub async fn subscriptions(req: Request<()>) -> tide::Result {
let sub = req.param("sub").unwrap_or_default().to_string();
let query = req.url().query().unwrap_or_default().to_string();
let action: Vec<String> = req.url().path().split('/').map(String::from).collect();
pub async fn subscriptions(req: Request<Body>) -> Result<Response<Body>, String> {
let sub = req.param("sub").unwrap_or_default();
let query = req.uri().query().unwrap_or_default().to_string();
let action: Vec<String> = req.uri().path().split('/').map(String::from).collect();
let mut sub_list = Preferences::new(req).subscriptions;
@ -108,18 +115,17 @@ pub async fn subscriptions(req: Request<()>) -> tide::Result {
// Redirect back to subreddit
// check for redirect parameter if unsubscribing from outside sidebar
let redirect_path = param(&format!("/?{}", query), "redirect");
let path = if !redirect_path.is_empty() {
format!("/{}/", redirect_path)
} else {
let path = if redirect_path.is_empty() {
format!("/r/{}", sub)
} else {
format!("/{}/", redirect_path)
};
let mut res = redirect(path);
// Delete cookie if empty, else set
if sub_list.is_empty() {
// res.del_cookie(&Cookie::build("subscriptions", "").path("/").finish());
res.remove_cookie(Cookie::build("subscriptions", "").path("/").finish());
res.remove_cookie("subscriptions".to_string());
} else {
res.insert_cookie(
Cookie::build("subscriptions", sub_list.join("+"))
@ -133,15 +139,15 @@ pub async fn subscriptions(req: Request<()>) -> tide::Result {
Ok(res)
}
pub async fn wiki(req: Request<()>) -> tide::Result {
let sub = req.param("sub").unwrap_or("reddit.com").to_string();
let page = req.param("page").unwrap_or("index").to_string();
pub async fn wiki(req: Request<Body>) -> Result<Response<Body>, String> {
let sub = req.param("sub").unwrap_or_else(|| "reddit.com".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);
match request(path).await {
Ok(res) => template(WikiTemplate {
match json(path).await {
Ok(response) => template(WikiTemplate {
sub,
wiki: rewrite_urls(res["data"]["content_html"].as_str().unwrap_or_default()),
wiki: rewrite_urls(response["data"]["content_html"].as_str().unwrap_or_default()),
page,
prefs: Preferences::new(req),
}),
@ -149,13 +155,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
async fn subreddit(sub: &str) -> Result<Subreddit, String> {
// Build the Reddit JSON API url
let path: String = format!("/r/{}/about.json?raw_json=1", sub);
// Send a request to the url
match request(path).await {
match json(path).await {
// If success, receive JSON in response
Ok(res) => {
// Metadata regarding the subreddit
@ -167,9 +192,9 @@ async fn subreddit(sub: &str) -> Result<Subreddit, String> {
let icon = if community_icon.is_empty() { val(&res, "icon_img") } else { community_icon.to_string() };
let sub = Subreddit {
name: val(&res, "display_name"),
title: val(&res, "title"),
description: val(&res, "public_description"),
name: esc!(&res, "display_name"),
title: esc!(&res, "title"),
description: esc!(&res, "public_description"),
info: rewrite_urls(&val(&res, "description_html").replace("\\", "")),
icon: format_url(&icon),
members: format_num(members),

View File

@ -1,7 +1,10 @@
// CRATES
use crate::utils::*;
use crate::client::json;
use crate::esc;
use crate::server::RequestExt;
use crate::utils::{error, format_url, param, template, Post, Preferences, User};
use askama::Template;
use tide::Request;
use hyper::{Body, Request, Response};
use time::OffsetDateTime;
// STRUCTS
@ -16,13 +19,17 @@ struct UserTemplate {
}
// 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
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
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
let posts = Post::fetch(&path, "Comment".to_string()).await;
@ -51,23 +58,23 @@ async fn user(name: &str) -> Result<User, String> {
let path: String = format!("/user/{}/about.json?raw_json=1", name);
// Send a request to the url
match request(path).await {
match json(path).await {
// If success, receive JSON in response
Ok(res) => {
// Grab creation date as unix timestamp
let created: i64 = res["data"]["created"].as_f64().unwrap_or(0.0).round() as i64;
// nested_val function used to parse JSON from Reddit APIs
// Closure used to parse JSON from Reddit APIs
let about = |item| res["data"]["subreddit"][item].as_str().unwrap_or_default().to_string();
// Parse the JSON output into a User struct
Ok(User {
name: name.to_string(),
title: about("title"),
title: esc!(about("title")),
icon: format_url(&about("icon_img")),
karma: res["data"]["total_karma"].as_i64().unwrap_or(0),
created: OffsetDateTime::from_unix_timestamp(created).format("%b %d '%y"),
banner: about("banner_img"),
banner: esc!(about("banner_img")),
description: about("public_description"),
})
}

View File

@ -1,13 +1,15 @@
//
// CRATES
//
use crate::{client::json, esc, server::RequestExt};
use askama::Template;
use cached::proc_macro::cached;
use cookie::Cookie;
use hyper::{Body, Request, Response};
use regex::Regex;
use serde_json::{from_str, Error, Value};
use serde_json::Value;
use std::collections::HashMap;
use tide::{http::url::Url, http::Cookie, Request, Response};
use time::{Duration, OffsetDateTime};
use url::Url;
// Post flair with content, background color and foreground color
pub struct Flair {
@ -123,7 +125,7 @@ impl Media {
let url = if post_type == "self" || post_type == "link" {
url_val.as_str().unwrap_or_default().to_string()
} else {
format_url(url_val.as_str().unwrap_or_default()).to_string()
format_url(url_val.as_str().unwrap_or_default())
};
(
@ -179,7 +181,7 @@ pub struct Post {
pub body: String,
pub author: Author,
pub permalink: String,
pub score: String,
pub score: (String, String),
pub upvote_ratio: i64,
pub post_type: String,
pub flair: Flair,
@ -189,7 +191,7 @@ pub struct Post {
pub domain: String,
pub rel_time: String,
pub created: String,
pub comments: String,
pub comments: (String, String),
pub gallery: Vec<GalleryMedia>,
}
@ -200,7 +202,7 @@ impl Post {
let post_list;
// Send a request to the url
match request(path.to_string()).await {
match json(path.to_string()).await {
// If success, receive JSON in response
Ok(response) => {
res = response;
@ -224,14 +226,14 @@ impl Post {
let (rel_time, created) = time(data["created_utc"].as_f64().unwrap_or_default());
let score = data["score"].as_i64().unwrap_or_default();
let ratio: f64 = data["upvote_ratio"].as_f64().unwrap_or(1.0) * 100.0;
let title = val(post, "title");
let title = esc!(post, "title");
// Determine the type of media along with the media URL
let (post_type, media, gallery) = Media::parse(&data).await;
posts.push(Self {
id: val(post, "id"),
title: if title.is_empty() { fallback_title.to_owned() } else { title },
title: esc!(if title.is_empty() { fallback_title.to_owned() } else { title }),
community: val(post, "subreddit"),
body: rewrite_urls(&val(post, "body_html")),
author: Author {
@ -242,14 +244,14 @@ impl Post {
data["author_flair_richtext"].as_array(),
data["author_flair_text"].as_str(),
),
text: val(post, "link_flair_text"),
text: esc!(post, "link_flair_text"),
background_color: val(post, "author_flair_background_color"),
foreground_color: val(post, "author_flair_text_color"),
},
distinguished: val(post, "distinguished"),
},
score: if data["hide_score"].as_bool().unwrap_or_default() {
"".to_string()
("\u{2022}".to_string(), "Hidden".to_string())
} else {
format_num(score)
},
@ -269,7 +271,7 @@ impl Post {
data["link_flair_richtext"].as_array(),
data["link_flair_text"].as_str(),
),
text: val(post, "link_flair_text"),
text: esc!(post, "link_flair_text"),
background_color: val(post, "link_flair_background_color"),
foreground_color: if val(post, "link_flair_text_color") == "dark" {
"black".to_string()
@ -305,7 +307,7 @@ pub struct Comment {
pub post_author: String,
pub body: String,
pub author: Author,
pub score: String,
pub score: (String, String),
pub rel_time: String,
pub created: String,
pub edited: (String, String),
@ -340,8 +342,8 @@ pub struct Subreddit {
pub description: String,
pub info: String,
pub icon: String,
pub members: String,
pub active: String,
pub members: (String, String),
pub active: (String, String),
pub wiki: bool,
}
@ -363,12 +365,13 @@ pub struct Preferences {
pub wide: String,
pub show_nsfw: String,
pub comment_sort: String,
pub post_sort: String,
pub subscriptions: Vec<String>,
}
impl Preferences {
// Build preferences from cookies
pub fn new(req: Request<()>) -> Self {
pub fn new(req: Request<Body>) -> Self {
Self {
theme: cookie(&req, "theme"),
front_page: cookie(&req, "front_page"),
@ -376,6 +379,7 @@ impl Preferences {
wide: cookie(&req, "wide"),
show_nsfw: cookie(&req, "show_nsfw"),
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(),
}
}
@ -394,7 +398,7 @@ pub fn param(path: &str, value: &str) -> String {
}
// 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));
cookie.value().to_string()
}
@ -408,12 +412,12 @@ pub fn format_url(url: &str) -> String {
Ok(parsed) => {
let domain = parsed.domain().unwrap_or_default();
let capture = |regex: &str, format: &str, levels: i16| {
let capture = |regex: &str, format: &str, segments: i16| {
Regex::new(regex)
.map(|re| match re.captures(url) {
Some(caps) => match levels {
1 => [format, &caps[1], "/"].join(""),
2 => [format, &caps[1], "/", &caps[2], "/"].join(""),
Some(caps) => match segments {
1 => [format, &caps[1]].join(""),
2 => [format, &caps[1], "/", &caps[2]].join(""),
_ => String::new(),
},
None => String::new(),
@ -441,21 +445,23 @@ pub fn format_url(url: &str) -> String {
// Rewrite Reddit links to Libreddit in body of text
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(),
Err(_) => String::new(),
}
}
// Append `m` and `k` for millions and thousands respectively
pub fn format_num(num: i64) -> String {
if num >= 1_000_000 {
pub fn format_num(num: i64) -> (String, String) {
let truncated = if num >= 1_000_000 || 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)
} else {
num.to_string()
}
};
(truncated, num.to_string())
}
// Parse a relative and absolute time from a UNIX timestamp
@ -483,23 +489,51 @@ pub fn val(j: &Value, k: &str) -> String {
j["data"][k].as_str().unwrap_or_default().to_string()
}
#[macro_export]
macro_rules! esc {
($f:expr) => {
$f.replace('<', "&lt;").replace('>', "&gt;")
};
($j:expr, $k:expr) => {
$j["data"][$k].as_str().unwrap_or_default().to_string().replace('<', "&lt;").replace('>', "&gt;")
};
}
// Escape < and > to accurately render HTML
// pub fn esc(j: &Value, k: &str) -> String {
// val(j,k)
// // .replace('&', "&amp;")
// .replace('<', "&lt;")
// .replace('>', "&gt;")
// // .replace('"', "&quot;")
// // .replace('\'', "&#x27;")
// // .replace('/', "&#x2f;")
// }
//
// NETWORKING
//
pub fn template(t: impl Template) -> tide::Result {
Ok(Response::builder(200).content_type("text/html").body(t.render().unwrap_or_default()).build())
pub fn template(t: impl Template) -> Result<Response<Body>, String> {
Ok(
Response::builder()
.status(200)
.header("content-type", "text/html")
.body(t.render().unwrap_or_default().into())
.unwrap_or_default(),
)
}
pub fn redirect(path: String) -> Response {
Response::builder(302)
.content_type("text/html")
pub fn redirect(path: String) -> Response<Body> {
Response::builder()
.status(302)
.header("content-type", "text/html")
.header("Location", &path)
.body(format!("Redirecting to <a href=\"{0}\">{0}</a>...", path))
.build()
.body(format!("Redirecting to <a href=\"{0}\">{0}</a>...", path).into())
.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 {
msg,
prefs: Preferences::new(req),
@ -507,57 +541,5 @@ pub async fn error(req: Request<()>, msg: String) -> tide::Result {
.render()
.unwrap_or_default();
Ok(Response::builder(404).content_type("text/html").body(body).build())
}
// 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);
// Build reddit-compliant user agent for Libreddit
let user_agent = format!("web:libreddit:{}", env!("CARGO_PKG_VERSION"));
// Send request using surf
let req = surf::get(&url).header("User-Agent", user_agent.as_str());
let client = surf::client().with(surf::middleware::Redirect::new(5));
let res = client.send(req).await;
let err = |msg: &str, e: String| -> Result<Value, String> {
eprintln!("{} - {}: {}", url, msg, e);
Err(msg.to_string())
};
match res {
Ok(mut response) => match response.take_body().into_string().await {
// If response is success
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 parse request body", e.to_string()),
},
Err(e) => err("Couldn't send request to Reddit", e.to_string()),
}
Ok(Response::builder().status(404).header("content-type", "text/html").body(body.into()).unwrap_or_default())
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

View File

@ -522,7 +522,26 @@ button.submit:hover > svg { stroke: var(--accent); }
.search_subreddit {
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 {
@ -628,13 +647,13 @@ a.search_subreddit:hover {
font-weight: bold;
}
.author_flair, .post_flair:empty {
.author_flair:empty, .post_flair:empty {
display: none;
}
.emoji {
width: 1em;
height: 1em;
width: 1.25em;
height: 1.25em;
display: inline-block;
background-size: contain;
background-position: 50% 50%;
@ -1115,46 +1134,6 @@ td, th {
/* Mobile */
@media screen and (max-width: 480px) {
#version { display: none; }
.post {
grid-template: "post_header post_header post_thumbnail" auto
"post_title post_title post_thumbnail" 1fr
"post_media post_media post_thumbnail" auto
"post_body post_body post_thumbnail" auto
"post_score post_footer post_thumbnail" auto
/ auto 1fr fit-content(min(20%, 152px));
}
.post_score {
margin: 5px 0px 20px 15px;
padding: 0;
}
.compact .post_score { padding: 0; }
.post_score::before { content: "↑" }
.post_header { font-size: 14px; }
.post_footer { margin-left: 15px; }
.replies > .comment {
margin-left: -25px;
padding: 5px 0;
}
.comment_left {
min-width: 45px;
padding: 5px 0px;
}
.comment_author { margin-left: 10px; }
.comment_score { min-width: 35px; }
.comment_data::marker { font-size: 18px; }
.created { width: 100%; }
}
@media screen and (max-width: 800px) {
body { padding-top: 120px }
@ -1196,3 +1175,60 @@ td, th {
#logo, #links { margin-bottom: 5px; }
#searchbox { width: calc(100vw - 35px); }
}
@media screen and (max-width: 480px) {
body { padding-top: 100px; }
#version { display: none; }
.post {
grid-template: "post_header post_header post_thumbnail" auto
"post_title post_title post_thumbnail" 1fr
"post_media post_media post_thumbnail" auto
"post_body post_body post_thumbnail" auto
"post_score post_footer post_thumbnail" auto
/ auto 1fr fit-content(min(20%, 152px));
}
.post_score {
margin: 5px 0px 20px 15px;
padding: 0;
}
.compact .post_score { padding: 0; }
.post_score::before { content: "↑" }
.post_header { font-size: 14px; }
.post_footer { margin-left: 15px; }
.replies > .comment {
margin-left: -12px;
padding: 5px 0;
}
.comment_left {
min-width: auto;
padding: 5px 0px;
align-items: initial;
margin-top: -5px;
}
.line {
margin-left: 5px;
}
/* .thread { margin-left: -5px; } */
.comment_right { padding: 5px 0 10px 2px; }
.comment_author { margin-left: 12px; }
.comment_data { margin-left: 12px; }
.comment_data::marker { font-size: 25px; }
.created { width: 100%; }
.comment_score {
min-width: 32px;
height: 20px;
font-size: 15px;
padding: 7px 0px;
margin-right: -5px;
}
}

View File

@ -15,11 +15,11 @@
<!-- Android -->
<meta name="mobile-web-app-capable" content="yes">
<!-- iOS Logo -->
<link href="/touch-icon-iphone.png/" rel="apple-touch-icon">
<link href="/touch-icon-iphone.png" rel="apple-touch-icon">
<!-- PWA Manifest -->
<link rel="manifest" type="application/json" href="/manifest.json/">
<link rel="shortcut icon" type="image/x-icon" href="/favicon.ico/">
<link rel="stylesheet" type="text/css" href="/style.css/">
<link rel="manifest" type="application/json" href="/manifest.json">
<link rel="shortcut icon" type="image/x-icon" href="/favicon.ico">
<link rel="stylesheet" type="text/css" href="/style.css">
{% endblock %}
</head>
<body class="
@ -35,7 +35,7 @@
</div>
{% block search %}{% endblock %}
<div id="links">
<a id="settings_link" href="/settings/">
<a id="settings_link" href="/settings">
<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">
<title>settings</title>

View File

@ -5,12 +5,12 @@
{% else if kind == "t1" %}
<div id="{{ id }}" class="comment">
<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>
<details class="comment_right" open>
<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 %}
<small class="author_flair">{% call utils::render_flair(author.flair.flair_parts) %}</small>
{% endif %}

View File

@ -13,7 +13,6 @@
<!-- Meta Tags -->
<meta name="author" content="u/{{ post.author.name }}">
<meta name="title" content="{{ post.title }} - r/{{ post.community }}">
<meta name="description" content="View on Libreddit, an alternative private front-end to Reddit.">
<meta property="og:type" content="website">
<meta property="og:url" content="{{ post.permalink }}">
<meta property="og:title" content="{{ post.title }} - r/{{ post.community }}">
@ -38,7 +37,7 @@
<p class="post_header">
<a class="post_subreddit" href="/r/{{ post.community }}">r/{{ post.community }}</a>
<span class="dot">&bull;</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 %}
<small class="author_flair">{% call utils::render_flair(post.author.flair.flair_parts) %}</small>
{% endif %}
@ -48,7 +47,7 @@
<p class="post_title">
<a href="{{ post.permalink }}">{{ post.title }}</a>
{% 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"
style="color:{{ post.flair.foreground_color }}; background:{{ post.flair.background_color }};">{% call utils::render_flair(post.flair.flair_parts) %}</a>
{% endif %}
@ -90,7 +89,7 @@
<!-- POST BODY -->
<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">
<ul id="post_links">
<li><a href="/{{ post.id }}">permalink</a></li>

View File

@ -34,12 +34,15 @@
<div id="search_subreddits">
{% for subreddit in subreddits %}
<a href="{{ subreddit.url }}" class="search_subreddit">
<p class="search_subreddit_header">
<span class="search_subreddit_name">{{ subreddit.name }}</span>
<span class="dot">&bull;</span>
<span class="search_subreddit_members">{{ subreddit.subscribers }} Members</span>
</p>
<p class="search_subreddit_description">{{ subreddit.description }}</p>
<div class="search_subreddit_left">{% if subreddit.icon != "" %}<img src="{{ subreddit.icon }}" alt="r/{{ subreddit.name }} icon">{% endif %}</div>
<div class="search_subreddit_right">
<p class="search_subreddit_header">
<span class="search_subreddit_name">{{ subreddit.name }}</span>
<span class="dot">&bull;</span>
<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>
{% endfor %}
</div>
@ -52,7 +55,7 @@
{% else %}
<div class="comment">
<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>
<details class="comment_right" open>

View File

@ -9,7 +9,7 @@
{% block content %}
<div id="settings">
<form action="/settings/" method="POST">
<form action="/settings" method="POST">
<div class="prefs">
<p>Appearance</p>
<div id="theme">
@ -36,6 +36,12 @@
<input type="checkbox" name="wide" {% if prefs.wide == "on" %}checked{% endif %}>
</div>
<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">
<label for="comment_sort">Default comment sort:</label>
<select name="comment_sort">

View File

@ -40,7 +40,7 @@
</form>
{% 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>
</form>
{% endif %}
@ -81,16 +81,16 @@
<div id="sub_details">
<label>Members</label>
<label>Active</label>
<div>{{ sub.members }}</div>
<div>{{ sub.active }}</div>
<div title="{{ sub.members.1 }}">{{ sub.members.0 }}</div>
<div title="{{ sub.active.1 }}">{{ sub.active.0 }}</div>
</div>
<div id="sub_subscription">
{% 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>
</form>
{% else %}
<form action="/r/{{ sub.name }}/subscribe/" method="POST">
<form action="/r/{{ sub.name }}/subscribe" method="POST">
<button class="subscribe">Subscribe</button>
</form>
{% endif %}

View File

@ -37,7 +37,7 @@
{% else %}
<div class="comment">
<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>
<details class="comment_right" open>
@ -65,7 +65,7 @@
</div>
<aside>
<div class="panel" id="user">
<img id="user_icon" src="{{ user.icon }}">
<img id="user_icon" src="{{ user.icon }}" alt="User icon">
<p id="user_title">{{ user.title }}</p>
<p id="user_name">u/{{ user.name }}</p>
<div id="user_description">{{ user.description }}</div>

View File

@ -15,7 +15,7 @@
{%- endmacro %}
{% 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 }}">
{% if root != "/r/" && !root.is_empty() %}
<div id="inside">
@ -66,7 +66,7 @@
</p>
<p class="post_title">
{% if post.flair.flair_parts.len() > 0 %}
<a href="/r/{{ post.community }}/search/?q=flair_name%3A%22{{ post.flair.text }}%22&restrict_sr=on"
<a href="/r/{{ post.community }}/search?q=flair_name%3A%22{{ post.flair.text }}%22&restrict_sr=on"
class="post_flair"
style="color:{{ post.flair.foreground_color }}; background:{{ post.flair.background_color }};">{% call render_flair(post.flair.flair_parts) %}</a>
{% endif %}
@ -86,9 +86,9 @@
</svg>
</a>
{% else if (prefs.layout.is_empty() || prefs.layout == "card") && post.post_type == "gif" %}
<video class="post_media_video short" src="{{ post.media.url }}" width="{{ post.media.width }}px" height="{{ post.media.height }}px" controls loop autoplay><a href={{ post.media.url }}>Video</a></video>
<video class="post_media_video short" src="{{ post.media.url }}" width="{{ post.media.width }}" height="{{ post.media.height }}" controls loop autoplay><a href={{ post.media.url }}>Video</a></video>
{% else if (prefs.layout.is_empty() || prefs.layout == "card") && post.post_type == "video" %}
<video class="post_media_video short" src="{{ post.media.url }}" width="{{ post.media.width }}px" height="{{ post.media.height }}px" poster="{{ post.media.poster }}" preload="none" controls autoplay><a href={{ post.media.url }}>Video</a></video>
<video class="post_media_video short" src="{{ post.media.url }}" width="{{ post.media.width }}" height="{{ post.media.height }}" poster="{{ post.media.poster }}" preload="none" controls autoplay><a href={{ post.media.url }}>Video</a></video>
{% else if post.post_type != "self" %}
<a class="post_thumbnail {% if post.thumbnail.url.is_empty() %}no_thumbnail{% endif %}" href="{% if post.post_type == "link" %}{{ post.media.url }}{% else %}{{ post.permalink }}{% endif %}">
{% if post.thumbnail.url.is_empty() %}
@ -108,9 +108,9 @@
</a>
{% 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">
<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>
{%- endmacro %}