TODO: pull paste dates and view count
This commit is contained in:
hkau 2024-03-31 18:55:36 -04:00
commit 3007763d2b
40 changed files with 5085 additions and 0 deletions

12
.gitignore vendored Normal file
View File

@ -0,0 +1,12 @@
/target
/static/css
Cargo.lock
*.db
# js
node_modules/
/static/js
bun.lockb
# env
.env

43
Cargo.toml Normal file
View File

@ -0,0 +1,43 @@
[package]
name = "paws-proxy"
authors = ["hkau"]
license = "MIT"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[features]
postgres = []
mysql = []
sqlite = []
default = ["sqlite"]
[dependencies]
actix-cors = "0.7.0"
actix-files = "0.6.5"
actix-web = "4.5.1"
askama = "0.12.1"
awc = { version = "3.4.0", features = ["rustls"] }
comrak = "0.22.0"
dotenv = "0.15.0"
env_logger = "0.11.3"
handlebars = "5.1.2"
hex_fmt = "0.3.0"
redis = "0.25.2"
regex = "1.10.4"
serde = "1.0.197"
serde_json = "1.0.115"
sha2 = "0.10.8"
sqlx = { version = "0.7.3", features = [
"sqlite",
"postgres",
"mysql",
"any",
"runtime-tokio",
"tls-native-tls",
] }
toml = "0.8.12"
urlencoding = "2.1.3"
uuid = { version = "1.8.0", features = ["v4"] }

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 hkau
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

47
README.md Normal file
View File

