This commit is contained in:
Dominic Harris 2022-02-28 16:10:02 -05:00
commit 656b561073
No known key found for this signature in database
GPG Key ID: 93CCF85F3E2A4F65
12 changed files with 2499 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
**/target/
config.json

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2022 Domterion
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.

15
README.md Normal file
View File

@ -0,0 +1,15 @@
<div align="center">
<h1>zer0b.in</h1>
zer0b.in, or zer0, is a hastebin like service
<br>
<br>
</div>
# API
[**GET**] `/p/:id` - Get a paste
[**POST**] `/p/n` - Post a new paste
# License
MIT

1884
backend/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

21
backend/Cargo.toml Normal file
View File

@ -0,0 +1,21 @@
[package]
name = "backend"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
serde = "1.0"
serde_json = "1.0"
actix-rt = "2.6.0"
actix-web = "4.0.0-rc.1"
actix-cors = "0.6.0"
sqlx = { version = "0.5", features = ["runtime-tokio-rustls", "postgres", "chrono"] }
chrono = { version = "0.4.19", features = ["serde"] }
nanoid = "0.4.0"

17
backend/schema.sql Normal file
View File

@ -0,0 +1,17 @@
CREATE TABLE IF NOT EXISTS pastes (
"id" TEXT PRIMARY KEY,
"content" TEXT NOT NULL,
"views" BIGINT DEFAULT 0,
"expires_at" TIMESTAMP WITHOUT TIME ZONE NOT NULL,
"created_at" TIMESTAMP WITHOUT TIME ZONE DEFAULT(NOW() AT TIME ZONE 'utc')
);
CREATE OR REPLACE FUNCTION deleteExpiredPastes() RETURNS trigger AS $pastes_expire$
BEGIN
DELETE FROM pastes WHERE "expires_at" < now() AT TIME ZONE 'utc';
RETURN NEW;
END;
$pastes_expire$ LANGUAGE plpgsql;
CREATE TRIGGER checkPastes BEFORE INSERT OR UPDATE ON pastes
FOR STATEMENT EXECUTE PROCEDURE deleteExpiredPastes();

27
backend/src/config.rs Normal file
View File

