From 624b46e345d4f3424c78be3a8b8c735227164699 Mon Sep 17 00:00:00 2001 From: Armored-Dragon Date: Wed, 8 Nov 2023 10:08:40 +0000 Subject: [PATCH] Blog Uploading support (#2) Provides basic functionality when uploading blog posts. Pending cleanup. Reviewed-on: https://git.armoreddragon.com/ArmoredDragon/yet-another-blog/pulls/2 Co-authored-by: Armored-Dragon Co-committed-by: Armored-Dragon --- .gitignore | 3 +- backend/core/core.js | 327 +++++++++++++++++- backend/core/external_api.js | 54 +++ backend/core/form_validation.js | 16 +- backend/core/internal_api.js | 29 +- backend/page_scripts.js | 103 +++++- backend/permissions.js | 2 + backend/settings.js | 5 + frontend/public/css/blog-list.css | 37 ++ frontend/public/css/blog-list.scss | 39 +++ .../public/css/{new-blog.css => blogNew.css} | 28 +- .../css/{new-blog.scss => blogNew.scss} | 30 +- frontend/public/css/blogSingle.css | 48 +++ frontend/public/css/blogSingle.scss | 62 ++++ frontend/public/css/theme.css | 87 ++++- frontend/public/css/theme.scss | 98 +++++- frontend/public/js/admin.js | 10 +- frontend/public/js/blogSingle.js | 8 + frontend/public/js/generic.js | 5 +- frontend/public/js/login.js | 6 +- frontend/public/js/newBlog.js | 207 ++++++++++- frontend/public/js/register.js | 6 +- frontend/views/admin.ejs | 16 +- frontend/views/author.ejs | 21 +- frontend/views/blogList.ejs | 11 +- frontend/views/blogNew.ejs | 42 ++- frontend/views/blogSingle.ejs | 35 ++ frontend/views/partials/blog-admin.ejs | 2 +- frontend/views/partials/blog-entry.ejs | 15 +- frontend/views/partials/header.ejs | 8 +- frontend/views/partials/pagination.ejs | 29 ++ package-lock.json | 28 ++ package.json | 1 + prisma/schema.prisma | 48 +-- yab.js | 32 +- 35 files changed, 1347 insertions(+), 151 deletions(-) rename frontend/public/css/{new-blog.css => blogNew.css} (78%) rename frontend/public/css/{new-blog.scss => blogNew.scss} (78%) create mode 100644 frontend/public/css/blogSingle.css create mode 100644 frontend/public/css/blogSingle.scss create mode 100644 frontend/public/js/blogSingle.js create mode 100644 frontend/views/blogSingle.ejs create mode 100644 frontend/views/partials/pagination.ejs diff --git a/.gitignore b/.gitignore index f41ce1f..db2f430 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ /data *.code-workspace .vscode/settings.json -/frontend/public/img/.dev \ No newline at end of file +/frontend/public/img/.dev +/prisma/migrations \ No newline at end of file diff --git a/backend/core/core.js b/backend/core/core.js index 46997f2..42a5d3c 100644 --- a/backend/core/core.js +++ b/backend/core/core.js @@ -13,14 +13,25 @@ const s3 = new S3Client({ endpoint: process.env.S3_ENDPOINT, }); const settings = require("../settings"); +const md = require("markdown-it")(); -// FIXME: This does not properly validate that a user was created async function registerUser(username, password, options) { const new_user = await prisma.user.create({ data: { username: username, password: password, ...options } }); - settings.act("SETUP_COMPLETE", true); - if (new_user) return { success: true, message: `Successfully created ${new_user.username}` }; -} + if (new_user.id) { + // If the user was created as an admin, make sure that the server knows the setup process is complete. + if (options.role === "ADMIN") settings.act("SETUP_COMPLETE", true); + + // Create a user profile page + const profile_page = await prisma.profilePage.create({ data: { owner: new_user.id } }); + if (!profile_page.id) return { success: false, message: `Error creating profile page for user ${new_user.username}` }; + + // User has been successfully created + return { success: true, message: `Successfully created ${new_user.username}` }; + } + + return { success: false, message: "Unknown error" }; +} async function getUser({ id, username } = {}) { if (id || username) { let user; @@ -31,5 +42,311 @@ async function getUser({ id, username } = {}) { else return { success: true, data: user }; } } +async function postBlog(blog_post, owner_id) { + // Check if user has permissions to upload a blog post + const user = await getUser({ id: owner_id }); + if (!user.success) return { success: false, message: "User not found" }; + if (user.data.role !== "ADMIN" && user.data.role !== "AUTHOR") return { success: false, message: "User is not permitted" }; -module.exports = { registerUser, getUser }; + const [year, month, day] = blog_post.date.split("-"); + const [hour, minute] = blog_post.time.split(":"); + let publish_date = new Date(year, month - 1, day, hour, minute); + + let blog_post_formatted = { + title: blog_post.title, + description: blog_post.description, + content: blog_post.content, + visibility: blog_post.unlisted ? "UNLISTED" : "PUBLISHED", + publish_date: publish_date, + }; + + const database_blog = await prisma.blogPost.create({ data: { ...blog_post_formatted, owner: { connect: { id: owner_id } } } }); + + let uploaded_images = []; + let uploaded_thumbnail = "DEFAULT"; + + if (blog_post.images) { + // For Each image, upload to S3 + for (let i = 0; blog_post.images.length > i; i++) { + const image = blog_post.images[i]; + const image_data = Buffer.from(image.data_blob.split(",")[1], "base64"); + const name = await _uploadImage(database_blog.id, "blog", false, image_data, image.id); + uploaded_images.push(name); + } + } + + // Upload thumbnail to S3 + if (blog_post.thumbnail) { + const image_data = Buffer.from(blog_post.thumbnail.data_blob.split(",")[1], "base64"); + const name = await _uploadImage(database_blog.id, "blog", true, image_data, blog_post.thumbnail.id); + uploaded_thumbnail = name; + } + + await prisma.blogPost.update({ where: { id: database_blog.id }, data: { images: uploaded_images, thumbnail: uploaded_thumbnail } }); + return { success: true, blog_id: database_blog.id }; +} +async function deleteBlog(blog_id, requester_id) { + const user = await getUser({ id: requester_id }); + const post = await getBlogList({ id: blog_id }); + + let can_delete = post.owner.id === user.data.id || user.data.role === "ADMIN"; + + if (can_delete) { + await prisma.blogPost.delete({ where: { id: post.id } }); + _deleteS3Directory(post.id, "blog"); + return { success: true }; + } + + return { success: false, message: "Action not permitted" }; +} +async function updateBlog(blog_post, requester_id) { + const user = await getUser({ id: requester_id }); + const post = await getBlogList({ id: blog_post.id, raw: true }); + + delete blog_post.id; + + let can_update = post.owner.id === user.data.id || user.data.role === "ADMIN"; + + if (!can_update) return { success: false, message: "User not permitted" }; + + const [year, month, day] = blog_post.date.split("-"); + const [hour, minute] = blog_post.time.split(":"); + let publish_date = new Date(year, month - 1, day, hour, minute); + + let blog_post_formatted = { + title: blog_post.title, + description: blog_post.description, + content: blog_post.content, + visibility: blog_post.unlisted ? "UNLISTED" : "PUBLISHED", + publish_date: publish_date, + }; + + await prisma.blogPost.update({ where: { id: post.id }, data: blog_post_formatted }); + + let uploaded_images = []; + let uploaded_thumbnail = "DEFAULT"; + + // For Each image, upload to S3 + if (blog_post.images) { + for (let i = 0; blog_post.images.length > i; i++) { + const image = blog_post.images[i]; + const image_data = Buffer.from(image.data_blob.split(",")[1], "base64"); + const name = await _uploadImage(post.id, "blog", false, image_data, image.id); + uploaded_images.push(name); + } + } + + let data_to_update = { + images: [...post.raw_images, ...uploaded_images], + }; + + if (blog_post.thumbnail) { + const image_data = Buffer.from(blog_post.thumbnail.data_blob.split(",")[1], "base64"); + const name = await _uploadImage(post.id, "blog", true, image_data, blog_post.thumbnail.id); + uploaded_thumbnail = name; + + data_to_update.thumbnail = uploaded_thumbnail; + } + + await prisma.blogPost.update({ where: { id: post.id }, data: data_to_update }); + + return { success: true }; +} +async function getBlogList({ id, visibility = "PUBLISHED", owner_id, raw = false } = {}, { limit = 10, page = 0 } = {}) { + if (id) { + // Get the database entry for the blog post + let post = await prisma.blogPost.findUnique({ where: { id: id }, include: { owner: true } }); + + if (!post) return null; + + if (raw) { + // Had to do this, only God knows why. + post.raw_images = []; + post.images.forEach((image) => post.raw_images.push(image)); + + post.raw_thumbnail = post.thumbnail; + post.raw_content = post.content; + } + + // Get the image urls for the post + for (i = 0; post.images.length > i; i++) { + post.images[i] = await _getImage(post.id, "blog", post.images[i]); + } + + // get thumbnail URL + post.thumbnail = await _getImage(post.id, "blog", post.thumbnail); + + // Render the markdown contents of the post + post.content = md.render(post.content); + + // Replace custom formatting with what we want + post.content = _format_blog_content(post.content, post.images); + + // Return the post with valid image urls + return post; + } + + const where_object = { + OR: [ + { + AND: [ + { + visibility: "PUBLISHED", + }, + { + publish_date: { + lte: new Date(), + }, + }, + ], + }, + + { + ownerid: owner_id, + }, + ], + }; + + const blog_posts = await prisma.blogPost.findMany({ + where: where_object, + take: limit, + skip: Math.max(page, 0) * limit, + include: { owner: true }, + orderBy: [{ publish_date: "desc" }, { created_date: "desc" }], + }); + // Get the thumbnails + for (i = 0; blog_posts.length > i; i++) { + blog_posts[i].thumbnail = await _getImage(blog_posts[i].id, "blog", blog_posts[i].thumbnail); + + // Get the image urls for the post + for (imgindx = 0; blog_posts[i].images.length > imgindx; imgindx++) { + blog_posts[i].images[imgindx] = await _getImage(blog_posts[i].id, "blog", blog_posts[i].images[imgindx]); + } + + // Render the markdown contents of the post + blog_posts[i].content = md.render(blog_posts[i].content); + + // Replace custom formatting with what we want + blog_posts[i].content = _format_blog_content(blog_posts[i].content, blog_posts[i].images); + } + // Calculate pagination + let pagination = await prisma.blogPost.count({ + where: where_object, + }); + return { data: blog_posts, pagination: _getNavigationList(page, Math.ceil(pagination / limit)) }; +} +async function deleteImage(image, requester_id) { + const user = await getUser({ id: requester_id }); + const post = await getBlogList({ 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.blogPost.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 _uploadImage(parent_id, parent_type, is_thumbnail, buffer, name) { + let size = { width: 1920, height: 1080 }; + if (is_thumbnail) size = { width: 300, height: 300 }; + + const compressed_image = await sharp(buffer, { animated: true }) + .resize({ ...size, withoutEnlargement: true, fit: "inside" }) + .webp({ quality: 90 }) + .toBuffer(); + + const params = { + Bucket: process.env.S3_BUCKET_NAME, + Key: `${process.env.ENVIRONMENT}/${parent_type}/${parent_id}/${name}.webp`, + Body: compressed_image, + ContentType: "image/webp", + }; + + const command = new PutObjectCommand(params); + await s3.send(command); + + return name; +} +async function _getImage(parent_id, parent_type, name) { + let params; + // Default image + if (name === "DEFAULT") params = { Bucket: process.env.S3_BUCKET_NAME, Key: `defaults/thumbnail.webp` }; + // Named image + else params = { Bucket: process.env.S3_BUCKET_NAME, Key: `${process.env.ENVIRONMENT}/${parent_type}/${parent_id}/${name}.webp` }; + + return await getSignedUrl(s3, new GetObjectCommand(params), { expiresIn: 3600 }); +} +async function _deleteS3Directory(id, type) { + // logger.verbose(`Deleting entire S3 image directory`); + // Erase database images from S3 server + const folder_params = { Bucket: process.env.S3_BUCKET_NAME, Prefix: `${process.env.ENVIRONMENT}/${type}/${id}` }; + + // Retrieve a list of objects in the specified directory + const listed_objects = await s3.send(new ListObjectsCommand(folder_params)); + + // If the directory is already empty, return + if (listed_objects?.Contents?.length === 0 || !listed_objects.Contents) return; + + // Create an object to specify the bucket and objects to be deleted + const delete_params = { + Bucket: process.env.S3_BUCKET_NAME, + Delete: { Objects: [] }, + }; + + // Iterate over each object and push its key to the deleteParams object + listed_objects.Contents.forEach(({ Key }) => { + delete_params.Delete.Objects.push({ Key }); + }); + + // Delete the objects specified in deleteParams + await s3.send(new DeleteObjectsCommand(delete_params)); + + // If there are more objects to delete (truncated result), recursively call the function again + // if (listed_objects.IsTruncated) await emptyS3Directory(bucket, dir); +} +function _format_blog_content(content, images) { + // Replace Images + const image_regex = /{image:([^}]+)}/g; + + // Replace Side-by-side + const side_by_side = /{sidebyside}(.*?){\/sidebyside}/gs; + + content = content.replace(image_regex, (match, image_name) => { + for (image of images) { + if (image.includes(image_name)) { + return `
`; + } + } + }); + + content = content.replace(side_by_side, (match, inner_content) => { + return `
${inner_content}
`; + }); + + // Finished formatting, return! + return content; +} +function _getNavigationList(current_page, max_page) { + current_page = Number(current_page); + max_page = Number(max_page); + + const pageList = [current_page - 2, current_page - 1, current_page, current_page + 1, current_page + 2].filter((num) => num >= 0 && num < max_page); + return pageList.slice(0, 5); +} + +module.exports = { registerUser, getUser, postBlog, updateBlog, getBlogList, deleteBlog, deleteImage }; diff --git a/backend/core/external_api.js b/backend/core/external_api.js index e69de29..00df8ac 100644 --- a/backend/core/external_api.js +++ b/backend/core/external_api.js @@ -0,0 +1,54 @@ +const feed_lib = require("feed").Feed; +const internal = require("./internal_api"); + +// TODO: Expose ATOM Feed items +function getBaseFeed() { + return new feed_lib({ + title: process.env.WEBSITE_NAME, + description: `${process.env.S3_REGION} RSS Feed`, + id: process.env.BASE_URL, + link: process.env.BASE_URL, + // image: "http://example.com/image.png", + // favicon: "http://example.com/favicon.ico", + // copyright: "All rights reserved 2013, John Doe", + // generator: "awesome", // optional, default = 'Feed for Node.js' + feedLinks: { + json: `${process.env.BASE_URL}/json`, + atom: `${process.env.BASE_URL}/atom`, + }, + }); +} + +async function getFeed({ type = "rss" }) { + // Get the base feed + let feed = getBaseFeed(); + + // Get posts + let posts = await internal.getBlogList({}, { limit: 20 }); + + // For each post, add a formatted object to the feed + posts.data.forEach((post) => { + let formatted = { + title: post.title, + description: post.description, + id: post.id, + link: `${process.env.BASE_URL}/blog/${post.id}`, + content: post.content, + date: new Date(post.publish_date), + image: post.thumbnail, + author: [ + { + name: post.owner.username, + link: `${process.env.BASE_URL}/author/${post.owner.username}`, + }, + ], + }; + + feed.addItem(formatted); + }); + // if (type === "rss") return feed.rss2(); + if (type === "atom") return feed.atom1(); + // if (type === "json") return feed.json1(); +} + +module.exports = { getFeed }; diff --git a/backend/core/form_validation.js b/backend/core/form_validation.js index eb6b506..c6ae3a9 100644 --- a/backend/core/form_validation.js +++ b/backend/core/form_validation.js @@ -1,19 +1,27 @@ const settings = require("../settings"); async function userRegistration(username, password) { + const active_settings = settings.getSettings(); if (!username) return { success: false, message: "No username provided" }; if (!password) return { success: false, message: "No password provided" }; - // TODO: Admin customizable minimum password length - if (password.length < 4) return { success: false, message: "Password not long enough" }; + if (password.length < active_settings.USER_MINIMUM_PASSWORD_LENGTH) return { success: false, message: "Password not long enough" }; // Check if username only uses URL safe characters - if (!is_url_safe(username)) return { success: false, message: "Username is not URL safe" }; + if (!_isUrlSafe(username)) return { success: false, message: "Username is not URL safe" }; // All good! Validation complete return { success: true }; } -function is_url_safe(str) { +async function blogPost(blog_object) { + // TODO: Validate blog posts before upload + // Check title length + // Check description length + // Check content length + // Check valid date +} + +function _isUrlSafe(str) { const pattern = /^[A-Za-z0-9\-_.~]+$/; return pattern.test(str); } diff --git a/backend/core/internal_api.js b/backend/core/internal_api.js index 60d4ff0..0589320 100644 --- a/backend/core/internal_api.js +++ b/backend/core/internal_api.js @@ -4,12 +4,12 @@ const settings = require("../settings"); async function registerUser(username, password) { // Get current and relevant settings - const s = Promise.all([settings.act("ACCOUNT_REGISTRATION"), settings.act("SETUP_COMPLETE")]); + const active_settings = settings.getSettings(); const form_valid = await validate.userRegistration(username, password); // Check form for errors // Set variables for easy reading - const registration_allowed = s[0]; - const setup_complete = s[1]; + const registration_allowed = active_settings.ACCOUNT_REGISTRATION; + const setup_complete = active_settings.SETUP_COMPLETE; if (!registration_allowed && setup_complete) return { success: false, message: "Registration is disabled" }; // Registration disabled if (!form_valid.success) return form_valid; // Registration details did not validate @@ -36,4 +36,25 @@ async function loginUser(username, password) { return { success: true, data: { username: existing_user.data.username, id: existing_user.data.id, password: existing_user.data.password } }; } -module.exports = { registerUser, loginUser }; +async function getBlogList({ id, visibility, owner_id, raw } = {}, { page = 0, limit = 10 } = {}) { + const blog_list = await core.getBlogList({ id: id, visibility: visibility, owner_id: owner_id, raw: raw }, { page: page, limit: limit }); + return blog_list; +} + +async function getUser({ id } = {}) { + return await core.getUser({ id: id }); +} + +async function postBlog(blog_post, owner_id) { + return await core.postBlog(blog_post, owner_id); +} +async function deleteBlog(blog_id, owner_id) { + return await core.deleteBlog(blog_id, owner_id); +} +async function updateBlog(blog_post, requester_id) { + return await core.updateBlog(blog_post, requester_id); +} +async function deleteImage(image_data, requester_id) { + return await core.deleteImage(image_data, requester_id); +} +module.exports = { registerUser, loginUser, postBlog, getBlogList, deleteBlog, updateBlog, deleteImage, getUser }; diff --git a/backend/page_scripts.js b/backend/page_scripts.js index 6a92fb3..f5b6c15 100644 --- a/backend/page_scripts.js +++ b/backend/page_scripts.js @@ -1,33 +1,81 @@ const internal = require("./core/internal_api"); +const external = require("./core/external_api"); const bcrypt = require("bcrypt"); const settings = require("./settings"); +function getDefaults(req) { + const active_settings = settings.getSettings(); + return { logged_in_user: req.session.user, website_name: process.env.WEBSITE_NAME, settings: active_settings }; +} + async function index(request, response) { // Check if the master admin has been created const is_setup_complete = (await settings.act("SETUP_COMPLETE")) || false; if (!is_setup_complete) return response.redirect("/register"); - response.render("index.ejs", { user: request.session.user || null, website_name: process.env.WEBSITE_NAME }); + response.redirect("/blog"); } function register(request, response) { - response.render("register.ejs", { user: request.session.user || null, website_name: process.env.WEBSITE_NAME }); + response.render("register.ejs", getDefaults(request)); } function login(request, response) { - response.render("login.ejs", { user: request.session.user || null, website_name: process.env.WEBSITE_NAME }); + response.render("login.ejs", getDefaults(request)); } function author(request, response) { - response.render("author.ejs", { user: request.session.user || null, website_name: process.env.WEBSITE_NAME }); + response.render("author.ejs", getDefaults(request)); } -function blogList(request, response) { - response.render("blogList.ejs", { user: request.session.user || null, website_name: process.env.WEBSITE_NAME }); +async function blogList(req, res) { + const blog_list = await internal.getBlogList({ owner_id: req.session.user?.id }, { page: req.query.page || 0 }); + res.render("blogList.ejs", { + ...getDefaults(req), + blog_list: blog_list.data, + pagination: blog_list.pagination, + current_page: req.query.page || 0, + loaded_page: req.path, + }); +} +async function blogSingle(req, res) { + const blog = await internal.getBlogList({ id: req.params.blog_id }); + if (blog === null) return res.redirect("/blog"); + res.render("blogSingle.ejs", { ...getDefaults(req), blog_post: blog }); } function blogNew(request, response) { - response.render("blogNew.ejs", { user: request.session.user || null, website_name: process.env.WEBSITE_NAME }); + // TODO: Turn date formatting into function + let existing_blog = {}; + let published_date_parts = new Date().toLocaleDateString().split("/"); + const formatted_date = `${published_date_parts[2]}-${published_date_parts[0].padStart(2, "0")}-${published_date_parts[1].padStart(2, "0")}`; + existing_blog.publish_date = formatted_date; + + let published_time_parts = new Date().toLocaleTimeString([], { timeStyle: "short" }).slice(0, 4).split(":"); + const formatted_time = `${published_time_parts[0].padStart(2, "0")}:${published_time_parts[1].padStart(2, "0")}`; + existing_blog.publish_time = formatted_time; + + response.render("blogNew.ejs", { ...getDefaults(request), existing_blog: existing_blog }); +} +async function blogEdit(req, res) { + const existing_blog = await internal.getBlogList({ id: req.params.blog_id, raw: true }); + + let published_date_parts = new Date(existing_blog.publish_date).toLocaleDateString().split("/"); + const formatted_date = `${published_date_parts[2]}-${published_date_parts[0].padStart(2, "0")}-${published_date_parts[1].padStart(2, "0")}`; + existing_blog.publish_date = formatted_date; + + let published_time_parts = new Date(existing_blog.publish_date).toLocaleTimeString([], { timeStyle: "short" }).slice(0, 4).split(":"); + const formatted_time = `${published_time_parts[0].padStart(2, "0")}:${published_time_parts[1].padStart(2, "0")}`; + existing_blog.publish_time = formatted_time; + + res.render("blogNew.ejs", { ...getDefaults(req), existing_blog: existing_blog }); } async function admin(request, response) { - const reg_allowed = await settings.userRegistrationAllowed(); - response.render("admin.ejs", { user: request.session.user || null, website_name: process.env.WEBSITE_NAME, settings: { registration_enabled: reg_allowed } }); + response.render("admin.ejs", { ...getDefaults(request) }); } +async function atom(req, res) { + res.type("application/xml"); + res.send(await external.getFeed({ type: "atom" })); +} +// async function rss(req, res) { +// res.type("application/rss+xml"); +// res.send(await external.getFeed({ type: "rss" })); +// } async function registerPost(request, response) { const hashedPassword = await bcrypt.hash(request.body.password, 10); // Hash the password for security :^) @@ -44,15 +92,46 @@ async function loginPost(request, response) { request.session.user = { username: login.data.username, id: login.data.id }; response.json({ success: true }); } - async function settingPost(request, response) { const user = await internal.getUser({ id: request.session.user.id }); if (!user.success) return response.json({ success: false, message: user.message }); if (user.data.role !== "ADMIN") return response.json({ success: false, message: "User is not permitted" }); - if (request.body.setting_name === "ACCOUNT_REGISTRATION") settings.setUserRegistrationAllowed(request.body.value); + settings.act(request.body.setting_name, request.body.value); response.json({ success: true }); } -module.exports = { index, register, login, author, blogList, blogNew, admin, registerPost, loginPost, settingPost }; +async function deleteImage(req, res) { + res.json(await internal.deleteImage(req.body, req.session.user.id)); +} + +async function postBlog(req, res) { + return res.json(await internal.postBlog(req.body, req.session.user.id)); +} +async function deleteBlog(req, res) { + return res.json(await internal.deleteBlog(req.body.id, req.session.user.id)); +} +async function updateBlog(req, res) { + return res.json(await internal.updateBlog(req.body, req.session.user.id)); +} +module.exports = { + index, + register, + login, + author, + blogList, + blogNew, + blogEdit, + blogSingle, + admin, + atom, + // rss, + registerPost, + loginPost, + settingPost, + postBlog, + deleteBlog, + deleteImage, + updateBlog, +}; diff --git a/backend/permissions.js b/backend/permissions.js index 056e961..6de55a6 100644 --- a/backend/permissions.js +++ b/backend/permissions.js @@ -1 +1,3 @@ +// TODO: Permissions file + function checkPermissions(role, { minimum = true }) {} diff --git a/backend/settings.js b/backend/settings.js index ef03781..4328bdb 100644 --- a/backend/settings.js +++ b/backend/settings.js @@ -4,9 +4,14 @@ persistent_setting.init({ dir: "data/site/" }); let settings = { SETUP_COMPLETE: false, ACCOUNT_REGISTRATION: false, + HIDE_LOGIN: false, BLOG_UPLOADING: false, USER_MINIMUM_PASSWORD_LENGTH: 6, + + BLOG_MINIMUM_TITLE_LENGTH: 6, + BLOG_MINIMUM_DESCRIPTION_LENGTH: 6, + BLOG_MINIMUM_CONTENT_LENGTH: 6, }; async function act(key, value) { diff --git a/frontend/public/css/blog-list.css b/frontend/public/css/blog-list.css index 811ca4c..d35e84a 100644 --- a/frontend/public/css/blog-list.css +++ b/frontend/public/css/blog-list.css @@ -23,11 +23,16 @@ } .blog-entry .thumbnail { width: 150px; + height: 150px; } .blog-entry .thumbnail img { height: 100%; width: 100%; } +.blog-entry .blog-info { + display: flex; + flex-direction: column; +} .blog-entry .blog-info .blog-title { font-size: 20px; border-bottom: 1px solid #9f9f9f; @@ -46,8 +51,40 @@ .blog-entry .blog-info .blog-description { color: #9f9f9f; margin-top: 10px; + max-height: 100px; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + word-break: none; + overflow: hidden; + text-align: left; +} +.blog-entry .blog-info .blog-action { + display: flex; + flex-direction: row; + margin-top: auto; +} +.blog-entry .blog-info .blog-action .date { + font-size: 16px; + color: gray; + margin-right: auto; +} +.blog-entry .blog-info .blog-action a { + color: white; + font-style: italic; } .blog-entry:last-of-type { margin-bottom: inherit; +} + +@media screen and (max-width: 500px) { + .page .blog-entry { + grid-template-columns: 75px auto; + margin-bottom: 20px; + } + .page .blog-entry .thumbnail { + width: 75px; + height: 75px; + } } \ No newline at end of file diff --git a/frontend/public/css/blog-list.scss b/frontend/public/css/blog-list.scss index 9ab4eb5..e4e9cbb 100644 --- a/frontend/public/css/blog-list.scss +++ b/frontend/public/css/blog-list.scss @@ -28,6 +28,7 @@ $quiet-text: #9f9f9f; .thumbnail { width: 150px; + height: 150px; img { height: 100%; width: 100%; @@ -35,6 +36,8 @@ $quiet-text: #9f9f9f; } .blog-info { + display: flex; + flex-direction: column; .blog-title { font-size: 20px; border-bottom: 1px solid $quiet-text; @@ -54,6 +57,29 @@ $quiet-text: #9f9f9f; .blog-description { color: $quiet-text; margin-top: 10px; + max-height: 100px; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + word-break: none; + overflow: hidden; + text-align: left; + } + + .blog-action { + display: flex; + flex-direction: row; + margin-top: auto; + + .date { + font-size: 16px; + color: gray; + margin-right: auto; + } + a { + color: white; + font-style: italic; + } } } } @@ -61,3 +87,16 @@ $quiet-text: #9f9f9f; .blog-entry:last-of-type { margin-bottom: inherit; } + +@media screen and (max-width: 500px) { + .page { + .blog-entry { + grid-template-columns: 75px auto; + margin-bottom: 20px; + .thumbnail { + width: 75px; + height: 75px; + } + } + } +} diff --git a/frontend/public/css/new-blog.css b/frontend/public/css/blogNew.css similarity index 78% rename from frontend/public/css/new-blog.css rename to frontend/public/css/blogNew.css index a17a1c8..6fd2595 100644 --- a/frontend/public/css/new-blog.css +++ b/frontend/public/css/blogNew.css @@ -13,7 +13,9 @@ } .e-header .e-thumbnail img { height: 100%; - width: 150px; + width: 100%; + -o-object-fit: cover; + object-fit: cover; } .e-header .e-description { width: 100%; @@ -52,11 +54,20 @@ aspect-ratio: 16/9; margin: auto; display: flex; + position: relative; } .e-image-area .image img { height: 100%; margin: auto; } +.e-image-area .image div { + position: absolute; + right: 0; + padding: 5px 10px; + box-sizing: border-box; + background-color: darkred; + cursor: pointer; +} .e-content { background-color: #222; @@ -65,18 +76,19 @@ } .e-content textarea { font-size: 16px; - min-height: 300px; + min-height: 200px; color: white; } .e-settings { - height: 40px; + min-height: 40px; width: 100%; background-color: #222; display: flex; flex-direction: row; padding: 5px; box-sizing: border-box; + margin-bottom: 1rem; } .e-settings .publish-date { display: flex; @@ -91,6 +103,16 @@ .e-settings textarea { width: 200px; } +.e-settings .horizontal-buttons { + width: 100%; + display: flex; + flex-direction: row; + flex-wrap: wrap; +} +.e-settings .horizontal-buttons button { + width: 200px; + height: 40px; +} input, textarea { diff --git a/frontend/public/css/new-blog.scss b/frontend/public/css/blogNew.scss similarity index 78% rename from frontend/public/css/new-blog.scss rename to frontend/public/css/blogNew.scss index 4905ec9..509640c 100644 --- a/frontend/public/css/new-blog.scss +++ b/frontend/public/css/blogNew.scss @@ -15,7 +15,8 @@ $background-body: #222; img { height: 100%; - width: 150px; + width: 100%; + object-fit: cover; } } @@ -60,11 +61,21 @@ $background-body: #222; aspect-ratio: 16/9; margin: auto; display: flex; + position: relative; img { height: 100%; margin: auto; } + + div { + position: absolute; + right: 0; + padding: 5px 10px; + box-sizing: border-box; + background-color: darkred; + cursor: pointer; + } } } @@ -74,19 +85,20 @@ $background-body: #222; margin-bottom: 10px; textarea { font-size: 16px; - min-height: 300px; + min-height: 200px; color: white; } } .e-settings { - height: 40px; + min-height: 40px; width: 100%; background-color: $background-body; display: flex; flex-direction: row; padding: 5px; box-sizing: border-box; + margin-bottom: 1rem; .publish-date { display: flex; div { @@ -101,6 +113,18 @@ $background-body: #222; textarea { width: 200px; } + + .horizontal-buttons { + width: 100%; + display: flex; + flex-direction: row; + flex-wrap: wrap; + + button { + width: 200px; + height: 40px; + } + } } input, diff --git a/frontend/public/css/blogSingle.css b/frontend/public/css/blogSingle.css new file mode 100644 index 0000000..0d4427c --- /dev/null +++ b/frontend/public/css/blogSingle.css @@ -0,0 +1,48 @@ +.page .title { + background-color: #222; + padding: 10px; + box-sizing: border-box; + font-size: 24px; +} +.page .image-container { + width: 100%; + margin-bottom: 4px; +} +.page .image-container img { + width: 100%; +} +.page .side-by-side { + display: flex; + flex-flow: row wrap; + place-content: space-around; +} +.page .side-by-side .image-container { + padding: 5px; + box-sizing: border-box; + width: 50%; + margin-bottom: 0; +} +.page h1 { + border-bottom: 1px solid #777; +} +.page h2 { + width: 50%; + border-bottom: 1px solid #777; +} +.page h3 { + width: 25%; + border-bottom: 1px solid #777; +} +.page h4 { + width: 20%; + border-bottom: 1px solid #777; +} +.page a { + color: white; +} + +@media screen and (max-width: 500px) { + .page .side-by-side .image-container { + width: 100%; + } +} \ No newline at end of file diff --git a/frontend/public/css/blogSingle.scss b/frontend/public/css/blogSingle.scss new file mode 100644 index 0000000..666c0ec --- /dev/null +++ b/frontend/public/css/blogSingle.scss @@ -0,0 +1,62 @@ +.page { + .title { + background-color: #222; + padding: 10px; + box-sizing: border-box; + font-size: 24px; + } + + .image-container { + width: 100%; + margin-bottom: 4px; + + img { + width: 100%; + } + } + + .side-by-side { + display: flex; + flex-flow: row wrap; + place-content: space-around; + + .image-container { + padding: 5px; + box-sizing: border-box; + width: 50%; + margin-bottom: 0; + } + } + + h1 { + border-bottom: 1px solid #777; + } + + h2 { + width: 50%; + border-bottom: 1px solid #777; + } + h3 { + width: 25%; + border-bottom: 1px solid #777; + } + + h4 { + width: 20%; + border-bottom: 1px solid #777; + } + + a { + color: white; + } +} + +@media screen and (max-width: 500px) { + .page { + .side-by-side { + .image-container { + width: 100%; + } + } + } +} diff --git a/frontend/public/css/theme.css b/frontend/public/css/theme.css index e227aaa..39fe2c3 100644 --- a/frontend/public/css/theme.css +++ b/frontend/public/css/theme.css @@ -2,6 +2,7 @@ body { margin: 0; background-color: #111; color: white; + font-family: Verdana, Geneva, Tahoma, sans-serif; } .header { @@ -58,11 +59,18 @@ button:focus { background-color: #122d57; } -button.good { +button.good, +a.good { background-color: #015b01; } -button.bad { +button.yellow, +a.yellow { + background-color: #4a4a00; +} + +button.bad, +a.bad { background-color: #8e0000; } @@ -75,9 +83,78 @@ button.bad { display: flex; flex-direction: row; } -.page .horizontal-button-container button { - width: 100px; +.page .horizontal-button-container button, +.page .horizontal-button-container a { + width: 120px; min-height: 30px; + text-decoration: none; + display: flex; + margin-right: 5px; +} +.page .horizontal-button-container button span, +.page .horizontal-button-container a span { + margin: auto; +} +.page .horizontal-button-container button:last-of-type, +.page .horizontal-button-container a:last-of-type { + margin-right: 0; +} +.page .blog-admin { + margin-bottom: 10px; +} +.page .pagination { + display: flex; + flex-direction: row; + width: 100%; + margin: 0 auto; +} +.page .pagination a { + height: 40px; + width: 150px; + margin-right: 5px; + display: flex; + text-decoration: none; + background-color: #222; +} +.page .pagination a span { + margin: auto; + color: white; + font-size: 20px; +} +.page .pagination a:last-of-type { + margin-right: 0; + margin-left: 5px; +} +.page .pagination a.disabled { + filter: brightness(50%); +} +.page .pagination .page-list { + flex-grow: 1; + display: flex; + flex-direction: row; + margin-bottom: 50px; +} +.page .pagination .page-list a { + width: 40px; + height: 40px; + display: flex; + text-decoration: none; + background-color: #222; + border-radius: 10px; + margin: 0 10px 0 0; +} +.page .pagination .page-list a span { + margin: auto; + color: white; +} +.page .pagination .page-list a:first-of-type { + margin: auto 10px auto auto; +} +.page .pagination .page-list a:last-of-type { + margin: auto auto auto 0; +} +.page .pagination .page-list a.active { + background-color: #993d00; } .hidden { @@ -86,6 +163,6 @@ button.bad { @media screen and (max-width: 1010px) { .page { - width: 100%; + width: 95%; } } \ No newline at end of file diff --git a/frontend/public/css/theme.scss b/frontend/public/css/theme.scss index 96b164e..4695f31 100644 --- a/frontend/public/css/theme.scss +++ b/frontend/public/css/theme.scss @@ -5,6 +5,7 @@ body { margin: 0; background-color: #111; color: white; + font-family: Verdana, Geneva, Tahoma, sans-serif; } .header { @@ -65,10 +66,16 @@ button:focus { background-color: #122d57; } -button.good { +button.good, +a.good { background-color: #015b01; } -button.bad { +button.yellow, +a.yellow { + background-color: #4a4a00; +} +button.bad, +a.bad { background-color: #8e0000; } @@ -81,9 +88,90 @@ button.bad { display: flex; flex-direction: row; - button { - width: 100px; + button, + a { + width: 120px; min-height: 30px; + text-decoration: none; + // background-color: #222; + display: flex; + margin-right: 5px; + + span { + margin: auto; + } + } + + button:last-of-type, + a:last-of-type { + margin-right: 0; + } + } + + .blog-admin { + margin-bottom: 10px; + } + + .pagination { + display: flex; + flex-direction: row; + width: 100%; + margin: 0 auto; + + a { + height: 40px; + width: 150px; + margin-right: 5px; + display: flex; + text-decoration: none; + background-color: #222; + + span { + margin: auto; + color: white; + font-size: 20px; + } + } + a:last-of-type { + margin-right: 0; + margin-left: 5px; + } + + a.disabled { + filter: brightness(50%); + } + + .page-list { + flex-grow: 1; + display: flex; + flex-direction: row; + margin-bottom: 50px; + + a { + width: 40px; + height: 40px; + display: flex; + text-decoration: none; + background-color: #222; + border-radius: 10px; + margin: 0 10px 0 0; + + span { + margin: auto; + color: white; + } + } + + a:first-of-type { + margin: auto 10px auto auto; + } + a:last-of-type { + margin: auto auto auto 0; + } + + a.active { + background-color: #993d00; + } } } } @@ -93,6 +181,6 @@ button.bad { } @media screen and (max-width: 1010px) { .page { - width: 100%; + width: 95%; } } diff --git a/frontend/public/js/admin.js b/frontend/public/js/admin.js index fb1d100..4987bd6 100644 --- a/frontend/public/js/admin.js +++ b/frontend/public/js/admin.js @@ -2,10 +2,10 @@ async function toggleState(setting_name, new_value, element_id) { // Show spinner qs(`#${element_id}`).parentNode.querySelector(".spinner").classList.remove("hidden"); - // Send request - const form = new FormData(); - form.append("setting_name", setting_name); - form.append("value", new_value); + const form = { + setting_name: setting_name, + value: JSON.stringify(new_value), + }; const response = await request("/setting", "POST", form); @@ -25,7 +25,7 @@ async function toggleState(setting_name, new_value, element_id) { qs(`#${element_id}`).children[0].innerText = new_text; // Function - const add_function = new_value ? "toggleState('ACCOUNT_REGISTRATION', false, this.id)" : "toggleState('ACCOUNT_REGISTRATION', true, this.id)"; + 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); } diff --git a/frontend/public/js/blogSingle.js b/frontend/public/js/blogSingle.js new file mode 100644 index 0000000..2133cb8 --- /dev/null +++ b/frontend/public/js/blogSingle.js @@ -0,0 +1,8 @@ +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 index 29a7906..5072956 100644 --- a/frontend/public/js/generic.js +++ b/frontend/public/js/generic.js @@ -5,7 +5,10 @@ const qsa = (selector) => document.querySelectorAll(selector); async function request(url, method, body) { const response = await fetch(url, { method: method, - body: body, + 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/login.js b/frontend/public/js/login.js index a18bf0c..302a288 100644 --- a/frontend/public/js/login.js +++ b/frontend/public/js/login.js @@ -4,11 +4,7 @@ async function requestLogin() { password: qs("#password").value, }; - const form = new FormData(); - form.append("username", account_information.username); - form.append("password", account_information.password); - - const account_response = await request("/login", "POST", form); + const account_response = await request("/login", "POST", account_information); // Check response for errors diff --git a/frontend/public/js/newBlog.js b/frontend/public/js/newBlog.js index 90a021f..3711683 100644 --- a/frontend/public/js/newBlog.js +++ b/frontend/public/js/newBlog.js @@ -1,36 +1,207 @@ -let content_images = []; // List of all images to upload +const blog_id = window.location.href.split("/")[4]; -const image_drop_area = qs(".e-image-area"); +let existing_images = []; +let pending_images = []; +let pending_thumbnail = {}; -// Stylize the drop area -image_drop_area.addEventListener("dragover", (event) => { - event.preventDefault(); - image_drop_area.style.border = "2px dashed #333"; -}); -image_drop_area.addEventListener("dragleave", () => { - image_drop_area.style.border = "2px dashed #ccc"; +const thumbnail_area = qs(".e-thumbnail"); +const image_area = qs(".e-image-area"); +const text_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 expand blog area +qs(".e-content textarea").addEventListener("input", (e) => { + e.target.style.height = e.target.scrollHeight + "px"; }); -image_drop_area.addEventListener("drop", async (e) => { - e.preventDefault(); - image_drop_area.style.border = "2px dashed #ccc"; +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++) { - let image_object = { id: crypto.randomUUID(), data_blob: new Blob([await files[i].arrayBuffer()]) }; - content_images.push(image_object); + // 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(); - console.log(content_images); }); +// 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, + unlisted: unlisted ? true : false, + date: qs("#date").value, + time: qs("#time").value, + }; + + // 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}`; + } +} + +// 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()); - // Add new entries based on saved list - content_images.forEach((image) => { - image_drop_area.insertAdjacentHTML("beforeend", `
`); + // Clear placeholder text + 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.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 = text_area.selectionStart; + const selectionEnd = text_area.selectionEnd; + + const textBefore = text_area.value.substring(0, selectionStart); + const textAfter = text_area.value.substring(selectionEnd); + + const updatedText = textBefore + customString + textAfter; + + text_area.value = updatedText; + + // Set the cursor position after the custom string + const newPosition = selectionStart + customString.length; + text_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/register.js b/frontend/public/js/register.js index 3c46079..ac27160 100644 --- a/frontend/public/js/register.js +++ b/frontend/public/js/register.js @@ -4,11 +4,7 @@ async function requestRegister() { password: qs("#password").value, }; - const form = new FormData(); - form.append("username", account_information.username); - form.append("password", account_information.password); - - const account_response = await request("/register", "POST", form); + const account_response = await request("/register", "POST", account_information); // Check response for errors diff --git a/frontend/views/admin.ejs b/frontend/views/admin.ejs index 3d9b026..c6f3451 100644 --- a/frontend/views/admin.ejs +++ b/frontend/views/admin.ejs @@ -21,13 +21,27 @@ - <% if (!settings.registration_enabled ) { %> + <% if (!settings.ACCOUNT_REGISTRATION ) { %> <% } else { %> <%}%> + +
+
Hide Login
+
+ + <% if (!settings.HIDE_LOGIN ) { %> + + <% } else { %> + + <%}%> +
+
diff --git a/frontend/views/author.ejs b/frontend/views/author.ejs index ff0d0d5..e423cca 100644 --- a/frontend/views/author.ejs +++ b/frontend/views/author.ejs @@ -3,19 +3,30 @@ - + - - <%= website_name %> | Author + <%= website_name %> | <%= blog_post.title %> <%- include("partials/header.ejs", {selected: 'home'}) %> -
+
+ <%if(logged_in_user) {%> +
+ +
+ + <%}%> +
<%= blog_post.title%>
+ + <%- blog_post.content %> +
<%- include("partials/footer.ejs") %> - + diff --git a/frontend/views/blogList.ejs b/frontend/views/blogList.ejs index d1fbff7..8bb8138 100644 --- a/frontend/views/blogList.ejs +++ b/frontend/views/blogList.ejs @@ -13,12 +13,15 @@ <%- include("partials/header.ejs", {selected: 'home'}) %>
- <%- include("partials/blog-admin.ejs") %> <%- include("partials/blog-entry.ejs", {thumbnail: '/img/.dev/square.png', title: 'Title', description: - 'Description', author: 'Author'}) %> + <%if(logged_in_user) {%> <%- include("partials/blog-admin.ejs") %> <%}%> + + <% for(post of blog_list) { %> + + <%- include("partials/blog-entry.ejs", {post:post}) %> + + <% } %> <%- include("partials/pagination.ejs") %>
<%- include("partials/footer.ejs") %> - - diff --git a/frontend/views/blogNew.ejs b/frontend/views/blogNew.ejs index ca4e0c4..54a2dc2 100644 --- a/frontend/views/blogNew.ejs +++ b/frontend/views/blogNew.ejs @@ -3,7 +3,7 @@ - + <%= website_name %> | New Blog @@ -14,27 +14,57 @@
+ <%if(existing_blog?.thumbnail) {%> + + <%} else {%> + <% } %>
- - + +
+ <% if(existing_blog.raw_images?.length) { %> <% for (image in existing_blog.raw_images) {%> +
+ + +
+ <%}%> <% } else {%>
Drop images here
+ <% } %>
- +
Publish On
- - + <% if(existing_blog.publish_date) {%> + + <%} else { %> + + <% } %> + + <% if(existing_blog.publish_date) {%> + + <%} else { %> + + <% } %> +
+
+
+
+ + <% if(existing_blog.id){%> + + <% } else {%> + + <% } %>
diff --git a/frontend/views/blogSingle.ejs b/frontend/views/blogSingle.ejs new file mode 100644 index 0000000..d458b3f --- /dev/null +++ b/frontend/views/blogSingle.ejs @@ -0,0 +1,35 @@ + + + + + + + + + <%= website_name %> | <%= blog_post.title %> + + + <%- include("partials/header.ejs", {selected: 'home'}) %> + +
+ + + <%if(logged_in_user) {%> + + + <%}%> +
<%= blog_post.title%>
+ + <%- blog_post.content %> +
+ + <%- include("partials/footer.ejs") %> + + + + diff --git a/frontend/views/partials/blog-admin.ejs b/frontend/views/partials/blog-admin.ejs index 7906d8f..405a0de 100644 --- a/frontend/views/partials/blog-admin.ejs +++ b/frontend/views/partials/blog-admin.ejs @@ -1,5 +1,5 @@ diff --git a/frontend/views/partials/blog-entry.ejs b/frontend/views/partials/blog-entry.ejs index 8d24658..9062063 100644 --- a/frontend/views/partials/blog-entry.ejs +++ b/frontend/views/partials/blog-entry.ejs @@ -1,9 +1,16 @@
- - + +
- -
<%= description %>
+ +
<%= post.description %>
+
+
<%= post.publish_date.toLocaleString('en-US', { dateStyle:'medium'}) %>
+ Read this post -> +
diff --git a/frontend/views/partials/header.ejs b/frontend/views/partials/header.ejs index 6f26d97..8199ca9 100644 --- a/frontend/views/partials/header.ejs +++ b/frontend/views/partials/header.ejs @@ -8,14 +8,14 @@
Blog
- <% if (user) { %> - + <% if (logged_in_user) { %> +
Profile
- <% } else {%> + <% } else {%> <% if(!settings.HIDE_LOGIN) {%>
Login
- <% } %> + <% } %> <% } %> diff --git a/frontend/views/partials/pagination.ejs b/frontend/views/partials/pagination.ejs new file mode 100644 index 0000000..86d8f3d --- /dev/null +++ b/frontend/views/partials/pagination.ejs @@ -0,0 +1,29 @@ + diff --git a/package-lock.json b/package-lock.json index e3ae669..a059812 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "ejs": "^3.1.9", "express": "^4.18.2", "express-session": "^1.17.3", + "feed": "^4.2.2", "markdown-it": "^13.0.1", "multer": "^1.4.5-lts.1", "node-persist": "^3.1.3", @@ -2358,6 +2359,17 @@ "fxparser": "src/cli/cli.js" } }, + "node_modules/feed": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/feed/-/feed-4.2.2.tgz", + "integrity": "sha512-u5/sxGfiMfZNtJ3OvQpXcvotFpYkL0n9u9mM2vkui2nGo8b4wvDkJ8gAkYqbA8QpGyFCv3RK0Z+Iv+9veCS9bQ==", + "dependencies": { + "xml-js": "^1.6.11" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/filelist": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", @@ -3459,6 +3471,11 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, + "node_modules/sax": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.3.0.tgz", + "integrity": "sha512-0s+oAmw9zLl1V1cS9BtZN7JAd0cW5e0QH4W3LWEK6a4LaLEA2OTpGYWDY+6XasBLtz6wkm3u1xRw95mRuJ59WA==" + }, "node_modules/semver": { "version": "7.5.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", @@ -3993,6 +4010,17 @@ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, + "node_modules/xml-js": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz", + "integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==", + "dependencies": { + "sax": "^1.2.4" + }, + "bin": { + "xml-js": "bin/cli.js" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index 590c049..25dccae 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "ejs": "^3.1.9", "express": "^4.18.2", "express-session": "^1.17.3", + "feed": "^4.2.2", "markdown-it": "^13.0.1", "multer": "^1.4.5-lts.1", "node-persist": "^3.1.3", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index cf80abd..adf710a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -16,38 +16,35 @@ model User { password String role Role @default(VISITOR) - blog_posts BlogPost[] - edits Edit[] + blog_posts BlogPost[] + profile_page ProfilePage? @@index([username, role]) } model BlogPost { - id String @id @unique @default(uuid()) - title String? - short_description String? - content String? - thumbnail String? - tags String[] - status PostStatus @default(DRAFT) - group Role @default(AUTHOR) - owner User? @relation(fields: [ownerid], references: [id]) - ownerid String? - edits Edit[] + id String @id @unique @default(uuid()) + title String? + description String? + content String? + thumbnail String? + images String[] + visibility PostStatus @default(UNLISTED) + owner User? @relation(fields: [ownerid], references: [id]) + ownerid String? // Dates publish_date DateTime? created_date DateTime @default(now()) } -model Edit { - id String @id @unique @default(uuid()) - owner User @relation(fields: [ownerid], references: [id]) - ownerid String - reason String? @default("No reason provided.") - - blogpost BlogPost? @relation(fields: [blogpost_id], references: [id], onDelete: Cascade) - blogpost_id String? +model ProfilePage { + id String @id @unique @default(uuid()) + content String? + images String[] + visibility PostStatus @default(UNLISTED) + owner User @relation(fields: [ownerid], references: [id]) + ownerid String @unique } enum Role { @@ -58,13 +55,6 @@ enum Role { } enum PostStatus { - DRAFT + UNLISTED PUBLISHED } - -enum ProjectStatus { - DELAYED - IN_PROGRESS - SUCCESS - FAILURE -} diff --git a/yab.js b/yab.js index 9ba8c24..cb2d6f0 100644 --- a/yab.js +++ b/yab.js @@ -1,9 +1,3 @@ -// Multer file handling -// REVIEW: Look for a way to drop this dependency? -const multer = require("multer"); -const multer_storage = multer.memoryStorage(); -const upload = multer({ storage: multer_storage }); - // Express const express = require("express"); const session = require("express-session"); @@ -11,10 +5,6 @@ const app = express(); const path = require("path"); -// Security and encryption -const bcrypt = require("bcrypt"); -const crypto = require("crypto"); - // Local modules const page_scripts = require("./backend/page_scripts"); @@ -22,14 +12,12 @@ const page_scripts = require("./backend/page_scripts"); app.set("view-engine", "ejs"); app.set("views", path.join(__dirname, "frontend/views")); app.use(express.static(path.join(__dirname, "frontend/public"))); -const bodyParser = require("body-parser"); -app.use(bodyParser.json()); -app.use(bodyParser.urlencoded({ extended: true })); -app.use(upload.array()); +app.use(express.json({ limit: "500mb" })); +app.use(express.urlencoded({ extended: false })); app.use( session({ - secret: crypto.randomBytes(128).toString("base64"), + secret: require("crypto").randomBytes(128).toString("base64"), resave: false, saveUninitialized: false, }) @@ -44,19 +32,21 @@ app.post("/register", checkNotAuthenticated, page_scripts.registerPost); // Account Required Endpoints app.post("/setting", checkAuthenticated, page_scripts.settingPost); app.get("/blog/new", checkAuthenticated, page_scripts.blogNew); -// app.post("/blog", checkAuthenticated, page_scripts.postBlog); +app.post("/api/web/blog", checkAuthenticated, page_scripts.postBlog); +app.delete("/api/web/blog", checkAuthenticated, page_scripts.deleteBlog); +app.patch("/api/web/blog", checkAuthenticated, page_scripts.updateBlog); +app.delete("/api/web/blog/image", checkAuthenticated, page_scripts.deleteImage); // app.delete("/logout", page_scripts.logout); -// Image Endpoints -// app.post("/api/image", checkAuthenticated, upload.fields([{ name: "image" }]), page_scripts.uploadImage); - // Endpoints app.get("/", page_scripts.index); app.get("/author/:author_username", page_scripts.author); app.get("/admin", checkAuthenticated, page_scripts.admin); app.get("/blog", page_scripts.blogList); -// app.get("/blog/:id", page_scripts.blogSingle); -// app.get("/projects", page_scripts.projectList); +app.get("/blog/:blog_id", page_scripts.blogSingle); +app.get("/blog/:blog_id/edit", checkAuthenticated, page_scripts.blogEdit); +app.get("/atom", page_scripts.atom); +// app.get("/rss", page_scripts.rss); function checkAuthenticated(req, res, next) { if (req.session.user) return next();