[add] pages::atomic_editor
[add] pages::atomic_editor::pages::dashboard_request [add] pages::atomic_editor::new_request [add] pages::atomic_editor::edit_request [add] api::pastes::edit_atomic_request [fix] markdown: hashtag in special elements [chore] bump version (v0.6.0 -> v0.7.0)
This commit is contained in:
parent
9c9beb4055
commit
2f06212cfd
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -6,6 +6,7 @@ Cargo.lock
|
|||
# js
|
||||
node_modules/
|
||||
/static/js
|
||||
bun.lockb
|
||||
|
||||
# env
|
||||
.env
|
||||
|
|
|
@ -3,7 +3,7 @@ name = "bundlrs"
|
|||
authors = ["hkau"]
|
||||
license = "MIT"
|
||||
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
edition = "2021"
|
||||
|
||||
rust-version = "1.75"
|
||||
|
|
|
@ -3,7 +3,8 @@
|
|||
"module": "index.ts",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
"@types/bun": "latest",
|
||||
"@types/htmlhint": "^1.1.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.0.0"
|
||||
|
@ -21,6 +22,8 @@
|
|||
"@codemirror/language": "^6.10.0",
|
||||
"@codemirror/state": "^6.4.0",
|
||||
"@codemirror/view": "^6.23.0",
|
||||
"highlight.js": "^11.9.0"
|
||||
"highlight.js": "^11.9.0",
|
||||
"htmlhint": "^1.1.4",
|
||||
"prettier": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
use actix_web::{get, post, web, HttpRequest, HttpResponse, Responder};
|
||||
|
||||
use crate::db::bundlesdb;
|
||||
use crate::db::bundlesdb::{self, AtomicPasteFSFile};
|
||||
use crate::{markdown, ssm, utility};
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
|
@ -25,6 +25,13 @@ struct EditInfo {
|
|||
new_custom_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct EditAtomicInfo {
|
||||
custom_url: String,
|
||||
path: String,
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct DeleteInfo {
|
||||
custom_url: String,
|
||||
|
@ -206,6 +213,101 @@ pub async fn edit_request(
|
|||
.body(serde_json::to_string(&res).unwrap());
|
||||
}
|
||||
|
||||
#[post("/api/edit-atomic")]
|
||||
pub async fn edit_atomic_request(
|
||||
req: HttpRequest,
|
||||
body: web::Json<EditAtomicInfo>,
|
||||
data: web::Data<bundlesdb::AppData>,
|
||||
) -> impl Responder {
|
||||
// this is essentially the same as edit_request but it handles the atomic JSON file system
|
||||
// ...it does NOT accept an edit password! users must be authenticated
|
||||
let custom_url: String = body.custom_url.trim().to_string();
|
||||
let path: String = body.path.trim().to_string();
|
||||
let content: String = body.content.trim().to_string();
|
||||
|
||||
// get owner
|
||||
let token_cookie = req.cookie("__Secure-Token");
|
||||
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
|
||||
if token_user.as_ref().unwrap().success == false {
|
||||
return HttpResponse::NotFound().body("Invalid token");
|
||||
}
|
||||
}
|
||||
|
||||
// get paste
|
||||
let paste: bundlesdb::DefaultReturn<Option<bundlesdb::Paste<String>>> =
|
||||
data.db.get_paste_by_url(custom_url.clone()).await;
|
||||
|
||||
if paste.success == false {
|
||||
return HttpResponse::Ok()
|
||||
.append_header(("Content-Type", "application/json"))
|
||||
.body(serde_json::to_string(&paste).unwrap());
|
||||
}
|
||||
|
||||
// make sure paste is an atomic paste
|
||||
let unwrap = paste.payload.unwrap();
|
||||
let is_atomic = unwrap.content.contains("\"_is_atomic\":true");
|
||||
|
||||
if is_atomic == false {
|
||||
return HttpResponse::NotFound().body("Paste is not atomic");
|
||||
}
|
||||
|
||||
// get file from path
|
||||
let real_content = serde_json::from_str::<bundlesdb::AtomicPaste>(&unwrap.content);
|
||||
|
||||
if real_content.is_err() {
|
||||
return HttpResponse::NotAcceptable().body("Paste failed to deserialize");
|
||||
}
|
||||
|
||||
let mut decoded = real_content.unwrap();
|
||||
|
||||
// check for existing file in atomic paste fs
|
||||
let existing = decoded.files.iter().position(|f| f.path == path);
|
||||
|
||||
if existing.is_some() {
|
||||
// remove existing file
|
||||
decoded.files.remove(existing.unwrap());
|
||||
}
|
||||
|
||||
// insert file
|
||||
decoded.files.push(AtomicPasteFSFile {
|
||||
path,
|
||||
content: content.clone(),
|
||||
});
|
||||
|
||||
// ...
|
||||
let res = data
|
||||
.db
|
||||
.edit_paste_by_url(
|
||||
custom_url,
|
||||
serde_json::to_string::<bundlesdb::AtomicPaste>(&decoded).unwrap(), // encode content
|
||||
String::new(),
|
||||
Option::None,
|
||||
Option::None,
|
||||
if token_user.is_some() {
|
||||
Option::Some(token_user.unwrap().payload.unwrap().username)
|
||||
} else {
|
||||
Option::None
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
// return
|
||||
return HttpResponse::Ok()
|
||||
.append_header(("Content-Type", "application/json"))
|
||||
.body(serde_json::to_string(&res).unwrap());
|
||||
}
|
||||
|
||||
#[post("/api/delete")]
|
||||
pub async fn delete_request(
|
||||
body: web::Json<DeleteInfo>,
|
||||
|
|
|
@ -45,7 +45,7 @@ pub struct Paste<M> {
|
|||
pub views: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, sqlx::FromRow, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Default, sqlx::FromRow, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct PasteIdentifier {
|
||||
pub custom_url: String,
|
||||
pub id: String,
|
||||
|
@ -62,6 +62,21 @@ pub struct PasteMetadata {
|
|||
pub embed_color: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AtomicPaste {
|
||||
// atomic pastes are a plain JSON file system storing HTML, CSS, and JS files only
|
||||
// they have the least amount of boilerplate for rendering!
|
||||
pub _is_atomic: bool, // this must exist so we know a paste's content is for an atomic paste
|
||||
pub files: Vec<AtomicPasteFSFile>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct AtomicPasteFSFile {
|
||||
// store only the bare minimum for the required file types
|
||||
pub path: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Default, PartialEq, sqlx::FromRow, Clone, Serialize, Deserialize)]
|
||||
pub struct Group<M> {
|
||||
// selectors
|
||||
|
@ -712,6 +727,50 @@ impl BundlesDB {
|
|||
};
|
||||
}
|
||||
|
||||
pub async fn get_atomic_pastes_by_owner(
|
||||
&self,
|
||||
owner: String,
|
||||
) -> DefaultReturn<Option<Vec<PasteIdentifier>>> {
|
||||
let query: &str = if (self.db._type == "sqlite") | (self.db._type == "mysql") {
|
||||
"SELECT * FROM \"Pastes\" WHERE \"metadata\" LIKE ? AND \"content\" LIKE ?"
|
||||
} else {
|
||||
"SELECT * FROM \"Pastes\" WHERE \"metadata\" LIKE $1 AND \"content\" LIKE $2"
|
||||
};
|
||||
|
||||
let c = &self.db.client;
|
||||
let res = sqlx::query(query)
|
||||
.bind::<&String>(&format!("%\"owner\":\"{}\"%", &owner))
|
||||
.bind("%\"_is_atomic\":true%")
|
||||
.fetch_all(c)
|
||||
.await;
|
||||
|
||||
if res.is_err() {
|
||||
return DefaultReturn {
|
||||
success: false,
|
||||
message: String::from(res.err().unwrap().to_string()),
|
||||
payload: Option::None,
|
||||
};
|
||||
}
|
||||
|
||||
// build res
|
||||
let mut full_res: Vec<PasteIdentifier> = Vec::new();
|
||||
|
||||
for row in res.unwrap() {
|
||||
let row = self.textify_row(row).data;
|
||||
full_res.push(PasteIdentifier {
|
||||
custom_url: row.get("custom_url").unwrap().to_string(),
|
||||
id: row.get("id").unwrap().to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// return
|
||||
return DefaultReturn {
|
||||
success: true,
|
||||
message: owner,
|
||||
payload: Option::Some(full_res),
|
||||
};
|
||||
}
|
||||
|
||||
// SET
|
||||
pub async fn create_paste(
|
||||
&self,
|
||||
|
|
|
@ -105,6 +105,7 @@ async fn main() -> std::io::Result<()> {
|
|||
.service(crate::api::pastes::render_request)
|
||||
.service(crate::api::pastes::create_request)
|
||||
.service(crate::api::pastes::edit_request)
|
||||
.service(crate::api::pastes::edit_atomic_request)
|
||||
.service(crate::api::pastes::delete_request)
|
||||
.service(crate::api::pastes::metadata_request)
|
||||
// POST api::pastes SSM
|
||||
|
@ -121,6 +122,10 @@ async fn main() -> std::io::Result<()> {
|
|||
.service(crate::pages::auth::login_request)
|
||||
.service(crate::pages::settings::user_settings_request)
|
||||
.service(crate::pages::settings::paste_settings_request)
|
||||
// GET dashboard (atomic pastes)
|
||||
.service(crate::pages::atomic_editor::dashboard_request)
|
||||
.service(crate::pages::atomic_editor::new_request)
|
||||
.service(crate::pages::atomic_editor::edit_request)
|
||||
// GET root
|
||||
.service(crate::pages::home::home_request)
|
||||
.service(crate::pages::home::robotstxt)
|
||||
|
|
|
@ -201,7 +201,7 @@ pub fn parse_markdown(input: &String) -> String {
|
|||
for capture in custom_element_regex.captures_iter(&out.clone()) {
|
||||
let name = capture.name("NAME").unwrap().as_str();
|
||||
|
||||
let atrs = capture.name("ATRS").unwrap().as_str();
|
||||
let atrs = capture.name("ATRS").unwrap().as_str().replace("$", "#");
|
||||
let mut atrs_split: Vec<String> = atrs.split("+").map(|s| s.to_string()).collect();
|
||||
|
||||
// make sure everything exists (before we try to call .unwrap on them!)
|
||||
|
@ -301,7 +301,7 @@ pub fn parse_markdown(input: &String) -> String {
|
|||
.unwrap();
|
||||
|
||||
for capture in ssm_regex.captures_iter(&out.clone()) {
|
||||
let content = capture.name("CONTENT").unwrap().as_str();
|
||||
let content = capture.name("CONTENT").unwrap().as_str().replace("$", "#");
|
||||
|
||||
// compile
|
||||
let css = ssm::parse_ssm_program(content.to_string());
|
||||
|
|
407
src/pages/atomic_editor.rs
Normal file
407
src/pages/atomic_editor.rs
Normal file
|
@ -0,0 +1,407 @@
|
|||
use actix_web::HttpRequest;
|
||||
use actix_web::{get, web, HttpResponse, Responder};
|
||||
|
||||
use yew::prelude::*;
|
||||
use yew::ServerRenderer;
|
||||
|
||||
use crate::components::navigation::Footer;
|
||||
use crate::db::bundlesdb::Paste;
|
||||
use crate::db::{self, bundlesdb};
|
||||
use crate::utility::{self, format_html};
|
||||
|
||||
#[derive(Default, Properties, PartialEq, serde::Deserialize)]
|
||||
struct EditQueryProps {
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default, Properties, PartialEq, serde::Deserialize)]
|
||||
struct FSProps {
|
||||
pub files: Vec<bundlesdb::AtomicPasteFSFile>,
|
||||
pub auth_state: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Default, Properties, PartialEq, serde::Deserialize)]
|
||||
struct EditProps {
|
||||
pub custom_url: String,
|
||||
pub file: bundlesdb::AtomicPasteFSFile,
|
||||
pub auth_state: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Default, Properties, PartialEq, serde::Deserialize)]
|
||||
struct NewProps {
|
||||
pub auth_state: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Default, Properties, PartialEq, serde::Deserialize)]
|
||||
struct Props {
|
||||
pub pastes: Vec<bundlesdb::PasteIdentifier>,
|
||||
pub auth_state: Option<bool>,
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
fn Dashboard(props: &Props) -> Html {
|
||||
return html! {
|
||||
<div class="flex flex-column g-4" style="height: 100dvh;">
|
||||
<main class="small">
|
||||
<div class="card round secondary flex g-4 flex-wrap mobile:flex-column justify-center align-center" id="pastes_list">
|
||||
{for props.pastes.iter().map(|p| html! {
|
||||
<a class="button secondary round mobile:max" href={format!("/d/atomic/{}?path=/index.html", &p.id)}>{&p.custom_url}</a>
|
||||
})}
|
||||
|
||||
<a class="button border round mobile:max" href="/d/atomic/new">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-plus-square"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M8 12h8"/><path d="M12 8v8"/></svg>
|
||||
{"New"}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
{"#pastes_list .button {
|
||||
display: flex;
|
||||
width: 10rem !important;
|
||||
height: 10rem !important;
|
||||
flex-direction: column;
|
||||
gap: var(--u-08);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}"}
|
||||
</style>
|
||||
|
||||
<Footer auth_state={props.auth_state} />
|
||||
</main>
|
||||
</div>
|
||||
};
|
||||
}
|
||||
|
||||
fn build_dashboard_renderer_with_props(props: Props) -> ServerRenderer<Dashboard> {
|
||||
return ServerRenderer::<Dashboard>::with_props(|| props);
|
||||
}
|
||||
|
||||
#[get("/d/atomic")]
|
||||
pub async fn dashboard_request(
|
||||
req: HttpRequest,
|
||||
data: web::Data<db::bundlesdb::AppData>,
|
||||
) -> impl Responder {
|
||||
// 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";
|
||||
}
|
||||
} else {
|
||||
// you must have an account to use atomic pastes
|
||||
// we'll likely track bandwidth used by atomic pastes and limit it in the future
|
||||
return HttpResponse::NotFound().body(
|
||||
"You must have an account to use atomic pastes.
|
||||
You can login at: /d/auth/login
|
||||
You can create an account at: /d/auth/register",
|
||||
);
|
||||
}
|
||||
|
||||
// fetch pastes
|
||||
let pastes = data
|
||||
.db
|
||||
.get_atomic_pastes_by_owner(token_user.clone().unwrap().payload.unwrap().username)
|
||||
.await;
|
||||
|
||||
// ...
|
||||
let renderer = build_dashboard_renderer_with_props(Props {
|
||||
pastes: pastes.payload.unwrap(),
|
||||
auth_state: if req.cookie("__Secure-Token").is_some() {
|
||||
Option::Some(true)
|
||||
} else {
|
||||
Option::Some(false)
|
||||
},
|
||||
});
|
||||
|
||||
return HttpResponse::Ok()
|
||||
.append_header(("Set-Cookie", set_cookie))
|
||||
.append_header(("Content-Type", "text/html"))
|
||||
.body(format_html(
|
||||
renderer.render().await,
|
||||
"<title>Atomic Dashboard - ::SITE_NAME::</title>",
|
||||
));
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
fn CreateNew(props: &NewProps) -> Html {
|
||||
return html! {
|
||||
<div class="flex flex-column g-4" style="height: 100dvh;">
|
||||
<main class="small flex flex-column g-4 align-center">
|
||||
<div class="card secondary round border" style="width: 25rem;" id="forms">
|
||||
<div id="error" class="mdnote note-error full" style="display: none;" />
|
||||
<form class="full flex flex-column g-4" action="/api/auth/register" id="create-site">
|
||||
<label for="custom_url"><b>{"Custom URL"}</b></label>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
name="custom_url"
|
||||
id="custom_url"
|
||||
placeholder="Custom URL"
|
||||
class="full round"
|
||||
minlength={4}
|
||||
maxlength={32}
|
||||
/>
|
||||
|
||||
<hr />
|
||||
|
||||
<button class="bundles-primary full round">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-plus"><path d="M5 12h14"/><path d="M12 5v14"/></svg>
|
||||
{"Create Site"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
{"import AuthPages from \"/static/js/NewAtomic.js\";"}
|
||||
</script>
|
||||
|
||||
<Footer auth_state={props.auth_state} />
|
||||
</main>
|
||||
</div>
|
||||
};
|
||||
}
|
||||
|
||||
fn build_new_renderer_with_props(props: NewProps) -> ServerRenderer<CreateNew> {
|
||||
return ServerRenderer::<CreateNew>::with_props(|| props);
|
||||
}
|
||||
|
||||
#[get("/d/atomic/new")]
|
||||
pub async fn new_request(
|
||||
req: HttpRequest,
|
||||
data: web::Data<db::bundlesdb::AppData>,
|
||||
) -> impl Responder {
|
||||
// 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";
|
||||
}
|
||||
} else {
|
||||
// you must have an account to use atomic pastes
|
||||
// we'll likely track bandwidth used by atomic pastes and limit it in the future
|
||||
return HttpResponse::NotFound().body(
|
||||
"You must have an account to use atomic pastes.
|
||||
You can login at: /d/auth/login
|
||||
You can create an account at: /d/auth/register",
|
||||
);
|
||||
}
|
||||
|
||||
// ...
|
||||
let renderer = build_new_renderer_with_props(NewProps {
|
||||
auth_state: if req.cookie("__Secure-Token").is_some() {
|
||||
Option::Some(true)
|
||||
} else {
|
||||
Option::Some(false)
|
||||
},
|
||||
});
|
||||
|
||||
return HttpResponse::Ok()
|
||||
.append_header(("Set-Cookie", set_cookie))
|
||||
.append_header(("Content-Type", "text/html"))
|
||||
.body(format_html(
|
||||
renderer.render().await,
|
||||
"<title>New Atomic Paste - ::SITE_NAME::</title>",
|
||||
));
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
fn EditPaste(props: &EditProps) -> Html {
|
||||
return html! {
|
||||
<div class="flex flex-column" style="height: 100dvh;">
|
||||
<div id="_doc" style="height: 100%; overflow: auto;" />
|
||||
<div class="card secondary flex mobile:justify-center justify-space-between align-center" style="
|
||||
overflow: auto hidden;
|
||||
border-top: 1px solid var(--background-surface2a);
|
||||
">
|
||||
// editor actions
|
||||
<b style="min-width: max-content;" class="device:desktop">{&props.file.path}</b>
|
||||
|
||||
<div class="flex g-4">
|
||||
<button class="round secondary" id="save">{"Save"}</button>
|
||||
<a href="?" class="button round secondary" id="save" target="_blank">{"Files"}</a>
|
||||
<div class="hr-left" />
|
||||
<button class="round border" id="preview">{"Preview"}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
{format!("import {{ create_editor }} from \"/static/js/AtomicEditor.js\";
|
||||
create_editor(document.getElementById('_doc'), '{}', '{}');
|
||||
globalThis.AtomicEditor.Update(`{}`)", &props.custom_url, &props.file.path, &props.file.content.replace("`", "\\`"))}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
{".cm-editor, .cm-line, .cm-line span { font-family: monospace !important; }"}
|
||||
</style>
|
||||
</div>
|
||||
};
|
||||
}
|
||||
|
||||
fn build_edit_renderer_with_props(props: EditProps) -> ServerRenderer<EditPaste> {
|
||||
return ServerRenderer::<EditPaste>::with_props(|| props);
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
fn PasteFiles(props: &FSProps) -> Html {
|
||||
return html! {
|
||||
<div class="flex flex-column" style="height: 100dvh;">
|
||||
<main class="small">
|
||||
<div class="card secondary round flex flex-column g-4">
|
||||
{for props.files.iter().map(|p| html! {
|
||||
<a href={format!("?path={}", &p.path)}>{&p.path}</a>
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Footer auth_state={props.auth_state} />
|
||||
</main>
|
||||
</div>
|
||||
};
|
||||
}
|
||||
|
||||
fn build_fs_renderer_with_props(props: FSProps) -> ServerRenderer<PasteFiles> {
|
||||
return ServerRenderer::<PasteFiles>::with_props(|| props);
|
||||
}
|
||||
|
||||
#[get("/d/atomic/{id:.*}")]
|
||||
pub async fn edit_request(
|
||||
req: HttpRequest,
|
||||
data: web::Data<db::bundlesdb::AppData>,
|
||||
info: web::Query<EditQueryProps>,
|
||||
) -> impl Responder {
|
||||
// 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";
|
||||
}
|
||||
} else {
|
||||
// you must have an account to use atomic pastes
|
||||
// we'll likely track bandwidth used by atomic pastes and limit it in the future
|
||||
return HttpResponse::NotFound().body(
|
||||
"You must have an account to use atomic pastes.
|
||||
You can login at: /d/auth/login
|
||||
You can create an account at: /d/auth/register",
|
||||
);
|
||||
}
|
||||
|
||||
// get paste
|
||||
let id: String = req.match_info().get("id").unwrap().to_string();
|
||||
let paste: bundlesdb::DefaultReturn<Option<Paste<String>>> = data.db.get_paste_by_id(id).await;
|
||||
|
||||
if paste.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>",
|
||||
));
|
||||
}
|
||||
|
||||
// make sure paste is an atomic paste
|
||||
let unwrap = paste.payload.unwrap();
|
||||
let is_atomic = unwrap.content.contains("\"_is_atomic\":true");
|
||||
|
||||
if is_atomic == false {
|
||||
return HttpResponse::NotFound().body("Paste is not atomic");
|
||||
}
|
||||
|
||||
// get file from path
|
||||
let real_content = serde_json::from_str::<bundlesdb::AtomicPaste>(&unwrap.content);
|
||||
|
||||
if real_content.is_err() {
|
||||
return HttpResponse::NotAcceptable().body("Paste failed to deserialize");
|
||||
}
|
||||
|
||||
let decoded = real_content.unwrap();
|
||||
|
||||
// show file list if path is none
|
||||
if info.path.is_none() {
|
||||
let renderer = build_fs_renderer_with_props(FSProps {
|
||||
files: decoded.files,
|
||||
auth_state: if req.cookie("__Secure-Token").is_some() {
|
||||
Option::Some(true)
|
||||
} else {
|
||||
Option::Some(false)
|
||||
},
|
||||
});
|
||||
|
||||
return HttpResponse::Ok()
|
||||
.append_header(("Set-Cookie", set_cookie))
|
||||
.append_header(("Content-Type", "text/html"))
|
||||
.body(format_html(
|
||||
renderer.render().await,
|
||||
&format!(
|
||||
"<title>Files in {} - ::SITE_NAME::</title>",
|
||||
&unwrap.custom_url
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
let path_unwrap = info.path.clone().unwrap();
|
||||
|
||||
// ...
|
||||
let file = decoded.files.iter().find(|f| f.path == path_unwrap);
|
||||
|
||||
if file.is_none() {
|
||||
return HttpResponse::NotAcceptable().body("Path does not exist");
|
||||
}
|
||||
|
||||
// ...
|
||||
let renderer = build_edit_renderer_with_props(EditProps {
|
||||
custom_url: unwrap.custom_url,
|
||||
file: file.unwrap().to_owned(),
|
||||
auth_state: if req.cookie("__Secure-Token").is_some() {
|
||||
Option::Some(true)
|
||||
} else {
|
||||
Option::Some(false)
|
||||
},
|
||||
});
|
||||
|
||||
return HttpResponse::Ok()
|
||||
.append_header(("Set-Cookie", set_cookie))
|
||||
.append_header(("Content-Type", "text/html"))
|
||||
.body(format_html(
|
||||
renderer.render().await,
|
||||
&format!("<title>{} - ::SITE_NAME::</title>", path_unwrap),
|
||||
));
|
||||
}
|
|
@ -1,3 +1,4 @@
|
|||
pub mod atomic_editor;
|
||||
pub mod auth;
|
||||
pub mod errors;
|
||||
pub mod home;
|
||||
|
|
|
@ -98,6 +98,27 @@ pub async fn paste_view_request(req: HttpRequest, data: web::Data<AppData>) -> i
|
|||
}
|
||||
|
||||
let unwrap = paste.payload.as_ref().unwrap();
|
||||
|
||||
// handle atomic pastes (just return index.html)
|
||||
if unwrap.content.contains("\"_is_atomic\":true") {
|
||||
let real_content = serde_json::from_str::<bundlesdb::AtomicPaste>(&unwrap.content);
|
||||
|
||||
if real_content.is_err() {
|
||||
return HttpResponse::NotAcceptable().body("Paste failed to deserialize");
|
||||
}
|
||||
|
||||
let decoded = real_content.unwrap();
|
||||
let index_html = decoded.files.iter().find(|f| f.path == "/index.html");
|
||||
|
||||
if index_html.is_none() {
|
||||
return HttpResponse::NotAcceptable()
|
||||
.body("Paste is missing a file at the path '/index.html'");
|
||||
}
|
||||
|
||||
return HttpResponse::Ok().body(index_html.unwrap().content.clone());
|
||||
}
|
||||
|
||||
// ...
|
||||
let metadata = serde_json::from_str::<bundlesdb::PasteMetadata>(&unwrap.metadata).unwrap();
|
||||
|
||||
// verify auth status
|
||||
|
|
419
static/ts/editors/AtomicEditor.ts
Normal file
419
static/ts/editors/AtomicEditor.ts
Normal file
|
@ -0,0 +1,419 @@
|
|||
/**
|
||||
* @file Handle Atomic Paste file editor
|
||||
* @name AtomicEditor.ts
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
// codemirror
|
||||
import { EditorState } from "@codemirror/state";
|
||||
|
||||
import {
|
||||
EditorView,
|
||||
keymap,
|
||||
highlightSpecialChars,
|
||||
drawSelection,
|
||||
highlightActiveLine,
|
||||
dropCursor,
|
||||
rectangularSelection,
|
||||
crosshairCursor,
|
||||
lineNumbers,
|
||||
highlightActiveLineGutter,
|
||||
placeholder,
|
||||
} from "@codemirror/view";
|
||||
|
||||
import {
|
||||
syntaxHighlighting,
|
||||
indentOnInput,
|
||||
bracketMatching,
|
||||
foldGutter,
|
||||
foldKeymap,
|
||||
HighlightStyle,
|
||||
indentUnit,
|
||||
} from "@codemirror/language";
|
||||
|
||||
import {
|
||||
autocompletion,
|
||||
completionKeymap,
|
||||
closeBrackets,
|
||||
closeBracketsKeymap,
|
||||
CompletionContext,
|
||||
} from "@codemirror/autocomplete";
|
||||
|
||||
import {
|
||||
defaultKeymap,
|
||||
history,
|
||||
historyKeymap,
|
||||
indentWithTab,
|
||||
} from "@codemirror/commands";
|
||||
|
||||
import { html, htmlCompletionSource } from "@codemirror/lang-html";
|
||||
import { tags } from "@lezer/highlight";
|
||||
|
||||
import { linter, Diagnostic, lintGutter } from "@codemirror/lint";
|
||||
|
||||
// prettier
|
||||
// @ts-ignore
|
||||
import * as prettier from "prettier/standalone.mjs";
|
||||
import type { Options } from "prettier";
|
||||
|
||||
import EstreePlugin from "prettier/plugins/estree";
|
||||
import BabelParser from "prettier/plugins/babel";
|
||||
import CSSParser from "prettier/plugins/postcss";
|
||||
import HTMLParser from "prettier/plugins/html";
|
||||
|
||||
// create editor theme
|
||||
export const DefaultHighlight = HighlightStyle.define([
|
||||
{
|
||||
tag: tags.keyword,
|
||||
color: "var(--red3)",
|
||||
},
|
||||
{
|
||||
tag: tags.tagName,
|
||||
color: "var(--red3)",
|
||||
textShadow: "0 0 1px var(--red3)",
|
||||
},
|
||||
{
|
||||
tag: tags.variableName,
|
||||
color: "var(--blue2)",
|
||||
},
|
||||
{
|
||||
tag: tags.propertyName,
|
||||
color: "var(--red)",
|
||||
},
|
||||
{
|
||||
tag: tags.comment,
|
||||
color: "var(--text-color-faded)",
|
||||
},
|
||||
{
|
||||
tag: tags.number,
|
||||
color: "var(--yellow)",
|
||||
},
|
||||
{
|
||||
tag: tags.string,
|
||||
color: "var(--green)",
|
||||
},
|
||||
{
|
||||
tag: tags.operator,
|
||||
color: "var(--red3)",
|
||||
},
|
||||
{
|
||||
tag: tags.bool,
|
||||
color: "var(--blue2)",
|
||||
},
|
||||
{
|
||||
tag: tags.attributeName,
|
||||
color: "var(--blue2)",
|
||||
},
|
||||
{
|
||||
tag: tags.attributeValue,
|
||||
color: "var(--green)",
|
||||
},
|
||||
]);
|
||||
|
||||
// create lint
|
||||
import { HTMLHint } from "htmlhint";
|
||||
|
||||
let LastLint = performance.now();
|
||||
export const HTMLLint = linter((view) => {
|
||||
let diagnostics: Diagnostic[] = [];
|
||||
|
||||
// get hints
|
||||
const hints = HTMLHint.verify(
|
||||
view.state.sliceDoc(0, view.state.doc.length),
|
||||
{
|
||||
"doctype-first": true,
|
||||
// attributes (https://htmlhint.com/docs/user-guide/list-rules#attributes)
|
||||
"attr-lowercase": true,
|
||||
"attr-value-not-empty": true,
|
||||
"attr-value-double-quotes": true,
|
||||
// tags (https://htmlhint.com/docs/user-guide/list-rules#tags)
|
||||
"tag-self-close": true,
|
||||
"tag-pair": true,
|
||||
// id (https://htmlhint.com/docs/user-guide/list-rules#id)
|
||||
"id-unique": true,
|
||||
}
|
||||
);
|
||||
|
||||
// turn hints into diagnostics
|
||||
if (hints.length > 0 && performance.now() - LastLint > 100) {
|
||||
LastLint = performance.now(); // can only run lint every 100ms
|
||||
|
||||
// ...
|
||||
for (const hint of hints) {
|
||||
if (hint.line === view.state.doc.lines) hint.line = 1; // do not add an error to the last line (breaks editor)
|
||||
const line = view.state.doc.line(hint.line);
|
||||
|
||||
diagnostics.push({
|
||||
from: line.from + hint.col - 1,
|
||||
to: line.from + hint.col + hint.raw.length - 1,
|
||||
severity: hint.type,
|
||||
message: `${hint.message} (${hint.line}:${hint.col})\n${hint.rule.id}: ${hint.rule.description}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// return
|
||||
return diagnostics;
|
||||
});
|
||||
|
||||
// create completion context
|
||||
|
||||
/**
|
||||
* @function BasicCompletion
|
||||
*
|
||||
* @param {CompletionContext} context
|
||||
* @return {*}
|
||||
*/
|
||||
function BasicCompletion(context: CompletionContext): any {
|
||||
let word = context.matchBefore(/\w*/);
|
||||
if (!word || (word.from == word.to && !context.explicit)) return null;
|
||||
|
||||
return {
|
||||
from: word.from,
|
||||
options: [
|
||||
{
|
||||
label: "boilerplate",
|
||||
type: "variable",
|
||||
apply: `<!DOCTYPE html>
|
||||
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Document</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<span>Hello, world!</span>
|
||||
</body>
|
||||
</html>`,
|
||||
info: "Basic HTML Page Boilerplate",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// create editor function
|
||||
export function create_editor(
|
||||
element: HTMLElement,
|
||||
custom_url: string,
|
||||
path: string
|
||||
) {
|
||||
if (globalThis.Bun) return; // must be run from client
|
||||
|
||||
const view = new EditorView({
|
||||
// @ts-ignore
|
||||
state: EditorState.create({
|
||||
doc: "",
|
||||
extensions: [
|
||||
placeholder(path),
|
||||
lineNumbers(),
|
||||
highlightActiveLineGutter(),
|
||||
highlightSpecialChars(),
|
||||
history(),
|
||||
foldGutter(),
|
||||
drawSelection(),
|
||||
dropCursor(),
|
||||
EditorState.allowMultipleSelections.of(true),
|
||||
syntaxHighlighting(DefaultHighlight, { fallback: true }),
|
||||
bracketMatching(),
|
||||
closeBrackets(),
|
||||
autocompletion({
|
||||
override: [BasicCompletion, htmlCompletionSource],
|
||||
activateOnTyping: true,
|
||||
}),
|
||||
rectangularSelection(),
|
||||
crosshairCursor(),
|
||||
highlightActiveLine(),
|
||||
lintGutter(),
|
||||
EditorView.lineWrapping,
|
||||
EditorView.updateListener.of(async (update) => {
|
||||
if (update.docChanged) {
|
||||
const content = update.state.doc.toString();
|
||||
if (content === "") return;
|
||||
|
||||
(globalThis as any).AtomicEditor.Content = content;
|
||||
}
|
||||
}),
|
||||
// keymaps
|
||||
indentOnInput(),
|
||||
indentUnit.of(" "),
|
||||
keymap.of({
|
||||
...closeBracketsKeymap,
|
||||
...defaultKeymap,
|
||||
...historyKeymap,
|
||||
...foldKeymap,
|
||||
...completionKeymap,
|
||||
...indentWithTab,
|
||||
}),
|
||||
keymap.of([
|
||||
// ...new line fix
|
||||
{
|
||||
key: "Enter",
|
||||
run: (): boolean => {
|
||||
// get current line
|
||||
const CurrentLine = view.state.doc.lineAt(
|
||||
view.state.selection.main.head
|
||||
);
|
||||
|
||||
// get indentation string (for automatic indent)
|
||||
let IndentationString =
|
||||
// gets everything before the first non-whitespace character
|
||||
CurrentLine.text.split(/[^\s]/)[0];
|
||||
|
||||
let ExtraCharacters = "";
|
||||
|
||||
// if last character of the line is }, add an indentation
|
||||
// } because it's automatically added after opened braces!
|
||||
if (
|
||||
CurrentLine.text[
|
||||
CurrentLine.text.length - 1
|
||||
] === "{" ||
|
||||
CurrentLine.text[
|
||||
CurrentLine.text.length - 1
|
||||
] === "}"
|
||||
) {
|
||||
IndentationString += " ";
|
||||
ExtraCharacters = "\n"; // auto insert line break after
|
||||
}
|
||||
|
||||
// start transaction
|
||||
const cursor = view.state.selection.main.head;
|
||||
const transaction = view.state.update({
|
||||
changes: {
|
||||
from: cursor,
|
||||
insert: `\n${IndentationString}${ExtraCharacters}`,
|
||||
},
|
||||
selection: {
|
||||
anchor:
|
||||
cursor + 1 + IndentationString.length,
|
||||
},
|
||||
scrollIntoView: true,
|
||||
});
|
||||
|
||||
if (transaction) {
|
||||
view.dispatch(transaction);
|
||||
}
|
||||
|
||||
// return
|
||||
return true;
|
||||
},
|
||||
},
|
||||
]),
|
||||
// language
|
||||
html({ autoCloseTags: true }),
|
||||
HTMLLint,
|
||||
],
|
||||
}),
|
||||
parent: element,
|
||||
});
|
||||
|
||||
// global functions
|
||||
(globalThis as any).AtomicEditor = {
|
||||
Content: "",
|
||||
Update: (content: string, clear: boolean = false) => {
|
||||
const transaction = view.state.update({
|
||||
changes: {
|
||||
from: 0,
|
||||
to: view.state.doc.length,
|
||||
insert: content,
|
||||
},
|
||||
scrollIntoView: true,
|
||||
});
|
||||
|
||||
if (transaction) {
|
||||
view.dispatch(transaction);
|
||||
}
|
||||
},
|
||||
Format: async () => {
|
||||
try {
|
||||
const formatted = await prettier.format(
|
||||
(globalThis as any).AtomicEditor.Content,
|
||||
{
|
||||
parser: "html",
|
||||
plugins: [
|
||||
EstreePlugin,
|
||||
BabelParser,
|
||||
HTMLParser,
|
||||
CSSParser,
|
||||
],
|
||||
htmlWhitespaceSensitivity: "ignore",
|
||||
// all from the project's .prettierrc
|
||||
useTabs: false,
|
||||
singleQuote: false,
|
||||
tabWidth: 4,
|
||||
trailingComma: "es5",
|
||||
printWidth: 85,
|
||||
semi: true,
|
||||
} as Options
|
||||
);
|
||||
|
||||
(globalThis as any).AtomicEditor.Update(formatted);
|
||||
} catch (err) {
|
||||
alert(err);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// handle interactions
|
||||
const preview_button = document.getElementById(
|
||||
"preview"
|
||||
) as HTMLButtonElement | null;
|
||||
|
||||
if (preview_button) {
|
||||
let url: string = "";
|
||||
preview_button.addEventListener("click", () => {
|
||||
if (url.length > 0) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// create blob
|
||||
const blob = new Blob([(globalThis as any).AtomicEditor.Content], {
|
||||
type: "text/html",
|
||||
});
|
||||
|
||||
// get url
|
||||
url = URL.createObjectURL(blob);
|
||||
|
||||
// open
|
||||
window.open(url);
|
||||
});
|
||||
}
|
||||
|
||||
const save_button = document.getElementById(
|
||||
"save"
|
||||
) as HTMLButtonElement | null;
|
||||
|
||||
if (save_button) {
|
||||
save_button.addEventListener("click", async () => {
|
||||
const res = await fetch("/api/edit-atomic", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
custom_url,
|
||||
path,
|
||||
content: (globalThis as any).AtomicEditor.Content,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const json = await res.json();
|
||||
|
||||
if (json.success === false) {
|
||||
return alert(json.message);
|
||||
} else {
|
||||
return alert("File saved");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// return
|
||||
return view;
|
||||
}
|
||||
|
||||
// default export
|
||||
export default {
|
||||
DefaultHighlight,
|
||||
create_editor,
|
||||
};
|
44
static/ts/pages/NewAtomic.ts
Normal file
44
static/ts/pages/NewAtomic.ts
Normal file
|
@ -0,0 +1,44 @@
|
|||
const error: HTMLElement = document.getElementById("error")!;
|
||||
const create_form: HTMLFormElement | null = document.getElementById(
|
||||
"create-site"
|
||||
) as HTMLFormElement | null;
|
||||
|
||||
if (create_form) {
|
||||
// create site
|
||||
create_form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const res = await fetch("/api/new", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
custom_url: create_form.custom_url.value,
|
||||
edit_password: crypto.randomUUID(),
|
||||
group_name: "",
|
||||
content: JSON.stringify({
|
||||
// db::bundlesdb::AtomicPaste
|
||||
_is_atomic: true,
|
||||
files: [
|
||||
{
|
||||
path: "/index.html",
|
||||
content: "<!-- New HTML Page -->",
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const json = await res.json();
|
||||
|
||||
if (json.success === false) {
|
||||
error.style.display = "block";
|
||||
error.innerHTML = `<div class="mdnote-title">${json.message}</div>`;
|
||||
} else {
|
||||
window.location.href = `/${json.payload.custom_url}`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// default export
|
||||
export default {};
|
|
@ -9,11 +9,13 @@ if (process.env.DO_NOT_CLEAR_DIST === undefined)
|
|||
|
||||
const output = await build({
|
||||
entrypoints: [
|
||||
"./static/ts/editors/AtomicEditor.ts",
|
||||
"./static/ts/editors/MarkdownEditor.ts",
|
||||
"./static/ts/editors/ClientFixMarkdown.ts",
|
||||
"./static/ts/editors/SettingsEditor.ts",
|
||||
"./static/ts/pages/AuthPages.ts",
|
||||
"./static/ts/pages/Footer.ts",
|
||||
"./static/ts/pages/NewAtomic.ts",
|
||||
],
|
||||
minify: {
|
||||
identifiers: true,
|
||||
|
|
|
@ -10,7 +10,6 @@
|
|||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
|
|
Loading…
Reference in a new issue