@ -0,0 +1,27 @@
use std::{fs::File, path::PathBuf};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone)]
pub struct Config {
pub pastes: PastesConfig,
pub databases: DatabasesConfig,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct PastesConfig {
pub character_limit: usize,
pub days_til_expiration: i64,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct DatabasesConfig {
pub postgres_uri: String,
}
pub fn load(path: PathBuf) -> Config {
let file = File::open(path).expect("Failed to load config.json");
let config = serde_json::from_reader(file).unwrap();
config
}

149
backend/src/main.rs Normal file
View File

@ -0,0 +1,149 @@
mod config;
mod models;
use std::{io, path::PathBuf};
use actix_cors::Cors;
use actix_web::{
get, post,
web::{self, Data},
App, HttpResponse, HttpServer, Responder,
};
use config::Config;
use chrono::Duration;
use models::{ApiError, ApiResponse, GetPasteResponse, NewPasteResponse, PartialPaste, Paste};
use nanoid::nanoid;
use sqlx::{postgres::PgPoolOptions, types::chrono::Utc, PgPool};
#[derive(Clone)]
struct AppState {
config: Config,
pool: PgPool,
}
#[get("/{id}")]
async fn get_paste(state: web::Data<AppState>, id: web::Path<String>) -> impl Responder {
let id = id.into_inner();
let res: Result<Paste, sqlx::Error> =
sqlx::query_as::<_, Paste>(r#"SELECT * FROM pastes WHERE "id" = $1"#)
.bind(id.clone())
.fetch_one(&state.pool)
.await;
match res {
Ok(p) => {
if let Err(_) =
sqlx::query(r#"UPDATE pastes SET "views" = "views" + 1 WHERE "id" = $1"#)
.bind(id.clone())
.execute(&state.pool)
.await
{
// we should probably handle this but eh
};
return HttpResponse::Ok().json(ApiResponse {
success: true,
data: GetPasteResponse {
id: p.id,
content: p.content,
views: p.views + 1,
expires_at: p.expires_at,
},
});
}
Err(e) => match e {
sqlx::Error::RowNotFound => {
return HttpResponse::InternalServerError().json(ApiResponse {
success: false,
data: ApiError {
message: format!("Paste {id} wasnt found."),
},
});
}
_ => {
return HttpResponse::InternalServerError().json(ApiResponse {
success: false,
data: ApiError {
message: "Unknown error occurred, please try again.".to_string(),
},
});
}
},
}
}
#[post("/n")]
async fn new_paste(state: web::Data<AppState>, data: web::Json<PartialPaste>) -> impl Responder {
if data.content.is_empty() || data.content.len() > state.config.pastes.character_limit {
let character_limit = state.config.pastes.character_limit;
return HttpResponse::BadRequest().json(ApiResponse {
success: false,
data: ApiError {
message: format!("Maximum file length exceeded, maximum is {character_limit} characters. Or the content is blank.."),
},
});
}
let id = nanoid!(10);
let expires_at = Utc::now() + Duration::days(state.config.pastes.days_til_expiration);
let res =
sqlx::query(r#"INSERT INTO pastes("id", "content", "expires_at") VALUES ($1, $2, $3)"#)
.bind(id.clone())
.bind(data.content.clone())
.bind(expires_at)
.execute(&state.pool)
.await;
match res {
Ok(_) => {
return HttpResponse::Ok().json(ApiResponse {
success: true,
data: NewPasteResponse {
id,
content: data.content.clone(),
},
});
}
Err(e) => {
return HttpResponse::InternalServerError().json(ApiResponse {
success: false,
data: ApiError {
message: "Unknown error occurred, please try again.".to_string(),
},
});
}
}
}
#[actix_rt::main]
async fn main() -> io::Result<()> {
let config = config::load(PathBuf::from("../config.json"));
let pool = PgPoolOptions::new()
.max_connections(100)
.connect(&config.databases.postgres_uri)
.await
.expect("Failed to connect to database");
let state = AppState { config, pool };
HttpServer::new(move || {
let cors = Cors::default()
.allow_any_header()
.allow_any_method()
.allow_any_origin()
.send_wildcard()
.max_age(3600);
App::new()
.wrap(cors)
.app_data(Data::new(state.clone()))
.service(web::scope("/p").service(get_paste).service(new_paste))
})
.bind("localhost:8000")?
.run()
.await
}

41
backend/src/models.rs Normal file
View File

@ -0,0 +1,41 @@
use chrono::NaiveDateTime;
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
#[derive(FromRow)]
pub struct Paste {
pub id: String,
pub content: String,
pub views: i64,
pub expires_at: NaiveDateTime,
}
#[derive(Deserialize)]
pub struct PartialPaste {
pub content: String,
}
#[derive(Serialize)]
pub struct ApiResponse<T> {
pub success: bool,
pub data: T,
}
#[derive(Serialize)]
pub struct NewPasteResponse {
pub id: String,
pub content: String,
}
#[derive(Serialize)]
pub struct GetPasteResponse {
pub id: String,
pub content: String,
pub views: i64,
pub expires_at: NaiveDateTime,
}
#[derive(Serialize)]
pub struct ApiError {
pub message: String,
}

75
frontend/src/index.html Normal file
View File

@ -0,0 +1,75 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>zer0b.in</title>
<link href="styles.css" rel="stylesheet" />
<script
src="https://kit.fontawesome.com/87d6c5dd5a.js"
crossorigin="anonymous"
></script>
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.4.0/styles/base16/solarized-dark.min.css"
integrity="sha512-kBHeOXtsKtA97/1O3ebZzWRIwiWEOmdrylPrOo3D2+pGhq1m+1CroSOVErIlsqn1xmYowKfQNVDhsczIzeLpmg=="
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>
<script
src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.4.0/highlight.min.js"
integrity="sha512-IaaKO80nPNs5j+VLxd42eK/7sYuXQmr+fyywCNA0e+C6gtQnuCXNtORe9xR4LqGPz5U9VpH+ff41wKs/ZmC3iA=="
crossorigin="anonymous"
referrerpolicy="no-referrer"
></script>
<script>
hljs.highlightAll();
</script>
<script
src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"
integrity="sha512-894YE6QWD5I59HgZOGReFYm4dnWc1Qt5NtvYSaNcOP+u1T9qYdvdihz0PPSiiqn/+/3e7Jo4EaG7TubfWGUrMQ=="
crossorigin="anonymous"
referrerpolicy="no-referrer"
></script>
</head>
<body>
<ul id="messages"></ul>
<div class="wrapper">
<div class="line-numbers"></div>
<!-- We only show this if they are viewing a paste-->
<pre
id="code-view-pre"
style="display: none"
><code id="code-view"></code></pre>
<!-- We only show the textarea if they are creating a new paste-->
<textarea
spellcheck="false"
autofocus
name="value"
id="text-area"
style="display: none"
></textarea>
</div>
<div class="button-wrapper">
<a href="/" class="logo">zer0b.in</a>
<div class="buttons">
<button id="save-button" class="btn">
<i class="fas fa-save fa-xl"></i>
</button>
<button id="new-button" class="btn">
<i class="fas fa-file-medical fa-2xl"></i>
</button>
</div>
</div>
<script src="./index.js"></script>
</body>
</html>

129
frontend/src/index.js Normal file
View File

@ -0,0 +1,129 @@
//var lineNumbers, editor, codeView, apiUrl, saveButton;
let lineNumbers = $(".line-numbers");
let editor = $("#text-area");
let codeViewPre = $("#code-view-pre");
let codeView = $("#code-view");
let messages = $("#messages");
let saveButton = $("#save-button");
let newButton = $("#new-button");
let apiUrl = "http://localhost:8000";
function postPaste(content, callback) {
var data = {
content,
};
$.ajax({
type: "POST",
url: `${apiUrl}/p/n`,
data: JSON.stringify(data),
dataType: "json",
contentType: "application/json",
success: function (res) {
callback(null, res);
},
error: function (xhr) {
callback(
JSON.parse(
xhr.responseText ||
`{"data": { "message": "Unknown error occurred.." } }`
)
);
},
});
}
function getPaste(id, callback) {
$.ajax({
type: "GET",
url: `${apiUrl}/p/${id}`,
contentType: "application/json",
success: function (res) {
callback(null, res);
},
error: function (xhr) {
callback(
JSON.parse(
xhr.responseText ||
`{"data": { "message": "Unknown error occurred.." } }`
)
);
},
});
}
function newPaste() {
lineNumbers.html("<div>&gt;</div>");
saveButton.prop("disabled", false);
newButton.prop("disabled", true);
editor.val("");
editor.show();
codeViewPre.hide();
}
function addMessage(message) {
let msg = $(`<li>${message || "Unknown error occurred.."}</li>`);
messages.prepend(msg);
setTimeout(function () {
msg.slideUp("fast", function () {
$(this).remove();
});
}, 3000);
}
function viewPaste(content) {
lineNumbers.html("");
for (let i = 1; i <= content.split("\n").length; i++) {
lineNumbers.append(`<div>${i}</div>`);
}
codeView.html(content);
editor.hide();
codeViewPre.show();
}
saveButton.click(function () {
if (editor.val() === "") {
return;
}
postPaste(editor.val(), function (err, res) {
if (err) {
addMessage(err["data"]["message"]);
} else {
window.location.href = `/?id=${res["data"]["id"]}`;
}
});
});
newButton.click(function () {
window.location.href = "/";
});
$(document).ready(function () {
let id = new URLSearchParams(window.location.search).get("id");
if (id == null) {
newPaste();
return;
}
getPaste(id, function (err, res) {
if (err) {
newPaste();
} else {
let content = res["data"]["content"];
viewPaste(hljs.highlightAuto(content).value);
saveButton.prop("disabled", true);
}
});
});

118
frontend/src/styles.css Normal file
View File

@ -0,0 +1,118 @@
html,
body {
height: 100%;
}
body {
background-color: #002b36;
margin: 0;
}
pre {
margin: 0;
}
.wrapper {
display: flex;
padding: 1rem 0.5rem;
font-size: 1rem;
height: calc(100% - 2rem);
}
.line-numbers {
color: #7d7d7d;
font-family: monospace;
text-align: end;
user-select: none;
font-size: 15px;
}
#code-view {
padding-top: 0;
padding-bottom: 0;
font-size: 15px;
}
textarea {
background-color: transparent;
resize: none;
width: 100%;
height: 100%;
padding: 0;
padding-left: 1rem;
margin: 0;
border: none;
outline: none;
color: white;
font-size: 15px;
}
.button-wrapper {
position: absolute;
top: 0;
right: 0;
padding: 1rem;
background-color: #00222b;
z-index: 1000;
}
.buttons {
display: flex;
}
.buttons > * + * {
margin-left: 0.25rem;
}
.btn {
cursor: pointer;
background: none;
color: #8fbcc8;
font-size: 1rem;
border: none;
transition: background-color 200ms ease-in-out;
display: inline-block;
text-decoration: none;
}
.btn:hover {
color: white;
}
.btn:disabled,
.btn[disabled] {
color: #255662;
}
.logo {
font-size: 1.8rem;
font-weight: bold;
text-align: center;
text-decoration: none;
color: #8fbcc8;
width: 100%;
font-family: monospace;
display: inline-block;
}
.logo:hover {
color: white;
}
#messages {
position: absolute;
top: 0;
right: 168px;
z-index: 1000;
padding: 0;
margin: 0;
list-style: none;
width: 400px;
}
#messages li {
background-color: rgba(37, 86, 98, 0.8);
font-family: monospace;
color: white;
padding: 7px;
}