@ -0,0 +1,47 @@
# 🐾 paws
*Paws is a simple Markdown pastebin proxy!*
> User authentication is handled by [Guppy](https://code.stellular.org/stellular/guppy)!
Guppy only supports the board-orientated markup features of Bundlrs.
**Guppy is designed to be able to interface with the same database as a Bundlrs instance!** It will use existing users from the `Users` table and existing posts from the `Logs` table.
## Configuration
Paws requires the following environment variables to integrate with the other required services:
* `GUPPY_ROOT` - root address of the guppy server
* `BUNDLRS_ROOT` - root address of the bundlrs server (used for markdown rendering since *some* pastebins use a CSRF token)
The rest of the configuration (for databases) can be found in the [Bundlrs README](https://code.stellular.org/stellular/bundlrs/src/branch/master/README.md).
## Example Paw
Paws uses simple "paw" files to transcribe API endpoints of different services to one unified API. Paws (referring to the files) are written in [TOML](https://toml.io), so they should be easy enough to write manually. You just need a few simple endpoints!
* `get` - Get existing paste
* `new` - Create new paste
* `edit` - Edit existing paste
* `delete` - Delete existing paste
* `render` - Render Markdown content
* `exists` - Check if a custom URL is already taken
You can provide a translation guide in the endpoint description that allows Paws (referring to the server) to transcribe its endpoints to the endpoints of the target platform with a simple syntax:
You can use `?` to represent an API endpoint parameter field (filled in order):
```toml
...
# /api/new
body = "{ \"custom_url\":\"?\", \"edit_password\":\"?\", ... }"
```
Aaaand that's it! That's all you really need to complete simple API endpoints.
Example paws are included in the `examples/paws/` directory.
## License
You can view the Paws license [here](https://code.stellular.org/stellular/paws/src/branch/master/LICENSE). Paws is not meant to circumvent measures set by given platforms and is only a fun experiment!

View File

@ -0,0 +1,4 @@
is_fake_raw = true
csrf = false
strip_unused = true
escape = true

View File

@ -0,0 +1,29 @@
[get]
path = "/api/url/?"
body = ""
content_type = ""
[new]
path = "/api/new"
body = "{\"custom_url\":\"?\",\"edit_password\":\"?\",\"content\":\"?\"}"
content_type = "application/json"
[edit]
path = "/api/edit"
body = "{\"custom_url\":\"?\",\"edit_password\":\"?\",\"content\":\"?\",\"new_custom_url\":\"?\",\"new_edit_password\":\"?\"}"
content_type = "application/json"
[delete]
path = "/api/delete"
body = "{\"custom_url\":\"?\",\"edit_password\":\"?\"}"
content_type = "application/json"
[render]
path = "/api/markdown"
body = "{\"text\":\"?\"}"
content_type = "application/json"
[exists]
path = "/api/exists/?"
body = ""
content_type = ""

View File

@ -0,0 +1,4 @@
is_fake_raw = true
csrf = true
strip_unused = false
escape = false

View File

@ -0,0 +1,29 @@
[get]
path = "/api/raw/?"
body = ""
content_type = ""
[new]
path = ""
body = "url=?&edit_code=?&text=?&csrfmiddlewaretoken=?"
content_type = "application/x-www-form-urlencoded"
[edit]
path = "/?/edit"
body = "edit_code=?&text=?&new_edit_code=?&new_url=?&csrfmiddlewaretoken=?"
content_type = "application/x-www-form-urlencoded"
[delete]
path = "/?/edit"
body = "edit_code=?&text=&new_edit_code=&new_url=&delete=delete&csrfmiddlewaretoken=?"
content_type = "application/x-www-form-urlencoded"
[render]
path = "/markdownx/markdownify/"
body = "-----------------------------28503205994244531566424312417\r\nContent-Disposition: form-data; name=\"csrfmiddlewaretoken\"\r\n\r\n::CSRF_HERE::\r\n-----------------------------28503205994244531566424312417\r\nContent-Disposition: form-data; name=\"content\"\r\n\r\n?\r\n-----------------------------28503205994244531566424312417--\r\n"
content_type = "multipart/form-data; boundary=---------------------------28503205994244531566424312417"
[exists]
path = "/?/exists"
body = ""
content_type = ""

26
justfile Normal file
View File

@ -0,0 +1,26 @@
build database="sqlite":
just docs
just styles
bun i
bun run static_build.ts
cargo build -r --no-default-features --features {{database}}
docs:
cargo doc --no-deps --document-private-items
test:
just docs
bun run static_build.ts
cargo run
run:
chmod +x ./target/release/paws-proxy
./target/release/paws-proxy
styles:
wget https://codeberg.org/api/packages/hkau/npm/fusion/-/1.0.11/fusion-1.0.11.tgz -O fusion.tgz
tar -xzf fusion.tgz
mv ./package/src/css ./static/css
sed -i -e 's/\"\/utility.css\"/\"\/static\/css\/utility.css\"/' ./static/css/fusion.css
rm -r ./package
rm ./fusion.tgz

23
package.json Normal file
View File

@ -0,0 +1,23 @@
{
"name": "pufferbb",
"module": "index.ts",
"type": "module",
"devDependencies": {
"@types/bun": "latest",
"@types/htmlhint": "^1.1.5"
},
"peerDependencies": {
"typescript": "^5.0.0"
},
"dependencies": {
"@codemirror/autocomplete": "^6.12.0",
"@codemirror/commands": "^6.3.3",
"@codemirror/lang-css": "^6.2.1",
"@codemirror/lang-markdown": "^6.2.3",
"@codemirror/language": "^6.10.0",
"@codemirror/state": "^6.4.0",
"@codemirror/view": "^6.23.0",
"codemirror": "^6.0.1",
"highlight.js": "^11.9.0"
}
}

54
src/api/auth.rs Normal file
View File

@ -0,0 +1,54 @@
use actix_web::{get, web, HttpRequest, HttpResponse, Responder};
use crate::db::pawsdb::AppData;
#[derive(Default, PartialEq, serde::Deserialize)]
pub struct CallbackQueryProps {
pub uid: Option<String>, // this uid will need to be sent to the client as a token
// the uid will also be sent to the client as a token on GUPPY_ROOT, meaning we'll have signed in here and there!
}
#[get("/api/auth/callback")]
pub async fn callback_request(info: web::Query<CallbackQueryProps>) -> impl Responder {
let set_cookie = if info.uid.is_some() {
format!("__Secure-Token={}; SameSite=Lax; Secure; Path=/; HostOnly=true; HttpOnly=true; Max-Age={}", info.uid.as_ref().unwrap(), 60 * 60 * 24 * 365)
} else {
String::new()
};
// return
return HttpResponse::Ok()
.append_header((
"Set-Cookie",
if info.uid.is_some() { &set_cookie } else { "" },
))
.append_header(("Content-Type", "text/html"))
.body(
"<head>
<meta http-equiv=\"Refresh\" content=\"0; URL=/d\" />
</head>",
);
}
#[get("/api/auth/logout")]
pub async fn logout(req: HttpRequest, data: web::Data<AppData>) -> impl Responder {
let cookie = req.cookie("__Secure-Token");
if cookie.is_none() {
return HttpResponse::NotAcceptable().body("Missing token");
}
let res = data
.db
.get_user_by_unhashed(cookie.unwrap().value().to_string()) // if the user is returned, that means the ID is valid
.await;
if !res.success {
return HttpResponse::NotAcceptable().body("Invalid token");
}
// return
return HttpResponse::Ok()
.append_header(("Set-Cookie", "__Secure-Token=refresh; SameSite=Strict; Secure; Path=/; HostOnly=true; HttpOnly=true; Max-Age=0"))
.append_header(("Content-Type", "text/plain"))
.body("You have been signed out. You can now close this tab.");
}

2
src/api/mod.rs Normal file
View File

@ -0,0 +1,2 @@
pub mod auth;
pub mod pastes;

632
src/api/pastes.rs Normal file
View File

@ -0,0 +1,632 @@
use crate::db::pawsdb::{AppData, DefaultReturn, Paw};
use actix_web::{get, post, web, HttpRequest, HttpResponse, Responder};
use awc::{cookie::Cookie, http::StatusCode, Client};
pub async fn render_markdown(http_client: Client, input: String, paw: Paw) -> Option<String> {
// render markdown
let mut endpoint = paw
.fill_endpoint(String::from("render"), [].to_vec(), [input].to_vec())
.unwrap();
// sucks to suck stupid csrf
// side note: using csrf on this form is completely useless and so we're just going to waste your resources here
let mut csrf: Cookie = Cookie::new("csrftoken", "");
if paw.config.csrf {
let res = http_client
.get(paw.target.clone())
.timeout(std::time::Duration::from_millis(5_000))
.insert_header((
"User-Agent",
"Mozilla/5.0 (X11; Linux x86_64; rv:124.0) Gecko/20100101 Firefox/124.0",
))
.insert_header(("referer", paw.target.clone()))
.send()
.await;
if res.is_err() {
return Option::None;
}
let cookie = res.unwrap().cookie("csrftoken"); // yoink
csrf = cookie.unwrap();
endpoint.body = endpoint.body.replacen("::CSRF_HERE::", &csrf.value(), 1);
}
// ...
let res = http_client
.post(endpoint.path)
.timeout(std::time::Duration::from_millis(20_000))
.insert_header((
"User-Agent",
"Mozilla/5.0 (X11; Linux x86_64; rv:124.0) Gecko/20100101 Firefox/124.0",
))
.insert_header(("Content-Type", endpoint.content_type))
.insert_header(("referer", paw.target.clone()))
.insert_header(("host", paw.target.replace("https://", ""))) // just for good measure
.cookie(csrf)
.send_body(endpoint.body)
.await;
if res.is_err() {
return Option::None;
}
// figure out what to do with content
let mut res = res.unwrap();
let body = res.body().limit(1_000_000).await;
if body.is_err() {
return Option::None;
}
// we're going to render this to string so we can manipulate it
let binding = body.unwrap();
let mut body = std::str::from_utf8(&binding).unwrap().to_string();
// allow some html
body = body.replace("&lt;style&gt;", "<style>");
body = body.replace("&lt;/style&gt;", "</style>");
// return
Option::Some(body)
}
#[derive(serde::Deserialize, serde::Serialize)]
struct RenderInfo {
text: String,
}
#[derive(serde::Deserialize, serde::Serialize)]
struct CreateInfo {
custom_url: String,
edit_password: String,
content: String,
}
#[derive(serde::Deserialize, serde::Serialize)]
struct EditInfo {
custom_url: String,
edit_password: String,
content: String,
new_custom_url: Option<String>,
new_edit_password: Option<String>,
}
#[derive(serde::Deserialize, serde::Serialize)]
struct DeleteInfo {
custom_url: String,
edit_password: String,
}
#[post("/api/{name:.*}/markdown")]
pub async fn render_request(
req: HttpRequest,
body: web::Json<RenderInfo>,
data: web::Data<AppData>,
) -> impl Responder {
// get paw
let name: String = req.match_info().get("name").unwrap().to_string();
let paw = data.db.get_paw_by_name(name).await;
if paw.success == false {
return HttpResponse::NotFound()
.append_header(("Content-Type", "text/plain"))
.body("404: Not Found");
}
let paw = paw.payload.unwrap();
// render
let res = render_markdown(data.http_client.clone(), body.text.clone(), paw).await;
if res.is_none() {
return HttpResponse::NotFound().body("Failed to fetch");
}
// this should return just plain html, so we can go ahead and return it
return HttpResponse::Ok()
.append_header(("Content-Type", "text/html"))
.body(res.unwrap());
}
#[post("/api/{name:.*}/new")]
pub async fn create_request(
req: HttpRequest,
body: web::Json<CreateInfo>,
data: web::Data<AppData>,
) -> impl Responder {
// get paw
let name: String = req.match_info().get("name").unwrap().to_string();
let paw = data.db.get_paw_by_name(name).await;
if paw.success == false {
return HttpResponse::NotFound()
.append_header(("Content-Type", "text/plain"))
.body("404: Not Found");
}
let paw = paw.payload.unwrap();
// create paste
let edit_password = if body.edit_password.is_empty() {
crate::utility::random_id()
} else {
body.edit_password.clone()
};
let custom_url = if body.custom_url.is_empty() {
crate::utility::random_id()
} else {
body.custom_url.clone()
};
let mut endpoint = paw
.fill_endpoint(
String::from("new"),
[].to_vec(),
[
custom_url.clone(),
edit_password.clone(),
body.content.clone(),
String::from("::CSRF_HERE::"),
]
.to_vec(),
)
.unwrap();
// sucks to suck stupid csrf
// side note: using csrf on this form is completely useless and so we're just going to waste your resources here
let mut csrf: Cookie = Cookie::new("csrftoken", "");
if paw.config.csrf {
// fetch cookie
let res = data
.http_client
.get(paw.target.clone())
.timeout(std::time::Duration::from_millis(5_000))
.insert_header((
"User-Agent",
"Mozilla/5.0 (X11; Linux x86_64; rv:124.0) Gecko/20100101 Firefox/124.0",
))
.insert_header(("referer", paw.target.clone()))
.send()
.await;
if res.is_err() {
return HttpResponse::NotFound().body(format!(
"Failed to fetch paste on server: {}",
res.err().unwrap()
));
}
let mut res = res.unwrap();
let cookie = res.cookie("csrftoken"); // yoink
csrf = cookie.unwrap(); // fill cookie, csrf will need both the cookie and the second part (taken below) to be present in the request!
// fill body content
let body_ = res.body().limit(1_000_000).await;
if body_.is_err() {
return HttpResponse::NotFound().body("Failed to fetch");
}
let binding = body_.unwrap();
let body_ = std::str::from_utf8(&binding).unwrap();
// pull request token from page body
let regex = regex::RegexBuilder::new("name=\"csrfmiddlewaretoken\" value=\"(.*?)\"")
.multi_line(true)
.build()
.unwrap();
let cap = regex.captures_at(body_, 0);
// cap.is_cap()
if cap.is_none() {
return HttpResponse::NotFound().body("Failed to fetch (couldn't take csrf token)");
}
let content = cap.unwrap().get(1).unwrap();
// fill token for request
endpoint.body = endpoint
.body
.replacen("::CSRF_HERE::", &content.as_str(), 1);
}
// ...
let res = data
.http_client
.post(endpoint.path)
.timeout(std::time::Duration::from_millis(20_000))
.insert_header((
"User-Agent",
"Mozilla/5.0 (X11; Linux x86_64; rv:124.0) Gecko/20100101 Firefox/124.0",
))
.insert_header(("Content-Type", endpoint.content_type))
.insert_header(("referer", paw.target.clone()))
.insert_header(("host", paw.target.replace("https://", ""))) // just for good measure
.cookie(csrf)
.send_body(endpoint.body)
.await;
if res.is_err() {
return HttpResponse::NotFound().body(format!(
"Failed to create paste on server: {}",
res.err().unwrap()
));
}
// we're going to do nothing with the content, now that we know it worked we're out of here!
return HttpResponse::Ok().body(
serde_json::to_string::<DefaultReturn<CreateInfo>>(&DefaultReturn {
success: true,
message: edit_password.clone(),
payload: CreateInfo {
custom_url,
edit_password,
content: body.content.clone(),
},
})
.unwrap(),
);
}
#[post("/api/{name:.*}/edit")]
pub async fn edit_request(
req: HttpRequest,
body: web::Json<EditInfo>,
data: web::Data<AppData>,
) -> impl Responder {
// get paw
let name: String = req.match_info().get("name").unwrap().to_string();
let paw = data.db.get_paw_by_name(name).await;
if paw.success == false {
return HttpResponse::NotFound()
.append_header(("Content-Type", "text/plain"))
.body("404: Not Found");
}
let paw = paw.payload.unwrap();
// edit paste
let new_edit_password = if body.new_edit_password.is_none() {
""
} else {
body.new_edit_password.as_ref().unwrap()
};
let new_custom_url = if body.new_custom_url.is_none() {
""
} else {
body.new_custom_url.as_ref().unwrap()
};
let mut endpoint = paw
.fill_endpoint(
String::from("edit"),
[body.custom_url.clone()].to_vec(),
[
body.custom_url.clone(),
body.edit_password.clone(),
body.content.clone(),
new_custom_url.to_string(),
new_edit_password.to_string(),
String::from("::CSRF_HERE::"),
]
.to_vec(),
)
.unwrap();
// handle strip_unused
if paw.config.strip_unused == true {
if new_edit_password.is_empty() {
endpoint.body = endpoint.body.replace("new_edit_password", "_unused_field");
endpoint.body = endpoint.body.replace("new_edit_code", "_unused_field");
}
if new_custom_url.is_empty() {
endpoint.body = endpoint.body.replace("new_custom_url", "_unused_field");
endpoint.body = endpoint.body.replace("new_url", "_unused_field");
}
}
// sucks to suck stupid csrf
// side note: using csrf on this form is completely useless and so we're just going to waste your resources here
let mut csrf: Cookie = Cookie::new("csrftoken", "");
if paw.config.csrf {
// fetch cookie
let res = data
.http_client
.get(paw.target.clone())
.timeout(std::time::Duration::from_millis(5_000))
.insert_header((
"User-Agent",
"Mozilla/5.0 (X11; Linux x86_64; rv:124.0) Gecko/20100101 Firefox/124.0",
))
.insert_header(("referer", paw.target.clone()))
.send()
.await;
if res.is_err() {
return HttpResponse::NotFound().body(format!(
"Failed to fetch paste on server: {}",
res.err().unwrap()
));
}
let mut res = res.unwrap();
let cookie = res.cookie("csrftoken"); // yoink
csrf = cookie.unwrap(); // fill cookie, csrf will need both the cookie and the second part (taken below) to be present in the request!
// fill body content
let body_ = res.body().limit(1_000_000).await;
if body_.is_err() {
return HttpResponse::NotFound().body("Failed to fetch");
}
let binding = body_.unwrap();
let body_ = std::str::from_utf8(&binding).unwrap();
// pull request token from page body
let regex = regex::RegexBuilder::new("name=\"csrfmiddlewaretoken\" value=\"(.*?)\"")
.multi_line(true)
.build()
.unwrap();
let cap = regex.captures_at(body_, 0);
// cap.is_cap()
if cap.is_none() {
return HttpResponse::NotFound().body("Failed to fetch (couldn't take csrf token)");
}
let content = cap.unwrap().get(1).unwrap();
// fill token for request
endpoint.body = endpoint
.body
.replacen("::CSRF_HERE::", &content.as_str(), 1);
}
// ...
let res = data
.http_client
.post(endpoint.path)
.timeout(std::time::Duration::from_millis(20_000))
.insert_header((
"User-Agent",
"Mozilla/5.0 (X11; Linux x86_64; rv:124.0) Gecko/20100101 Firefox/124.0",
))
.insert_header(("Content-Type", endpoint.content_type))
.insert_header(("referer", paw.target.clone()))
.insert_header(("host", paw.target.replace("https://", ""))) // just for good measure
.cookie(csrf)
.send_body(endpoint.body)
.await;
if res.is_err() | (res.as_ref().unwrap().status() != StatusCode::OK) {
return HttpResponse::NotAcceptable()
.append_header(("Content-Type", "application/json"))
.body(
serde_json::to_string::<DefaultReturn<String>>(&DefaultReturn {
success: false,
message: String::from("NOT ACCEPTABLE: Failed to edit paste on server"),
payload: new_custom_url.to_string(),
})
.unwrap(),
);
}
// we're going to do nothing with the content, now that we know it worked we're out of here!
let ncu = new_custom_url.to_string();
return HttpResponse::Ok()
.append_header(("Content-Type", "application/json"))
.body(
serde_json::to_string::<DefaultReturn<String>>(&DefaultReturn {
success: true,
message: String::from("Paste edited"),
payload: if ncu.is_empty() {
body.custom_url.clone()
} else {
ncu
},
})
.unwrap(),
);
}
#[post("/api/{name:.*}/delete")]
pub async fn delete_request(
req: HttpRequest,
body: web::Json<DeleteInfo>,
data: web::Data<AppData>,
) -> impl Responder {
// get paw
let name: String = req.match_info().get("name").unwrap().to_string();
let paw = data.db.get_paw_by_name(name).await;
if paw.success == false {
return HttpResponse::NotFound()
.append_header(("Content-Type", "text/plain"))
.body("404: Not Found");
}
let paw = paw.payload.unwrap();
// delete paste
let mut endpoint = paw
.fill_endpoint(
String::from("delete"),
[body.custom_url.clone()].to_vec(),
[
body.custom_url.clone(),
body.edit_password.clone(),
String::from("::CSRF_HERE::"),
]
.to_vec(),
)
.unwrap();
// sucks to suck stupid csrf
// side note: using csrf on this form is completely useless and so we're just going to waste your resources here
let mut csrf: Cookie = Cookie::new("csrftoken", "");
if paw.config.csrf {
// fetch cookie
let res = data
.http_client
.get(paw.target.clone())
.timeout(std::time::Duration::from_millis(5_000))
.insert_header((
"User-Agent",
"Mozilla/5.0 (X11; Linux x86_64; rv:124.0) Gecko/20100101 Firefox/124.0",
))
.insert_header(("referer", paw.target.clone()))
.send()
.await;
if res.is_err() {
return HttpResponse::NotFound().body(format!(
"Failed to fetch paste on server: {}",
res.err().unwrap()
));
}
let mut res = res.unwrap();
let cookie = res.cookie("csrftoken"); // yoink
csrf = cookie.unwrap(); // fill cookie, csrf will need both the cookie and the second part (taken below) to be present in the request!
// fill body content
let body_ = res.body().limit(1_000_000).await;
if body_.is_err() {
return HttpResponse::NotFound().body("Failed to fetch");
}
let binding = body_.unwrap();
let body_ = std::str::from_utf8(&binding).unwrap();
// pull request token from page body
let regex = regex::RegexBuilder::new("name=\"csrfmiddlewaretoken\" value=\"(.*?)\"")
.multi_line(true)
.build()
.unwrap();
let cap = regex.captures_at(body_, 0);
// cap.is_cap()
if cap.is_none() {
return HttpResponse::NotFound().body("Failed to fetch (couldn't take csrf token)");
}
let content = cap.unwrap().get(1).unwrap();
// fill token for request
endpoint.body = endpoint
.body
.replacen("::CSRF_HERE::", &content.as_str(), 1);
}
// ...
let res = data
.http_client
.post(endpoint.path)
.timeout(std::time::Duration::from_millis(20_000))
.insert_header((
"User-Agent",
"Mozilla/5.0 (X11; Linux x86_64; rv:124.0) Gecko/20100101 Firefox/124.0",
))
.insert_header(("Content-Type", endpoint.content_type))
.insert_header(("referer", paw.target.clone()))
.insert_header(("host", paw.target.replace("https://", ""))) // just for good measure
.cookie(csrf)
.send_body(endpoint.body)
.await;
if res.is_err() | (res.as_ref().unwrap().status() != StatusCode::OK) {
return HttpResponse::NotAcceptable()
.append_header(("Content-Type", "application/json"))
.body(
serde_json::to_string::<DefaultReturn<String>>(&DefaultReturn {
success: false,
message: String::from("NOT ACCEPTABLE: Failed to delete paste on server"),
payload: String::new(),
})
.unwrap(),
);
}
// we're going to do nothing with the content, now that we know it worked we're out of here!
return HttpResponse::Ok()
.append_header(("Content-Type", "application/json"))
.body(
serde_json::to_string::<DefaultReturn<String>>(&DefaultReturn {
success: true,
message: String::from("Paste deleted"),
payload: String::new(),
})
.unwrap(),
);
}
#[get("/api/{name:.*}/exists/{custom_url:.*}")]
pub async fn exists_request(req: HttpRequest, data: web::Data<AppData>) -> impl Responder {
// get paw
let name: String = req.match_info().get("name").unwrap().to_string();
let custom_url: String = req.match_info().get("custom_url").unwrap().to_string();
let paw = data.db.get_paw_by_name(name).await;
if paw.success == false {
return HttpResponse::NotFound()
.append_header(("Content-Type", "text/plain"))
.body("404: Not Found");
}
let paw = paw.payload.unwrap();
// get endpoint
let endpoint = paw
.fill_endpoint(
String::from("exists"),
[custom_url.clone()].to_vec(),
[].to_vec(),
)
.unwrap();
// ...
let res = data
.http_client
.get(endpoint.path)
.timeout(std::time::Duration::from_millis(20_000))
.insert_header((
"User-Agent",
"Mozilla/5.0 (X11; Linux x86_64; rv:124.0) Gecko/20100101 Firefox/124.0",
))
.insert_header(("Content-Type", endpoint.content_type))
.insert_header(("referer", paw.target.clone()))
.insert_header(("host", paw.target.replace("https://", ""))) // just for good measure
.send_body(endpoint.body)
.await;
if res.is_err() {
return HttpResponse::NotFound().body(format!(
"Failed to create paste on server: {}",
res.err().unwrap()
));
}
// fill body content
let mut res = res.unwrap();
let body_ = res.body().limit(1_000_000).await;
if body_.is_err() {
return HttpResponse::NotFound().body("Failed to fetch");
}
let binding = body_.unwrap();
let body_ = std::str::from_utf8(&binding).unwrap();
return HttpResponse::Ok().body(body_.to_lowercase());
}

38
src/config.rs Normal file
View File

@ -0,0 +1,38 @@
use std::{env, ops::Index};
#[allow(dead_code)]
pub fn collect_arguments() -> Vec<String> {
return env::args().collect::<Vec<String>>();
}
#[allow(dead_code)]
pub fn get_named_argument(args: &Vec<String>, name: &str) -> Option<String> {
for (i, v) in args.iter().enumerate() {
// if name does not match, continue
if v != &format!("--{}", name) {
continue;
};
// return value
let val: &String = args.index(i + 1);
// ...make sure val exists (return None if it doesn't!)
if val.is_empty() {
return Option::None;
}
return Option::Some(String::from(val));
}
return Option::None;
}
pub fn get_var(var: &str) -> Option<String> {
let res = env::var(var);
if res.is_ok() {
Option::Some(res.unwrap())
} else {
Option::None
}
}

112
src/db/cachedb.rs Normal file
View File

@ -0,0 +1,112 @@
//! # CacheDB
//!
//! Redis connection.
//!
//! Identifiers should be a string following this format: `TYPE_OF_OBJECT:OBJECT_ID`. For pastes this would look like: `paste:{custom_url}`
use redis::Commands;
#[derive(Clone)]
pub struct CacheDB {
pub client: redis::Client,
}
impl CacheDB {
pub async fn new() -> CacheDB {
return CacheDB {
client: redis::Client::open("redis://127.0.0.1:6379").unwrap(),
};
}
pub async fn get_con(&self) -> redis::Connection {
self.client.get_connection().unwrap()
}
// GET
/// Get a cache object by its identifier
///
/// # Arguments:
/// * `id` - `String` of the object's id
pub async fn get(&self, id: String) -> Option<String> {
// fetch from database
let mut c = self.get_con().await;
let res = c.get(id);
if res.is_err() {
return Option::None;
}
// return
Option::Some(res.unwrap())
}
// SET
/// Set a cache object by its identifier and content
///
/// # Arguments:
/// * `id` - `String` of the object's id
/// * `content` - `String` of the object's content
pub async fn set(&self, id: String, content: String) -> bool {
// set
let mut c = self.get_con().await;
let res: Result<String, redis::RedisError> = c.set(id, content);
if res.is_err() {
return false;
}
// return
true
}
/// Update a cache object by its identifier and content
///
/// # Arguments:
/// * `id` - `String` of the object's id
/// * `content` - `String` of the object's content
pub async fn update(&self, id: String, content: String) -> bool {
self.set(id, content).await
}
/// Remove a cache object by its identifier
///
/// # Arguments:
/// * `id` - `String` of the object's id
pub async fn remove(&self, id: String) -> bool {
// remove
let mut c = self.get_con().await;
let res: Result<String, redis::RedisError> = c.del(id);
if res.is_err() {
return false;
}
// return
true
}
/// Remove a cache object by its identifier('s start)
///
/// # Arguments:
/// * `id` - `String` of the object's id('s start)
pub async fn remove_starting_with(&self, id: String) -> bool {
let mut c = self.get_con().await;
// get keys
let mut cmd = redis::cmd("DEL");
let keys: Result<Vec<String>, redis::RedisError> = c.keys(id);
for key in keys.unwrap() {
cmd.arg(key);
}
// remove
let res: Result<String, redis::RedisError> = cmd.query(&mut c);
if res.is_err() {
return false;
}
// return
true
}
}

3
src/db/mod.rs Normal file
View File

@ -0,0 +1,3 @@
pub mod pawsdb;
pub mod cachedb;
pub mod sql;

675
src/db/pawsdb.rs Normal file
View File

@ -0,0 +1,675 @@
//! # PawsDB
//! Database handler for all database types
use super::{
cachedb::CacheDB,
sql::{self, Database, DatabaseOpts},
};
use sqlx::{Column, Row};
use crate::utility;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Clone)]
pub struct AppData {
pub db: PawsDB,
pub http_client: awc::Client,
}
#[derive(Debug, Default, PartialEq, sqlx::FromRow, Clone, Serialize, Deserialize)]
/// A user object
pub struct UserState<M> {
// selectors
pub username: String,
pub id_hashed: String, // users use their UNHASHED id to login, it is used as their session id too!
// the hashed id is the only id that should ever be public!
pub role: String,
// dates
pub timestamp: u128,
// ...
pub metadata: M,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq)]
pub struct RoleLevel {
pub elevation: i32, // this marks the level of the role, 0 should always be member
// users cannot manage users of a higher elevation than them
pub name: String, // role name, shown on user profiles
pub permissions: Vec<String>, // a vec of user permissions (ex: "ManagePastes")
}
#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq)]
pub struct FullUser<M> {
pub user: UserState<M>,
pub level: RoleLevel,
}
#[derive(Default, Clone, sqlx::FromRow, Serialize, Deserialize, PartialEq)]
pub struct RoleLevelLog {
pub id: String,
pub level: RoleLevel,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq)]
pub struct UserMetadata {
pub about: String,
pub avatar_url: Option<String>,
pub secondary_token: Option<String>,
pub allow_mail: Option<String>, // yes/no
pub nickname: Option<String>, // user display name
pub page_template: Option<String>, // profile handlebars template
}
#[allow(dead_code)]
#[derive(Debug, Serialize, Deserialize, Clone)]
/// Default API return value
pub struct DefaultReturn<T> {
pub success: bool,
pub message: String,
pub payload: T,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct DatabaseReturn {
pub data: HashMap<String, String>,
}
#[derive(Default, Clone, Serialize, Deserialize, PartialEq)]
pub struct Paw {
pub name: String,
pub target: String,
pub config: PawConfig,
pub endpoints: HashMap<String, PawEndpoint>,
}
#[derive(Default, Clone, Serialize, Deserialize, PartialEq)]
pub struct PawConfig {
pub is_fake_raw: bool,
pub csrf: bool,
pub strip_unused: bool, // remove new_edit_password and new_custom_url if they aren't needed
pub escape: bool, // if we should escape \n in params
}
#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq)]
pub struct PawEndpoint {
pub path: String,
pub content_type: String,
// templates
pub body: String,
}
impl Paw {
pub fn fill_endpoint(
&self,
name: String,
path_params: Vec<String>,
body_params: Vec<String>,
) -> Option<PawEndpoint> {
// get endpoint
let endpoint = self.endpoints.get(&name);
if endpoint.is_none() {
return Option::None;
}
let endpoint = endpoint.unwrap();
// run replacements
let mut path = endpoint.path.clone();
for param in path_params {
path = path.replacen("?", &param, 1);
}
let mut body = endpoint.body.clone();
for (i, param) in body_params.iter().enumerate() {
if self.config.csrf && ((name == "edit") | (name == "delete")) {
// rentry has a very annoying "api"
// these are the fixes we have to do to make it a good api
if i == 0 {
// don't include url in body
continue;
}
if i == 2 && name != "delete" {
// url encode content
body = body.replacen("?", &urlencoding::encode(param).to_string(), 1);
continue;
}
}
if self.config.escape == true {
let escaped = serde_json::json!(param).to_string();
let mut chars = escaped.chars();
chars.next();
chars.next_back();
let escaped = chars.as_str();
// ...
body = body.replacen("?", escaped, 1);
} else {
body = body.replacen("?", &param, 1);
}
}
// return
return Option::Some(PawEndpoint {
path: format!("{}{path}", &self.target),
content_type: endpoint.content_type.clone(),
body,
});
}
}
// ...
#[derive(Clone)]
#[cfg(feature = "postgres")]
pub struct PawsDB {
pub db: Database<sqlx::PgPool>,
pub options: DatabaseOpts,
pub cachedb: CacheDB,
}
#[derive(Clone)]
#[cfg(feature = "mysql")]
pub struct PawsDB {
pub db: Database<sqlx::MySqlPool>,
pub options: DatabaseOpts,
pub cachedb: CacheDB,
}
#[derive(Clone)]
#[cfg(feature = "sqlite")]
pub struct PawsDB {
pub db: Database<sqlx::SqlitePool>,
pub options: DatabaseOpts,
pub cachedb: CacheDB,
}
impl PawsDB {
pub async fn new(options: DatabaseOpts) -> PawsDB {
return PawsDB {
db: sql::create_db(options.clone()).await,
options,
cachedb: CacheDB::new().await,
};
}
pub async fn init(&self) {
// create tables
let c = &self.db.client;
let _ = sqlx::query(
"CREATE TABLE IF NOT EXISTS \"Paws\" (
name VARCHAR(1000000),
target VARCHAR(1000000),
config VARCHAR(1000000),
endpoints VARCHAR(1000000)
)",
)
.execute(c)
.await;
}
#[cfg(feature = "sqlite")]
fn textify_row(&self, row: sqlx::sqlite::SqliteRow) -> DatabaseReturn {
// get all columns
let columns = row.columns();
// create output
let mut out: HashMap<String, String> = HashMap::new();
for column in columns {
let value = row.get(column.name());
out.insert(column.name().to_string(), value);
}
// return
return DatabaseReturn { data: out };
}
#[cfg(feature = "postgres")]
fn textify_row(&self, row: sqlx::postgres::PgRow) -> DatabaseReturn {
// get all columns
let columns = row.columns();
// create output
let mut out: HashMap<String, String> = HashMap::new();
for column in columns {
let value = row.get(column.name());
out.insert(column.name().to_string(), value);
}
// return
return DatabaseReturn { data: out };
}
#[cfg(feature = "mysql")]
fn textify_row(&self, row: sqlx::mysql::MySqlRow) -> DatabaseReturn {
// get all columns
let columns = row.columns();
// create output
let mut out: HashMap<String, String> = HashMap::new();
for column in columns {
let value = row.try_get::<Vec<u8>, _>(column.name());
if value.is_ok() {
// returned bytes instead of text :(
// we're going to convert this to a string and then add it to the output!
out.insert(
column.name().to_string(),
std::str::from_utf8(value.unwrap().as_slice())
.unwrap()
.to_string(),
);
} else {
// already text
let value = row.get(column.name());
out.insert(column.name().to_string(), value);
}
}
// return
return DatabaseReturn { data: out };
}
// paws
// GET
/// Get a [`Paw`] by its `name`
///
/// # Arguments:
/// * `name` - `String` of the paw's name
pub async fn get_paw_by_name(&self, name: String) -> DefaultReturn<Option<Paw>> {
// fetch from database
let query: &str = if (self.db._type == "sqlite") | (self.db._type == "mysql") {
"SELECT * FROM \"Paws\" WHERE \"name\" = ?"
} else {
"SELECT * FROM \"Paws\" WHERE \"name\" = $1"
};
let c = &self.db.client;
let res = sqlx::query(query).bind::<&String>(&name).fetch_one(c).await;
if res.is_err() {
return DefaultReturn {
success: false,
message: String::from("Paw does not exist"),
payload: Option::None,
};
}
// ...
let row = res.unwrap();
let row = self.textify_row(row).data;
// ...
let paw = Paw {
name: row.get("name").unwrap().to_string(),
target: row.get("target").unwrap().to_string(),
config: toml::from_str::<PawConfig>(row.get("config").unwrap()).unwrap(),
endpoints: toml::from_str::<HashMap<String, PawEndpoint>>(
row.get("endpoints").unwrap(),
)
.unwrap(),
};
// return
return DefaultReturn {
success: true,
message: String::from("Paw exists"),
payload: Option::Some(paw),
};
}
// users
// GET
/// Get a user by their hashed ID
///
/// # Arguments:
/// * `hashed` - `String` of the user's hashed ID
pub async fn get_user_by_hashed(
&self,
hashed: String,
) -> DefaultReturn<Option<FullUser<String>>> {