From b2354645b2b232734b0ccf0be450aa23e0e7d654 Mon Sep 17 00:00:00 2001 From: Armored Dragon Date: Thu, 25 Apr 2024 08:49:49 -0500 Subject: [PATCH] Media upload pruning. Uploaded media is now pruned automatically every time a post is updated. Minor cleanup. Groundwork for media types other than images. Signed-off-by: Armored Dragon --- backend/core/core.js | 136 +++++---- backend/core/internal_api.js | 4 +- frontend/public/img/textarea/sidebyside.svg | 38 --- frontend/public/img/textarea/video.svg | 38 --- frontend/public/js/admin.js | 72 ----- frontend/public/js/blogSingle.js | 8 - frontend/public/js/generic.js | 15 - frontend/public/js/newBlog.js | 257 ------------------ frontend/public/js/postList.js | 6 - .../views/themes/default/js/richTextEditor.js | 3 + 10 files changed, 82 insertions(+), 495 deletions(-) delete mode 100644 frontend/public/img/textarea/sidebyside.svg delete mode 100644 frontend/public/img/textarea/video.svg delete mode 100644 frontend/public/js/admin.js delete mode 100644 frontend/public/js/blogSingle.js delete mode 100644 frontend/public/js/generic.js delete mode 100644 frontend/public/js/newBlog.js delete mode 100644 frontend/public/js/postList.js diff --git a/backend/core/core.js b/backend/core/core.js index 56961ea..85d8a58 100644 --- a/backend/core/core.js +++ b/backend/core/core.js @@ -276,9 +276,30 @@ async function editPost({ requester_id, post_id, post_content }) { // Save the updated post to the database await prisma.post.update({ where: { id: post.id }, data: post_formatted }); + // Prune the post to save on storage + await pruneMedia({ parent_id: post_id, parent_type: "posts" }); + return _r(true); } -async function deletePost({ requester_id, post_id }) {} +async function deletePost({ requester_id, post_id }) { + let user = await getUser({ user_id: requester_id }); + let post = await getPost({ post_id: post_id }); + + if (!user.success) return { success: false, message: user.message || "User does not exist" }; + user = user.data; + + if (!post.success) return { success: false, message: post.message || "Post does not exist" }; + post = post.data; + + let can_delete = post.owner.id === user.id || user.role === "ADMIN"; + + if (!can_delete) return { success: false, message: "Action not permitted" }; + + await prisma.post.delete({ where: { id: post.id } }); + _deleteS3Directory(post.id, "post"); + + return { success: true }; +} // User Profiles async function getBiography({ requester_id, author_id }) { if (!author_id) return _r(false, "No Author specified."); @@ -328,58 +349,81 @@ async function updateBiography({ requester_id, author_id, biography_content }) { return _r(true); } -// TODO: Replace -async function deleteBlog(blog_id, requester_id) { - const user = await getUser({ user_id: requester_id }); - const post = await getPost({ post_id: blog_id }); - - if (!post.success) return { success: false, message: post.message || "Post does not exist" }; - - let can_delete = post.data.owner.id === user.data.id || user.data.role === "ADMIN"; - - if (can_delete) { - await prisma.post.delete({ where: { id: post.data.id } }); - _deleteS3Directory(post.data.id, "blog"); - return { success: true }; - } - - return { success: false, message: "Action not permitted" }; -} -async function uploadMedia({ parent_id, file_buffer, file_extension }) { +async function uploadMedia({ parent_id, parent_type, file_buffer, content_type }) { if (!use_s3_storage) return null; const content_name = crypto.randomUUID(); let maximum_image_resolution = { width: 1920, height: 1080 }; - // const image_extensions = ["png", "webp", "jpg", "jpeg"]; - // const video_extensions = ["mp4", "webm", "mkv", "avi"]; - // Images const compressed_image = await sharp(Buffer.from(file_buffer.split(",")[1], "base64"), { animated: true }) .resize({ ...maximum_image_resolution, withoutEnlargement: true, fit: "inside" }) .webp({ quality: 90, animated: true }) .toBuffer(); + let extension; + let s3_content_type; + + if (content_type.includes("image/")) { + extension = ".webp"; + s3_content_type = "image/webp"; + } + const params = { Bucket: process.env.S3_BUCKET_NAME, - Key: `${process.env.ENVIRONMENT}/posts/${parent_id}/${content_name}.webp`, + Key: `${process.env.ENVIRONMENT}/${parent_type}/${parent_id}/${content_name}${extension}`, Body: compressed_image, - ContentType: "image/webp", + ContentType: s3_content_type, }; const command = new PutObjectCommand(params); await s3.send(command); - return content_name; + return content_name + extension; } -async function getMedia({ parent_id, file_name }) { +async function getMedia({ parent_id, parent_type, file_name }) { if (!use_s3_storage) return null; - const params = { Bucket: process.env.S3_BUCKET_NAME, Key: `${process.env.ENVIRONMENT}/posts/${parent_id}/${file_name}.webp` }; + const params = { Bucket: process.env.S3_BUCKET_NAME, Key: `${process.env.ENVIRONMENT}/${parent_type}/${parent_id}/${file_name}` }; return await getSignedUrl(s3, new GetObjectCommand(params), { expiresIn: 3600 }); } -// TODO: -// Will be done automatically in the background. -// Unreferenced images and media will be deleted -async function deleteMedia({ parent_id, file_name }) {} + +async function deleteMedia({ parent_id, parent_type, file_name }) { + const request_params = { + Bucket: process.env.S3_BUCKET_NAME, + Key: `${process.env.ENVIRONMENT}/${parent_type}/${parent_id}/${file_name}`, + }; + + const command = new DeleteObjectCommand(request_params); + await s3.send(command); + + return { success: true }; +} + +// This cleans up all unused and unreferenced media files. +// NOTE: Only made for posts, as that is all that there is right now +async function pruneMedia({ parent_id, parent_type }) { + let post = await getPost({ post_id: parent_id }); + + if (!post.success) return { success: false, message: post.message || "Post does not exist" }; + post = post.data; + + // const total_number_of_media = post.raw_media.length; + + for (let media_index = 0; post.raw_media.length > media_index; media_index++) { + if (!post.raw_content.includes(post.raw_media[media_index])) { + // Delete the media off of the S3 server + let delete_request = await deleteMedia({ parent_id: parent_id, parent_type: parent_type, file_name: post.raw_media[media_index] }); + if (!delete_request.success) continue; + + // Remove from the list in the database + await post.raw_media.splice(media_index, 1); + // Save in the database + await prisma.post.update({ where: { id: parent_id }, data: { media: post.raw_media } }); + + // Delete was successful, move the index back to account for new array length + media_index--; + } + } +} async function getTags({ order = "count" } = {}) { if (order == "count") { @@ -404,32 +448,6 @@ async function getTags({ order = "count" } = {}) { // Will be done automatically in the background async function deleteTag({ tag_id }) {} -// async function deleteImage(image, requester_id) { -// const user = await getUser({ id: requester_id }); -// const post = await getBlog({ id: image.parent, raw: true }); - -// // Check if post exists -// if (!post) return { success: false, message: "Post does not exist" }; - -// // Check for permissions -// if (post.owner.id !== user.data.id || user.data.role !== "ADMIN") return { success: false, message: "User is not permitted" }; - -// let image_index = post.raw_images.indexOf(image.id); - -// post.raw_images.splice(image_index, 1); - -// await prisma.post.update({ where: { id: post.id }, data: { images: post.raw_images } }); - -// const request_params = { -// Bucket: process.env.S3_BUCKET_NAME, -// Key: `${process.env.ENVIRONMENT}/${image.parent_type}/${image.parent}/${image.id}.webp`, -// }; - -// const command = new DeleteObjectCommand(request_params); -// await s3.send(command); - -// return { success: true }; -// } async function _deleteS3Directory(id, type) { // Erase database images from S3 server const folder_params = { Bucket: process.env.S3_BUCKET_NAME, Prefix: `${process.env.ENVIRONMENT}/${type}/${id}` }; @@ -467,7 +485,7 @@ async function _renderPost(post) { if (post.media) { for (i = 0; post.media.length > i; i++) { - post.media[i] = await getMedia({ parent_id: post.id, file_name: post.media[i] }); + post.media[i] = await getMedia({ parent_id: post.id, parent_type: "posts", file_name: post.media[i] }); } } @@ -599,4 +617,4 @@ const _r = (s, m) => { return { success: s, message: m }; }; -module.exports = { settings, newUser, getUser, editUser, getPost, newPost, editPost, getBiography, updateBiography, uploadMedia, deleteBlog, getTags, postSetting, getSetting }; +module.exports = { settings, newUser, getUser, editUser, getPost, newPost, editPost, deletePost, getBiography, updateBiography, uploadMedia, getTags, postSetting, getSetting }; diff --git a/backend/core/internal_api.js b/backend/core/internal_api.js index c07b749..8bc5354 100644 --- a/backend/core/internal_api.js +++ b/backend/core/internal_api.js @@ -46,7 +46,7 @@ async function postSetting(request, response) { async function postImage(request, response) { // TODO: Permissions for uploading images // TODO: Verification for image uploading - return response.json(await core.uploadMedia({ parent_id: request.body.post_id, file_buffer: request.body.buffer })); + return response.json(await core.uploadMedia({ parent_id: request.body.post_id, parent_type: request.body.parent_type, file_buffer: request.body.buffer, content_type: request.body.content_type })); } async function deleteImage(req, res) { // TODO: Permissions for deleting image @@ -54,7 +54,7 @@ async function deleteImage(req, res) { } async function deleteBlog(req, res) { // TODO: Permissions for deleting blog - return res.json(await core.deleteBlog(req.body.id, req.session.user.id)); + return res.json(await core.deletePost({ post_id: req.body.id, requester_id: req.session.user.id })); } async function patchBlog(req, res) { // FIXME: validate does not return post id diff --git a/frontend/public/img/textarea/sidebyside.svg b/frontend/public/img/textarea/sidebyside.svg deleted file mode 100644 index 406a71e..0000000 --- a/frontend/public/img/textarea/sidebyside.svg +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - diff --git a/frontend/public/img/textarea/video.svg b/frontend/public/img/textarea/video.svg deleted file mode 100644 index 11aec2c..0000000 --- a/frontend/public/img/textarea/video.svg +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - diff --git a/frontend/public/js/admin.js b/frontend/public/js/admin.js deleted file mode 100644 index 3bfa6fd..0000000 --- a/frontend/public/js/admin.js +++ /dev/null @@ -1,72 +0,0 @@ -async function toggleState(setting_name, new_value, element_id) { - // Show spinner - qs(`#${element_id}`).parentNode.querySelector(".spinner").classList.remove("hidden"); - - const form = { - setting_name: setting_name, - value: JSON.stringify(new_value), - }; - - const response = await request("/setting", "POST", form); - - qs(`#${element_id}`).parentNode.querySelector(".spinner").classList.add("hidden"); - - // TODO: On failure, notify the user - // Check response for errors - if (response.body.success) { - // Update visual to reflect current setting - // Class - const add_class = new_value ? "good" : "bad"; - const remove_class = new_value ? "bad" : "good"; - qs(`#${element_id}`).classList.remove(remove_class); - qs(`#${element_id}`).classList.add(add_class); - - // Text - const new_text = new_value ? "Enabled" : "Disabled"; - qs(`#${element_id}`).children[0].innerText = new_text; - - // Function - const add_function = new_value ? `toggleState('${setting_name}', false, this.id)` : `toggleState('${setting_name}', true, this.id)`; - qs(`#${element_id}`).removeAttribute("onclick"); - qs(`#${element_id}`).setAttribute("onclick", add_function); - } -} - -async function updateValue(key_pressed, setting_name, new_value, element_id) { - if (key_pressed !== 13) return; - // Show spinner - qs(`#${element_id}`).parentNode.querySelector(".spinner").classList.remove("hidden"); - - const form = { - setting_name: setting_name, - value: new_value, - }; - - const response = await request("/setting", "POST", form); - - qs(`#${element_id}`).parentNode.querySelector(".spinner").classList.add("hidden"); - - // TODO: On failure, notify the user - // Check response for errors - if (response.body.success) { - } -} - -function toggleActiveCategory(category_id) { - // Pages ---------------- - // Hide all pages - qsa(".category-page").forEach((page) => { - page.classList.add("hidden"); - }); - // Show requested page - qs(`#${category_id}`).classList.remove("hidden"); - - // Navigation bar ------- - // Unactive all buttons - qsa(".category-navigation button").forEach((btn) => { - btn.classList.remove("active"); - }); - // Active current page - qs(`#${category_id}-nav-btn`).classList.add("active"); - qs(`#${category_id}-nav-btn`).blur(); // Unfocus the button -} diff --git a/frontend/public/js/blogSingle.js b/frontend/public/js/blogSingle.js deleted file mode 100644 index 2133cb8..0000000 --- a/frontend/public/js/blogSingle.js +++ /dev/null @@ -1,8 +0,0 @@ -const delete_post_btn = qs("#delete-post"); - -if (delete_post_btn) { - delete_post_btn.addEventListener("click", async () => { - let req = await request("/api/web/blog", "DELETE", { id: window.location.href.split("/")[4] }); - if (req.body.success) location.reload(); - }); -} diff --git a/frontend/public/js/generic.js b/frontend/public/js/generic.js deleted file mode 100644 index 6a035a5..0000000 --- a/frontend/public/js/generic.js +++ /dev/null @@ -1,15 +0,0 @@ -// Quick document selectors -const qs = (selector) => document.querySelector(selector); -const qsa = (selector) => document.querySelectorAll(selector); - -// TODO: Try/Catch for failed requests -async function request(url, method, body) { - const response = await fetch(url, { - method: method, - headers: { - "Content-Type": "application/json", // Set the Content-Type header - }, - body: JSON.stringify(body), - }); - return { status: response.status, body: await response.json() }; -} diff --git a/frontend/public/js/newBlog.js b/frontend/public/js/newBlog.js deleted file mode 100644 index d7b1f72..0000000 --- a/frontend/public/js/newBlog.js +++ /dev/null @@ -1,257 +0,0 @@ -const blog_id = window.location.href.split("/")[4]; - -let existing_images = []; -let pending_images = []; -let pending_thumbnail = {}; - -const thumbnail_area = qs(".e-thumbnail"); -const image_area = qs(".e-image-area"); -const post_content_area = qs(".e-content textarea"); - -// Style -function stylizeDropArea(element) { - // Drag over start - element.addEventListener("dragover", (e) => { - e.preventDefault(); - e.target.classList.add("drag-over"); - }); - - // Drag over leave - element.addEventListener("dragleave", (e) => { - e.target.classList.remove("drag-over"); - }); - // Do nothing on drop - element.addEventListener("drop", (e) => { - e.preventDefault(); - }); -} - -// Auto resize on page load -post_content_area.style.height = post_content_area.scrollHeight + "px"; -post_content_area.style.minHeight = post_content_area.scrollHeight + "px"; - -// Auto expand blog area -post_content_area.addEventListener("input", (e) => { - post_content_area.style.height = post_content_area.scrollHeight + "px"; - post_content_area.style.minHeight = e.target.scrollHeight + "px"; -}); - -stylizeDropArea(thumbnail_area); -stylizeDropArea(image_area); - -// Upload an image to the blog post -image_area.addEventListener("drop", async (e) => { - const files = e.dataTransfer.files; - - for (let i = 0; i < files.length; i++) { - // Each dropped image will be stored in this formatted object - const image_object = { - id: crypto.randomUUID(), - data_blob: new Blob([await files[i].arrayBuffer()]), - content_type: files[i].type, - }; - - // Add the image's data to the list - pending_images.push(image_object); - } - - // Update the displayed images - updateImages(); -}); - -// Upload an image to the blog post -thumbnail_area.addEventListener("drop", async (e) => { - const file = e.dataTransfer.files[0]; - - // The thumbnail will be stored in this formatted object - const image_object = { - id: crypto.randomUUID(), - data_blob: new Blob([await file.arrayBuffer()]), - content_type: file.type, - }; - - // Add the image's data to the list - pending_thumbnail = image_object; - - // Update the visible thumbnail - qs(".e-thumbnail img").src = URL.createObjectURL(image_object.data_blob); - - // Update the displayed images - updateImages(); -}); - -// Publish or Update a blog post with the new data -async function publishBlog(unlisted, edit) { - // Format our request we will send to the server - let form_data = { - title: qs("#title").value, - description: qs("#description").value, - content: qs("#content").value, - visibility: unlisted ? "UNLISTED" : "PUBLISHED", - tags: [], - date: qs("#date").value, - time: qs("#time").value, - }; - - // Get our tags, trim them, then shove them into an array - const tags_value = qs("#tags").value || ""; - if (tags_value.length) { - let tags_array = qs("#tags").value.split(","); - tags_array.forEach((tag) => form_data.tags.push(tag.trim())); - } - - // If we have a thumbnail, read the thumbnail image and store it - if (pending_thumbnail.data_blob) { - form_data.thumbnail = { ...pending_thumbnail, data_blob: await _readFile(pending_thumbnail.data_blob) }; - } - - // We have images to upload - if (pending_images.length + existing_images.length > 0) { - // Initialize the image array - form_data.images = []; - - // Read the image, convert to base64, update the existing variable with the base64 - for (let i = 0; pending_images.length > i; i++) { - form_data.images.push({ ...pending_images[i], data_blob: await _readFile(pending_images[i].data_blob) }); - } - } - - // We are making edits to a post, not uploading a new one - if (edit) { - form_data.id = blog_id; - } - - // Send the request! - const method = edit ? "PATCH" : "post"; - - const res = await request("/api/web/blog", method, form_data); - - if (res.body.success) { - window.location.href = `/blog/${res.body.blog_id || blog_id}`; - } -} - -// Send a request to delete an image -async function deleteImage(image_id) { - const res = await request("/api/web/blog/image", "delete", { id: image_id, parent: blog_id, parent_type: "blog" }); - if (res.body.success) { - // Remove from existing images (If it exists) - let image = existing_images.find((item) => item.id === image_id); - if (image) existing_images.splice(existing_images.indexOf(image), 1); - - image = pending_images.find((item) => item.id === image_id); - if (image) pending_images.splice(pending_images.indexOf(image), 1); - - updateImages(); - } -} - -// We need to read the file contents in order to convert it to base64 to send to the server -function _readFile(file) { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - - reader.onload = () => resolve(reader.result); - reader.onerror = reject; - - reader.readAsDataURL(file); - }); -} - -function customDragString() { - const images = qsa(".e-image-area .image img"); - - images.forEach((image) => { - image.addEventListener("dragstart", (event) => { - event.dataTransfer.setData("text/plain", event.target.getAttribute("data-image_id")); - }); - }); -} - -function updateImages() { - const image_div = (img_id, img_url) => ``; - - // Clear existing listings - qsa(".e-image-area .image").forEach((entry) => entry.remove()); - - // Clear placeholder text - if (existing_images.length + pending_images.length > 0) if (qs(".e-image-area .placeholder")) qs(".e-image-area .placeholder").remove(); - - existing_images.forEach((image) => { - image_area.insertAdjacentHTML("beforeend", image_div(image.id, image.url)); - }); - - // Add new entries based on saved list - pending_images.forEach((image) => { - image_area.insertAdjacentHTML("beforeend", image_div(image.id, URL.createObjectURL(image.data_blob))); - }); - - customDragString(); -} - -// Text area custom text editor -qs("#insert-sidebyside").addEventListener("click", () => textareaAction("{sidebyside}{/sidebyside}", 12)); -qs("#insert-video").addEventListener("click", () => textareaAction("{video:}", 7)); -qs("#insert-h1").addEventListener("click", () => textareaAction("# ")); -qs("#insert-h2").addEventListener("click", () => textareaAction("## ")); -qs("#insert-h3").addEventListener("click", () => textareaAction("### ")); -qs("#insert-h4").addEventListener("click", () => textareaAction("#### ")); -qs("#insert-underline").addEventListener("click", () => textareaAction("_", undefined, true)); -qs("#insert-italics").addEventListener("click", () => textareaAction("*", undefined, true)); -qs("#insert-bold").addEventListener("click", () => textareaAction("__", undefined, true)); -qs("#insert-strike").addEventListener("click", () => textareaAction("~~", undefined, true)); -qs("#insert-sup").addEventListener("click", () => textareaAction("^", undefined, true)); - -function textareaAction(insert, cursor_position, dual_side) { - // Insert the custom string at the cursor position - const selectionStart = post_content_area.selectionStart; - const selectionEnd = post_content_area.selectionEnd; - - const textBefore = post_content_area.value.substring(0, selectionStart); - const textAfter = post_content_area.value.substring(selectionEnd); - const selectedText = post_content_area.value.substring(selectionStart, selectionEnd); - - let updatedText; - - if (dual_side) { - updatedText = `${textBefore}${insert}${selectedText}${insert}${textAfter}`; - } else { - updatedText = `${textBefore}${insert}${selectedText}${textAfter}`; - } - - post_content_area.value = updatedText; - - // Set the cursor position after the custom string - qs(".e-content textarea").focus(); - const newPosition = selectionStart + (cursor_position || insert.length); - post_content_area.setSelectionRange(newPosition, newPosition); -} - -post_content_area.addEventListener("drop", (event) => { - event.preventDefault(); - - // Get the custom string from the drag data - const customString = `\{image:${event.dataTransfer.getData("text/plain")}\}\n`; - - // Insert the custom string at the cursor position - const selectionStart = post_content_area.selectionStart; - const selectionEnd = post_content_area.selectionEnd; - - const textBefore = post_content_area.value.substring(0, selectionStart); - const textAfter = post_content_area.value.substring(selectionEnd); - - const updatedText = textBefore + customString + textAfter; - - post_content_area.value = updatedText; - - // Set the cursor position after the custom string - const newPosition = selectionStart + customString.length; - post_content_area.setSelectionRange(newPosition, newPosition); -}); - -// Load the existing images into our existing_images variable -qsa(".e-image-area img").forEach((image) => { - existing_images.push({ id: image.getAttribute("data-image_id"), url: image.src }); -}); - -updateImages(); diff --git a/frontend/public/js/postList.js b/frontend/public/js/postList.js deleted file mode 100644 index 8de545a..0000000 --- a/frontend/public/js/postList.js +++ /dev/null @@ -1,6 +0,0 @@ -qs("#search-btn").addEventListener("click", search); - -function search() { - const url_query = `search=${qs("input").value}`; - window.location.href = `${window.location.origin}${window.location.pathname}?${url_query}`; -} diff --git a/frontend/views/themes/default/js/richTextEditor.js b/frontend/views/themes/default/js/richTextEditor.js index 70b5720..06611bb 100644 --- a/frontend/views/themes/default/js/richTextEditor.js +++ b/frontend/views/themes/default/js/richTextEditor.js @@ -48,9 +48,12 @@ rich_text_editors.forEach((editor) => { data_blob: new Blob([await files[i].arrayBuffer()]), content_type: files[i].type, }; + let form_data = { buffer: await _readFile(image_object.data_blob), + content_type: image_object.content_type, post_id: window.location.href.split("/")[4], + parent_type: "posts", }; const image_uploading_request = await request("/api/web/image", "POST", form_data);