Blog Uploading support (#2)

Provides basic functionality when uploading blog posts.

Pending cleanup.

Reviewed-on: #2
Co-authored-by: Armored-Dragon <forgejo3829105@armoreddragon.com>
Co-committed-by: Armored-Dragon <forgejo3829105@armoreddragon.com>
pull/2/head
Armored Dragon 2023-11-08 10:08:40 +00:00 committed by Armored Dragon
parent f5cab625ec
commit 624b46e345
35 changed files with 1347 additions and 151 deletions

1
.gitignore vendored
View File

@ -4,3 +4,4 @@
*.code-workspace *.code-workspace
.vscode/settings.json .vscode/settings.json
/frontend/public/img/.dev /frontend/public/img/.dev
/prisma/migrations

View File

@ -13,14 +13,25 @@ const s3 = new S3Client({
endpoint: process.env.S3_ENDPOINT, endpoint: process.env.S3_ENDPOINT,
}); });
const settings = require("../settings"); 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) { async function registerUser(username, password, options) {
const new_user = await prisma.user.create({ data: { username: username, password: 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 } = {}) { async function getUser({ id, username } = {}) {
if (id || username) { if (id || username) {
let user; let user;
@ -31,5 +42,311 @@ async function getUser({ id, username } = {}) {
else return { success: true, data: user }; 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 `<div class='image-container'><img src='${image}'></div>`;
}
}
});
content = content.replace(side_by_side, (match, inner_content) => {
return `<div class='side-by-side'>${inner_content}</div>`;
});
// 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 };

View File

@ -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 };

View File

@ -1,19 +1,27 @@
const settings = require("../settings"); const settings = require("../settings");
async function userRegistration(username, password) { async function userRegistration(username, password) {
const active_settings = settings.getSettings();
if (!username) return { success: false, message: "No username provided" }; if (!username) return { success: false, message: "No username provided" };
if (!password) return { success: false, message: "No password provided" }; if (!password) return { success: false, message: "No password provided" };
// TODO: Admin customizable minimum password length if (password.length < active_settings.USER_MINIMUM_PASSWORD_LENGTH) return { success: false, message: "Password not long enough" };
if (password.length < 4) return { success: false, message: "Password not long enough" };
// Check if username only uses URL safe characters // 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 // All good! Validation complete
return { success: true }; 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\-_.~]+$/; const pattern = /^[A-Za-z0-9\-_.~]+$/;
return pattern.test(str); return pattern.test(str);
} }

View File

@ -4,12 +4,12 @@ const settings = require("../settings");
async function registerUser(username, password) { async function registerUser(username, password) {
// Get current and relevant settings // 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 const form_valid = await validate.userRegistration(username, password); // Check form for errors
// Set variables for easy reading // Set variables for easy reading
const registration_allowed = s[0]; const registration_allowed = active_settings.ACCOUNT_REGISTRATION;
const setup_complete = s[1]; const setup_complete = active_settings.SETUP_COMPLETE;
if (!registration_allowed && setup_complete) return { success: false, message: "Registration is disabled" }; // Registration disabled 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 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 } }; 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 };

View File

@ -1,33 +1,81 @@
const internal = require("./core/internal_api"); const internal = require("./core/internal_api");
const external = require("./core/external_api");
const bcrypt = require("bcrypt"); const bcrypt = require("bcrypt");
const settings = require("./settings"); 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) { async function index(request, response) {
// Check if the master admin has been created // Check if the master admin has been created
const is_setup_complete = (await settings.act("SETUP_COMPLETE")) || false; const is_setup_complete = (await settings.act("SETUP_COMPLETE")) || false;
if (!is_setup_complete) return response.redirect("/register"); 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) { 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) { 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) { 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) { async function blogList(req, res) {
response.render("blogList.ejs", { user: request.session.user || null, website_name: process.env.WEBSITE_NAME }); 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) { 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) { async function admin(request, response) {
const reg_allowed = await settings.userRegistrationAllowed(); response.render("admin.ejs", { ...getDefaults(request) });
response.render("admin.ejs", { user: request.session.user || null, website_name: process.env.WEBSITE_NAME, settings: { registration_enabled: reg_allowed } });
} }
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) { async function registerPost(request, response) {
const hashedPassword = await bcrypt.hash(request.body.password, 10); // Hash the password for security :^) 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 }; request.session.user = { username: login.data.username, id: login.data.id };
response.json({ success: true }); response.json({ success: true });
} }
async function settingPost(request, response) { async function settingPost(request, response) {
const user = await internal.getUser({ id: request.session.user.id }); const user = await internal.getUser({ id: request.session.user.id });
if (!user.success) return response.json({ success: false, message: user.message }); 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 (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 }); 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,
};

View File

@ -1 +1,3 @@
// TODO: Permissions file
function checkPermissions(role, { minimum = true }) {} function checkPermissions(role, { minimum = true }) {}

View File

@ -4,9 +4,14 @@ persistent_setting.init({ dir: "data/site/" });
let settings = { let settings = {
SETUP_COMPLETE: false, SETUP_COMPLETE: false,
ACCOUNT_REGISTRATION: false, ACCOUNT_REGISTRATION: false,
HIDE_LOGIN: false,
BLOG_UPLOADING: false, BLOG_UPLOADING: false,
USER_MINIMUM_PASSWORD_LENGTH: 6, 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) { async function act(key, value) {

View File

@ -23,11 +23,16 @@
} }
.blog-entry .thumbnail { .blog-entry .thumbnail {
width: 150px; width: 150px;
height: 150px;
} }
.blog-entry .thumbnail img { .blog-entry .thumbnail img {
height: 100%; height: 100%;
width: 100%; width: 100%;
} }
.blog-entry .blog-info {
display: flex;
flex-direction: column;
}
.blog-entry .blog-info .blog-title { .blog-entry .blog-info .blog-title {
font-size: 20px; font-size: 20px;
border-bottom: 1px solid #9f9f9f; border-bottom: 1px solid #9f9f9f;
@ -46,8 +51,40 @@
.blog-entry .blog-info .blog-description { .blog-entry .blog-info .blog-description {
color: #9f9f9f; color: #9f9f9f;
margin-top: 10px; 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 { .blog-entry:last-of-type {
margin-bottom: inherit; 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;
}
}

View File

@ -28,6 +28,7 @@ $quiet-text: #9f9f9f;
.thumbnail { .thumbnail {
width: 150px; width: 150px;
height: 150px;
img { img {
height: 100%; height: 100%;
width: 100%; width: 100%;
@ -35,6 +36,8 @@ $quiet-text: #9f9f9f;
} }
.blog-info { .blog-info {
display: flex;
flex-direction: column;
.blog-title { .blog-title {
font-size: 20px; font-size: 20px;
border-bottom: 1px solid $quiet-text; border-bottom: 1px solid $quiet-text;
@ -54,6 +57,29 @@ $quiet-text: #9f9f9f;
.blog-description { .blog-description {
color: $quiet-text; color: $quiet-text;
margin-top: 10px; 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 { .blog-entry:last-of-type {
margin-bottom: inherit; 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;
}
}
}
}

View File

@ -13,7 +13,9 @@
} }
.e-header .e-thumbnail img { .e-header .e-thumbnail img {
height: 100%; height: 100%;
width: 150px; width: 100%;
-o-object-fit: cover;
object-fit: cover;
} }
.e-header .e-description { .e-header .e-description {
width: 100%; width: 100%;
@ -52,11 +54,20 @@
aspect-ratio: 16/9; aspect-ratio: 16/9;
margin: auto; margin: auto;
display: flex; display: flex;
position: relative;
} }
.e-image-area .image img { .e-image-area .image img {
height: 100%; height: 100%;
margin: auto; 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 { .e-content {
background-color: #222; background-color: #222;
@ -65,18 +76,19 @@
} }
.e-content textarea { .e-content textarea {
font-size: 16px; font-size: 16px;
min-height: 300px; min-height: 200px;
color: white; color: white;
} }
.e-settings { .e-settings {
height: 40px; min-height: 40px;
width: 100%; width: 100%;
background-color: #222; background-color: #222;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
padding: 5px; padding: 5px;
box-sizing: border-box; box-sizing: border-box;
margin-bottom: 1rem;
} }
.e-settings .publish-date { .e-settings .publish-date {
display: flex; display: flex;
@ -91,6 +103,16 @@
.e-settings textarea { .e-settings textarea {
width: 200px; 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, input,
textarea { textarea {

View File

@ -15,7 +15,8 @@ $background-body: #222;
img { img {
height: 100%; height: 100%;
width: 150px; width: 100%;
object-fit: cover;
} }
} }
@ -60,11 +61,21 @@ $background-body: #222;
aspect-ratio: 16/9; aspect-ratio: 16/9;
margin: auto; margin: auto;
display: flex; display: flex;
position: relative;
img { img {
height: 100%; height: 100%;
margin: auto; 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; margin-bottom: 10px;
textarea { textarea {
font-size: 16px; font-size: 16px;
min-height: 300px; min-height: 200px;
color: white; color: white;
} }
} }
.e-settings { .e-settings {
height: 40px; min-height: 40px;
width: 100%; width: 100%;
background-color: $background-body; background-color: $background-body;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
padding: 5px; padding: 5px;
box-sizing: border-box; box-sizing: border-box;
margin-bottom: 1rem;
.publish-date { .publish-date {
display: flex; display: flex;
div { div {
@ -101,6 +113,18 @@ $background-body: #222;
textarea { textarea {
width: 200px; width: 200px;
} }
.horizontal-buttons {
width: 100%;
display: flex;
flex-direction: row;
flex-wrap: wrap;
button {
width: 200px;
height: 40px;
}
}
} }
input, input,

View File

@ -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%;
}
}

View File

@ -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%;
}
}
}
}

View File

@ -2,6 +2,7 @@ body {
margin: 0; margin: 0;
background-color: #111; background-color: #111;
color: white; color: white;
font-family: Verdana, Geneva, Tahoma, sans-serif;
} }
.header { .header {
@ -58,11 +59,18 @@ button:focus {
background-color: #122d57; background-color: #122d57;
} }
button.good { button.good,
a.good {
background-color: #015b01; background-color: #015b01;
} }
button.bad { button.yellow,
a.yellow {
background-color: #4a4a00;
}
button.bad,
a.bad {
background-color: #8e0000; background-color: #8e0000;
} }
@ -75,9 +83,78 @@ button.bad {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
} }
.page .horizontal-button-container button { .page .horizontal-button-container button,
width: 100px; .page .horizontal-button-container a {
width: 120px;
min-height: 30px; 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 { .hidden {
@ -86,6 +163,6 @@ button.bad {
@media screen and (max-width: 1010px) { @media screen and (max-width: 1010px) {
.page { .page {
width: 100%; width: 95%;
} }
} }

View File

@ -5,6 +5,7 @@ body {
margin: 0; margin: 0;
background-color: #111; background-color: #111;
color: white; color: white;
font-family: Verdana, Geneva, Tahoma, sans-serif;
} }
.header { .header {
@ -65,10 +66,16 @@ button:focus {
background-color: #122d57; background-color: #122d57;
} }
button.good { button.good,
a.good {
background-color: #015b01; background-color: #015b01;
} }
button.bad { button.yellow,
a.yellow {
background-color: #4a4a00;
}
button.bad,
a.bad {
background-color: #8e0000; background-color: #8e0000;
} }
@ -81,9 +88,90 @@ button.bad {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
button { button,
width: 100px; a {
width: 120px;
min-height: 30px; 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) { @media screen and (max-width: 1010px) {
.page { .page {
width: 100%; width: 95%;
} }
} }

View File

@ -2,10 +2,10 @@ async function toggleState(setting_name, new_value, element_id) {
// Show spinner // Show spinner
qs(`#${element_id}`).parentNode.querySelector(".spinner").classList.remove("hidden"); qs(`#${element_id}`).parentNode.querySelector(".spinner").classList.remove("hidden");
// Send request const form = {
const form = new FormData(); setting_name: setting_name,
form.append("setting_name", setting_name); value: JSON.stringify(new_value),
form.append("value", new_value); };
const response = await request("/setting", "POST", form); 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; qs(`#${element_id}`).children[0].innerText = new_text;
// Function // 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}`).removeAttribute("onclick");
qs(`#${element_id}`).setAttribute("onclick", add_function); qs(`#${element_id}`).setAttribute("onclick", add_function);
} }

View File

@ -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();
});
}

View File

@ -5,7 +5,10 @@ const qsa = (selector) => document.querySelectorAll(selector);
async function request(url, method, body) { async function request(url, method, body) {
const response = await fetch(url, { const response = await fetch(url, {
method: method, 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() }; return { status: response.status, body: await response.json() };
} }

View File

@ -4,11 +4,7 @@ async function requestLogin() {
password: qs("#password").value, password: qs("#password").value,
}; };
const form = new FormData(); const account_response = await request("/login", "POST", account_information);
form.append("username", account_information.username);
form.append("password", account_information.password);
const account_response = await request("/login", "POST", form);
// Check response for errors // Check response for errors

View File

@ -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 const thumbnail_area = qs(".e-thumbnail");
image_drop_area.addEventListener("dragover", (event) => { const image_area = qs(".e-image-area");
event.preventDefault(); const text_area = qs(".e-content textarea");
image_drop_area.style.border = "2px dashed #333";
});
image_drop_area.addEventListener("dragleave", () => {
image_drop_area.style.border = "2px dashed #ccc";
});
image_drop_area.addEventListener("drop", async (e) => { // Style
function stylizeDropArea(element) {
// Drag over start
element.addEventListener("dragover", (e) => {
e.preventDefault(); e.preventDefault();
image_drop_area.style.border = "2px dashed #ccc"; 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";
});
stylizeDropArea(thumbnail_area);
stylizeDropArea(image_area);
// Upload an image to the blog post
image_area.addEventListener("drop", async (e) => {
const files = e.dataTransfer.files; const files = e.dataTransfer.files;
for (let i = 0; i < files.length; i++) { for (let i = 0; i < files.length; i++) {
let image_object = { id: crypto.randomUUID(), data_blob: new Blob([await files[i].arrayBuffer()]) }; // Each dropped image will be stored in this formatted object
content_images.push(image_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(); 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() { function updateImages() {
const image_div = (img_id, img_url) =>
`<div class="image"><img data-image_id="${img_id}" src="${img_url}" /><div><a onclick="deleteImage('${img_id}')">X</a></div></div>`;
// Clear existing listings // Clear existing listings
qsa(".e-image-area .image").forEach((entry) => entry.remove()); qsa(".e-image-area .image").forEach((entry) => entry.remove());
// Add new entries based on saved list // Clear placeholder text
content_images.forEach((image) => { if (qs(".e-image-area .placeholder")) qs(".e-image-area .placeholder").remove();
image_drop_area.insertAdjacentHTML("beforeend", `<div class="image"><img src="${URL.createObjectURL(image.data_blob)}" /></div>`);
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();

View File

@ -4,11 +4,7 @@ async function requestRegister() {
password: qs("#password").value, password: qs("#password").value,
}; };
const form = new FormData(); const account_response = await request("/register", "POST", account_information);
form.append("username", account_information.username);
form.append("password", account_information.password);
const account_response = await request("/register", "POST", form);
// Check response for errors // Check response for errors

View File

@ -21,13 +21,27 @@
<div class="spinner hidden"> <div class="spinner hidden">
<div>↺</div> <div>↺</div>
</div> </div>
<% if (!settings.registration_enabled ) { %> <% if (!settings.ACCOUNT_REGISTRATION ) { %>
<button id="registration_toggle" onclick="toggleState('ACCOUNT_REGISTRATION', true, this.id)" class="bad"><div>Disabled</div></button> <button id="registration_toggle" onclick="toggleState('ACCOUNT_REGISTRATION', true, this.id)" class="bad"><div>Disabled</div></button>
<% } else { %> <% } else { %>
<button id="registration_toggle" onclick="toggleState('ACCOUNT_REGISTRATION', false, this.id)" class="good"><div>Enabled</div></button> <button id="registration_toggle" onclick="toggleState('ACCOUNT_REGISTRATION', false, this.id)" class="good"><div>Enabled</div></button>
<%}%> <%}%>
</div> </div>
</div> </div>
<div class="settings-row">
<div class="label">Hide Login</div>
<div class="button-group">
<div class="spinner hidden">
<div>↺</div>
</div>
<% if (!settings.HIDE_LOGIN ) { %>
<button id="hidelogin_toggle" onclick="toggleState('HIDE_LOGIN', true, this.id)" class="bad"><div>Disabled</div></button>
<% } else { %>
<button id="hidelogin_toggle" onclick="toggleState('HIDE_LOGIN', false, this.id)" class="good"><div>Enabled</div></button>
<%}%>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -3,19 +3,30 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" type="text/css" href="/css/index.css" /> <link rel="stylesheet" type="text/css" href="/css/blogSingle.css" />
<link rel="stylesheet" type="text/css" href="/css/theme.css" /> <link rel="stylesheet" type="text/css" href="/css/theme.css" />
<link rel="stylesheet" type="text/css" href="/css/blog-list.css" />
<script src="/js/generic.js"></script> <script src="/js/generic.js"></script>
<title><%= website_name %> | Author</title> <title><%= website_name %> | <%= blog_post.title %></title>
</head> </head>
<body> <body>
<%- include("partials/header.ejs", {selected: 'home'}) %> <%- include("partials/header.ejs", {selected: 'home'}) %>
<div class="page"></div> <div class="page">
<%if(logged_in_user) {%>
<div class="blog-admin">
<div class="horizontal-button-container">
<a class="yellow" href=""><span>Edit Profile</span></a>
</div>
</div>
<%}%>
<div class="title"><%= blog_post.title%></div>
<%- blog_post.content %>
</div>
<%- include("partials/footer.ejs") %> <%- include("partials/footer.ejs") %>
</body> </body>
</html> </html>
<script defer src="/js/login.js"></script> <script defer src="/js/blogSingle.js"></script>

View File

@ -13,12 +13,15 @@
<%- include("partials/header.ejs", {selected: 'home'}) %> <%- include("partials/header.ejs", {selected: 'home'}) %>
<div class="page"> <div class="page">
<%- include("partials/blog-admin.ejs") %> <%- include("partials/blog-entry.ejs", {thumbnail: '/img/.dev/square.png', title: 'Title', description: <%if(logged_in_user) {%> <%- include("partials/blog-admin.ejs") %> <%}%>
'Description', author: 'Author'}) %> <!-- -->
<% for(post of blog_list) { %>
<!-- -->
<%- include("partials/blog-entry.ejs", {post:post}) %>
<!-- -->
<% } %> <%- include("partials/pagination.ejs") %>
</div> </div>
<%- include("partials/footer.ejs") %> <%- include("partials/footer.ejs") %>
</body> </body>
</html> </html>
<script defer src="/js/login.js"></script>

View File

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" type="text/css" href="/css/new-blog.css" /> <link rel="stylesheet" type="text/css" href="/css/blogNew.css" />
<link rel="stylesheet" type="text/css" href="/css/theme.css" /> <link rel="stylesheet" type="text/css" href="/css/theme.css" />
<script src="/js/generic.js"></script> <script src="/js/generic.js"></script>
<title><%= website_name %> | New Blog</title> <title><%= website_name %> | New Blog</title>
@ -14,27 +14,57 @@
<div class="page"> <div class="page">
<div class="e-header"> <div class="e-header">
<div class="e-thumbnail"> <div class="e-thumbnail">
<%if(existing_blog?.thumbnail) {%>
<img src="<%= existing_blog?.thumbnail %>" />
<%} else {%>
<img src="/img/.dev/square.png" /> <img src="/img/.dev/square.png" />
<% } %>
</div> </div>
<div class="e-description"> <div class="e-description">
<input type="text" placeholder="Title..." /> <input id="title" type="text" placeholder="Title..." value="<%= existing_blog.title %>" />
<textarea placeholder="Description..."></textarea> <textarea id="description" placeholder="Description..."><%= existing_blog.description %></textarea>
</div> </div>
</div> </div>
<div class="e-image-area"> <div class="e-image-area">
<% if(existing_blog.raw_images?.length) { %> <% for (image in existing_blog.raw_images) {%>
<div class="image">
<img data-image_id="<%=existing_blog.raw_images[image]%>" src="<%= existing_blog.images[image] %>" />
<div><a onclick="deleteImage('<%= existing_blog.raw_images[image] %>')">X</a></div>
</div>
<%}%> <% } else {%>
<div class="placeholder">Drop images here</div> <div class="placeholder">Drop images here</div>
<% } %>
</div> </div>
<div class="e-content"> <div class="e-content">
<textarea placeholder="Tell us about your subject..."></textarea> <textarea id="content" placeholder="Tell us about your subject..."><%= existing_blog.raw_content %></textarea>
</div> </div>
<div class="e-settings"> <div class="e-settings">
<div class="publish-date"> <div class="publish-date">
<div>Publish On</div> <div>Publish On</div>
<input type="date" value="2023-09-26" /> <% if(existing_blog.publish_date) {%>
<input type="time" value="13:00" step="3600" /> <input id="date" type="date" value="<%= existing_blog.publish_date %>" />
<%} else { %>
<input id="date" type="date" value="1990-01-01" />
<% } %>
<!-- -->
<% if(existing_blog.publish_date) {%>
<input id="time" type="time" step="3600" value="<%= existing_blog.publish_time %>" />
<%} else { %>
<input id="time" type="time" value="13:00" step="3600" />
<% } %>
</div>
</div>
<div class="e-settings">
<div class="horizontal-buttons">
<button onclick="publishBlog(true)" class="yellow"><span>Unlisted</span></button>
<% if(existing_blog.id){%>
<button onclick="publishBlog(false, true)"><span>Edit</span></button>
<% } else {%>
<button onclick="publishBlog()"><span>Publish</span></button>
<% } %>
</div> </div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,35 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" type="text/css" href="/css/blogSingle.css" />
<link rel="stylesheet" type="text/css" href="/css/theme.css" />
<script src="/js/generic.js"></script>
<title><%= website_name %> | <%= blog_post.title %></title>
</head>
<body>
<%- include("partials/header.ejs", {selected: 'home'}) %>
<div class="page">
<!-- TODO: Check if user is the owner or an admin -->
<!-- TODO: Confirmation before deleting post -->
<%if(logged_in_user) {%>
<div class="blog-admin">
<div class="horizontal-button-container">
<a id="delete-post" href="#" class="bad"><span>Delete Post</span></a>
<a class="yellow" href="/blog/<%=blog_post.id %>/edit"><span>Edit Post</span></a>
</div>
</div>
<%}%>
<div class="title"><%= blog_post.title%></div>
<%- blog_post.content %>
</div>
<%- include("partials/footer.ejs") %>
</body>
</html>
<script defer src="/js/blogSingle.js"></script>

View File

@ -1,5 +1,5 @@
<div class="blog-admin"> <div class="blog-admin">
<div class="horizontal-button-container"> <div class="horizontal-button-container">
<a href="/blog/new">New Post</a> <a href="/blog/new"><span>New Post</span></a>
</div> </div>
</div> </div>

View File

@ -1,9 +1,16 @@
<div class="blog-entry"> <div class="blog-entry">
<a src="#" class="thumbnail"> <a href="/blog/<%= post.id %>" class="thumbnail">
<img src="<%= thumbnail %>" /> <img src="<%= post.thumbnail %>" />
</a> </a>
<div class="blog-info"> <div class="blog-info">
<div class="blog-title"><a href="#"><%= title %></a> <a href="#" class="author">By: <%= author %></a></div> <div class="blog-title">
<div class="blog-description"><%= description %></div> <a href="/blog/<%= post.id %>"><%= post.title %> </a>
<a href="/author/<%= post.owner.id %>" class="author">By: <%= post.owner.username %></a>
</div>
<div class="blog-description"><%= post.description %></div>
<div class="blog-action">
<div class="date"><%= post.publish_date.toLocaleString('en-US', { dateStyle:'medium'}) %></div>
<a href="/blog/<%= post.id %>">Read this post -></a>
</div>
</div> </div>
</div> </div>

View File

@ -8,14 +8,14 @@
<a class="nav-button" href="/blog"> <a class="nav-button" href="/blog">
<div>Blog</div> <div>Blog</div>
</a> </a>
<% if (user) { %> <% if (logged_in_user) { %>
<a class="nav-button" href="/author/<%= user.username %>"> <a class="nav-button" href="/author/<%= logged_in_user.username %>">
<div>Profile</div> <div>Profile</div>
</a> </a>
<% } else {%> <% } else {%> <% if(!settings.HIDE_LOGIN) {%>
<a class="nav-button" href="/login"> <a class="nav-button" href="/login">
<div>Login</div> <div>Login</div>
</a> </a>
<% } %> <% } %> <% } %>
</div> </div>
</div> </div>

View File

@ -0,0 +1,29 @@
<div class="pagination">
<% if(pagination.includes(Number(current_page) - 1)) {%>
<a href="<%= loaded_page %>?page=<%= Number(current_page) - 1 %>">
<span>Previous</span>
</a>
<% } else {%>
<a class="disabled">
<span>Previous</span>
</a>
<%}%>
<div class="page-list">
<% for(page of pagination) { %> <% if (page == current_page) {%>
<a href="#" class="active"><span><%=page + 1%></span></a>
<% } else { %>
<a href="<%= loaded_page %>?page=<%=page %>" class=""><span><%=page + 1%></span></a>
<% } %> <% } %>
</div>
<% if(pagination.includes(Number(current_page) + 1)) {%>
<a href="<%= loaded_page %>?page=<%= Number(current_page) + 1 %>">
<span>Next</span>
</a>
<% } else {%>
<a class="disabled">
<span>Next</span>
</a>
<%}%>
</div>

28
package-lock.json generated
View File

@ -17,6 +17,7 @@
"ejs": "^3.1.9", "ejs": "^3.1.9",
"express": "^4.18.2", "express": "^4.18.2",
"express-session": "^1.17.3", "express-session": "^1.17.3",
"feed": "^4.2.2",
"markdown-it": "^13.0.1", "markdown-it": "^13.0.1",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"node-persist": "^3.1.3", "node-persist": "^3.1.3",
@ -2358,6 +2359,17 @@
"fxparser": "src/cli/cli.js" "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": { "node_modules/filelist": {
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", "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", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" "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": { "node_modules/semver": {
"version": "7.5.4", "version": "7.5.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", "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", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" "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": { "node_modules/xtend": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

View File

@ -33,6 +33,7 @@
"ejs": "^3.1.9", "ejs": "^3.1.9",
"express": "^4.18.2", "express": "^4.18.2",
"express-session": "^1.17.3", "express-session": "^1.17.3",
"feed": "^4.2.2",
"markdown-it": "^13.0.1", "markdown-it": "^13.0.1",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"node-persist": "^3.1.3", "node-persist": "^3.1.3",

View File

@ -17,7 +17,7 @@ model User {
role Role @default(VISITOR) role Role @default(VISITOR)
blog_posts BlogPost[] blog_posts BlogPost[]
edits Edit[] profile_page ProfilePage?
@@index([username, role]) @@index([username, role])
} }
@ -25,29 +25,26 @@ model User {
model BlogPost { model BlogPost {
id String @id @unique @default(uuid()) id String @id @unique @default(uuid())
title String? title String?
short_description String? description String?
content String? content String?
thumbnail String? thumbnail String?
tags String[] images String[]
status PostStatus @default(DRAFT) visibility PostStatus @default(UNLISTED)
group Role @default(AUTHOR)
owner User? @relation(fields: [ownerid], references: [id]) owner User? @relation(fields: [ownerid], references: [id])
ownerid String? ownerid String?
edits Edit[]
// Dates // Dates
publish_date DateTime? publish_date DateTime?
created_date DateTime @default(now()) created_date DateTime @default(now())
} }
model Edit { model ProfilePage {
id String @id @unique @default(uuid()) id String @id @unique @default(uuid())
content String?
images String[]
visibility PostStatus @default(UNLISTED)
owner User @relation(fields: [ownerid], references: [id]) owner User @relation(fields: [ownerid], references: [id])
ownerid String ownerid String @unique
reason String? @default("No reason provided.")
blogpost BlogPost? @relation(fields: [blogpost_id], references: [id], onDelete: Cascade)
blogpost_id String?
} }
enum Role { enum Role {
@ -58,13 +55,6 @@ enum Role {
} }
enum PostStatus { enum PostStatus {
DRAFT UNLISTED
PUBLISHED PUBLISHED
} }
enum ProjectStatus {
DELAYED
IN_PROGRESS
SUCCESS
FAILURE
}

32
yab.js
View File

@ -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 // Express
const express = require("express"); const express = require("express");
const session = require("express-session"); const session = require("express-session");
@ -11,10 +5,6 @@ const app = express();
const path = require("path"); const path = require("path");
// Security and encryption
const bcrypt = require("bcrypt");
const crypto = require("crypto");
// Local modules // Local modules
const page_scripts = require("./backend/page_scripts"); 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("view-engine", "ejs");
app.set("views", path.join(__dirname, "frontend/views")); app.set("views", path.join(__dirname, "frontend/views"));
app.use(express.static(path.join(__dirname, "frontend/public"))); app.use(express.static(path.join(__dirname, "frontend/public")));
const bodyParser = require("body-parser"); app.use(express.json({ limit: "500mb" }));
app.use(bodyParser.json()); app.use(express.urlencoded({ extended: false }));
app.use(bodyParser.urlencoded({ extended: true }));
app.use(upload.array());
app.use( app.use(
session({ session({
secret: crypto.randomBytes(128).toString("base64"), secret: require("crypto").randomBytes(128).toString("base64"),
resave: false, resave: false,
saveUninitialized: false, saveUninitialized: false,
}) })
@ -44,19 +32,21 @@ app.post("/register", checkNotAuthenticated, page_scripts.registerPost);
// Account Required Endpoints // Account Required Endpoints
app.post("/setting", checkAuthenticated, page_scripts.settingPost); app.post("/setting", checkAuthenticated, page_scripts.settingPost);
app.get("/blog/new", checkAuthenticated, page_scripts.blogNew); 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); // app.delete("/logout", page_scripts.logout);
// Image Endpoints
// app.post("/api/image", checkAuthenticated, upload.fields([{ name: "image" }]), page_scripts.uploadImage);
// Endpoints // Endpoints
app.get("/", page_scripts.index); app.get("/", page_scripts.index);
app.get("/author/:author_username", page_scripts.author); app.get("/author/:author_username", page_scripts.author);
app.get("/admin", checkAuthenticated, page_scripts.admin); app.get("/admin", checkAuthenticated, page_scripts.admin);
app.get("/blog", page_scripts.blogList); app.get("/blog", page_scripts.blogList);
// app.get("/blog/:id", page_scripts.blogSingle); app.get("/blog/:blog_id", page_scripts.blogSingle);
// app.get("/projects", page_scripts.projectList); 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) { function checkAuthenticated(req, res, next) {
if (req.session.user) return next(); if (req.session.user) return next();