[add] user profiles

[fix] don't allow /api/url or /api/id on pastes with "private_source" set to "on"
[add] "SITE_NAME" environment variable
[add] booklist ban
This commit is contained in:
hkau 2024-01-23 18:47:10 -05:00
parent 8a3ecd8930
commit 9c9beb4055
13 changed files with 286 additions and 18 deletions

4
.gitignore vendored
View file

@ -10,5 +10,5 @@ node_modules/
# env
.env
# test
git_repos/
# testing
booklist.txt

View file

@ -3,7 +3,7 @@ name = "bundlrs"
authors = ["hkau"]
license = "MIT"
version = "0.5.2"
version = "0.6.0"
edition = "2021"
rust-version = "1.75"

View file

@ -44,6 +44,7 @@ Environment variables:
- `DB_USER "user"` **required** (only if `--db-type` is `postgres` or `mysql`)
- `DB_PASS "pass"` **required** (only if `--db-type` is `postgres` or `mysql`)
- `DB_NAME "name"` **required** (only if `--db-type` is `postgres` or `mysql`)
- `SITE_NAME "name"` optional (defaults to `Bundlrs`)
## Features

View file

@ -297,6 +297,19 @@ pub async fn get_from_url_request(
let res: bundlesdb::DefaultReturn<Option<bundlesdb::Paste<String>>> =
data.db.get_paste_by_url(custom_url).await;
// if res.metadata contains '"private_source":"on"', return NotFound
if res.payload.is_some()
&& res
.clone()
.payload
.unwrap()
.metadata
.contains("\"private_source\":\"on\",")
{
return HttpResponse::NotFound()
.body("You do not have permission to view this paste's contents.");
}
// return
return HttpResponse::Ok()
.append_header(("Content-Type", "application/json"))
@ -317,6 +330,19 @@ pub async fn get_from_id_request(
let res: bundlesdb::DefaultReturn<Option<bundlesdb::Paste<String>>> =
data.db.get_paste_by_id(id).await;
// if res.metadata contains '"private_source":"on"', return NotFound
if res.payload.is_some()
&& res
.clone()
.payload
.unwrap()
.metadata
.contains("\"private_source\":\"on\",")
{
return HttpResponse::NotFound()
.body("You do not have permission to view this paste's contents.");
}
// return
return HttpResponse::Ok()
.append_header(("Content-Type", "application/json"))

62
src/booklist.rs Normal file
View file

@ -0,0 +1,62 @@
// the "booklist" is a file stored in the cwd which contains a list of blocked URLs
// these URLs can be claimed, but will always return the same dummy record when fetched from the database
// example booklist:
//
// ;
// url1;url2;url3
//
// example booklist 2:
//
// ,
// url1, url2, url3
//
// the first line of a booklist defines the separator (\n works)
use std::fs;
#[allow(dead_code)]
#[derive(Debug)]
struct BookList {
separator: String,
full: String,
}
#[allow(dead_code)]
fn fetch_booklist() -> BookList {
let content = fs::read_to_string(format!("booklist.txt"));
if content.is_err() {
return BookList {
separator: String::new(),
full: String::new(),
};
}
// get sep
let content = content.unwrap();
let sep = &content.lines().next();
// return
return BookList {
separator: if sep.is_some() {
sep.unwrap().to_string()
} else {
String::from("\n")
},
full: content,
};
}
pub fn check_booklist(looking_for: &String) -> bool {
// load booklist
let list = fetch_booklist();
// check for word
let split: Vec<String> = list
.full
.split(&list.separator)
.map(|s| s.to_string())
.collect::<Vec<String>>();
return split.contains(looking_for) | split.contains(&format!("\n{}", looking_for));
}

View file

@ -37,7 +37,7 @@ pub fn Footer(props: &FooterProps) -> Html {
</ul>
<p style="font-size: 12px; margin: 0.4rem 0 0 0;">
<a href="https://codeberg.org/SentryTwo/bundlrs">{"bundlrs"}</a>
<a href="https://codeberg.org/SentryTwo/bundlrs">{"::SITE_NAME::"}</a>
{" - Markdown Delivery Service"}
</p>

View file

@ -77,7 +77,7 @@ pub struct GroupMetadata {
pub owner: String, // custom_url of owner paste
}
#[derive(PartialEq, sqlx::FromRow, Clone, Serialize, Deserialize)]
#[derive(Default, PartialEq, sqlx::FromRow, Clone, Serialize, Deserialize)]
pub struct UserState {
// selectors
pub username: String,
@ -558,6 +558,39 @@ impl BundlesDB {
query: &str,
selector: &str,
) -> DefaultReturn<Option<Paste<String>>> {
// check if we're fetching a booklist url
let is_banned = crate::booklist::check_booklist(&selector.to_lowercase());
if is_banned == true {
return DefaultReturn {
success: true,
message: String::from("Paste exists (booklist)"),
payload: Option::Some(Paste {
custom_url: selector.to_string(),
id: String::new(),
group_name: String::new(),
edit_password: String::new(),
pub_date: 0,
edit_date: 0,
content: String::new(),
content_html: String::from(
"This custom URL has been blocked by the server booklist.txt file. This is an automatically generated body content.",
),
metadata: serde_json::to_string::<PasteMetadata>(&PasteMetadata {
owner: String::from(""),
private_source: String::from("on"),
title: Option::Some(String::new()),
description: Option::Some(String::new()),
favicon: Option::None,
embed_color: Option::None,
})
.unwrap(),
views: 0,
}),
};
}
// ...
let c = &self.db.client;
let res = sqlx::query(query)
.bind::<&String>(&selector.to_lowercase())

View file

@ -16,6 +16,8 @@ mod pages;
mod markdown;
mod ssm;
mod booklist;
use crate::db::bundlesdb::{AppData, BundlesDB};
use crate::db::sql::DatabaseOpts;
@ -122,6 +124,7 @@ async fn main() -> std::io::Result<()> {
// GET root
.service(crate::pages::home::home_request)
.service(crate::pages::home::robotstxt)
.service(crate::pages::auth::profile_view_request)
.service(crate::pages::paste_view::paste_view_request) // must be run last as it matches all other paths!
// ERRORS
.default_service(web::to(|| async {

View file

@ -1,11 +1,22 @@
use actix_web::{get, HttpRequest, HttpResponse, Responder};
use actix_web::HttpResponse;
use actix_web::{get, web, HttpRequest, Responder};
use yew::prelude::*;
use yew::ServerRenderer;
use crate::components::navigation::Footer;
use crate::db::bundlesdb::{self, AppData, UserState};
use crate::utility;
use crate::utility::format_html;
use crate::components::navigation::Footer;
#[derive(Default, Properties, PartialEq)]
struct Props {
pub user: UserState,
pub paste_count: usize,
pub auth_state: Option<bool>,
}
#[function_component]
fn Register() -> Html {
return html! {
@ -102,7 +113,7 @@ pub async fn register_request(req: HttpRequest) -> impl Responder {
.append_header(("Content-Type", "text/html"))
.body(format_html(
renderer.render().await,
"<title>Register - Bundlrs</title>",
"<title>Register - ::SITE_NAME::</title>",
));
}
@ -118,6 +129,127 @@ pub async fn login_request(req: HttpRequest) -> impl Responder {
.append_header(("Content-Type", "text/html"))
.body(format_html(
renderer.render().await,
"<title>Login - Bundlrs</title>",
"<title>Login - ::SITE_NAME::</title>",
));
}
#[function_component]
fn ProfileView(props: &Props) -> Html {
return html! {
<main class="small flex flex-column g-4">
<div class="flex justify-space-between align-center">
<h1 class="no-margin">{"~"}{&props.user.username}</h1>
</div>
<div class="card secondary round">
<ul>
<li>{"Role: "}<span class="chip badge">{&props.user.role}</span></li>
<li>{"Joined: "}<span class="date-time-to-localize">{&props.user.timestamp}</span></li>
<li>{"Paste count: "}{&props.paste_count}</li>
</ul>
<hr />
<details class="full round">
<summary>{"Developer Options"}</summary>
<div class="card">
<ul>
<li>{"Pastes API: "}<code>{"/api/owner/"}{&props.user.username}</code></li>
</ul>
</div>
</details>
</div>
<Footer auth_state={props.auth_state} />
</main>
};
}
fn build_renderer_with_props(props: Props) -> ServerRenderer<ProfileView> {
return ServerRenderer::<ProfileView>::with_props(|| props);
}
#[get("/~{username:.*}")]
pub async fn profile_view_request(req: HttpRequest, data: web::Data<AppData>) -> impl Responder {
// get paste
let username: String = req.match_info().get("username").unwrap().to_string();
let username_c = username.clone();
let user: bundlesdb::DefaultReturn<Option<UserState>> =
data.db.get_user_by_username(username).await;
if user.success == false {
let renderer = ServerRenderer::<crate::pages::errors::_404Page>::new();
return HttpResponse::NotFound()
.append_header(("Content-Type", "text/html"))
.body(utility::format_html(
renderer.render().await,
"<title>404: Not Found</title>",
));
}
let unwrap = user.payload.as_ref().unwrap();
// verify auth status
let token_cookie = req.cookie("__Secure-Token");
let mut set_cookie: &str = "";
let token_user = if token_cookie.is_some() {
Option::Some(
data.db
.get_user_by_hashed(token_cookie.as_ref().unwrap().value().to_string()) // if the user is returned, that means the ID is valid
.await,
)
} else {
Option::None
};
if token_user.is_some() {
// make sure user exists, refresh token if not
if token_user.as_ref().unwrap().success == false {
set_cookie = "__Secure-Token=refresh; SameSite=Strict; Secure; Path=/; HostOnly=true; HttpOnly=true; Max-Age=0";
}
}
// ...
let pastes_res: bundlesdb::DefaultReturn<Option<Vec<bundlesdb::PasteIdentifier>>> =
data.db.get_pastes_by_owner(username_c.clone()).await;
let renderer = build_renderer_with_props(Props {
user: unwrap.clone(),
paste_count: if pastes_res.success {
pastes_res.payload.unwrap().len()
} else {
0
},
auth_state: if req.cookie("__Secure-Token").is_some() {
Option::Some(req.cookie("__Secure-Token").is_some())
} else {
Option::Some(false)
},
});
let render = renderer.render();
return HttpResponse::Ok()
.append_header(("Set-Cookie", set_cookie))
.append_header(("Content-Type", "text/html"))
.body(format_html(
render.await,
&format!(
"<title>{}</title>
<meta property=\"og:url\" content=\"{}\" />
<meta property=\"og:title\" content=\"{}\" />
<meta property=\"og:description\" content=\"{}\" />",
&username_c,
&format!(
"{}{}",
req.headers().get("Host").unwrap().to_str().unwrap(),
req.head().uri.to_string()
),
// extras
&username_c,
format!("{} on ::SITE_NAME::", &username_c)
),
));
}

View file

@ -285,7 +285,7 @@ pub async fn home_request(
.append_header(("Content-Type", "text/html"))
.body(format_html(
renderer.render().await,
"<title>Bundlrs</title>
"<title>::SITE_NAME::</title>
<meta property=\"og:title\" content=\"Create a new paste...\" />
<meta property=\"og:description\" content=\"Bundlrs, the open-source Rust rewrite of Bundles.\" />",
));

View file

@ -58,7 +58,7 @@ fn PasteView(props: &Props) -> Html {
</span>
if &metadata.owner.is_empty() == &false {
<span>{"Owner: "} <span id="data-time-to-localize">{&metadata.owner}</span></span>
<span>{"Owner: "} <a href={format!("/~{}", &metadata.owner)}>{&metadata.owner}</a></span>
}
<span>{"Views: "}{&props.paste.views}</span>
@ -122,9 +122,11 @@ pub async fn paste_view_request(req: HttpRequest, data: web::Data<AppData>) -> i
// count view (this will check for an existing view!)
let payload = &token_user.as_ref().unwrap().payload;
data.db
.add_view_to_url(&url_c, &payload.as_ref().unwrap().username)
.await;
if payload.as_ref().is_some() {
data.db
.add_view_to_url(&url_c, &payload.as_ref().unwrap().username)
.await;
}
}
// ...

View file

@ -124,7 +124,7 @@ pub async fn user_settings_request(req: HttpRequest, data: web::Data<AppData>) -
.body(format_html(
render.await,
"<title>User Settings</title>
<meta property=\"og:title\" content=\"User Settings - Bundlrs\" />",
<meta property=\"og:title\" content=\"User Settings - ::SITE_NAME::\" />",
));
}
@ -180,7 +180,7 @@ pub async fn paste_settings_request(req: HttpRequest, data: web::Data<AppData>)
render.await,
&format!(
"<title>{}</title>
<meta property=\"og:title\" content=\"{} (paste settings) - Bundlrs\" />",
<meta property=\"og:title\" content=\"{} (paste settings) - ::SITE_NAME::\" />",
&url_c, &url_c
),
));

View file

@ -4,6 +4,8 @@ use hex_fmt::HexFmt;
use sha2::{Digest, Sha256};
use uuid::Uuid;
use crate::config;
// ids
#[allow(dead_code)]
pub fn uuid() -> String {
@ -43,6 +45,9 @@ pub fn format_html(input: String, head: &str) -> String {
String::new()
};
// ...
let site_name = config::get_var("SITE_NAME");
// ...
return format!(
"<!DOCTYPE html>
@ -55,7 +60,7 @@ pub fn format_html(input: String, head: &str) -> String {
{}
<meta name=\"theme-color\" content=\"#ff9999\" />
<meta property=\"og:type\" content=\"website\" />
<meta property=\"og:site_name\" content=\"bundlrs\" />
<meta property=\"og:site_name\" content=\"::SITE_NAME::\" />
{head}
<link rel=\"stylesheet\" href=\"/static/style.css\" />
@ -73,5 +78,9 @@ pub fn format_html(input: String, head: &str) -> String {
""
}
)
.to_string();
.to_string().replace("::SITE_NAME::", if site_name.is_some() {
site_name.unwrap()
} else {
"Bundlrs".to_string()
}.as_str());
}