Compare commits
114 Commits
Author | SHA1 | Date | |
---|---|---|---|
dd51b23dc4 | |||
52d9698879 | |||
20f6945160 | |||
10c73fad7f | |||
2bddc952cb | |||
1de01d7283 | |||
9183ce1921 | |||
a197df89ff | |||
be2a1d876b | |||
686d61801f | |||
5d643277bc | |||
a3ec44149c | |||
83ba0fb913 | |||
55e9915bb0 | |||
5cd5b553b0 | |||
2b2bd8421b | |||
47d01a0dca | |||
0a69937238 | |||
6d08f2dd24 | |||
4a06882dc8 | |||
3e567d9acf | |||
8034594006 | |||
2f3315dcfc | |||
df118764df | |||
d78f82649e | |||
80fb3a5c18 | |||
518d5753a7 | |||
de38f7ef18 | |||
dd67b52199 | |||
9cfab348eb | |||
e1f7b6d0c0 | |||
a606e48435 | |||
2091f26bda | |||
b3341b49c0 | |||
65e4ceff7b | |||
bacb22f7f9 | |||
902c9a6e42 | |||
c586de66ba | |||
e466be8946 | |||
bed3465475 | |||
8560e8a37a | |||
3652342f46 | |||
58127b17d8 | |||
2f4deb221a | |||
38230ed473 | |||
71501b064c | |||
47a58ea05c | |||
14ecf3cf60 | |||
aa7c8c85df | |||
0cb7031c36 | |||
93cfc713c6 | |||
ff8685ae4c | |||
f06320a4ae | |||
809be42e01 | |||
58ca085521 | |||
4a40e16277 | |||
fee2cb1b56 | |||
8785bc95f5 | |||
16454213cf | |||
6feb347c27 | |||
e731cfbac4 | |||
008924fff8 | |||
ebbdd7185f | |||
402b3149e1 | |||
ac5ef89dff | |||
7edca18f8d | |||
cf45d53fdd | |||
2a475d127a | |||
3fa523e67b | |||
3fbb433e37 | |||
5fbcfd850f | |||
c758db84ec | |||
90d3063f93 | |||
82a601d534 | |||
12a1b3f459 | |||
e23eaf0be0 | |||
821709c8d2 | |||
653b0e7024 | |||
c7a2c43287 | |||
9824370771 | |||
d87b96d0ea | |||
6eae4bc47a | |||
1bcb070fbb | |||
24bc758090 | |||
ffbb1cf7cd | |||
cbf1f540d6 | |||
f8e0d2d4b9 | |||
8a27b2bac8 | |||
69941d9efd | |||
956de50419 | |||
d790264a62 | |||
f4f2d8a377 | |||
dd908c9f68 | |||
9e1948733d | |||
9df1dfae32 | |||
cfbee1bb81 | |||
8430cbc6f3 | |||
a9dd2e6f2c | |||
36964982fb | |||
0742a33304 | |||
7f320b3143 | |||
58f4fc4e77 | |||
7d8faefad0 | |||
ba9b5afd4e | |||
ae09f77bf6 | |||
5030c418de | |||
4ccd6b1751 | |||
7d17aa0627 | |||
4b73e2d914 | |||
0a140a6ffc | |||
e837d84105 | |||
f6d791ccd9 | |||
effaeb7508 | |||
6257faf9dc |
36
.github/workflows/docker-build.yml
vendored
Normal file
36
.github/workflows/docker-build.yml
vendored
Normal file
@ -0,0 +1,36 @@
|
||||
name: Docker Multi-Architecture Build
|
||||
|
||||
on:
|
||||
push:
|
||||
paths-ignore:
|
||||
- "**.md"
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
build-docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
with:
|
||||
platforms: all
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
with:
|
||||
version: latest
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: spikecodes/libreddit:latest
|
2
.github/workflows/rust.yml
vendored
2
.github/workflows/rust.yml
vendored
@ -11,7 +11,7 @@ env:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-18.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
1
CODEOWNERS
Normal file
1
CODEOWNERS
Normal file
@ -0,0 +1 @@
|
||||
* @spikecodes
|
1920
Cargo.lock
generated
1920
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
24
Cargo.toml
24
Cargo.toml
@ -3,19 +3,19 @@ name = "libreddit"
|
||||
description = " Alternative private front-end to Reddit"
|
||||
license = "AGPL-3.0"
|
||||
repository = "https://github.com/spikecodes/libreddit"
|
||||
version = "0.2.9"
|
||||
version = "0.3.0"
|
||||
authors = ["spikecodes <19519553+spikecodes@users.noreply.github.com>"]
|
||||
edition = "2018"
|
||||
|
||||
[dependencies]
|
||||
base64 = "0.13"
|
||||
actix-web = { version = "3.3", features = ["rustls"] }
|
||||
futures = "0.3"
|
||||
askama = "0.10"
|
||||
ureq = "2"
|
||||
serde = { version = "1.0", default_features = false, features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
async-recursion = "0.3"
|
||||
url = "2.2"
|
||||
regex = "1.4"
|
||||
time = "0.2"
|
||||
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"
|
||||
clap = { version = "2.33.3", default-features = false }
|
||||
time = "0.2.25"
|
||||
|
16
Dockerfile
16
Dockerfile
@ -1,9 +1,17 @@
|
||||
FROM rust:alpine as builder
|
||||
FROM rust:latest as builder
|
||||
|
||||
WORKDIR /usr/src/libreddit
|
||||
COPY . .
|
||||
RUN apk add --no-cache g++ openssl-dev
|
||||
RUN cargo install --path .
|
||||
|
||||
FROM alpine:latest
|
||||
|
||||
FROM debian:buster-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y libcurl4 && rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=builder /usr/local/cargo/bin/libreddit /usr/local/bin/libreddit
|
||||
CMD ["libreddit"]
|
||||
RUN useradd --system --user-group --home-dir /nonexistent --no-create-home --shell /usr/sbin/nologin libreddit
|
||||
USER libreddit
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["libreddit"]
|
||||
|
42
README.md
42
README.md
@ -2,7 +2,7 @@
|
||||
|
||||
> An alternative private front-end to Reddit
|
||||
|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
@ -15,16 +15,22 @@
|
||||
|
||||
---
|
||||
|
||||
**BTC:** bc1qwyxjnafpu3gypcpgs025cw9wa7ryudtecmwa6y
|
||||
|
||||
**XMR:** 45FJrEuFPtG2o7QZz2Nps77TbHD4sPqxViwbdyV9A6ktfHiWs47UngG5zXPcLoDXAc8taeuBgeNjfeprwgeXYXhN3C9tVSR
|
||||
|
||||
---
|
||||
|
||||
## Jump to...
|
||||
- [About](#about)
|
||||
- [Teddit Comparison](#how-does-it-compare-to-teddit)
|
||||
- [Comparison](#comparison)
|
||||
- [Installation](#installation)
|
||||
- [Cargo](#a-cargo)
|
||||
- [Docker](#b-docker)
|
||||
- [AUR](#c-aur)
|
||||
- [GitHub Releases](#d-github-releases)
|
||||
- [Repl.it](#e-replit)
|
||||
- [Cargo](#1-cargo)
|
||||
- [Docker](#2-docker)
|
||||
- [AUR](#3-aur)
|
||||
- [GitHub Releases](#4-github-releases)
|
||||
- [Repl.it](#5-replit)
|
||||
- [Deployment](#deployment)
|
||||
|
||||
---
|
||||
@ -35,13 +41,16 @@ Feel free to [open an issue](https://github.com/spikecodes/libreddit/issues/new)
|
||||
|
||||
| Website | Country | Cloudflare |
|
||||
|-|-|-|
|
||||
| [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.insanity.wtf](https://libreddit.insanity.wtf) | 🇺🇸 US | ✅ |
|
||||
| [libreddit.kavin.rocks](https://libreddit.kavin.rocks) | 🇮🇳 IN | ✅ |
|
||||
| [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.kavin.rocks](https://libreddit.kavin.rocks) | 🇮🇳 IN | ✅ |
|
||||
| [libreddit.himiko.cloud](https://libreddit.himiko.cloud) | 🇧🇬 BG | |
|
||||
| [libreddit.bcow.xyz](https://libreddit.bcow.xyz) | 🇺🇸 US | |
|
||||
| [spjmllawtheisznfs7uryhxumin26ssv2draj7oope3ok3wuhy43eoyd.onion](http://spjmllawtheisznfs7uryhxumin26ssv2draj7oope3ok3wuhy43eoyd.onion) | 🇮🇳 IN | |
|
||||
| [fwhhsbrbltmrct5hshrnqlqygqvcgmnek3cnka55zj4y7nuus5muwyyd.onion](http://fwhhsbrbltmrct5hshrnqlqygqvcgmnek3cnka55zj4y7nuus5muwyyd.onion) | 🇩🇪 DE | |
|
||||
| [libreddit.himiko7xl2skojc6odi7hykl626gt4qki3vxdbv33u2u3af76d6k32ad.onion](http://libreddit.himiko7xl2skojc6odi7hykl626gt4qki3vxdbv33u2u3af76d6k32ad.onion) | 🇧🇬 BG | |
|
||||
| [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.
|
||||
|
||||
@ -54,9 +63,9 @@ Find Libreddit on 💬 [Matrix](https://matrix.to/#/#libreddit:kde.org), 🐋 [D
|
||||
## Built with
|
||||
|
||||
- [Rust](https://www.rust-lang.org/) - Programming language
|
||||
- [Actix Web](https://github.com/actix/actix-web) - Web server
|
||||
- [Tide](https://github.com/http-rs/tide) - Web server
|
||||
- [Askama](https://github.com/djc/askama) - Templating engine
|
||||
- [ureq](https://github.com/algesten/ureq) - HTTP client
|
||||
- [Surf](https://github.com/http-rs/surf) - HTTP client
|
||||
|
||||
## 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.
|
||||
@ -188,13 +197,6 @@ Once installed, deploy Libreddit to `0.0.0.0:8080` by running:
|
||||
libreddit
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
| Short | Long | Example |
|
||||
|-------|--------------------|-----------------------------------|
|
||||
| `-a` | `--address` | `libreddit --adress=0.0.0.0:8111` |
|
||||
| `-r` | `--redirect-https` | `libreddit --redirect-https` |
|
||||
|
||||
## Building
|
||||
|
||||
```
|
||||
|
373
src/main.rs
373
src/main.rs
@ -1,10 +1,3 @@
|
||||
// Import Crates
|
||||
use actix_web::{
|
||||
dev::{Service, ServiceResponse},
|
||||
middleware, web, App, HttpResponse, HttpServer,
|
||||
};
|
||||
use futures::future::FutureExt;
|
||||
|
||||
// Reference local files
|
||||
mod post;
|
||||
mod proxy;
|
||||
@ -14,129 +7,257 @@ mod subreddit;
|
||||
mod user;
|
||||
mod utils;
|
||||
|
||||
// Create Services
|
||||
async fn style() -> HttpResponse {
|
||||
HttpResponse::Ok().content_type("text/css").body(include_str!("../static/style.css"))
|
||||
}
|
||||
// Import Crates
|
||||
use clap::{App, Arg};
|
||||
use proxy::handler;
|
||||
use tide::{
|
||||
utils::{async_trait, After},
|
||||
Middleware, Next, Request, Response,
|
||||
};
|
||||
use utils::{error, redirect};
|
||||
|
||||
async fn robots() -> HttpResponse {
|
||||
HttpResponse::Ok()
|
||||
.header("Cache-Control", "public, max-age=1209600, s-maxage=86400")
|
||||
.body("User-agent: *\nAllow: /")
|
||||
}
|
||||
// Build middleware
|
||||
struct HttpsRedirect<HttpsOnly>(HttpsOnly);
|
||||
struct NormalizePath;
|
||||
|
||||
async fn favicon() -> HttpResponse {
|
||||
HttpResponse::Ok()
|
||||
.content_type("image/x-icon")
|
||||
.header("Cache-Control", "public, max-age=1209600, s-maxage=86400")
|
||||
.body(include_bytes!("../static/favicon.ico").as_ref())
|
||||
}
|
||||
#[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";
|
||||
|
||||
#[actix_web::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
let mut address = "0.0.0.0:8080".to_string();
|
||||
let mut force_https = false;
|
||||
if self.0.into() && !secure {
|
||||
let mut secured = request.url().to_owned();
|
||||
secured.set_scheme("https").unwrap_or_default();
|
||||
|
||||
for arg in std::env::args().collect::<Vec<String>>() {
|
||||
match arg.split('=').collect::<Vec<&str>>()[0] {
|
||||
"--address" | "-a" => address = arg.split('=').collect::<Vec<&str>>()[1].to_string(),
|
||||
"--redirect-https" | "-r" => force_https = true,
|
||||
_ => (),
|
||||
Ok(redirect(secured.to_string()))
|
||||
} else {
|
||||
Ok(next.run(request).await)
|
||||
}
|
||||
}
|
||||
|
||||
// start http server
|
||||
println!("Running Libreddit v{} on {}!", env!("CARGO_PKG_VERSION"), &address);
|
||||
|
||||
HttpServer::new(move || {
|
||||
App::new()
|
||||
// Redirect to HTTPS if "--redirect-https" enabled
|
||||
.wrap_fn(move |req, srv| {
|
||||
let secure = req.connection_info().scheme() == "https";
|
||||
let https_url = format!("https://{}{}", req.connection_info().host(), req.uri().to_string());
|
||||
srv.call(req).map(move |res: Result<ServiceResponse, _>| {
|
||||
if force_https && !secure {
|
||||
Ok(ServiceResponse::new(
|
||||
res.unwrap().request().to_owned(),
|
||||
HttpResponse::Found().header("Location", https_url).finish(),
|
||||
))
|
||||
} else {
|
||||
res
|
||||
}
|
||||
})
|
||||
})
|
||||
// Append trailing slash and remove double slashes
|
||||
.wrap(middleware::NormalizePath::default())
|
||||
// Apply default headers for security
|
||||
.wrap(
|
||||
middleware::DefaultHeaders::new()
|
||||
.header("Referrer-Policy", "no-referrer")
|
||||
.header("X-Content-Type-Options", "nosniff")
|
||||
.header("X-Frame-Options", "DENY")
|
||||
.header(
|
||||
"Content-Security-Policy",
|
||||
"default-src 'none'; media-src 'self'; style-src 'self' 'unsafe-inline'; base-uri 'none'; img-src 'self' data:; form-action 'self'; frame-ancestors 'none';",
|
||||
),
|
||||
)
|
||||
// Default service in case no routes match
|
||||
.default_service(web::get().to(|| utils::error("Nothing here".to_string())))
|
||||
// Read static files
|
||||
.route("/style.css/", web::get().to(style))
|
||||
.route("/favicon.ico/", web::get().to(favicon))
|
||||
.route("/robots.txt/", web::get().to(robots))
|
||||
// Proxy media through Libreddit
|
||||
.route("/proxy/{url:.*}/", web::get().to(proxy::handler))
|
||||
// Browse user profile
|
||||
.service(
|
||||
web::scope("/{scope:user|u}").service(
|
||||
web::scope("/{username}").route("/", web::get().to(user::profile)).service(
|
||||
web::scope("/comments/{id}/{title}")
|
||||
.route("/", web::get().to(post::item))
|
||||
.route("/{comment_id}/", web::get().to(post::item)),
|
||||
),
|
||||
),
|
||||
)
|
||||
// Configure settings
|
||||
.service(web::resource("/settings/").route(web::get().to(settings::get)).route(web::post().to(settings::set)))
|
||||
// Subreddit services
|
||||
.service(
|
||||
web::scope("/r/{sub}")
|
||||
// See posts and info about subreddit
|
||||
.route("/", web::get().to(subreddit::page))
|
||||
.route("/{sort:hot|new|top|rising|controversial}/", web::get().to(subreddit::page))
|
||||
// Handle subscribe/unsubscribe
|
||||
.route("/{action:subscribe|unsubscribe}/", web::post().to(subreddit::subscriptions))
|
||||
// View post on subreddit
|
||||
.service(
|
||||
web::scope("/comments/{id}/{title}")
|
||||
.route("/", web::get().to(post::item))
|
||||
.route("/{comment_id}/", web::get().to(post::item)),
|
||||
)
|
||||
// Search inside subreddit
|
||||
.route("/search/", web::get().to(search::find))
|
||||
// View wiki of subreddit
|
||||
.service(
|
||||
web::scope("/wiki")
|
||||
.route("/", web::get().to(subreddit::wiki))
|
||||
.route("/{page}/", web::get().to(subreddit::wiki)),
|
||||
),
|
||||
)
|
||||
// Front page
|
||||
.route("/", web::get().to(subreddit::page))
|
||||
.route("/{sort:best|hot|new|top|rising|controversial}/", web::get().to(subreddit::page))
|
||||
// View Reddit wiki
|
||||
.service(
|
||||
web::scope("/wiki")
|
||||
.route("/", web::get().to(subreddit::wiki))
|
||||
.route("/{page}/", web::get().to(subreddit::wiki)),
|
||||
)
|
||||
// Search all of Reddit
|
||||
.route("/search/", web::get().to(search::find))
|
||||
// Short link for post
|
||||
.route("/{id:.{5,6}}/", web::get().to(post::item))
|
||||
})
|
||||
.bind(&address)
|
||||
.unwrap_or_else(|e| panic!("Cannot bind to the address {}: {}", address, e))
|
||||
.run()
|
||||
.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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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())
|
||||
}
|
||||
|
||||
// Required for iOS App Icons
|
||||
async fn iphone_logo(_req: Request<()>) -> tide::Result {
|
||||
Ok(
|
||||
Response::builder(200)
|
||||
.content_type("image/png")
|
||||
.body(include_bytes!("../static/apple-touch-icon.png").as_ref())
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
async fn favicon(_req: Request<()>) -> tide::Result {
|
||||
Ok(
|
||||
Response::builder(200)
|
||||
.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(),
|
||||
)
|
||||
}
|
||||
|
||||
async fn resource(body: &str, content_type: &str, cache: bool) -> tide::Result {
|
||||
let mut res = Response::new(200);
|
||||
|
||||
if cache {
|
||||
res.insert_header("Cache-Control", "public, max-age=1209600, s-maxage=86400");
|
||||
}
|
||||
|
||||
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")
|
||||
.version(env!("CARGO_PKG_VERSION"))
|
||||
.about("Private front-end for Reddit written in Rust ")
|
||||
.arg(
|
||||
Arg::with_name("address")
|
||||
.short("a")
|
||||
.long("address")
|
||||
.value_name("ADDRESS")
|
||||
.help("Sets address to listen on")
|
||||
.default_value("0.0.0.0")
|
||||
.takes_value(true),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("port")
|
||||
.short("p")
|
||||
.long("port")
|
||||
.value_name("PORT")
|
||||
.help("Port to listen on")
|
||||
.default_value("8080")
|
||||
.takes_value(true),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("redirect-https")
|
||||
.short("r")
|
||||
.long("redirect-https")
|
||||
.help("Redirect all HTTP requests to HTTPS")
|
||||
.takes_value(false),
|
||||
)
|
||||
.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 listener = format!("{}:{}", address, port);
|
||||
|
||||
println!("Starting Libreddit...");
|
||||
|
||||
// Start HTTP server
|
||||
let mut app = tide::new();
|
||||
|
||||
// Redirect to HTTPS if "--redirect-https" enabled
|
||||
app.with(HttpsRedirect(force_https));
|
||||
|
||||
// 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)
|
||||
}));
|
||||
|
||||
// Read static files
|
||||
app.at("/style.css/").get(|_| resource(include_str!("../static/style.css"), "text/css", false));
|
||||
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);
|
||||
|
||||
// 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"]));
|
||||
|
||||
// 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("/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);
|
||||
|
||||
// Configure settings
|
||||
app.at("/settings/").get(settings::get).post(settings::set);
|
||||
app.at("/settings/restore/").get(settings::restore);
|
||||
|
||||
// Subreddit services
|
||||
app.at("/r/:sub/").get(subreddit::page);
|
||||
|
||||
app.at("/r/:sub/subscribe/").post(subreddit::subscriptions);
|
||||
app.at("/r/:sub/unsubscribe/").post(subreddit::subscriptions);
|
||||
|
||||
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/search/").get(search::find);
|
||||
|
||||
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/:sort/").get(subreddit::page);
|
||||
|
||||
// Front page
|
||||
app.at("/").get(subreddit::page);
|
||||
|
||||
// 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);
|
||||
|
||||
// Search all of Reddit
|
||||
app.at("/search/").get(search::find);
|
||||
|
||||
// Handle about pages
|
||||
app.at("/about/").get(|req| error(req, "About pages aren't here yet".to_string()));
|
||||
|
||||
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,
|
||||
}
|
||||
});
|
||||
|
||||
// Default service in case no routes match
|
||||
app.at("*").get(|req| error(req, "Nothing here".to_string()));
|
||||
|
||||
println!("Running Libreddit v{} on {}!", env!("CARGO_PKG_VERSION"), listener);
|
||||
|
||||
app.listen(&listener).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
94
src/post.rs
94
src/post.rs
@ -1,6 +1,6 @@
|
||||
// CRATES
|
||||
use crate::utils::*;
|
||||
use actix_web::{HttpRequest, HttpResponse};
|
||||
use tide::Request;
|
||||
|
||||
use async_recursion::async_recursion;
|
||||
|
||||
@ -14,11 +14,12 @@ struct PostTemplate {
|
||||
post: Post,
|
||||
sort: String,
|
||||
prefs: Preferences,
|
||||
single_thread: bool,
|
||||
}
|
||||
|
||||
pub async fn item(req: HttpRequest) -> HttpResponse {
|
||||
pub async fn item(req: Request<()>) -> tide::Result {
|
||||
// Build Reddit API path
|
||||
let mut path: String = format!("{}.json?{}&raw_json=1", req.path(), req.query_string());
|
||||
let mut path: String = format!("{}.json?{}&raw_json=1", req.url().path(), req.url().query().unwrap_or_default());
|
||||
|
||||
// Set sort to sort query parameter
|
||||
let mut sort: String = param(&path, "sort");
|
||||
@ -29,12 +30,15 @@ pub async fn item(req: HttpRequest) -> HttpResponse {
|
||||
// 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.path(), req.query_string(), sort);
|
||||
path = format!("{}.json?{}&sort={}&raw_json=1", req.url().path(), req.url().query().unwrap_or_default(), sort);
|
||||
}
|
||||
|
||||
// Log the post ID being fetched in debug mode
|
||||
#[cfg(debug_assertions)]
|
||||
dbg!(req.match_info().get("id").unwrap_or(""));
|
||||
dbg!(req.param("id").unwrap_or(""));
|
||||
|
||||
let single_thread = &req.param("comment_id").is_ok();
|
||||
let highlighted_comment = &req.param("comment_id").unwrap_or_default();
|
||||
|
||||
// Send a request to the url, receive JSON in response
|
||||
match request(path).await {
|
||||
@ -42,21 +46,19 @@ pub async fn item(req: HttpRequest) -> HttpResponse {
|
||||
Ok(res) => {
|
||||
// Parse the JSON into Post and Comment structs
|
||||
let post = parse_post(&res[0]).await;
|
||||
let comments = parse_comments(&res[1]).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
|
||||
let s = PostTemplate {
|
||||
template(PostTemplate {
|
||||
comments,
|
||||
post,
|
||||
sort,
|
||||
prefs: prefs(req),
|
||||
}
|
||||
.render()
|
||||
.unwrap();
|
||||
HttpResponse::Ok().content_type("text/html").body(s)
|
||||
prefs: Preferences::new(req),
|
||||
single_thread: *single_thread,
|
||||
})
|
||||
}
|
||||
// If the Reddit API returns an error, exit and send error page to user
|
||||
Err(msg) => error(msg).await,
|
||||
Err(msg) => error(req, msg).await,
|
||||
}
|
||||
}
|
||||
|
||||
@ -72,22 +74,23 @@ async fn parse_post(json: &serde_json::Value) -> Post {
|
||||
let ratio: f64 = post["data"]["upvote_ratio"].as_f64().unwrap_or(1.0) * 100.0;
|
||||
|
||||
// Determine the type of media along with the media URL
|
||||
let (post_type, media) = media(&post["data"]).await;
|
||||
let (post_type, media, gallery) = Media::parse(&post["data"]).await;
|
||||
|
||||
// Build a post using data parsed from Reddit post API
|
||||
Post {
|
||||
id: val(post, "id"),
|
||||
title: val(post, "title"),
|
||||
community: val(post, "subreddit"),
|
||||
body: rewrite_url(&val(post, "selftext_html")),
|
||||
body: rewrite_urls(&val(post, "selftext_html")).replace("\\", ""),
|
||||
author: Author {
|
||||
name: val(post, "author"),
|
||||
flair: Flair {
|
||||
flair_parts: parse_rich_flair(
|
||||
val(post, "author_flair_type"),
|
||||
flair_parts: FlairPart::parse(
|
||||
post["data"]["author_flair_type"].as_str().unwrap_or_default(),
|
||||
post["data"]["author_flair_richtext"].as_array(),
|
||||
post["data"]["author_flair_text"].as_str(),
|
||||
),
|
||||
text: val(post, "link_flair_text"),
|
||||
background_color: val(post, "author_flair_background_color"),
|
||||
foreground_color: val(post, "author_flair_text_color"),
|
||||
},
|
||||
@ -102,13 +105,15 @@ async fn parse_post(json: &serde_json::Value) -> Post {
|
||||
url: format_url(val(post, "thumbnail").as_str()),
|
||||
width: post["data"]["thumbnail_width"].as_i64().unwrap_or_default(),
|
||||
height: post["data"]["thumbnail_height"].as_i64().unwrap_or_default(),
|
||||
poster: "".to_string(),
|
||||
},
|
||||
flair: Flair {
|
||||
flair_parts: parse_rich_flair(
|
||||
val(post, "link_flair_type"),
|
||||
flair_parts: FlairPart::parse(
|
||||
post["data"]["link_flair_type"].as_str().unwrap_or_default(),
|
||||
post["data"]["link_flair_richtext"].as_array(),
|
||||
post["data"]["link_flair_text"].as_str(),
|
||||
),
|
||||
text: val(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()
|
||||
@ -124,12 +129,13 @@ async fn parse_post(json: &serde_json::Value) -> Post {
|
||||
rel_time,
|
||||
created,
|
||||
comments: format_num(post["data"]["num_comments"].as_i64().unwrap_or_default()),
|
||||
gallery,
|
||||
}
|
||||
}
|
||||
|
||||
// COMMENTS
|
||||
#[async_recursion]
|
||||
async fn parse_comments(json: &serde_json::Value) -> Vec<Comment> {
|
||||
async fn parse_comments(json: &serde_json::Value, post_link: &str, post_author: &str, highlighted_comment: &str) -> Vec<Comment> {
|
||||
// Separate the comment JSON into a Vector of comments
|
||||
let comment_data = match json["data"]["children"].as_array() {
|
||||
Some(f) => f.to_owned(),
|
||||
@ -140,47 +146,65 @@ async fn parse_comments(json: &serde_json::Value) -> Vec<Comment> {
|
||||
|
||||
// For each comment, retrieve the values to build a Comment object
|
||||
for comment in comment_data {
|
||||
let unix_time = comment["data"]["created_utc"].as_f64().unwrap_or_default();
|
||||
if unix_time == 0.0 {
|
||||
continue;
|
||||
}
|
||||
let kind = comment["kind"].as_str().unwrap_or_default().to_string();
|
||||
let data = &comment["data"];
|
||||
|
||||
let unix_time = data["created_utc"].as_f64().unwrap_or_default();
|
||||
let (rel_time, created) = time(unix_time);
|
||||
|
||||
let score = comment["data"]["score"].as_i64().unwrap_or(0);
|
||||
let body = rewrite_url(&val(&comment, "body_html"));
|
||||
let edited = match data["edited"].as_f64() {
|
||||
Some(stamp) => time(stamp),
|
||||
None => (String::new(), String::new()),
|
||||
};
|
||||
|
||||
let replies: Vec<Comment> = if comment["data"]["replies"].is_object() {
|
||||
parse_comments(&comment["data"]["replies"]).await
|
||||
let score = data["score"].as_i64().unwrap_or(0);
|
||||
let body = rewrite_urls(&val(&comment, "body_html"));
|
||||
|
||||
// If this comment contains replies, handle those too
|
||||
let replies: Vec<Comment> = if data["replies"].is_object() {
|
||||
parse_comments(&data["replies"], post_link, post_author, highlighted_comment).await
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
dbg!();
|
||||
let parent_kind_and_id = val(&comment, "parent_id");
|
||||
let parent_info = parent_kind_and_id.split('_').collect::<Vec<&str>>();
|
||||
|
||||
let id = val(&comment, "id");
|
||||
let highlighted = id == highlighted_comment;
|
||||
|
||||
comments.push(Comment {
|
||||
id: val(&comment, "id"),
|
||||
id,
|
||||
kind,
|
||||
parent_id: parent_info[1].to_string(),
|
||||
parent_kind: parent_info[0].to_string(),
|
||||
post_link: post_link.to_string(),
|
||||
post_author: post_author.to_string(),
|
||||
body,
|
||||
author: Author {
|
||||
name: val(&comment, "author"),
|
||||
flair: Flair {
|
||||
flair_parts: parse_rich_flair(
|
||||
val(&comment, "author_flair_type"),
|
||||
comment["data"]["author_flair_richtext"].as_array(),
|
||||
comment["data"]["author_flair_text"].as_str(),
|
||||
flair_parts: FlairPart::parse(
|
||||
data["author_flair_type"].as_str().unwrap_or_default(),
|
||||
data["author_flair_richtext"].as_array(),
|
||||
data["author_flair_text"].as_str(),
|
||||
),
|
||||
text: val(&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 comment["data"]["score_hidden"].as_bool().unwrap_or_default() {
|
||||
score: if data["score_hidden"].as_bool().unwrap_or_default() {
|
||||
"•".to_string()
|
||||
} else {
|
||||
format_num(score)
|
||||
},
|
||||
rel_time,
|
||||
created,
|
||||
edited,
|
||||
replies,
|
||||
highlighted,
|
||||
});
|
||||
}
|
||||
|
||||
|
65
src/proxy.rs
65
src/proxy.rs
@ -1,47 +1,32 @@
|
||||
use actix_web::{client::Client, error, web, Error, HttpResponse, Result};
|
||||
use url::Url;
|
||||
use surf::Body;
|
||||
use tide::{Request, Response};
|
||||
|
||||
use base64::decode;
|
||||
pub async fn handler(req: Request<()>, format: &str, params: Vec<&str>) -> tide::Result {
|
||||
let mut url = format.to_string();
|
||||
|
||||
pub async fn handler(web::Path(b64): web::Path<String>) -> Result<HttpResponse> {
|
||||
let domains = vec![
|
||||
// THUMBNAILS
|
||||
"a.thumbs.redditmedia.com",
|
||||
"b.thumbs.redditmedia.com",
|
||||
// EMOJI
|
||||
"emoji.redditmedia.com",
|
||||
// ICONS
|
||||
"styles.redditmedia.com",
|
||||
"www.redditstatic.com",
|
||||
// PREVIEWS
|
||||
"preview.redd.it",
|
||||
"external-preview.redd.it",
|
||||
// MEDIA
|
||||
"i.redd.it",
|
||||
"v.redd.it",
|
||||
];
|
||||
for name in params {
|
||||
let param = req.param(name).unwrap_or_default();
|
||||
url = url.replacen("{}", param, 1);
|
||||
}
|
||||
|
||||
let decoded = decode(b64).map(|bytes| String::from_utf8(bytes).unwrap_or_default());
|
||||
request(url).await
|
||||
}
|
||||
|
||||
match decoded {
|
||||
Ok(media) => match Url::parse(media.as_str()) {
|
||||
Ok(url) => {
|
||||
let domain = url.domain().unwrap_or_default();
|
||||
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();
|
||||
|
||||
if domains.contains(&domain) {
|
||||
Client::default().get(media.replace("&", "&")).send().await.map_err(Error::from).map(|res| {
|
||||
HttpResponse::build(res.status())
|
||||
.header("Cache-Control", "public, max-age=1209600, s-maxage=86400")
|
||||
.header("Content-Length", res.headers().get("Content-Length").unwrap().to_owned())
|
||||
.header("Content-Type", res.headers().get("Content-Type").unwrap().to_owned())
|
||||
.streaming(res)
|
||||
})
|
||||
} else {
|
||||
Err(error::ErrorForbidden("Resource must be from Reddit"))
|
||||
}
|
||||
}
|
||||
_ => Err(error::ErrorBadRequest("Can't parse base64 into URL")),
|
||||
},
|
||||
_ => Err(error::ErrorBadRequest("Can't decode base64")),
|
||||
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()),
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
// CRATES
|
||||
use crate::utils::{cookie, error, fetch_posts, param, prefs, request, val, Post, Preferences};
|
||||
use actix_web::{HttpRequest, HttpResponse};
|
||||
use crate::utils::{cookie, error, param, request, template, val, Post, Preferences};
|
||||
use askama::Template;
|
||||
use tide::Request;
|
||||
|
||||
// STRUCTS
|
||||
struct SearchParams {
|
||||
@ -32,10 +32,11 @@ struct SearchTemplate {
|
||||
}
|
||||
|
||||
// SERVICES
|
||||
pub async fn find(req: HttpRequest) -> HttpResponse {
|
||||
pub async fn find(req: Request<()>) -> tide::Result {
|
||||
let nsfw_results = if cookie(&req, "show_nsfw") == "on" { "&include_over_18=on" } else { "" };
|
||||
let path = format!("{}.json?{}{}", req.path(), req.query_string(), nsfw_results);
|
||||
let sub = req.match_info().get("sub").unwrap_or("").to_string();
|
||||
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 query = param(&path, "q");
|
||||
|
||||
let sort = if param(&path, "sort").is_empty() {
|
||||
"relevance".to_string()
|
||||
@ -44,35 +45,31 @@ pub async fn find(req: HttpRequest) -> HttpResponse {
|
||||
};
|
||||
|
||||
let subreddits = if param(&path, "restrict_sr").is_empty() {
|
||||
search_subreddits(param(&path, "q")).await
|
||||
search_subreddits(&query).await
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
match fetch_posts(&path, String::new()).await {
|
||||
Ok((posts, after)) => HttpResponse::Ok().content_type("text/html").body(
|
||||
SearchTemplate {
|
||||
posts,
|
||||
subreddits,
|
||||
sub,
|
||||
params: SearchParams {
|
||||
q: param(&path, "q"),
|
||||
sort,
|
||||
t: param(&path, "t"),
|
||||
before: param(&path, "after"),
|
||||
after,
|
||||
restrict_sr: param(&path, "restrict_sr"),
|
||||
},
|
||||
prefs: prefs(req),
|
||||
}
|
||||
.render()
|
||||
.unwrap(),
|
||||
),
|
||||
Err(msg) => error(msg).await,
|
||||
match Post::fetch(&path, String::new()).await {
|
||||
Ok((posts, after)) => template(SearchTemplate {
|
||||
posts,
|
||||
subreddits,
|
||||
sub,
|
||||
params: SearchParams {
|
||||
q: query.replace('"', """),
|
||||
sort,
|
||||
t: param(&path, "t"),
|
||||
before: param(&path, "after"),
|
||||
after,
|
||||
restrict_sr: param(&path, "restrict_sr"),
|
||||
},
|
||||
prefs: Preferences::new(req),
|
||||
}),
|
||||
Err(msg) => error(req, msg).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn search_subreddits(q: String) -> Vec<Subreddit> {
|
||||
async fn search_subreddits(q: &str) -> Vec<Subreddit> {
|
||||
let subreddit_search_path = format!("/subreddits/search.json?q={}&limit=3", q.replace(' ', "+"));
|
||||
|
||||
// Send a request to the url
|
||||
|
@ -1,7 +1,7 @@
|
||||
// CRATES
|
||||
use crate::utils::{prefs, Preferences};
|
||||
use actix_web::{cookie::Cookie, web::Form, HttpRequest, HttpResponse};
|
||||
use crate::utils::{redirect, template, Preferences};
|
||||
use askama::Template;
|
||||
use tide::{http::Cookie, Request};
|
||||
use time::{Duration, OffsetDateTime};
|
||||
|
||||
// STRUCTS
|
||||
@ -11,7 +11,8 @@ struct SettingsTemplate {
|
||||
prefs: Preferences,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[derive(serde::Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct SettingsForm {
|
||||
theme: Option<String>,
|
||||
front_page: Option<String>,
|
||||
@ -19,38 +20,68 @@ pub struct SettingsForm {
|
||||
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: HttpRequest) -> HttpResponse {
|
||||
let s = SettingsTemplate { prefs: prefs(req) }.render().unwrap();
|
||||
HttpResponse::Ok().content_type("text/html").body(s)
|
||||
pub async fn get(req: Request<()>) -> tide::Result {
|
||||
template(SettingsTemplate { prefs: Preferences::new(req) })
|
||||
}
|
||||
|
||||
// Set cookies using response "Set-Cookie" header
|
||||
pub async fn set(_req: HttpRequest, form: Form<SettingsForm>) -> HttpResponse {
|
||||
let mut res = HttpResponse::Found();
|
||||
pub async fn set(mut req: Request<()>) -> tide::Result {
|
||||
let form: SettingsForm = req.body_form().await.unwrap_or_default();
|
||||
|
||||
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 values = vec![form.theme, form.front_page, form.layout, form.wide, form.comment_sort, form.show_nsfw];
|
||||
|
||||
for (i, name) in names.iter().enumerate() {
|
||||
match values[i] {
|
||||
Some(value) => res.cookie(
|
||||
Cookie::build(name.to_owned(), value)
|
||||
match values.get(i) {
|
||||
Some(value) => res.insert_cookie(
|
||||
Cookie::build(name.to_owned(), value.to_owned().unwrap_or_default())
|
||||
.path("/")
|
||||
.http_only(true)
|
||||
.expires(OffsetDateTime::now_utc() + Duration::weeks(52))
|
||||
.finish(),
|
||||
),
|
||||
None => res.del_cookie(&Cookie::named(name.to_owned())),
|
||||
None => res.remove_cookie(Cookie::named(name.to_owned())),
|
||||
};
|
||||
}
|
||||
|
||||
res
|
||||
.content_type("text/html")
|
||||
.set_header("Location", "/settings")
|
||||
.body(r#"Redirecting to <a href="/settings">settings</a>..."#)
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
// Set cookies using response "Set-Cookie" header
|
||||
pub async fn restore(req: Request<()>) -> tide::Result {
|
||||
let form: SettingsForm = req.query()?;
|
||||
|
||||
let path = match form.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) {
|
||||
Some(value) => res.insert_cookie(
|
||||
Cookie::build(name.to_owned(), value.to_owned().unwrap_or_default())
|
||||
.path("/")
|
||||
.http_only(true)
|
||||
.expires(OffsetDateTime::now_utc() + Duration::weeks(52))
|
||||
.finish(),
|
||||
),
|
||||
None => res.remove_cookie(Cookie::named(name.to_owned())),
|
||||
};
|
||||
}
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
|
136
src/subreddit.rs
136
src/subreddit.rs
@ -1,7 +1,7 @@
|
||||
// CRATES
|
||||
use crate::utils::*;
|
||||
use actix_web::{cookie::Cookie, HttpRequest, HttpResponse, Result};
|
||||
use askama::Template;
|
||||
use tide::{http::Cookie, Request};
|
||||
use time::{Duration, OffsetDateTime};
|
||||
|
||||
// STRUCTS
|
||||
@ -25,28 +25,25 @@ struct WikiTemplate {
|
||||
}
|
||||
|
||||
// SERVICES
|
||||
pub async fn page(req: HttpRequest) -> HttpResponse {
|
||||
pub async fn page(req: Request<()>) -> tide::Result {
|
||||
// Build Reddit API path
|
||||
let subscribed = cookie(&req, "subscriptions");
|
||||
let front_page = cookie(&req, "front_page");
|
||||
let sort = req.match_info().get("sort").unwrap_or("hot").to_string();
|
||||
let sort = req.param("sort").unwrap_or_else(|_| req.param("id").unwrap_or("hot")).to_string();
|
||||
|
||||
let sub = req
|
||||
.match_info()
|
||||
.get("sub")
|
||||
.map(String::from)
|
||||
.unwrap_or(if front_page == "default" || front_page.is_empty() {
|
||||
if subscribed.is_empty() {
|
||||
"popular".to_string()
|
||||
} else {
|
||||
subscribed.to_owned()
|
||||
}
|
||||
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()
|
||||
} else {
|
||||
front_page.to_owned()
|
||||
});
|
||||
subscribed.to_owned()
|
||||
}
|
||||
} else {
|
||||
front_page.to_owned()
|
||||
});
|
||||
|
||||
let path = format!("/r/{}/{}.json?{}", sub, sort, req.query_string());
|
||||
let path = format!("/r/{}/{}.json?{}&raw_json=1", sub, sort, req.url().query().unwrap_or_default());
|
||||
|
||||
match fetch_posts(&path, String::new()).await {
|
||||
match Post::fetch(&path, String::new()).await {
|
||||
Ok((posts, after)) => {
|
||||
// If you can get subreddit posts, also request subreddit metadata
|
||||
let sub = if !sub.contains('+') && sub != subscribed && sub != "popular" && sub != "all" {
|
||||
@ -54,7 +51,7 @@ pub async fn page(req: HttpRequest) -> HttpResponse {
|
||||
subreddit(&sub).await.unwrap_or_default()
|
||||
} else if sub == subscribed {
|
||||
// Subscription feed
|
||||
if req.path().starts_with("/r/") {
|
||||
if req.url().path().starts_with("/r/") {
|
||||
subreddit(&sub).await.unwrap_or_default()
|
||||
} else {
|
||||
Subreddit::default()
|
||||
@ -69,42 +66,62 @@ pub async fn page(req: HttpRequest) -> HttpResponse {
|
||||
Subreddit::default()
|
||||
};
|
||||
|
||||
let s = SubredditTemplate {
|
||||
template(SubredditTemplate {
|
||||
sub,
|
||||
posts,
|
||||
sort: (sort, param(&path, "t")),
|
||||
ends: (param(&path, "after"), after),
|
||||
prefs: prefs(req),
|
||||
}
|
||||
.render()
|
||||
.unwrap();
|
||||
HttpResponse::Ok().content_type("text/html").body(s)
|
||||
prefs: Preferences::new(req),
|
||||
})
|
||||
}
|
||||
Err(msg) => error(msg).await,
|
||||
Err(msg) => match msg.as_str() {
|
||||
"quarantined" => error(req, format!("r/{} has been quarantined by Reddit", sub)).await,
|
||||
"private" => error(req, format!("r/{} is a private community", sub)).await,
|
||||
"banned" => error(req, format!("r/{} has been banned from Reddit", sub)).await,
|
||||
_ => error(req, msg).await,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Sub or unsub by setting subscription cookie using response "Set-Cookie" header
|
||||
pub async fn subscriptions(req: HttpRequest) -> HttpResponse {
|
||||
let mut res = HttpResponse::Found();
|
||||
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();
|
||||
|
||||
let sub = req.match_info().get("sub").unwrap_or_default().to_string();
|
||||
let action = req.match_info().get("action").unwrap_or_default().to_string();
|
||||
let mut sub_list = prefs(req.to_owned()).subs;
|
||||
let mut sub_list = Preferences::new(req).subscriptions;
|
||||
|
||||
// Modify sub list based on action
|
||||
if action == "subscribe" && !sub_list.contains(&sub) {
|
||||
sub_list.push(sub.to_owned());
|
||||
sub_list.sort();
|
||||
} else if action == "unsubscribe" {
|
||||
sub_list.retain(|s| s != &sub);
|
||||
// Find each subreddit name (separated by '+') in sub parameter
|
||||
for part in sub.split('+') {
|
||||
// Modify sub list based on action
|
||||
if action.contains(&"subscribe".to_string()) && !sub_list.contains(&part.to_owned()) {
|
||||
// Add each sub name to the subscribed list
|
||||
sub_list.push(part.to_owned());
|
||||
// Reorder sub names alphabettically
|
||||
sub_list.sort_by_key(|a| a.to_lowercase())
|
||||
} else if action.contains(&"unsubscribe".to_string()) {
|
||||
// Remove sub name from subscribed list
|
||||
sub_list.retain(|s| s != part);
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
format!("/r/{}", sub)
|
||||
};
|
||||
|
||||
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.del_cookie(&Cookie::build("subscriptions", "").path("/").finish());
|
||||
res.remove_cookie(Cookie::build("subscriptions", "").path("/").finish());
|
||||
} else {
|
||||
res.cookie(
|
||||
res.insert_cookie(
|
||||
Cookie::build("subscriptions", sub_list.join("+"))
|
||||
.path("/")
|
||||
.http_only(true)
|
||||
@ -113,39 +130,22 @@ pub async fn subscriptions(req: HttpRequest) -> HttpResponse {
|
||||
);
|
||||
}
|
||||
|
||||
// Redirect back to subreddit
|
||||
// check for redirect parameter if unsubscribing from outside sidebar
|
||||
let redirect_path = param(&req.uri().to_string(), "redirect");
|
||||
let path = if !redirect_path.is_empty() && redirect_path.starts_with('/') {
|
||||
redirect_path
|
||||
} else {
|
||||
format!("/r/{}", sub)
|
||||
};
|
||||
|
||||
res
|
||||
.content_type("text/html")
|
||||
.set_header("Location", path.to_owned())
|
||||
.body(format!("Redirecting to <a href=\"{0}\">{0}</a>...", path))
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
pub async fn wiki(req: HttpRequest) -> HttpResponse {
|
||||
let sub = req.match_info().get("sub").unwrap_or("reddit.com").to_string();
|
||||
let page = req.match_info().get("page").unwrap_or("index").to_string();
|
||||
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();
|
||||
let path: String = format!("/r/{}/wiki/{}.json?raw_json=1", sub, page);
|
||||
|
||||
match request(path).await {
|
||||
Ok(res) => {
|
||||
let s = WikiTemplate {
|
||||
sub,
|
||||
wiki: rewrite_url(res["data"]["content_html"].as_str().unwrap_or_default()),
|
||||
page,
|
||||
prefs: prefs(req),
|
||||
}
|
||||
.render()
|
||||
.unwrap();
|
||||
HttpResponse::Ok().content_type("text/html").body(s)
|
||||
}
|
||||
Err(msg) => error(msg).await,
|
||||
Ok(res) => template(WikiTemplate {
|
||||
sub,
|
||||
wiki: rewrite_urls(res["data"]["content_html"].as_str().unwrap_or_default()),
|
||||
page,
|
||||
prefs: Preferences::new(req),
|
||||
}),
|
||||
Err(msg) => error(req, msg).await,
|
||||
}
|
||||
}
|
||||
|
||||
@ -170,8 +170,8 @@ async fn subreddit(sub: &str) -> Result<Subreddit, String> {
|
||||
name: val(&res, "display_name"),
|
||||
title: val(&res, "title"),
|
||||
description: val(&res, "public_description"),
|
||||
info: rewrite_url(&val(&res, "description_html").replace("\\", "")),
|
||||
icon: format_url(icon.as_str()),
|
||||
info: rewrite_urls(&val(&res, "description_html").replace("\\", "")),
|
||||
icon: format_url(&icon),
|
||||
members: format_num(members),
|
||||
active: format_num(active),
|
||||
wiki: res["data"]["wiki_enabled"].as_bool().unwrap_or_default(),
|
||||
|
27
src/user.rs
27
src/user.rs
@ -1,7 +1,7 @@
|
||||
// CRATES
|
||||
use crate::utils::{error, fetch_posts, format_url, param, prefs, request, Post, Preferences, User};
|
||||
use actix_web::{HttpRequest, HttpResponse, Result};
|
||||
use crate::utils::*;
|
||||
use askama::Template;
|
||||
use tide::Request;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
// STRUCTS
|
||||
@ -16,42 +16,39 @@ struct UserTemplate {
|
||||
}
|
||||
|
||||
// FUNCTIONS
|
||||
pub async fn profile(req: HttpRequest) -> HttpResponse {
|
||||
pub async fn profile(req: Request<()>) -> tide::Result {
|
||||
// Build the Reddit JSON API path
|
||||
let path = format!("{}.json?{}&raw_json=1", req.path(), req.query_string());
|
||||
let path = format!("{}.json?{}&raw_json=1", req.url().path(), req.url().query().unwrap_or_default());
|
||||
|
||||
// Retrieve other variables from Libreddit request
|
||||
let sort = param(&path, "sort");
|
||||
let username = req.match_info().get("username").unwrap_or("").to_string();
|
||||
let username = req.param("name").unwrap_or("").to_string();
|
||||
|
||||
// Request user posts/comments from Reddit
|
||||
let posts = fetch_posts(&path, "Comment".to_string()).await;
|
||||
let posts = Post::fetch(&path, "Comment".to_string()).await;
|
||||
|
||||
match posts {
|
||||
Ok((posts, after)) => {
|
||||
// If you can get user posts, also request user data
|
||||
let user = user(&username).await.unwrap_or_default();
|
||||
|
||||
let s = UserTemplate {
|
||||
template(UserTemplate {
|
||||
user,
|
||||
posts,
|
||||
sort: (sort, param(&path, "t")),
|
||||
ends: (param(&path, "after"), after),
|
||||
prefs: prefs(req),
|
||||
}
|
||||
.render()
|
||||
.unwrap();
|
||||
HttpResponse::Ok().content_type("text/html").body(s)
|
||||
prefs: Preferences::new(req),
|
||||
})
|
||||
}
|
||||
// If there is an error show error page
|
||||
Err(msg) => error(msg).await,
|
||||
Err(msg) => error(req, msg).await,
|
||||
}
|
||||
}
|
||||
|
||||
// USER
|
||||
async fn user(name: &str) -> Result<User, String> {
|
||||
// Build the Reddit JSON API path
|
||||
let path: String = format!("/user/{}/about.json", name);
|
||||
let path: String = format!("/user/{}/about.json?raw_json=1", name);
|
||||
|
||||
// Send a request to the url
|
||||
match request(path).await {
|
||||
@ -67,7 +64,7 @@ async fn user(name: &str) -> Result<User, String> {
|
||||
Ok(User {
|
||||
name: name.to_string(),
|
||||
title: about("title"),
|
||||
icon: format_url(about("icon_img").as_str()),
|
||||
icon: format_url(&about("icon_img")),
|
||||
karma: res["data"]["total_karma"].as_i64().unwrap_or(0),
|
||||
created: OffsetDateTime::from_unix_timestamp(created).format("%b %d '%y"),
|
||||
banner: about("banner_img"),
|
||||
|
658
src/utils.rs
658
src/utils.rs
@ -1,23 +1,18 @@
|
||||
//
|
||||
// CRATES
|
||||
//
|
||||
use actix_web::{cookie::Cookie, HttpRequest, HttpResponse, Result};
|
||||
use askama::Template;
|
||||
use base64::encode;
|
||||
use cached::proc_macro::cached;
|
||||
use regex::Regex;
|
||||
use serde_json::{from_str, Value};
|
||||
use serde_json::{from_str, Error, Value};
|
||||
use std::collections::HashMap;
|
||||
use tide::{http::url::Url, http::Cookie, Request, Response};
|
||||
use time::{Duration, OffsetDateTime};
|
||||
use url::Url;
|
||||
// use cached::proc_macro::cached;
|
||||
|
||||
//
|
||||
// STRUCTS
|
||||
//
|
||||
|
||||
// Post flair with content, background color and foreground color
|
||||
pub struct Flair {
|
||||
pub flair_parts: Vec<FlairPart>,
|
||||
pub text: String,
|
||||
pub background_color: String,
|
||||
pub foreground_color: String,
|
||||
}
|
||||
@ -28,6 +23,42 @@ pub struct FlairPart {
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
impl FlairPart {
|
||||
pub fn parse(flair_type: &str, rich_flair: Option<&Vec<Value>>, text_flair: Option<&str>) -> Vec<Self> {
|
||||
// Parse type of flair
|
||||
match flair_type {
|
||||
// If flair contains emojis and text
|
||||
"richtext" => match rich_flair {
|
||||
Some(rich) => rich
|
||||
.iter()
|
||||
// For each part of the flair, extract text and emojis
|
||||
.map(|part| {
|
||||
let value = |name: &str| part[name].as_str().unwrap_or_default();
|
||||
Self {
|
||||
flair_part_type: value("e").to_string(),
|
||||
value: match value("e") {
|
||||
"text" => value("t").to_string(),
|
||||
"emoji" => format_url(value("u")),
|
||||
_ => String::new(),
|
||||
},
|
||||
}
|
||||
})
|
||||
.collect::<Vec<Self>>(),
|
||||
None => Vec::new(),
|
||||
},
|
||||
// If flair contains only text
|
||||
"text" => match text_flair {
|
||||
Some(text) => vec![Self {
|
||||
flair_part_type: "text".to_string(),
|
||||
value: text.to_string(),
|
||||
}],
|
||||
None => Vec::new(),
|
||||
},
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Author {
|
||||
pub name: String,
|
||||
pub flair: Flair,
|
||||
@ -44,6 +75,100 @@ pub struct Media {
|
||||
pub url: String,
|
||||
pub width: i64,
|
||||
pub height: i64,
|
||||
pub poster: String,
|
||||
}
|
||||
|
||||
impl Media {
|
||||
pub async fn parse(data: &Value) -> (String, Self, Vec<GalleryMedia>) {
|
||||
let mut gallery = Vec::new();
|
||||
|
||||
// If post is a video, return the video
|
||||
let (post_type, url_val) = if data["preview"]["reddit_video_preview"]["fallback_url"].is_string() {
|
||||
// Return reddit video
|
||||
("video", &data["preview"]["reddit_video_preview"]["fallback_url"])
|
||||
} else if data["secure_media"]["reddit_video"]["fallback_url"].is_string() {
|
||||
// Return reddit video
|
||||
("video", &data["secure_media"]["reddit_video"]["fallback_url"])
|
||||
} else if data["post_hint"].as_str().unwrap_or("") == "image" {
|
||||
// Handle images, whether GIFs or pics
|
||||
let preview = &data["preview"]["images"][0];
|
||||
let mp4 = &preview["variants"]["mp4"];
|
||||
|
||||
if mp4.is_object() {
|
||||
// Return the mp4 if the media is a gif
|
||||
("gif", &mp4["source"]["url"])
|
||||
} else {
|
||||
// Return the picture if the media is an image
|
||||
if data["domain"] == "i.redd.it" {
|
||||
("image", &data["url"])
|
||||
} else {
|
||||
("image", &preview["source"]["url"])
|
||||
}
|
||||
}
|
||||
} else if data["is_self"].as_bool().unwrap_or_default() {
|
||||
// If type is self, return permalink
|
||||
("self", &data["permalink"])
|
||||
} else if data["is_gallery"].as_bool().unwrap_or_default() {
|
||||
// If this post contains a gallery of images
|
||||
gallery = GalleryMedia::parse(&data["gallery_data"]["items"], &data["media_metadata"]);
|
||||
|
||||
("gallery", &data["url"])
|
||||
} else {
|
||||
// If type can't be determined, return url
|
||||
("link", &data["url"])
|
||||
};
|
||||
|
||||
let source = &data["preview"]["images"][0]["source"];
|
||||
|
||||
let url = if post_type == "self" || post_type == "link" {
|
||||
url_val.as_str().unwrap_or_default().to_string()
|
||||
} else {
|
||||
format_url(url_val.as_str().unwrap_or_default()).to_string()
|
||||
};
|
||||
|
||||
(
|
||||
post_type.to_string(),
|
||||
Self {
|
||||
url,
|
||||
width: source["width"].as_i64().unwrap_or_default(),
|
||||
height: source["height"].as_i64().unwrap_or_default(),
|
||||
poster: format_url(source["url"].as_str().unwrap_or_default()),
|
||||
},
|
||||
gallery,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct GalleryMedia {
|
||||
pub url: String,
|
||||
pub width: i64,
|
||||
pub height: i64,
|
||||
pub caption: String,
|
||||
pub outbound_url: String,
|
||||
}
|
||||
|
||||
impl GalleryMedia {
|
||||
fn parse(items: &Value, metadata: &Value) -> Vec<Self> {
|
||||
items
|
||||
.as_array()
|
||||
.unwrap_or(&Vec::new())
|
||||
.iter()
|
||||
.map(|item| {
|
||||
// For each image in gallery
|
||||
let media_id = item["media_id"].as_str().unwrap_or_default();
|
||||
let image = &metadata[media_id]["s"];
|
||||
|
||||
// Construct gallery items
|
||||
Self {
|
||||
url: format_url(image["u"].as_str().unwrap_or_default()),
|
||||
width: image["x"].as_i64().unwrap_or_default(),
|
||||
height: image["y"].as_i64().unwrap_or_default(),
|
||||
caption: item["caption"].as_str().unwrap_or_default().to_string(),
|
||||
outbound_url: item["outbound_url"].as_str().unwrap_or_default().to_string(),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<Self>>()
|
||||
}
|
||||
}
|
||||
|
||||
// Post containing content, metadata and media
|
||||
@ -65,17 +190,134 @@ pub struct Post {
|
||||
pub rel_time: String,
|
||||
pub created: String,
|
||||
pub comments: String,
|
||||
pub gallery: Vec<GalleryMedia>,
|
||||
}
|
||||
|
||||
impl Post {
|
||||
// Fetch posts of a user or subreddit and return a vector of posts and the "after" value
|
||||
pub async fn fetch(path: &str, fallback_title: String) -> Result<(Vec<Self>, String), String> {
|
||||
let res;
|
||||
let post_list;
|
||||
|
||||
// Send a request to the url
|
||||
match request(path.to_string()).await {
|
||||
// If success, receive JSON in response
|
||||
Ok(response) => {
|
||||
res = response;
|
||||
}
|
||||
// If the Reddit API returns an error, exit this function
|
||||
Err(msg) => return Err(msg),
|
||||
}
|
||||
|
||||
// Fetch the list of posts from the JSON response
|
||||
match res["data"]["children"].as_array() {
|
||||
Some(list) => post_list = list,
|
||||
None => return Err("No posts found".to_string()),
|
||||
}
|
||||
|
||||
let mut posts: Vec<Self> = Vec::new();
|
||||
|
||||
// For each post from posts list
|
||||
for post in post_list {
|
||||
let data = &post["data"];
|
||||
|
||||
let (rel_time, created) = time(data["created_utc"].as_f64().unwrap_or_default());
|
||||
let score = data["score"].as_i64().unwrap_or_default();
|
||||
let ratio: f64 = data["upvote_ratio"].as_f64().unwrap_or(1.0) * 100.0;
|
||||
let title = val(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 },
|
||||
community: val(post, "subreddit"),
|
||||
body: rewrite_urls(&val(post, "body_html")),
|
||||
author: Author {
|
||||
name: val(post, "author"),
|
||||
flair: Flair {
|
||||
flair_parts: FlairPart::parse(
|
||||
data["author_flair_type"].as_str().unwrap_or_default(),
|
||||
data["author_flair_richtext"].as_array(),
|
||||
data["author_flair_text"].as_str(),
|
||||
),
|
||||
text: val(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()
|
||||
} else {
|
||||
format_num(score)
|
||||
},
|
||||
upvote_ratio: ratio as i64,
|
||||
post_type,
|
||||
thumbnail: Media {
|
||||
url: format_url(val(post, "thumbnail").as_str()),
|
||||
width: data["thumbnail_width"].as_i64().unwrap_or_default(),
|
||||
height: data["thumbnail_height"].as_i64().unwrap_or_default(),
|
||||
poster: "".to_string(),
|
||||
},
|
||||
media,
|
||||
domain: val(post, "domain"),
|
||||
flair: Flair {
|
||||
flair_parts: FlairPart::parse(
|
||||
data["link_flair_type"].as_str().unwrap_or_default(),
|
||||
data["link_flair_richtext"].as_array(),
|
||||
data["link_flair_text"].as_str(),
|
||||
),
|
||||
text: val(post, "link_flair_text"),
|
||||
background_color: val(post, "link_flair_background_color"),
|
||||
foreground_color: if val(post, "link_flair_text_color") == "dark" {
|
||||
"black".to_string()
|
||||
} else {
|
||||
"white".to_string()
|
||||
},
|
||||
},
|
||||
flags: Flags {
|
||||
nsfw: data["over_18"].as_bool().unwrap_or_default(),
|
||||
stickied: data["stickied"].as_bool().unwrap_or_default(),
|
||||
},
|
||||
permalink: val(post, "permalink"),
|
||||
rel_time,
|
||||
created,
|
||||
comments: format_num(data["num_comments"].as_i64().unwrap_or_default()),
|
||||
gallery,
|
||||
});
|
||||
}
|
||||
|
||||
Ok((posts, res["data"]["after"].as_str().unwrap_or_default().to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "comment.html", escape = "none")]
|
||||
// Comment with content, post, score and data/time that it was posted
|
||||
pub struct Comment {
|
||||
pub id: String,
|
||||
pub kind: String,
|
||||
pub parent_id: String,
|
||||
pub parent_kind: String,
|
||||
pub post_link: String,
|
||||
pub post_author: String,
|
||||
pub body: String,
|
||||
pub author: Author,
|
||||
pub score: String,
|
||||
pub rel_time: String,
|
||||
pub created: String,
|
||||
pub edited: (String, String),
|
||||
pub replies: Vec<Comment>,
|
||||
pub highlighted: bool,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "error.html", escape = "none")]
|
||||
pub struct ErrorTemplate {
|
||||
pub msg: String,
|
||||
pub prefs: Preferences,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@ -113,14 +355,6 @@ pub struct Params {
|
||||
pub before: Option<String>,
|
||||
}
|
||||
|
||||
// Error template
|
||||
#[derive(Template)]
|
||||
#[template(path = "error.html", escape = "none")]
|
||||
pub struct ErrorTemplate {
|
||||
pub msg: String,
|
||||
pub prefs: Preferences,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Preferences {
|
||||
pub theme: String,
|
||||
@ -129,27 +363,29 @@ pub struct Preferences {
|
||||
pub wide: String,
|
||||
pub show_nsfw: String,
|
||||
pub comment_sort: String,
|
||||
pub subs: Vec<String>,
|
||||
pub subscriptions: Vec<String>,
|
||||
}
|
||||
|
||||
impl Preferences {
|
||||
// Build preferences from cookies
|
||||
pub fn new(req: Request<()>) -> Self {
|
||||
Self {
|
||||
theme: cookie(&req, "theme"),
|
||||
front_page: cookie(&req, "front_page"),
|
||||
layout: cookie(&req, "layout"),
|
||||
wide: cookie(&req, "wide"),
|
||||
show_nsfw: cookie(&req, "show_nsfw"),
|
||||
comment_sort: cookie(&req, "comment_sort"),
|
||||
subscriptions: cookie(&req, "subscriptions").split('+').map(String::from).filter(|s| !s.is_empty()).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// FORMATTING
|
||||
//
|
||||
|
||||
// Build preferences from cookies
|
||||
pub fn prefs(req: HttpRequest) -> Preferences {
|
||||
Preferences {
|
||||
theme: cookie(&req, "theme"),
|
||||
front_page: cookie(&req, "front_page"),
|
||||
layout: cookie(&req, "layout"),
|
||||
wide: cookie(&req, "wide"),
|
||||
show_nsfw: cookie(&req, "show_nsfw"),
|
||||
comment_sort: cookie(&req, "comment_sort"),
|
||||
subs: cookie(&req, "subscriptions").split('+').map(String::from).filter(|s| !s.is_empty()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
// Grab a query param from a url
|
||||
// Grab a query parameter from a url
|
||||
pub fn param(path: &str, value: &str) -> String {
|
||||
match Url::parse(format!("https://libredd.it/{}", path).as_str()) {
|
||||
Ok(url) => url.query_pairs().into_owned().collect::<HashMap<_, _>>().get(value).unwrap_or(&String::new()).to_owned(),
|
||||
@ -157,9 +393,10 @@ pub fn param(path: &str, value: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
// Parse Cookie value from request
|
||||
pub fn cookie(req: &HttpRequest, name: &str) -> String {
|
||||
actix_web::HttpMessage::cookie(req, name).unwrap_or_else(|| Cookie::new(name, "")).value().to_string()
|
||||
// Parse a cookie value from request
|
||||
pub fn cookie(req: &Request<()>, name: &str) -> String {
|
||||
let cookie = req.cookie(name).unwrap_or_else(|| Cookie::named(name));
|
||||
cookie.value().to_string()
|
||||
}
|
||||
|
||||
// Direct urls to proxy if proxy is enabled
|
||||
@ -167,14 +404,47 @@ pub fn format_url(url: &str) -> String {
|
||||
if url.is_empty() || url == "self" || url == "default" || url == "nsfw" || url == "spoiler" {
|
||||
String::new()
|
||||
} else {
|
||||
format!("/proxy/{}", encode(url).as_str())
|
||||
match Url::parse(url) {
|
||||
Ok(parsed) => {
|
||||
let domain = parsed.domain().unwrap_or_default();
|
||||
|
||||
let capture = |regex: &str, format: &str, levels: 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(""),
|
||||
_ => String::new(),
|
||||
},
|
||||
None => String::new(),
|
||||
})
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
match domain {
|
||||
"v.redd.it" => capture(r"https://v\.redd\.it/(.*)/DASH_([0-9]{2,4}(\.mp4|$))", "/vid/", 2),
|
||||
"i.redd.it" => capture(r"https://i\.redd\.it/(.*)", "/img/", 1),
|
||||
"a.thumbs.redditmedia.com" => capture(r"https://a\.thumbs\.redditmedia\.com/(.*)", "/thumb/a/", 1),
|
||||
"b.thumbs.redditmedia.com" => capture(r"https://b\.thumbs\.redditmedia\.com/(.*)", "/thumb/b/", 1),
|
||||
"emoji.redditmedia.com" => capture(r"https://emoji\.redditmedia\.com/(.*)/(.*)", "/emoji/", 2),
|
||||
"preview.redd.it" => capture(r"https://preview\.redd\.it/(.*)\?(.*)", "/preview/pre/", 2),
|
||||
"external-preview.redd.it" => capture(r"https://external\-preview\.redd\.it/(.*)\?(.*)", "/preview/external-pre/", 2),
|
||||
"styles.redditmedia.com" => capture(r"https://styles\.redditmedia\.com/(.*)", "/style/", 1),
|
||||
"www.redditstatic.com" => capture(r"https://www\.redditstatic\.com/(.*)", "/static/", 1),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
Err(_) => String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rewrite Reddit links to Libreddit in body of text
|
||||
pub fn rewrite_url(text: &str) -> String {
|
||||
let re = Regex::new(r#"href="(https://|http://|)(www.|)(reddit).(com)/"#).unwrap();
|
||||
re.replace_all(text, r#"href="/"#).to_string()
|
||||
pub fn rewrite_urls(text: &str) -> String {
|
||||
match Regex::new(r#"href="(https|http|)://(www.|old.|np.|)(reddit).(com)/"#) {
|
||||
Ok(re) => re.replace_all(text, r#"href="/"#).to_string(),
|
||||
Err(_) => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
// Append `m` and `k` for millions and thousands respectively
|
||||
@ -188,82 +458,7 @@ pub fn format_num(num: i64) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn media(data: &Value) -> (String, Media) {
|
||||
let post_type: &str;
|
||||
// If post is a video, return the video
|
||||
let url = if data["preview"]["reddit_video_preview"]["fallback_url"].is_string() {
|
||||
post_type = "video";
|
||||
format_url(data["preview"]["reddit_video_preview"]["fallback_url"].as_str().unwrap_or_default())
|
||||
} else if data["secure_media"]["reddit_video"]["fallback_url"].is_string() {
|
||||
post_type = "video";
|
||||
format_url(data["secure_media"]["reddit_video"]["fallback_url"].as_str().unwrap_or_default())
|
||||
// Handle images, whether GIFs or pics
|
||||
} else if data["post_hint"].as_str().unwrap_or("") == "image" {
|
||||
let preview = data["preview"]["images"][0].clone();
|
||||
match preview["variants"]["mp4"].as_object() {
|
||||
// Return the mp4 if the media is a gif
|
||||
Some(gif) => {
|
||||
post_type = "gif";
|
||||
format_url(gif["source"]["url"].as_str().unwrap_or_default())
|
||||
}
|
||||
// Return the picture if the media is an image
|
||||
None => {
|
||||
post_type = "image";
|
||||
format_url(preview["source"]["url"].as_str().unwrap_or_default())
|
||||
}
|
||||
}
|
||||
} else if data["is_self"].as_bool().unwrap_or_default() {
|
||||
post_type = "self";
|
||||
data["permalink"].as_str().unwrap_or_default().to_string()
|
||||
} else {
|
||||
post_type = "link";
|
||||
data["url"].as_str().unwrap_or_default().to_string()
|
||||
};
|
||||
|
||||
(
|
||||
post_type.to_string(),
|
||||
Media {
|
||||
url,
|
||||
width: data["preview"]["images"][0]["source"]["width"].as_i64().unwrap_or_default(),
|
||||
height: data["preview"]["images"][0]["source"]["height"].as_i64().unwrap_or_default(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn parse_rich_flair(flair_type: String, rich_flair: Option<&Vec<Value>>, text_flair: Option<&str>) -> Vec<FlairPart> {
|
||||
// Parse type of flair
|
||||
match flair_type.as_str() {
|
||||
// If flair contains emojis and text
|
||||
"richtext" => match rich_flair {
|
||||
Some(rich) => rich
|
||||
.iter()
|
||||
// For each part of the flair, extract text and emojis
|
||||
.map(|part| {
|
||||
let value = |name: &str| part[name].as_str().unwrap_or_default();
|
||||
FlairPart {
|
||||
flair_part_type: value("e").to_string(),
|
||||
value: match value("e") {
|
||||
"text" => value("t").to_string(),
|
||||
"emoji" => format_url(value("u")),
|
||||
_ => String::new(),
|
||||
},
|
||||
}
|
||||
})
|
||||
.collect::<Vec<FlairPart>>(),
|
||||
None => Vec::new(),
|
||||
},
|
||||
// If flair contains only text
|
||||
"text" => match text_flair {
|
||||
Some(text) => vec![FlairPart {
|
||||
flair_part_type: "text".to_string(),
|
||||
value: text.to_string(),
|
||||
}],
|
||||
None => Vec::new(),
|
||||
},
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
// Parse a relative and absolute time from a UNIX timestamp
|
||||
pub fn time(created: f64) -> (String, String) {
|
||||
let time = OffsetDateTime::from_unix_timestamp(created.round() as i64);
|
||||
let time_delta = OffsetDateTime::now_utc() - time;
|
||||
@ -280,210 +475,89 @@ pub fn time(created: f64) -> (String, String) {
|
||||
format!("{}m ago", time_delta.whole_minutes())
|
||||
};
|
||||
|
||||
(rel_time, time.format("%b %d %Y, %H:%M UTC"))
|
||||
(rel_time, time.format("%b %d %Y, %H:%M:%S UTC"))
|
||||
}
|
||||
|
||||
//
|
||||
// JSON PARSING
|
||||
//
|
||||
|
||||
// val() function used to parse JSON from Reddit APIs
|
||||
pub fn val(j: &Value, k: &str) -> String {
|
||||
j["data"][k].as_str().unwrap_or_default().to_string()
|
||||
}
|
||||
|
||||
// Fetch posts of a user or subreddit and return a vector of posts and the "after" value
|
||||
pub async fn fetch_posts(path: &str, fallback_title: String) -> Result<(Vec<Post>, String), String> {
|
||||
let res;
|
||||
let post_list;
|
||||
|
||||
// Send a request to the url
|
||||
match request(path.to_string()).await {
|
||||
// If success, receive JSON in response
|
||||
Ok(response) => {
|
||||
res = response;
|
||||
}
|
||||
// If the Reddit API returns an error, exit this function
|
||||
Err(msg) => return Err(msg),
|
||||
}
|
||||
|
||||
// Fetch the list of posts from the JSON response
|
||||
match res["data"]["children"].as_array() {
|
||||
Some(list) => post_list = list,
|
||||
None => return Err("No posts found".to_string()),
|
||||
}
|
||||
|
||||
let mut posts: Vec<Post> = Vec::new();
|
||||
|
||||
// For each post from posts list
|
||||
for post in post_list {
|
||||
let (rel_time, created) = time(post["data"]["created_utc"].as_f64().unwrap_or_default());
|
||||
let score = post["data"]["score"].as_i64().unwrap_or_default();
|
||||
let ratio: f64 = post["data"]["upvote_ratio"].as_f64().unwrap_or(1.0) * 100.0;
|
||||
let title = val(post, "title");
|
||||
|
||||
// Determine the type of media along with the media URL
|
||||
let (post_type, media) = media(&post["data"]).await;
|
||||
|
||||
posts.push(Post {
|
||||
id: val(post, "id"),
|
||||
title: if title.is_empty() { fallback_title.to_owned() } else { title },
|
||||
community: val(post, "subreddit"),
|
||||
body: rewrite_url(&val(post, "body_html")),
|
||||
author: Author {
|
||||
name: val(post, "author"),
|
||||
flair: Flair {
|
||||
flair_parts: parse_rich_flair(
|
||||
val(post, "author_flair_type"),
|
||||
post["data"]["author_flair_richtext"].as_array(),
|
||||
post["data"]["author_flair_text"].as_str(),
|
||||
),
|
||||
background_color: val(post, "author_flair_background_color"),
|
||||
foreground_color: val(post, "author_flair_text_color"),
|
||||
},
|
||||
distinguished: val(post, "distinguished"),
|
||||
},
|
||||
score: if post["data"]["hide_score"].as_bool().unwrap_or_default() {
|
||||
"•".to_string()
|
||||
} else {
|
||||
format_num(score)
|
||||
},
|
||||
upvote_ratio: ratio as i64,
|
||||
post_type,
|
||||
thumbnail: Media {
|
||||
url: format_url(val(post, "thumbnail").as_str()),
|
||||
width: post["data"]["thumbnail_width"].as_i64().unwrap_or_default(),
|
||||
height: post["data"]["thumbnail_height"].as_i64().unwrap_or_default(),
|
||||
},
|
||||
media,
|
||||
domain: val(post, "domain"),
|
||||
flair: Flair {
|
||||
flair_parts: parse_rich_flair(
|
||||
val(post, "link_flair_type"),
|
||||
post["data"]["link_flair_richtext"].as_array(),
|
||||
post["data"]["link_flair_text"].as_str(),
|
||||
),
|
||||
background_color: val(post, "link_flair_background_color"),
|
||||
foreground_color: if val(post, "link_flair_text_color") == "dark" {
|
||||
"black".to_string()
|
||||
} else {
|
||||
"white".to_string()
|
||||
},
|
||||
},
|
||||
flags: Flags {
|
||||
nsfw: post["data"]["over_18"].as_bool().unwrap_or_default(),
|
||||
stickied: post["data"]["stickied"].as_bool().unwrap_or_default(),
|
||||
},
|
||||
permalink: val(post, "permalink"),
|
||||
rel_time,
|
||||
created,
|
||||
comments: format_num(post["data"]["num_comments"].as_i64().unwrap_or_default()),
|
||||
});
|
||||
}
|
||||
|
||||
Ok((posts, res["data"]["after"].as_str().unwrap_or_default().to_string()))
|
||||
}
|
||||
|
||||
//
|
||||
// NETWORKING
|
||||
//
|
||||
|
||||
pub async fn error(msg: String) -> HttpResponse {
|
||||
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 redirect(path: String) -> Response {
|
||||
Response::builder(302)
|
||||
.content_type("text/html")
|
||||
.header("Location", &path)
|
||||
.body(format!("Redirecting to <a href=\"{0}\">{0}</a>...", path))
|
||||
.build()
|
||||
}
|
||||
|
||||
pub async fn error(req: Request<()>, msg: String) -> tide::Result {
|
||||
let body = ErrorTemplate {
|
||||
msg,
|
||||
prefs: Preferences::default(),
|
||||
prefs: Preferences::new(req),
|
||||
}
|
||||
.render()
|
||||
.unwrap_or_default();
|
||||
HttpResponse::NotFound().content_type("text/html").body(body)
|
||||
|
||||
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=60, result = true)]
|
||||
#[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 awc
|
||||
// async fn send(url: &str) -> Result<String, (bool, String)> {
|
||||
// let client = actix_web::client::Client::default();
|
||||
// let response = client.get(url).header("User-Agent", format!("web:libreddit:{}", env!("CARGO_PKG_VERSION"))).send().await;
|
||||
// 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));
|
||||
|
||||
// match response {
|
||||
// Ok(mut payload) => {
|
||||
// // Get first number of response HTTP status code
|
||||
// match payload.status().to_string().chars().next() {
|
||||
// // If success
|
||||
// Some('2') => Ok(String::from_utf8(payload.body().limit(20_000_000).await.unwrap_or_default().to_vec()).unwrap_or_default()),
|
||||
// // If redirection
|
||||
// Some('3') => match payload.headers().get("location") {
|
||||
// Some(location) => Err((true, location.to_str().unwrap_or_default().to_string())),
|
||||
// None => Err((false, "Page not found".to_string())),
|
||||
// },
|
||||
// // Otherwise
|
||||
// _ => Err((false, "Page not found".to_string())),
|
||||
// }
|
||||
// }
|
||||
// Err(e) => { dbg!(e); Err((false, "Couldn't send request to Reddit, this instance may be being rate-limited. Try another.".to_string())) },
|
||||
// }
|
||||
// }
|
||||
let res = client.send(req).await;
|
||||
|
||||
// // Print error if debugging then return error based on error message
|
||||
// fn err(url: String, msg: String) -> Result<Value, String> {
|
||||
// // #[cfg(debug_assertions)]
|
||||
// dbg!(format!("{} - {}", url, msg));
|
||||
// Err(msg)
|
||||
// };
|
||||
let err = |msg: &str, e: String| -> Result<Value, String> {
|
||||
eprintln!("{} - {}: {}", url, msg, e);
|
||||
Err(msg.to_string())
|
||||
};
|
||||
|
||||
// // Parse JSON from body. If parsing fails, return error
|
||||
// fn json(url: String, body: String) -> Result<Value, String> {
|
||||
// match from_str(body.as_str()) {
|
||||
// Ok(json) => Ok(json),
|
||||
// Err(_) => err(url, "Failed to parse page JSON data".to_string()),
|
||||
// }
|
||||
// }
|
||||
|
||||
// // Make request to Reddit using send function
|
||||
// match send(&url).await {
|
||||
// // If success, parse and return body
|
||||
// Ok(body) => json(url, body),
|
||||
// // Follow any redirects
|
||||
// Err((true, location)) => match send(location.as_str()).await {
|
||||
// // If success, parse and return body
|
||||
// Ok(body) => json(url, body),
|
||||
// // Follow any redirects again
|
||||
// Err((true, location)) => err(url, location),
|
||||
// // Return errors if request fails
|
||||
// Err((_, msg)) => err(url, msg),
|
||||
// },
|
||||
// // Return errors if request fails
|
||||
// Err((_, msg)) => err(url, msg),
|
||||
// }
|
||||
|
||||
// Send request using ureq
|
||||
match ureq::get(&url).set("User-Agent", user_agent.as_str()).call() {
|
||||
// If response is success
|
||||
Ok(response) => {
|
||||
// Parse the response from Reddit as JSON
|
||||
let json_string = &response.into_string().unwrap_or_default();
|
||||
match from_str(json_string) {
|
||||
Ok(json) => Ok(json),
|
||||
Err(e) => {
|
||||
println!("{} - Failed to parse page JSON data: {} - {}", url, e, json_string);
|
||||
Err("Failed to parse page JSON data".to_string())
|
||||
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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
// If response is error
|
||||
Err(ureq::Error::Status(_, _)) => {
|
||||
#[cfg(debug_assertions)]
|
||||
dbg!(format!("{} - Page not found", url));
|
||||
Err("Page not found".to_string())
|
||||
}
|
||||
// If failed to send request
|
||||
Err(e) => {
|
||||
println!("{} - Couldn't send request to Reddit: {}", url, e);
|
||||
Err("Couldn't send request to Reddit, this instance may be being rate-limited. Try another.".to_string())
|
||||
}
|
||||
Err(e) => err("Couldn't parse request body", e.to_string()),
|
||||
},
|
||||
Err(e) => err("Couldn't send request to Reddit", e.to_string()),
|
||||
}
|
||||
}
|
||||
|
BIN
static/apple-touch-icon.png
Normal file
BIN
static/apple-touch-icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.5 KiB |
Binary file not shown.
Before Width: | Height: | Size: 789 B After Width: | Height: | Size: 4.2 KiB |
BIN
static/favicon.png
Normal file
BIN
static/favicon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 969 B |
BIN
static/logo.png
Normal file
BIN
static/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 7.9 KiB |
23
static/manifest.json
Normal file
23
static/manifest.json
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "Libreddit",
|
||||
"short_name": "Libreddit",
|
||||
"display": "standalone",
|
||||
"background_color": "#1f1f1f",
|
||||
"description": "An alternative private front-end to Reddit",
|
||||
"theme_color": "#1f1f1f",
|
||||
"icons": [
|
||||
{
|
||||
"src": "logo.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "apple-touch-icon.png",
|
||||
"sizes": "180x180"
|
||||
},
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "32x32"
|
||||
}
|
||||
]
|
||||
}
|
183
static/style.css
183
static/style.css
@ -51,6 +51,21 @@
|
||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Black theme setting */
|
||||
.black {
|
||||
--accent: #009a9a;
|
||||
--green: #00a229;
|
||||
--text: white;
|
||||
--foreground: #0f0f0f;
|
||||
--background: black;
|
||||
--outside: black;
|
||||
--post: black;
|
||||
--panel-border: 2px solid #0f0f0f;
|
||||
--highlighted: #0f0f0f;
|
||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
|
||||
/* General */
|
||||
|
||||
::selection {
|
||||
@ -58,6 +73,10 @@
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
}
|
||||
|
||||
html, body, div, h1, h2, h3, h4, h5, h6, ul, ol, dl, li, dt, dd, p, blockquote,
|
||||
pre, form, fieldset, table, th, td, select, input {
|
||||
margin: 0;
|
||||
@ -241,10 +260,11 @@ aside {
|
||||
}
|
||||
|
||||
#user_description, #sub_description {
|
||||
margin: 0 20px;
|
||||
margin: 0 15px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
#user_name, #user_description:not(:empty), #user_icon
|
||||
#user_name, #user_description:not(:empty), #user_icon,
|
||||
#sub_name, #sub_icon, #sub_description:not(:empty) {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
@ -265,6 +285,10 @@ aside {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
#multisub {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.subscribe, .unsubscribe {
|
||||
padding: 10px 20px;
|
||||
border-radius: 5px;
|
||||
@ -281,9 +305,9 @@ aside {
|
||||
background-color: var(--highlighted);
|
||||
}
|
||||
|
||||
/* Subscribed subreddit list */
|
||||
/* Feeds */
|
||||
|
||||
#subscriptions {
|
||||
#feeds {
|
||||
position: relative;
|
||||
border-radius: 5px;
|
||||
border: var(--panel-border);
|
||||
@ -294,14 +318,15 @@ aside {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
#subscriptions > summary {
|
||||
#feeds > summary {
|
||||
padding: 8px 15px;
|
||||
}
|
||||
|
||||
#sub_list {
|
||||
#feed_list {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
min-width: 100%;
|
||||
max-height: 500px;
|
||||
border-radius: 5px;
|
||||
box-shadow: var(--shadow);
|
||||
background: var(--outside);
|
||||
@ -310,17 +335,24 @@ aside {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
#sub_list > a {
|
||||
#feed_list > p {
|
||||
font-size: 13px;
|
||||
opacity: 0.5;
|
||||
padding: 5px 20px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
#feed_list > a {
|
||||
padding: 10px 20px;
|
||||
transition: 0.2s background;
|
||||
}
|
||||
|
||||
#sub_list > .selected {
|
||||
#feed_list > .selected {
|
||||
background-color: var(--accent);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
#sub_list > a:not(.selected):hover {
|
||||
#feed_list > a:not(.selected):hover {
|
||||
background-color: var(--foreground);
|
||||
}
|
||||
|
||||
@ -528,6 +560,12 @@ a.search_subreddit:hover {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.thread_nav {
|
||||
color: var(--accent);
|
||||
font-weight: bold;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.post {
|
||||
border-radius: 5px;
|
||||
background: var(--post);
|
||||
@ -590,6 +628,10 @@ a.search_subreddit:hover {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.author_flair, .post_flair:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.emoji {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
@ -610,27 +652,61 @@ a.search_subreddit:hover {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.post_media {
|
||||
.post_media_image, .post .__NoScript_PlaceHolder__, .post_media_video, .gallery {
|
||||
max-width: calc(100% - 40px);
|
||||
height: auto;
|
||||
align-self: center;
|
||||
margin-top: 15px;
|
||||
margin: 5px auto;
|
||||
grid-area: post_media;
|
||||
background-color: var(--highlighted);
|
||||
background-image: url("data:image/svg+xml;utf8,<svg viewBox='0 0 100 100' width='100' height='100' xmlns='http://www.w3.org/2000/svg'><path d='M15,20 h70 a10,10 0 0 1 10,10 v45 a10,10 0 0 1 -10,10 h-70 a10,10 0 0 1 -10,-10 v-45 a10,10 0 0 1 10,-10 z' fill='none' stroke='rgba(128,128,128,0.5)' stroke-width='3' /><path d='M15,75 l25,-35 l15,20 l10,-10 l20, 25 z' stroke='none' fill='rgba(128,128,128,0.5)' /><circle cx='75' cy='35' r='7' stroke='none' fill='rgba(128,128,128,0.5)'/></svg>");
|
||||
background-position: 50%;
|
||||
background-repeat: no-repeat;
|
||||
margin: 15px auto 5px auto;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.post_media.short {
|
||||
|
||||
.post_media_video.short {
|
||||
max-height: 512px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.post_media_image.short svg, .post_media_image.short img{
|
||||
max-height: 512px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.post_media_image svg{
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
align-self: center;
|
||||
background-color: var(--highlighted);
|
||||
background-image: url("data:image/svg+xml;utf8,<svg viewBox='0 0 100 100' width='100' height='100' xmlns='http://www.w3.org/2000/svg'><path d='M15,20 h70 a10,10 0 0 1 10,10 v45 a10,10 0 0 1 -10,10 h-70 a10,10 0 0 1 -10,-10 v-45 a10,10 0 0 1 10,-10 z' fill='none' stroke='rgba(128,128,128,0.5)' stroke-width='3' /><path d='M15,75 l25,-35 l15,20 l10,-10 l20, 25 z' stroke='none' fill='rgba(128,128,128,0.5)' /><circle cx='75' cy='35' r='7' stroke='none' fill='rgba(128,128,128,0.5)'/></svg>");
|
||||
background-position: 50%;
|
||||
background-repeat: no-repeat;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
.post_media_image img {
|
||||
max-width: 100%;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
.gallery img {
|
||||
max-width: 100%;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
.gallery figcaption {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.gallery .outbound_url {
|
||||
color: var(--accent);
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
display: block;
|
||||
padding-top: 5px;
|
||||
}
|
||||
|
||||
#post_url {
|
||||
color: var(--accent);
|
||||
margin: 5px 20px;
|
||||
margin: 5px 15px;
|
||||
grid-area: post_media;
|
||||
}
|
||||
|
||||
@ -639,6 +715,7 @@ a.search_subreddit:hover {
|
||||
font-weight: normal;
|
||||
margin: 5px 15px;
|
||||
grid-area: post_body;
|
||||
width: calc(100% - 30px);
|
||||
}
|
||||
|
||||
.post_footer {
|
||||
@ -707,6 +784,10 @@ a.search_subreddit:hover {
|
||||
align-self: end;
|
||||
}
|
||||
|
||||
.post_thumbnail img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.stickied {
|
||||
--accent: var(--green);
|
||||
border: 1px solid var(--green);
|
||||
@ -779,7 +860,12 @@ a.search_subreddit:hover {
|
||||
.comment_body {
|
||||
opacity: 0.9;
|
||||
font-weight: normal;
|
||||
margin: 10px 5px;
|
||||
padding: 5px 5px;
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
.comment_body.highlighted {
|
||||
background: var(--highlighted);
|
||||
}
|
||||
|
||||
.comment_body > p:not(:first-child) {
|
||||
@ -809,6 +895,12 @@ a.search_subreddit:hover {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.edited {
|
||||
opacity: 0.4;
|
||||
font-style: italic;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.line {
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
@ -877,19 +969,21 @@ a.search_subreddit:hover {
|
||||
|
||||
/* Settings */
|
||||
|
||||
#settings, #settings > form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
#settings {
|
||||
max-width: 450px;
|
||||
}
|
||||
|
||||
#settings_note {
|
||||
font-size: 14px;
|
||||
max-width: 300px;
|
||||
margin-top: 10px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
#settings_note a {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
|
||||
.prefs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -898,6 +992,7 @@ a.search_subreddit:hover {
|
||||
padding: 20px;
|
||||
background: var(--post);
|
||||
border-radius: 5px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.prefs > div {
|
||||
@ -906,10 +1001,7 @@ a.search_subreddit:hover {
|
||||
width: 100%;
|
||||
height: 35px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.prefs > div:not(:last-of-type) {
|
||||
margin-bottom: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.prefs select {
|
||||
@ -936,28 +1028,16 @@ input[type="submit"] {
|
||||
-moz-appearance: none;
|
||||
}
|
||||
|
||||
#settings_subs {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#settings_subs > li {
|
||||
display: flex;
|
||||
margin: 10px 0;
|
||||
}
|
||||
#settings_subs > li:last-of-type { margin-bottom: 0; }
|
||||
|
||||
#settings_subs > li > span {
|
||||
padding: 10px 0;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
#settings_subs .unsubscribe {
|
||||
margin-left: 30px;
|
||||
}
|
||||
|
||||
/* Markdown */
|
||||
|
||||
.md {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.md > *:not(:first-child) {
|
||||
margin-top: 20px;
|
||||
}
|
||||
@ -1026,6 +1106,13 @@ td, th {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
/* Errors */
|
||||
|
||||
#error { text-align: center; }
|
||||
#error h1 { margin-bottom: 10px; }
|
||||
#error h3 { opacity: 0.85; }
|
||||
#error a { color: var(--accent); }
|
||||
|
||||
/* Mobile */
|
||||
|
||||
@media screen and (max-width: 480px) {
|
||||
@ -1095,6 +1182,10 @@ td, th {
|
||||
right: 10px;
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
#settings {
|
||||
max-width: unset;
|
||||
}
|
||||
|
||||
aside, #subreddit, #user {
|
||||
margin: 0;
|
||||
|
@ -6,8 +6,20 @@
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<meta name="description" content="View on Libreddit, an alternative private front-end to Reddit.">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="shortcut icon" type="image/x-icon" href="/favicon.ico">
|
||||
<link rel="stylesheet" type="text/css" href="/style.css">
|
||||
<!-- General PWA -->
|
||||
<meta name="theme-color" content="#1F1F1F">
|
||||
<!-- iOS Application -->
|
||||
<meta name="apple-mobile-web-app-title" content="Libreddit">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||
<!-- Android -->
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<!-- iOS Logo -->
|
||||
<link href="/touch-icon-iphone.png/" rel="apple-touch-icon">
|
||||
<!-- PWA Manifest -->
|
||||
<link rel="manifest" type="application/json" href="/manifest.json/">
|
||||
<link rel="shortcut icon" type="image/x-icon" href="/favicon.ico/">
|
||||
<link rel="stylesheet" type="text/css" href="/style.css/">
|
||||
{% endblock %}
|
||||
</head>
|
||||
<body class="
|
||||
@ -17,21 +29,25 @@
|
||||
<!-- NAVIGATION BAR -->
|
||||
<nav>
|
||||
<div id="logo">
|
||||
<a id="libreddit" href="/">
|
||||
<span id="lib">lib</span><span id="reddit">reddit.</span>
|
||||
</a>
|
||||
<a id="libreddit" href="/"><span id="lib">lib</span><span id="reddit">reddit.</span></a>
|
||||
<span id="version">v{{ env!("CARGO_PKG_VERSION") }}</span>
|
||||
{% block subscriptions %}{% endblock %}
|
||||
</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"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<title>settings</title>
|
||||
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||
</svg>
|
||||
</a>
|
||||
<a id="code" href="https://github.com/spikecodes/libreddit">
|
||||
<span>code</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<title>code</title>
|
||||
<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
25
templates/comment.html
Normal file
25
templates/comment.html
Normal file
@ -0,0 +1,25 @@
|
||||
{% import "utils.html" as utils %}
|
||||
|
||||
{% if kind == "more" && parent_kind == "t1" %}
|
||||
<a class="deeper_replies" href="{{ post_link }}{{ parent_id }}">→ More replies</a>
|
||||
{% else if kind == "t1" %}
|
||||
<div id="{{ id }}" class="comment">
|
||||
<div class="comment_left">
|
||||
<p class="comment_score">{{ score }}</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>
|
||||
{% if author.flair.flair_parts.len() > 0 %}
|
||||
<small class="author_flair">{% call utils::render_flair(author.flair.flair_parts) %}</small>
|
||||
{% endif %}
|
||||
<a href="{{ post_link }}{{ id }}/?context=3" class="created" title="{{ created }}">{{ rel_time }}</a>
|
||||
{% if edited.0 != "".to_string() %}<span class="edited" title="{{ edited.1 }}">edited {{ edited.0 }}</span>{% endif %}
|
||||
</summary>
|
||||
<div class="comment_body {% if highlighted %}highlighted{% endif %}">{{ body }}</div>
|
||||
<blockquote class="replies">{% for c in replies -%}{{ c.render().unwrap() }}{%- endfor %}
|
||||
</blockquote>
|
||||
</details>
|
||||
</div>
|
||||
{% endif %}
|
@ -2,5 +2,8 @@
|
||||
{% block title %}Error: {{ msg }}{% endblock %}
|
||||
{% block sortstyle %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1 style="text-align: center; font-size: 50px;">{{ msg }}</h1>
|
||||
<div id="error">
|
||||
<h1>{{ msg }}</h1>
|
||||
<h3>Head back <a href="/">home</a>?</h3>
|
||||
</div>
|
||||
{% endblock %}
|
@ -10,36 +10,26 @@
|
||||
{% block root %}/r/{{ post.community }}{% endblock %}{% block location %}r/{{ post.community }}{% endblock %}
|
||||
{% block head %}
|
||||
{% call super() %}
|
||||
<!-- 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 }}">
|
||||
<meta property="og:description" content="View on Libreddit, an alternative private front-end to Reddit.">
|
||||
<meta property="og:image" content="{{ post.thumbnail.url }}">
|
||||
<meta property="twitter:card" content="summary_large_image">
|
||||
<meta property="twitter:url" content="{{ post.permalink }}">
|
||||
<meta property="twitter:title" content="{{ post.title }} - r/{{ post.community }}">
|
||||
<meta property="twitter:description" content="View on Libreddit, an alternative private front-end to Reddit.">
|
||||
<meta property="twitter:image" content="{{ post.thumbnail.url }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block subscriptions %}
|
||||
{% call utils::sub_list(post.community.as_str()) %}
|
||||
{% endblock %}
|
||||
|
||||
<!-- OPEN COMMENT MACRO -->
|
||||
{% macro comment(item) -%}
|
||||
<div id="{{ item.id }}" class="comment">
|
||||
<div class="comment_left">
|
||||
<p class="comment_score">{{ item.score }}</p>
|
||||
<div class="line"></div>
|
||||
</div>
|
||||
<details class="comment_right" open>
|
||||
<summary class="comment_data">
|
||||
<a class="comment_author {{ item.author.distinguished }} {% if item.author.name == post.author.name %}op{% endif %}" href="/u/{{ item.author.name }}">u/{{ item.author.name }}</a>
|
||||
{% if item.author.flair.flair_parts.len() > 0 %}
|
||||
<small class="author_flair">{% call utils::render_flair(item.author.flair.flair_parts) %}</small>
|
||||
{% endif %}
|
||||
<span class="created" title="{{ post.created }}">{{ item.rel_time }}</span>
|
||||
</summary>
|
||||
<div class="comment_body">{{ item.body }}</div>
|
||||
{%- endmacro %}
|
||||
|
||||
<!-- CLOSE COMMENT MACRO -->
|
||||
{% macro close() %}
|
||||
</details></div>
|
||||
{% endmacro %}
|
||||
|
||||
{% block content %}
|
||||
<div id="column_one">
|
||||
|
||||
@ -58,26 +48,42 @@
|
||||
<p class="post_title">
|
||||
<a href="{{ post.permalink }}">{{ post.title }}</a>
|
||||
{% if post.flair.flair_parts.len() > 0 %}
|
||||
<small class="post_flair" style="color:{{ post.flair.foreground_color }}; background:{{ post.flair.background_color }};">{% call utils::render_flair(post.flair.flair_parts) %}</small>
|
||||
<a href="/r/{{ post.community }}/search/?q=flair_name%3A%22{{ post.flair.text }}%22&restrict_sr=on"
|
||||
class="post_flair"
|
||||
style="color:{{ post.flair.foreground_color }}; background:{{ post.flair.background_color }};">{% call utils::render_flair(post.flair.flair_parts) %}</a>
|
||||
{% endif %}
|
||||
{% if post.flags.nsfw %} <small class="nsfw">NSFW</small>{% endif %}
|
||||
</p>
|
||||
|
||||
<!-- POST MEDIA -->
|
||||
{% if post.post_type == "image" %}
|
||||
<a href="{{ post.media.url }}" style="display:contents" >
|
||||
<svg class="post_media"
|
||||
<a href="{{ post.media.url }}" class="post_media_image" >
|
||||
<svg
|
||||
width="{{ post.media.width }}px"
|
||||
height="{{ post.media.height }}px"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<image width="100%" height="100%" href="{{ post.media.url }}"/>
|
||||
<desc>
|
||||
<img alt="Post image" src="{{ post.media.url }}"/>
|
||||
</dev>
|
||||
</desc>
|
||||
</svg>
|
||||
</a>
|
||||
{% else if post.post_type == "video" || post.post_type == "gif" %}
|
||||
<video class="post_media" src="{{ post.media.url }}" controls autoplay loop></video>
|
||||
<video class="post_media_video" src="{{ post.media.url }}" controls autoplay loop><a href={{ post.media.url }}>Video</a></video>
|
||||
{% else if post.post_type == "gallery" %}
|
||||
<div class="gallery">
|
||||
{% for image in post.gallery -%}
|
||||
<figure>
|
||||
<a href="{{ image.url }}" ><img alt="Gallery image" src="{{ image.url }}"/></a>
|
||||
<figcaption>
|
||||
<p>{{ image.caption }}</p>
|
||||
{% if image.outbound_url.len() > 0 %}
|
||||
<p><a class="outbound_url" href="{{ image.outbound_url }}">{{ image.outbound_url }}</a>
|
||||
{% endif %}
|
||||
</figcaption>
|
||||
</figure>
|
||||
{%- endfor %}
|
||||
</div>
|
||||
{% else if post.post_type == "link" %}
|
||||
<a id="post_url" href="{{ post.media.url }}">{{ post.media.url }}</a>
|
||||
{% endif %}
|
||||
@ -110,25 +116,14 @@
|
||||
<!-- COMMENTS -->
|
||||
{% for c in comments -%}
|
||||
<div class="thread">
|
||||
<!-- EACH COMMENT -->
|
||||
{% call comment(c) %}
|
||||
<blockquote class="replies">{% for reply1 in c.replies %}{% call comment(reply1) %}
|
||||
<!-- FIRST-LEVEL REPLIES -->
|
||||
<blockquote class="replies">{% for reply2 in reply1.replies %}{% call comment(reply2) %}
|
||||
<!-- SECOND-LEVEL REPLIES -->
|
||||
<blockquote class="replies">{% for reply3 in reply2.replies %}{% call comment(reply3) %}
|
||||
<!-- THIRD-LEVEL REPLIES -->
|
||||
{% if reply3.replies.len() > 0 %}
|
||||
<!-- LINK TO CONTINUE REPLIES -->
|
||||
<a class="deeper_replies" href="{{ post.permalink }}{{ reply3.id }}">→ More replies</a>
|
||||
{% endif %}
|
||||
{% call close() %}
|
||||
{% endfor %}
|
||||
</blockquote>{% call close() %}
|
||||
{% endfor %}
|
||||
</blockquote>{% call close() %}
|
||||
{% endfor %}
|
||||
</blockquote>{% call close() %}
|
||||
{% if single_thread %}
|
||||
<p class="thread_nav"><a href="/{{ post.id }}">View all comments</a></p>
|
||||
{% if c.parent_kind == "t1" %}
|
||||
<p class="thread_nav"><a href="?context=9999">Show parent comments</a></p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{{ c.render().unwrap() }}
|
||||
</div>
|
||||
{%- endfor %}
|
||||
|
||||
|
@ -48,54 +48,7 @@
|
||||
|
||||
{% if post.flags.nsfw && prefs.show_nsfw != "on" %}
|
||||
{% else if post.title != "Comment" %}
|
||||
<div class="post {% if post.flags.stickied %}stickied{% endif %}">
|
||||
<p class="post_header">
|
||||
<a class="post_subreddit" href="/r/{{ post.community }}">r/{{ post.community }}</a>
|
||||
<span class="dot">•</span>
|
||||
<a class="post_author" href="/u/{{ post.author.name }}">u/{{ post.author.name }}</a>
|
||||
<span class="dot">•</span>
|
||||
<span class="created" title="{{ post.created }}">{{ post.rel_time }}</span>
|
||||
</p>
|
||||
<p class="post_title">
|
||||
{% if post.flair.flair_parts.len() > 0 %}
|
||||
<small class="post_flair" style="color:{{ post.flair.foreground_color }}; background:{{ post.flair.background_color }};">{% call utils::render_flair(post.flair.flair_parts) %}</small>
|
||||
{% endif %}
|
||||
<a href="{{ post.permalink }}">{{ post.title }}</a>{% if post.flags.nsfw %} <small class="nsfw">NSFW</small>{% endif %}
|
||||
</p>
|
||||
<!-- POST MEDIA/THUMBNAIL -->
|
||||
{% if (prefs.layout.is_empty() || prefs.layout == "card") && post.post_type == "image" %}
|
||||
<a href="{{ post.media.url }}" style="display:contents" >
|
||||
<svg class="post_media {% if post.media.height / post.media.width < 2 %}short{% endif %}"
|
||||
width="{{ post.media.width }}px"
|
||||
height="{{ post.media.height }}px"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<image width="100%" height="100%" href="{{ post.media.url }}"/>
|
||||
<desc>
|
||||
<img alt="Post image" src="{{ post.media.url }}"/>
|
||||
</dev>
|
||||
</svg>
|
||||
</a>
|
||||
{% else if post.post_type != "self" %}
|
||||
<a class="post_thumbnail {% if post.thumbnail.url.is_empty() %}no_thumbnail{% endif %}" href="{% if post.post_type == "link" %}{{ post.media.url }}{% else %}{{ post.permalink }}{% endif %}">
|
||||
{% if post.thumbnail.url.is_empty() %}
|
||||
<svg viewBox="0 0 100 106" width="140" height="53" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Thumbnail</title>
|
||||
<path d="M35,15h-15a10,10 0,0,0 0,20h25a10,10 0,0,0 10,-10m-12.5,0a10, 10 0,0,1 10, -10h25a10,10 0,0,1 0,20h-15" fill="none" stroke-width="5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
{% else %}
|
||||
<svg width="{{ post.thumbnail.width }}px" height="{{ post.thumbnail.height }}px" xmlns="http://www.w3.org/2000/svg">
|
||||
<image alt="Thumbnail" width="100%" height="100%" href="{{ post.thumbnail.url }}"/>
|
||||
</svg>
|
||||
{% endif %}
|
||||
<span>{% if post.post_type == "link" %}{{ post.domain }}{% else %}{{ post.post_type }}{% endif %}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<div class="post_score">{{ post.score }}<span class="label"> Upvotes</span></div>
|
||||
<div class="post_footer">
|
||||
<a href="{{ post.permalink }}" class="post_comments">{{ post.comments }} comments</a>
|
||||
</div>
|
||||
</div>
|
||||
{% call utils::post_in_list(post) %}
|
||||
{% else %}
|
||||
<div class="comment">
|
||||
<div class="comment_left">
|
||||
|
@ -9,13 +9,13 @@
|
||||
|
||||
{% block content %}
|
||||
<div id="settings">
|
||||
<form action="/settings" method="POST">
|
||||
<form action="/settings/" method="POST">
|
||||
<div class="prefs">
|
||||
<p>Appearance</p>
|
||||
<div id="theme">
|
||||
<label for="theme">Theme:</label>
|
||||
<select name="theme">
|
||||
{% call utils::options(prefs.theme, ["system", "light", "dark"], "system") %}
|
||||
{% call utils::options(prefs.theme, ["system", "light", "dark", "black"], "system") %}
|
||||
</select>
|
||||
</div>
|
||||
<p>Interface</p>
|
||||
@ -46,25 +46,27 @@
|
||||
<label for="show_nsfw">Show NSFW posts:</label>
|
||||
<input type="checkbox" name="show_nsfw" {% if prefs.show_nsfw == "on" %}checked{% endif %}>
|
||||
</div>
|
||||
<input id="save" type="submit" value="Save">
|
||||
</div>
|
||||
<p id="settings_note"><b>Note:</b> settings are saved in browser cookies. Clearing your cookie data will reset them.</p>
|
||||
<input id="save" type="submit" value="Save">
|
||||
</form>
|
||||
{% if prefs.subs.len() > 0 %}
|
||||
<aside class="prefs">
|
||||
{% if prefs.subscriptions.len() > 0 %}
|
||||
<div class="prefs" id="settings_subs">
|
||||
<p>Subscribed Subreddits</p>
|
||||
<ul id="settings_subs">
|
||||
{% for sub in prefs.subs %}
|
||||
<li>
|
||||
<span>{{ sub }}</span>
|
||||
<form action="/r/{{ sub }}/unsubscribe/?redirect=/settings" method="POST">
|
||||
<button class="unsubscribe">Unsubscribe</button>
|
||||
</form>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</aside>
|
||||
{% for sub in prefs.subscriptions %}
|
||||
<div>
|
||||
<span>{{ sub }}</span>
|
||||
<form action="/r/{{ sub }}/unsubscribe/?redirect=settings" method="POST">
|
||||
<button class="unsubscribe">Unsubscribe</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div id="settings_note">
|
||||
<p><b>Note:</b> settings and subscriptions are saved in browser cookies. Clearing your cookies will reset them.</p><br>
|
||||
<p>You can restore your current settings and subscriptions after clearing your cookies using <a href="/settings/restore/?theme={{ prefs.theme }}&front_page={{ prefs.front_page }}&layout={{ prefs.layout }}&wide={{ prefs.wide }}&comment_sort={{ prefs.comment_sort }}&show_nsfw={{ prefs.show_nsfw }}&subscriptions={{ prefs.subscriptions.join("%2B") }}">this link</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
@ -39,58 +39,17 @@
|
||||
{% endif %}
|
||||
</form>
|
||||
|
||||
{% if sub.name.contains("+") %}
|
||||
<form action="/r/{{ sub.name }}/subscribe/" method="POST">
|
||||
<button id="multisub" class="subscribe" title="Subscribe to each sub in this multireddit">Subscribe to Multireddit</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<div id="posts">
|
||||
{% for post in posts %}
|
||||
{% if !(post.flags.nsfw && prefs.show_nsfw != "on") %}
|
||||
<hr class="sep" />
|
||||
<div class="post {% if post.flags.stickied %}stickied{% endif %}">
|
||||
<p class="post_header">
|
||||
<a class="post_subreddit" href="/r/{{ post.community }}">r/{{ post.community }}</a>
|
||||
<span class="dot">•</span>
|
||||
<a class="post_author" href="/u/{{ post.author.name }}">u/{{ post.author.name }}</a>
|
||||
<span class="dot">•</span>
|
||||
<span class="created" title="{{ post.created }}">{{ post.rel_time }}</span>
|
||||
</p>
|
||||
<p class="post_title">
|
||||
{% if post.flair.flair_parts.len() > 0 %}
|
||||
<small class="post_flair" style="color:{{ post.flair.foreground_color }}; background:{{ post.flair.background_color }};">{% call utils::render_flair(post.flair.flair_parts) %}</small>
|
||||
{% endif %}
|
||||
<a href="{{ post.permalink }}">{{ post.title }}</a>{% if post.flags.nsfw %} <small class="nsfw">NSFW</small>{% endif %}
|
||||
</p>
|
||||
<!-- POST MEDIA/THUMBNAIL -->
|
||||
{% if (prefs.layout.is_empty() || prefs.layout == "card") && post.post_type == "image" %}
|
||||
<a href="{{ post.media.url }}" style="display:contents" >
|
||||
<svg class="post_media {% if post.media.height / post.media.width < 2 %}short{% endif %}"
|
||||
width="{{ post.media.width }}px"
|
||||
height="{{ post.media.height }}px"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<image width="100%" height="100%" href="{{ post.media.url }}"/>
|
||||
<desc>
|
||||
<img alt="Post image" src="{{ post.media.url }}"/>
|
||||
</dev>
|
||||
</svg>
|
||||
</a>
|
||||
{% else if post.post_type != "self" %}
|
||||
<a class="post_thumbnail {% if post.thumbnail.url.is_empty() %}no_thumbnail{% endif %}" href="{% if post.post_type == "link" %}{{ post.media.url }}{% else %}{{ post.permalink }}{% endif %}">
|
||||
{% if post.thumbnail.url.is_empty() %}
|
||||
<svg viewBox="0 0 100 106" width="140" height="53" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Thumbnail</title>
|
||||
<path d="M35,15h-15a10,10 0,0,0 0,20h25a10,10 0,0,0 10,-10m-12.5,0a10, 10 0,0,1 10, -10h25a10,10 0,0,1 0,20h-15" fill="none" stroke-width="5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
{% else %}
|
||||
<svg width="{{ post.thumbnail.width }}px" height="{{ post.thumbnail.height }}px" xmlns="http://www.w3.org/2000/svg">
|
||||
<image alt="Thumbnail" width="100%" height="100%" href="{{ post.thumbnail.url }}"/>
|
||||
</svg>
|
||||
{% endif %}
|
||||
<span>{% if post.post_type == "link" %}{{ post.domain }}{% else %}{{ post.post_type }}{% endif %}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<div class="post_score">{{ post.score }}<span class="label"> Upvotes</span></div>
|
||||
<div class="post_footer">
|
||||
<a href="{{ post.permalink }}" class="post_comments">{{ post.comments }} comments</a>
|
||||
</div>
|
||||
</div>
|
||||
{% call utils::post_in_list(post) %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
@ -126,12 +85,12 @@
|
||||
<div>{{ sub.active }}</div>
|
||||
</div>
|
||||
<div id="sub_subscription">
|
||||
{% if prefs.subs.contains(sub.name) %}
|
||||
<form action="/r/{{ sub.name }}/unsubscribe" method="POST">
|
||||
{% if prefs.subscriptions.contains(sub.name) %}
|
||||
<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 %}
|
||||
|
@ -33,54 +33,7 @@
|
||||
|
||||
{% if post.flags.nsfw && prefs.show_nsfw != "on" %}
|
||||
{% else if post.title != "Comment" %}
|
||||
<div class="post {% if post.flags.stickied %}stickied{% endif %}">
|
||||
<p class="post_header">
|
||||
<a class="post_subreddit" href="/r/{{ post.community }}">r/{{ post.community }}</a>
|
||||
<span class="dot">•</span>
|
||||
<a class="post_author" href="/u/{{ post.author.name }}">u/{{ post.author.name }}</a>
|
||||
<span class="dot">•</span>
|
||||
<span class="created" title="{{ post.created }}">{{ post.rel_time }}</span>
|
||||
</p>
|
||||
<p class="post_title">
|
||||
{% if post.flair.flair_parts.len() > 0 %}
|
||||
<small class="post_flair" style="color:{{ post.flair.foreground_color }}; background:{{ post.flair.background_color }};">{% call utils::render_flair(post.flair.flair_parts) %}</small>
|
||||
{% endif %}
|
||||
<a href="{{ post.permalink }}">{{ post.title }}</a>{% if post.flags.nsfw %} <small class="nsfw">NSFW</small>{% endif %}
|
||||
</p>
|
||||
<!-- POST MEDIA/THUMBNAIL -->
|
||||
{% if (prefs.layout.is_empty() || prefs.layout == "card") && post.post_type == "image" %}
|
||||
<a href="{{ post.media.url }}" style="display:contents" >
|
||||
<svg class="post_media {% if post.media.height / post.media.width < 2 %}short{% endif %}"
|
||||
width="{{ post.media.width }}px"
|
||||
height="{{ post.media.height }}px"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<image width="100%" height="100%" href="{{ post.media.url }}"/>
|
||||
<desc>
|
||||
<img alt="Post image" src="{{ post.media.url }}"/>
|
||||
</dev>
|
||||
</svg>
|
||||
</a>
|
||||
{% else if post.post_type != "self" %}
|
||||
<a class="post_thumbnail {% if post.thumbnail.url.is_empty() %}no_thumbnail{% endif %}" href="{% if post.post_type == "link" %}{{ post.media.url }}{% else %}{{ post.permalink }}{% endif %}">
|
||||
{% if post.thumbnail.url.is_empty() %}
|
||||
<svg viewBox="0 0 100 106" width="140" height="53" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Thumbnail</title>
|
||||
<path d="M35,15h-15a10,10 0,0,0 0,20h25a10,10 0,0,0 10,-10m-12.5,0a10, 10 0,0,1 10, -10h25a10,10 0,0,1 0,20h-15" fill="none" stroke-width="5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
{% else %}
|
||||
<svg width="{{ post.thumbnail.width }}px" height="{{ post.thumbnail.height }}px" xmlns="http://www.w3.org/2000/svg">
|
||||
<image alt="Thumbnail" width="100%" height="100%" href="{{ post.thumbnail.url }}"/>
|
||||
</svg>
|
||||
{% endif %}
|
||||
<span>{% if post.post_type == "link" %}{{ post.domain }}{% else %}{{ post.post_type }}{% endif %}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<div class="post_score">{{ post.score }}<span class="label"> Upvotes</span></div>
|
||||
<div class="post_footer">
|
||||
<a href="{{ post.permalink }}" class="post_comments">{{ post.comments }} comments</a>
|
||||
</div>
|
||||
</div>
|
||||
{% call utils::post_in_list(post) %}
|
||||
{% else %}
|
||||
<div class="comment">
|
||||
<div class="comment_left">
|
||||
|
@ -1,7 +1,7 @@
|
||||
{% macro options(current, values, default) -%}
|
||||
{% for value in values %}
|
||||
<option value="{{ value }}" {% if current == value || (current == "" && value == default) %}selected{% endif %}>
|
||||
{{ format!("{}{}", value.get(0..1).unwrap().to_uppercase(), value.get(1..).unwrap()) }}
|
||||
{{ format!("{}{}", value.get(0..1).unwrap_or_default().to_uppercase(), value.get(1..).unwrap_or_default()) }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
{%- endmacro %}
|
||||
@ -9,7 +9,7 @@
|
||||
{% macro sort(root, methods, selected) -%}
|
||||
{% for method in methods %}
|
||||
<a {% if method == selected %}class="selected"{% endif %} href="{{ root }}/{{ method }}">
|
||||
{{ format!("{}{}", method.get(0..1).unwrap().to_uppercase(), method.get(1..).unwrap()) }}
|
||||
{{ format!("{}{}", method.get(0..1).unwrap_or_default().to_uppercase(), method.get(1..).unwrap_or_default()) }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
{%- endmacro %}
|
||||
@ -20,7 +20,7 @@
|
||||
{% if root != "/r/" && !root.is_empty() %}
|
||||
<div id="inside">
|
||||
<input type="checkbox" name="restrict_sr" id="restrict_sr">
|
||||
<label for="restrict_sr" class="search_label">in {{ root }}</label>
|
||||
<label for="restrict_sr" class="search_label" title="Restrict search to this subreddit">in {{ root }}</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
<button class="submit">
|
||||
@ -33,22 +33,84 @@
|
||||
</form>
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro render_flair(flair) -%}
|
||||
{% for flair_part in flair %}
|
||||
{% if flair_part.flair_part_type == "emoji" %}<span class="emoji" style="background-image:url('{{ flair_part.value }}');"></span>
|
||||
{% else if flair_part.flair_part_type == "text" %}<span>{{ flair_part.value }}</span>{% endif %}
|
||||
{% endfor %}
|
||||
{% macro render_flair(flair_parts) -%}
|
||||
{% for flair_part in flair_parts %}{% if flair_part.flair_part_type == "emoji" %}<span class="emoji" style="background-image:url('{{ flair_part.value }}');"></span>{% else if flair_part.flair_part_type == "text" && !flair_part.value.is_empty() %}<span>{{ flair_part.value }}</span>{% endif %}{% endfor %}
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro sub_list(current) -%}
|
||||
{% if prefs.subs.len() > 0 %}
|
||||
<details id="subscriptions">
|
||||
<summary>Subscriptions</summary>
|
||||
<div id="sub_list">
|
||||
{% for sub in prefs.subs %}
|
||||
{% if prefs.subscriptions.len() > 0 %}
|
||||
<details id="feeds">
|
||||
<summary>Feeds</summary>
|
||||
<div id="feed_list">
|
||||
<p>MAIN FEEDS</p>
|
||||
<a href="/">Home</a>
|
||||
<a href="/r/popular">Popular</a>
|
||||
<a href="/r/all">All</a>
|
||||
<p>REDDIT FEEDS</p>
|
||||
{% for sub in prefs.subscriptions %}
|
||||
<a href="/r/{{ sub }}" {% if sub == current %}class="selected"{% endif %}>{{ sub }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro post_in_list(post) -%}
|
||||
<div class="post {% if post.flags.stickied %}stickied{% endif %}">
|
||||
<p class="post_header">
|
||||
<a class="post_subreddit" href="/r/{{ post.community }}">r/{{ post.community }}</a>
|
||||
<span class="dot">•</span>
|
||||
<a class="post_author" href="/u/{{ post.author.name }}">u/{{ post.author.name }}</a>
|
||||
<span class="dot">•</span>
|
||||
<span class="created" title="{{ post.created }}">{{ post.rel_time }}</span>
|
||||
</p>
|
||||
<p class="post_title">
|
||||
{% if post.flair.flair_parts.len() > 0 %}
|
||||
<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 %}
|
||||
<a href="{{ post.permalink }}">{{ post.title }}</a>{% if post.flags.nsfw %} <small class="nsfw">NSFW</small>{% endif %}
|
||||
</p>
|
||||
<!-- POST MEDIA/THUMBNAIL -->
|
||||
{% if (prefs.layout.is_empty() || prefs.layout == "card") && post.post_type == "image" %}
|
||||
<a href="{{ post.media.url }}" class="post_media_image {% if post.media.height / post.media.width < 2 %}short{% endif %}" >
|
||||
<svg
|
||||
width="{{ post.media.width }}px"
|
||||
height="{{ post.media.height }}px"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<image width="100%" height="100%" href="{{ post.media.url }}"/>
|
||||
<desc>
|
||||
<img alt="Post image" src="{{ post.media.url }}"/>
|
||||
</desc>
|
||||
</svg>
|
||||
</a>
|
||||
{% else if (prefs.layout.is_empty() || prefs.layout == "card") && post.post_type == "gif" %}
|
||||
<video class="post_media_video short" src="{{ post.media.url }}" width="{{ post.media.width }}px" height="{{ post.media.height }}px" 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>
|
||||
{% else if post.post_type != "self" %}
|
||||
<a class="post_thumbnail {% if post.thumbnail.url.is_empty() %}no_thumbnail{% endif %}" href="{% if post.post_type == "link" %}{{ post.media.url }}{% else %}{{ post.permalink }}{% endif %}">
|
||||
{% if post.thumbnail.url.is_empty() %}
|
||||
<svg viewBox="0 0 100 106" width="140" height="53" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Thumbnail</title>
|
||||
<path d="M35,15h-15a10,10 0,0,0 0,20h25a10,10 0,0,0 10,-10m-12.5,0a10, 10 0,0,1 10, -10h25a10,10 0,0,1 0,20h-15" fill="none" stroke-width="5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
{% else %}
|
||||
<svg width="{{ post.thumbnail.width }}px" height="{{ post.thumbnail.height }}px" xmlns="http://www.w3.org/2000/svg">
|
||||
<image width="100%" height="100%" href="{{ post.thumbnail.url }}"/>
|
||||
<desc>
|
||||
<img alt="Thumbnail" src="{{ post.thumbnail.url }}"/>
|
||||
</desc>
|
||||
</svg>
|
||||
{% endif %}
|
||||
<span>{% if post.post_type == "link" %}{{ post.domain }}{% else %}{{ post.post_type }}{% endif %}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<div class="post_score">{{ post.score }}<span class="label"> Upvotes</span></div>
|
||||
<div class="post_footer">
|
||||
<a href="{{ post.permalink }}" class="post_comments">{{ post.comments }} comments</a>
|
||||
</div>
|
||||
</div>
|
||||
{%- endmacro %}
|
||||
|
Reference in New Issue
Block a user