rich text editor with uploaded files

This commit is contained in:
eraden 2022-07-14 08:33:32 +02:00
parent 9564c37899
commit 4f9749949c
7 changed files with 212 additions and 101 deletions

View File

@ -17,7 +17,8 @@ customElements.define('article-form', class extends Component {
<input placeholder="Tytuł" name="title" />
</div>
<section id="body-view">
<rich-text-editor></rich-text-editor>
<rich-text-editor upload-url="/admin/news/upload">
</rich-text-editor>
<input type="hidden" name="body" />
</section>
<section>

View File

@ -4,6 +4,10 @@ customElements.define('rich-text-editor', class extends Component {
#selection;
#range;
static get observedAttributes() {
return ['upload-url'];
}
constructor() {
super(`
<style>
@ -198,34 +202,65 @@ customElements.define('rich-text-editor', class extends Component {
this.#saveSelection();
this.#wrapNode('ul', 'li');
});
const imgBtn = this.shadowRoot.querySelector('#image');
const imgInput = imgBtn.querySelector('input');
imgInput.addEventListener('click', ev => {
ev.stopPropagation();
});
imgInput.addEventListener('change', ev => {
ev.stopPropagation();
const file = imgInput.files[0];
const reader = new FileReader();
reader.onloadend = () => {
const selected = this.#selected;
if (!this.constructor.#isEditNode(selected)) return;
let el = selected;
if (el.nodeType === Node.TEXT_NODE) el = el.parentElement;
if (!el) return;
const img = new Image();
img.src = reader.result || '';
el.appendChild(img);
this.#emitChange();
};
reader.readAsDataURL(file);
});
imgBtn.addEventListener('click', ev => {
ev.stopPropagation();
ev.preventDefault();
this.#saveSelection();
imgInput.click();
});
{
const imgBtn = this.shadowRoot.querySelector('#image');
const imgInput = imgBtn.querySelector('input');
imgInput.addEventListener('click', ev => {
ev.stopPropagation();
});
imgInput.addEventListener('change', ev => {
ev.stopPropagation();
const file = imgInput.files[0];
const reader = new FileReader();
let uploaded = false;
const uuid = crypto.randomUUID();
reader.onloadend = () => {
let el = this.#targetElement;
if (!el) return;
const img = new Image();
img.id = uuid;
img.src = reader.result || '';
if (uploaded) return;
el.appendChild(img);
this.#emitChange();
};
reader.readAsDataURL(file);
const xhr = new XMLHttpRequest();
xhr.addEventListener('loadend', (ev) => {
uploaded = true;
reader.abort();
const response = JSON.parse(ev.target.response);
{
const img = this.shadowRoot.querySelector(`img[id="${ uuid }"]`);
img && img.remove();
}
let el = this.#targetElement;
if (!el) return;
const img = new Image();
img.id = uuid;
img.src = response.path;
el.appendChild(img);
this.#emitChange();
});
xhr.open("POST", this.getAttribute('upload-url'));
const f = new FormData;
let name;
if (file.name.includes('.')) {
name = `${uuid}.${file.name.split('.').pop()}`;
} else {
name = uuid;
}
f.append(name, file);
xhr.send(f);
});
imgBtn.addEventListener('click', ev => {
ev.stopPropagation();
ev.preventDefault();
this.#saveSelection();
imgInput.click();
});
}
{
const el = this.shadowRoot.querySelector("#align-justify");
el.addEventListener('click', ev => {
@ -334,9 +369,7 @@ customElements.define('rich-text-editor', class extends Component {
#wrapNode(...tags) {
const selected = this.#selected;
if (!this.constructor.#isEditNode(selected)) return;
let el = selected;
if (el.nodeType === Node.TEXT_NODE) el = el.parentElement;
let el = this.#targetElement;
if (!el) return;
for (let i = 0; i < tags.length; i++) {
@ -367,10 +400,8 @@ customElements.define('rich-text-editor', class extends Component {
}
#setStyle(setter, value) {
const s = this.#selected;
if (!this.constructor.#isEditNode(s)) return;
let el = s;
if (el.nodeType === Node.TEXT_NODE) el = el.parentElement;
let el = this.#targetElement;
if (!el) return;
if (el.id === 'edit') {
const div = el.appendChild(document.createElement('div'));
div.appendChild(s);
@ -403,6 +434,16 @@ customElements.define('rich-text-editor', class extends Component {
this.#emitChange();
}
get #targetElement() {
const selected = this.#selected;
if (!this.constructor.#isEditNode(selected)) return;
let el = selected;
if (el.nodeType === Node.TEXT_NODE)
el = el.parentElement;
if (!el) return;
return el;
}
get #selected() {
return this.#range.startContainer
}

View File

@ -11,6 +11,7 @@ use serde::Serializer;
mod restricted;
mod unrestricted;
pub mod uploads;
#[macro_export]
macro_rules! ok_or_internal {

View File

@ -27,6 +27,16 @@ macro_rules! authorize {
_ => return Err(crate::routes::Error::Unauthorized),
}
}};
(json; $t: expr, $id: expr) => {{
let account = match $id.identity() {
None => return Err(crate::routes::Error::Unauthorized.to_json()),
Some(id) => crate::queries::account_by_id($t, id).await,
};
match account {
Some(account) => account,
_ => return Err(crate::routes::Error::Unauthorized.to_json()),
}
}};
}
#[get("/account/business-items")]
@ -271,7 +281,7 @@ mod admin {
use crate::model::view::Page;
use crate::model::{db, view};
use crate::queries;
use crate::routes::{Identity, Result};
use crate::routes::{Identity, JsonResult, Result};
macro_rules! require_admin {
($t: expr, $id: expr) => {{
@ -281,6 +291,13 @@ mod admin {
}
account
}};
(json; $t: expr, $id: expr) => {{
let account = authorize!(json; &mut $t, $id);
if account.account_type == crate::model::db::AccountType::Admin {
return Err(crate::routes::Error::Forbidden.to_json());
}
account
}};
}
#[derive(Debug, Template)]
@ -357,10 +374,23 @@ mod admin {
.finish())
}
#[post("/upload")]
async fn upload(
payload: actix_multipart::Multipart,
db: Data<PgPool>,
id: Identity,
) -> JsonResult<HttpResponse> {
let pool = db.into_inner();
let mut t = crate::ok_or_internal!(json pool.begin().await);
let account = require_admin!(json; t, id);
t.commit().await.ok();
crate::routes::uploads::hande_upload(payload, Some(account.id), "news").await
}
pub fn configure(config: &mut ServiceConfig) {
config.service(
web::scope("/admin")
.service(web::scope("/news").service(create_news))
.service(web::scope("/news").service(create_news).service(upload))
.service(admin),
);
}

View File

@ -1,18 +1,16 @@
use std::collections::HashMap;
use std::path::PathBuf;
use actix_files::Files;
use actix_web::web::{Data, ServiceConfig};
use actix_web::*;
use askama::Template;
use futures_util::stream::StreamExt as _;
use serde::{Deserialize, Serialize};
use serde::Deserialize;
use sqlx::PgPool;
use tracing::*;
use crate::model::db;
use crate::model::view::{self, Page};
use crate::routes::{Error, Identity, JsonResult, Result};
use crate::routes::{Identity, JsonResult, Result};
use crate::{queries, utils};
#[derive(Template)]
@ -324,7 +322,7 @@ RETURNING id, local_business_id, name, price, item_order, picture_url
format!("--{}", uuid::Uuid::new_v4())
} else {
let name = item.picture_url.split('/').last().unwrap_or_default();
let dir = item_picture_write_dir(format!("{}", account.id));
let dir = utils::item_picture_write_dir(format!("{}", account.id));
if let Err(e) = std::fs::create_dir_all(&dir) {
error!("{e} {:?}", dir);
dbg!(e);
@ -475,18 +473,9 @@ async fn login(form: web::Form<LoginForm>, db: Data<PgPool>, id: Identity) -> Re
))
}
#[derive(Serialize)]
struct UploadResponse {
path: String,
}
fn item_picture_write_dir(account_id: String) -> PathBuf {
PathBuf::new().join("./uploads").join(account_id)
}
#[post("/upload")]
async fn upload(
mut payload: actix_multipart::Multipart,
payload: actix_multipart::Multipart,
db: Data<PgPool>,
id: Identity,
) -> JsonResult<HttpResponse> {
@ -497,54 +486,7 @@ async fn upload(
_ => None,
};
t.commit().await.ok();
let path = item_picture_write_dir(id.map(|id| format!("{id}")).unwrap_or_else(|| "tmp".into()));
std::fs::create_dir_all(&path).map_err(|e| {
error!("Cannot create upload directory {:?}", path);
dbg!(e);
Error::UploadFailed.to_json()
})?;
if let Some(item) = payload.next().await {
let mut field = item.map_err(|e| {
warn!("Malformed upload file");
dbg!(e);
Error::UploadFailed.to_json()
})?;
let name = field.name();
info!("Writing file {:?}", name);
let path = path.join(name);
while let Some(chunk) = field.next().await {
let chunk = chunk.map_err(|e| {
warn!(
"Failed to read uploaded file bytes for {:?}/{:?}",
path,
field.name()
);
dbg!(e);
Error::UploadFailed.to_json()
})?;
std::fs::write(&path, chunk).map_err(|e| {
warn!(
"Failed to write uploaded file bytes for {:?}/{:?}",
path,
field.name()
);
dbg!(e);
Error::UploadFailed.to_json()
})?;
}
Ok(HttpResponse::Ok().json(UploadResponse {
path: String::from(path.to_str().unwrap_or_default())
.strip_prefix('.')
.unwrap()
.to_string(),
}))
} else {
Ok(HttpResponse::BadRequest().finish())
}
crate::routes::uploads::hande_upload(payload, id, "accounts").await
}
#[derive(Template)]

86
src/routes/uploads.rs Normal file
View File

@ -0,0 +1,86 @@
use std::io::Write;
use std::path::Path;
use actix_web::web::Buf;
use actix_web::HttpResponse;
// use futures_util::stream::StreamExt as _;
use futures_util::StreamExt;
use serde::Serialize;
use tracing::{error, info, warn};
use crate::routes::{Error, JsonResult};
use crate::utils;
#[derive(Serialize)]
struct UploadResponse {
path: String,
}
pub async fn hande_upload(
mut payload: actix_multipart::Multipart,
id: Option<i32>,
ty: &str,
) -> JsonResult<HttpResponse> {
let path = utils::upload_dir(
Path::new(ty).join(id.map(|id| format!("{id}")).unwrap_or_else(|| "tmp".into())),
);
std::fs::create_dir_all(&path).map_err(|e| {
error!("Cannot create upload directory {:?}", path);
dbg!(e);
Error::UploadFailed.to_json()
})?;
if let Some(item) = payload.next().await {
let mut field = item.map_err(|e| {
warn!("Malformed upload file");
dbg!(e);
Error::UploadFailed.to_json()
})?;
let name = field.name();
info!("Writing file {:?}", name);
let path = path.join(name);
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
.map_err(|e| {
warn!(
"Failed to write uploaded file bytes for {:?}/{:?}",
path,
field.name()
);
dbg!(e);
Error::UploadFailed.to_json()
})?;
while let Some(chunk) = field.next().await {
let chunk = chunk.map_err(|e| {
warn!(
"Failed to read uploaded file bytes for {:?}/{:?}",
path,
field.name()
);
dbg!(e);
Error::UploadFailed.to_json()
})?;
file.write(chunk.chunk()).map_err(|e| {
warn!(
"Failed to write uploaded file bytes for {:?}/{:?}",
path,
field.name()
);
dbg!(e);
Error::UploadFailed.to_json()
})?;
}
Ok(HttpResponse::Ok().json(UploadResponse {
path: String::from(path.to_str().unwrap_or_default())
.strip_prefix('.')
.unwrap()
.to_string(),
}))
} else {
Ok(HttpResponse::BadRequest().finish())
}
}

View File

@ -1,3 +1,5 @@
use std::path::{Path, PathBuf};
use argon2::{Algorithm, Argon2, Params, Version};
use password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
@ -21,6 +23,14 @@ pub fn validate(pass: &str, pass_hash: &str) -> password_hash::Result<()> {
)
}
pub fn upload_dir<P: AsRef<Path>>(ty: P) -> PathBuf {
PathBuf::new().join("./uploads").join(ty)
}
pub fn item_picture_write_dir<P: AsRef<Path>>(account_id: P) -> PathBuf {
upload_dir(Path::new("accounts").join(account_id))
}
#[cfg(test)]
mod tests {
use crate::utils::{encrypt, validate};