diff --git a/backend/app.js b/backend/app.js index 3e90b0e..3e59eb4 100644 --- a/backend/app.js +++ b/backend/app.js @@ -11,6 +11,7 @@ import socialnetworkRouter from './routers/socialnetworkRouter.js'; import forumRouter from './routers/forumRouter.js'; import falukantRouter from './routers/falukantRouter.js'; import friendshipRouter from './routers/friendshipRouter.js'; +import blogRouter from './routers/blogRouter.js'; import cors from 'cors'; import './jobs/sessionCleanup.js'; @@ -40,9 +41,16 @@ app.use('/api/socialnetwork', socialnetworkRouter); app.use('/api/forum', forumRouter); app.use('/api/falukant', falukantRouter); app.use('/api/friendships', friendshipRouter); +app.use('/api/blog', blogRouter); -app.use((req, res) => { - res.status(404).send('404 Not Found'); +// Serve frontend SPA for non-API routes to support history mode clean URLs +const frontendDir = path.join(__dirname, '../frontend'); +app.use(express.static(path.join(frontendDir, 'dist'))); +app.get(/^\/(?!api\/).*/, (req, res) => { + res.sendFile(path.join(frontendDir, 'dist', 'index.html')); }); +// Fallback 404 for unknown API routes +app.use('/api/*', (req, res) => res.status(404).send('404 Not Found')); + export default app; diff --git a/backend/controllers/blogController.js b/backend/controllers/blogController.js new file mode 100644 index 0000000..048f2bc --- /dev/null +++ b/backend/controllers/blogController.js @@ -0,0 +1,174 @@ +import BlogService from '../services/blogService.js'; +import SocialNetworkService from '../services/socialnetworkService.js'; + +const blogService = new BlogService(); +const socialService = new SocialNetworkService(); + +export const createBlog = async (req, res) => { + try { + const { userid } = req.headers; // creator must be logged in + const blog = await blogService.createBlog(userid, req.body || {}); + res.json(blog); + } catch (e) { + res.status(e.status || 400).json({ error: e.message }); + } +}; + +export const updateBlog = async (req, res) => { + try { + const { userid } = req.headers; + const { id } = req.params; + const blog = await blogService.updateBlog(userid, id, req.body || {}); + res.json(blog); + } catch (e) { + res.status(e.status || 400).json({ error: e.message }); + } +}; + +export const listBlogs = async (req, res) => { + try { + const { userid } = req.headers; // optional for access checks + const blogs = await blogService.listBlogsForViewer(userid || null); + res.json(blogs); + } catch (e) { + res.status(400).json({ error: e.message }); + } +}; + +export const getBlog = async (req, res) => { + try { + const { userid } = req.headers; + const { id } = req.params; + const blog = await blogService.getBlog(id, userid || null); + res.json(blog); + } catch (e) { + res.status(e.status || 404).json({ error: e.message }); + } +}; + +export const createPost = async (req, res) => { + try { + const { userid } = req.headers; + const { id } = req.params; + const post = await blogService.createPost(userid, id, req.body || {}); + res.json(post); + } catch (e) { + res.status(e.status || 400).json({ error: e.message }); + } +}; + +export const listPosts = async (req, res) => { + try { + const { userid } = req.headers; + const { id } = req.params; + const page = Math.max(1, parseInt(req.query.page || '1', 10)); + const pageSize = Math.min(50, Math.max(1, parseInt(req.query.pageSize || '10', 10))); + const all = await blogService.listPosts(id, userid || null); + const total = all.length; + const start = (page - 1) * pageSize; + const posts = all.slice(start, start + pageSize); + res.json({ items: posts, page, pageSize, total }); + } catch (e) { + res.status(e.status || 400).json({ error: e.message }); + } +}; + +export const listBlogImages = async (req, res) => { + try { + const { userid } = req.headers; + const user = await blogService.getUserByHashedId(userid); + const blogFolder = await blogService.ensureBlogFolder(user.id); + const images = await socialService.getFolderImageList(userid, blogFolder.id); + res.json({ folderId: blogFolder.id, images }); + } catch (e) { + res.status(400).json({ error: e.message }); + } +}; + +export const uploadBlogImage = async (req, res) => { + try { + const { userid } = req.headers; + const user = await blogService.getUserByHashedId(userid); + const blogFolder = await blogService.ensureBlogFolder(user.id); + // Default visibility mirrors blog folder visibilities (already applied) + const formData = { + title: req.body?.title || 'Blog Image', + description: req.body?.description || '', + folderId: blogFolder.id, + visibility: JSON.stringify([]) + }; + const file = req.file; + const image = await socialService.uploadImage(userid, file, formData); + res.status(201).json(image); + } catch (e) { + res.status(400).json({ error: e.message }); + } +}; + +export const getPublicBlogImageByHash = async (req, res) => { + try { + const { hash } = req.params; + const filePath = await socialService.getImageFilePathPublicByHash(hash); + res.sendFile(filePath, err => { + if (err) { + console.error('Error sending file:', err); + res.status(500).json({ error: 'Error sending file' }); + } + }); + } catch (e) { + res.status(e.status || 403).json({ error: e.message || 'Access denied or image not found' }); + } +}; + +export const shareBlog = async (req, res) => { + try { + const { userid } = req.headers; + const { id } = req.params; + const { toFriends = false, emails = [] } = req.body || {}; + // Prefer configured frontend base for public links; fallback to request origin + const feBase = process.env.FRONTEND_BASE_URL; + const proto = (req.headers['x-forwarded-proto'] || req.protocol || 'http').split(',')[0].trim(); + const host = (req.headers['x-forwarded-host'] || req.get('host')); + const origin = (feBase && feBase.trim()) ? feBase.replace(/\/$/, '') : `${proto}://${host}`; + + // Compute slug from username + title (no spaces, remove unsafe chars) + const { user, blog } = await blogService.assertOwner(userid, id); + const titleNoSpaces = (blog.title || '').toString().replace(/\s+/g, ''); + const base = `${user.username || 'user'}${titleNoSpaces}`; + const slug = base.replace(/[^a-zA-Z0-9_-]/g, ''); + // Pretty public URL under frontend: /blogs/:slug + const url = `${origin}/blogs/${encodeURIComponent(slug)}`; + + const result = await blogService.shareBlog(userid, id, { toFriends, emails }, url); + res.json(result); + } catch (e) { + res.status(e.status || 400).json({ error: e.message }); + } +}; + +export const resolveBlogSlug = async (req, res) => { + try { + const { slug } = req.params; + const blogId = await blogService.resolveSlug(slug); + if (!blogId) return res.status(404).json({ error: 'Not found' }); + // Redirect to SPA history route so the client router loads the blog + const feBase = process.env.FRONTEND_BASE_URL; + const proto = (req.headers['x-forwarded-proto'] || req.protocol || 'http').split(',')[0].trim(); + const host = (req.headers['x-forwarded-host'] || req.get('host')); + const origin = (feBase && feBase.trim()) ? feBase.replace(/\/$/, '') : `${proto}://${host}`; + return res.redirect(302, `${origin}/blogs/${blogId}/${encodeURIComponent(slug)}`); + } catch (e) { + res.status(400).json({ error: e.message }); + } +}; + +export const resolveBlogSlugId = async (req, res) => { + try { + const { slug } = req.params; + const blogId = await blogService.resolveSlug(slug); + if (!blogId) return res.status(404).json({ error: 'Not found' }); + res.json({ id: blogId }); + } catch (e) { + res.status(400).json({ error: e.message }); + } +}; diff --git a/backend/controllers/navigationController.js b/backend/controllers/navigationController.js index 8547391..05c6155 100644 --- a/backend/controllers/navigationController.js +++ b/backend/controllers/navigationController.js @@ -32,6 +32,10 @@ const menuStructure = { visible: ["all"], path: "/socialnetwork/guestbook" }, + blog: { + visible: ["all"], + path: "/blogs" + }, usersearch: { visible: ["all"], path: "/socialnetwork/search" diff --git a/backend/models/associations.js b/backend/models/associations.js index 82fd012..497f1ac 100644 --- a/backend/models/associations.js +++ b/backend/models/associations.js @@ -95,6 +95,8 @@ import PoliticalOfficeHistory from './falukant/log/political_office_history.js'; import ElectionHistory from './falukant/log/election_history.js'; import Underground from './falukant/data/underground.js'; import UndergroundType from './falukant/type/underground.js'; +import Blog from './community/blog.js'; +import BlogPost from './community/blog_post.js'; export default function setupAssociations() { // RoomType 1:n Room @@ -758,4 +760,12 @@ export default function setupAssociations() { foreignKey: 'victimId', as: 'victimUndergrounds' }); + + // Blog associations + Blog.belongsTo(User, { foreignKey: 'user_id', as: 'owner' }); + User.hasMany(Blog, { foreignKey: 'user_id', as: 'blogs' }); + BlogPost.belongsTo(Blog, { foreignKey: 'blog_id', as: 'blog' }); + Blog.hasMany(BlogPost, { foreignKey: 'blog_id', as: 'posts' }); + BlogPost.belongsTo(User, { foreignKey: 'user_id', as: 'author' }); + User.hasMany(BlogPost, { foreignKey: 'user_id', as: 'blogPosts' }); } diff --git a/backend/models/community/blog.js b/backend/models/community/blog.js new file mode 100644 index 0000000..4cc1050 --- /dev/null +++ b/backend/models/community/blog.js @@ -0,0 +1,60 @@ +import { Model, DataTypes } from 'sequelize'; +import { sequelize } from '../../utils/sequelize.js'; + +class Blog extends Model {} + +Blog.init({ + userId: { + type: DataTypes.INTEGER, + allowNull: false, + field: 'user_id' + }, + title: { + type: DataTypes.STRING(255), + allowNull: false, + }, + description: { + type: DataTypes.TEXT, + allowNull: true, + }, + // 'public' or 'logged_in' + visibility: { + type: DataTypes.STRING(20), + allowNull: false, + defaultValue: 'public', + }, + ageMin: { + type: DataTypes.INTEGER, + allowNull: true, + field: 'age_min' + }, + ageMax: { + type: DataTypes.INTEGER, + allowNull: true, + field: 'age_max' + }, + // 'm' | 'f' | null; comma-separated for future-proofing (e.g., 'm,f') + genders: { + type: DataTypes.STRING(10), + allowNull: true, + }, + createdAt: { + type: DataTypes.DATE, + defaultValue: DataTypes.NOW, + field: 'created_at' + }, + updatedAt: { + type: DataTypes.DATE, + defaultValue: DataTypes.NOW, + field: 'updated_at' + } +}, { + sequelize, + modelName: 'Blog', + tableName: 'blog', + schema: 'community', + timestamps: true, + underscored: true, +}); + +export default Blog; diff --git a/backend/models/community/blog_post.js b/backend/models/community/blog_post.js new file mode 100644 index 0000000..4460f1d --- /dev/null +++ b/backend/models/community/blog_post.js @@ -0,0 +1,44 @@ +import { Model, DataTypes } from 'sequelize'; +import { sequelize } from '../../utils/sequelize.js'; + +class BlogPost extends Model {} + +BlogPost.init({ + blogId: { + type: DataTypes.INTEGER, + allowNull: false, + field: 'blog_id' + }, + userId: { + type: DataTypes.INTEGER, + allowNull: false, + field: 'user_id' + }, + title: { + type: DataTypes.STRING(255), + allowNull: false, + }, + content: { + type: DataTypes.TEXT, + allowNull: false, + }, + createdAt: { + type: DataTypes.DATE, + defaultValue: DataTypes.NOW, + field: 'created_at' + }, + updatedAt: { + type: DataTypes.DATE, + defaultValue: DataTypes.NOW, + field: 'updated_at' + } +}, { + sequelize, + modelName: 'BlogPost', + tableName: 'blog_post', + schema: 'community', + timestamps: true, + underscored: true, +}); + +export default BlogPost; diff --git a/backend/models/index.js b/backend/models/index.js index 3849673..0cf35c1 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -34,6 +34,8 @@ import MessageHistory from './forum/message_history.js'; import MessageImage from './forum/message_image.js'; import ForumForumPermission from './forum/forum_forum_permission.js'; import Friendship from './community/friendship.js'; +import Blog from './community/blog.js'; +import BlogPost from './community/blog_post.js'; import FalukantUser from './falukant/data/user.js'; import RegionType from './falukant/type/region.js'; @@ -140,6 +142,8 @@ const models = { MessageHistory, MessageImage, Friendship, + Blog, + BlogPost, // Falukant core RegionType, diff --git a/backend/routers/blogRouter.js b/backend/routers/blogRouter.js new file mode 100644 index 0000000..b0871ef --- /dev/null +++ b/backend/routers/blogRouter.js @@ -0,0 +1,32 @@ +import { Router } from 'express'; +import { authenticate } from '../middleware/authMiddleware.js'; +import multer from 'multer'; +import { createBlog, updateBlog, listBlogs, getBlog, createPost, listPosts, listBlogImages, uploadBlogImage, getPublicBlogImageByHash, shareBlog, resolveBlogSlug, resolveBlogSlugId } from '../controllers/blogController.js'; + +const upload = multer(); + +const router = Router(); + +// Public list (filtered by visibility); if logged in, pass userid in headers for access +router.get('/blogs', listBlogs); +// Public get (access enforced per blog) +router.get('/blogs/:id', getBlog); +// Public list posts (access enforced per blog) +router.get('/blogs/:id/posts', listPosts); // supports ?page=&pageSize= +// Public serve image by hash if folder has 'everyone' visibility +router.get('/blogs/images/:hash', getPublicBlogImageByHash); +// Public: resolve pretty slug to SPA route and JSON id +router.get('/blogs/slug/:slug', resolveBlogSlug); +router.get('/blogs/slug/:slug/id', resolveBlogSlugId); + +// Create/Update require login (any logged-in user can create/edit their own blog); posts require login +router.post('/blogs', authenticate, createBlog); +router.put('/blogs/:id', authenticate, updateBlog); +router.post('/blogs/:id/posts', authenticate, createPost); +router.post('/blogs/:id/share', authenticate, shareBlog); + +// Blog images: list and upload to user's Blog folder +router.get('/blogs/:id/images', authenticate, listBlogImages); +router.post('/blogs/:id/images', authenticate, upload.single('image'), uploadBlogImage); + +export default router; diff --git a/backend/services/blogService.js b/backend/services/blogService.js new file mode 100644 index 0000000..766c678 --- /dev/null +++ b/backend/services/blogService.js @@ -0,0 +1,246 @@ +import BaseService from './BaseService.js'; +import Blog from '../models/community/blog.js'; +import BlogPost from '../models/community/blog_post.js'; +import User from '../models/community/user.js'; +import UserParam from '../models/community/user_param.js'; +import UserParamType from '../models/type/user_param.js'; +import UserParamValue from '../models/type/user_param_value.js'; +import { Op } from 'sequelize'; +import Folder from '../models/community/folder.js'; +import Image from '../models/community/image.js'; +import ImageVisibilityType from '../models/type/image_visibility.js'; +import FolderImageVisibility from '../models/community/folder_image_visibility.js'; +import friendshipService from './friendshipService.js'; +import { notifyUser } from '../utils/socket.js'; +import { sendBlogShareEmail } from './emailService.js'; +import { sequelize } from '../utils/sequelize.js'; + +export default class BlogService extends BaseService { + async createBlog(hashedUserId, { title, description, visibility, ageMin, ageMax, genders }) { + const user = await this.getUserByHashedId(hashedUserId); + const blog = await Blog.create({ + userId: user.id, + title, + description: description || null, + visibility: visibility || 'public', + ageMin: ageMin ?? null, + ageMax: ageMax ?? null, + genders: Array.isArray(genders) ? genders.join(',') : (genders || null), + }); + await this.ensureBlogFolder(user.id); + await this.applyBlogFolderVisibility(user.id, blog.visibility); + return blog; + } + + async assertOwner(hashedUserId, blogId) { + const user = await this.getUserByHashedId(hashedUserId); + const blog = await Blog.findByPk(blogId); + if (!blog) { + const err = new Error('Blog not found'); + err.status = 404; + throw err; + } + if (blog.userId !== user.id) { + const err = new Error('Access denied'); + err.status = 403; + throw err; + } + return { user, blog }; + } + + async shareBlog(hashedUserId, blogId, { toFriends = false, emails = [] } = {}, url) { + const { user, blog } = await this.assertOwner(hashedUserId, blogId); + + // Notify friends (accepted) + let notifiedFriends = 0; + if (toFriends) { + try { + const friends = await friendshipService.getFriendships(hashedUserId, true); + for (const f of friends) { + if (f.accepted && f.user?.hashedId) { + notifiedFriends++; + notifyUser(f.user.hashedId, 'blogShared', { + blogId: blogId, + title: blog.title, + url, + by: user.username, + }); + } + } + } catch (e) { + // Continue even if notifications fail + console.error('Error notifying friends for blog share:', e); + } + } + + // Send emails + let emailsSent = 0; + if (Array.isArray(emails) && emails.length) { + const uniqueValid = Array.from(new Set( + emails + .map(e => (typeof e === 'string' ? e.trim() : '')) + .filter(e => e && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e)) + )); + for (const to of uniqueValid) { + try { + await sendBlogShareEmail(to, url, blog.title, user.username); + emailsSent++; + } catch (e) { + console.error('Error sending blog share email to', to, e); + } + } + } + + return { url, notifiedFriends, emailsSent }; + } + + async resolveSlug(slug) { + // Try username+title match first (legacy), then title-only match. + const sqlConcat = ` + SELECT b.id + FROM community.blog b + JOIN community.user u ON u.id = b.user_id + WHERE LOWER( + regexp_replace(u.username, '[^a-zA-Z0-9_-]', '', 'g') || + regexp_replace(regexp_replace(b.title, '\\s+', '', 'g'), '[^a-zA-Z0-9_-]', '', 'g') + ) = LOWER(:slug) + LIMIT 1 + `; + const [concatRows] = await sequelize.query(sqlConcat, { replacements: { slug } }); + if (concatRows && concatRows.length) return concatRows[0].id; + const sqlTitle = ` + SELECT b.id + FROM community.blog b + WHERE LOWER( + regexp_replace(regexp_replace(b.title, '\\s+', '', 'g'), '[^a-zA-Z0-9_-]', '', 'g') + ) = LOWER(:slug) + ORDER BY b.updated_at DESC + LIMIT 1 + `; + const [titleRows] = await sequelize.query(sqlTitle, { replacements: { slug } }); + if (titleRows && titleRows.length) return titleRows[0].id; + return null; + } + + async updateBlog(hashedUserId, blogId, data) { + const user = await this.getUserByHashedId(hashedUserId); + const blog = await Blog.findOne({ where: { id: blogId, userId: user.id } }); + if (!blog) throw new Error('Blog not found or access denied'); + const patch = { ...data }; + if (Array.isArray(patch.genders)) patch.genders = patch.genders.join(','); + const prevVisibility = blog.visibility; + await blog.update(patch); + if (patch.visibility && patch.visibility !== prevVisibility) { + await this.applyBlogFolderVisibility(user.id, blog.visibility); + } + return blog; + } + + async listBlogsForViewer(viewerHashedId = null) { + // Public blogs are always visible. Logged-in can also see logged_in, plus filter by age/gender if provided (per blog when set). + let viewer = null, age = null, gender = null; + if (viewerHashedId) { + try { viewer = await this.getUserByHashedId(viewerHashedId); } catch { viewer = null; } + if (viewer) { + const params = await this.getUserParams(viewer.id, ['birthdate', 'gender']); + for (const p of params) { + if (p.paramType.description === 'birthdate') age = this.calculateAge(p.value); + if (p.paramType.description === 'gender') { + const gv = await UserParamValue.findOne({ where: { id: p.value } }); + gender = gv ? gv.value : null; + } + } + } + } + const where = viewer ? { } : { visibility: 'public' }; + const blogs = await Blog.findAll({ where, include: [{ model: User, as: 'owner', attributes: ['username', 'hashedId'] }] }); + return blogs.filter(b => this.checkBlogAccess(b, !!viewer, age, gender)); + } + + checkBlogAccess(blog, isLoggedIn, age, gender) { + if (blog.visibility === 'public') return true; + if (!isLoggedIn) return false; + // age range check if provided + if (blog.ageMin != null && (age == null || age < blog.ageMin)) return false; + if (blog.ageMax != null && (age == null || age > blog.ageMax)) return false; + // gender check if provided + if (blog.genders) { + const allowed = blog.genders.split(',').map(s => s.trim()).filter(Boolean); + if (allowed.length && (!gender || !allowed.includes(gender))) return false; + } + return true; + } + + async getBlog(blogId, viewerHashedId = null) { + const blog = await Blog.findByPk(blogId, { include: [{ model: User, as: 'owner', attributes: ['username', 'hashedId'] }] }); + if (!blog) throw new Error('Blog not found'); + let age = null, gender = null, logged = false; + if (viewerHashedId) { + try { + const viewer = await this.getUserByHashedId(viewerHashedId); + logged = !!viewer; + if (viewer) { + const params = await this.getUserParams(viewer.id, ['birthdate', 'gender']); + for (const p of params) { + if (p.paramType.description === 'birthdate') age = this.calculateAge(p.value); + if (p.paramType.description === 'gender') { + const gv = await UserParamValue.findOne({ where: { id: p.value } }); + gender = gv ? gv.value : null; + } + } + } + } catch {} + } + if (!this.checkBlogAccess(blog, logged, age, gender)) { + const err = new Error('Access denied'); + err.status = 403; + throw err; + } + return blog; + } + + async createPost(hashedUserId, blogId, { title, content }) { + const user = await this.getUserByHashedId(hashedUserId); + const blog = await Blog.findOne({ where: { id: blogId } }); + if (!blog) throw new Error('Blog not found'); + // Only logged-in users; editor access: any logged-in (as requested) + return await BlogPost.create({ blogId: blog.id, userId: user.id, title, content }); + } + + async listPosts(blogId, viewerHashedId = null) { + // Enforce blog visibility before listing + await this.getBlog(blogId, viewerHashedId); + // pagination support via query params will be handled in controller; keep a method variant with args + return await BlogPost.findAll({ where: { blogId }, order: [['created_at', 'desc']] }); + } + + // Utilities for Blog image folder + async ensureBlogFolder(userId) { + // find root folder + let root = await Folder.findOne({ where: { parentId: null, userId } }); + if (!root) { + // fallback: create minimal root with default everyone visibility + root = await Folder.create({ name: 'root', parentId: null, userId }); + const everyone = await ImageVisibilityType.findOne({ where: { description: 'everyone' } }); + if (everyone) await FolderImageVisibility.create({ folderId: root.id, visibilityTypeId: everyone.id }); + } + let blogFolder = await Folder.findOne({ where: { parentId: root.id, userId, name: 'Blog' } }); + if (!blogFolder) { + blogFolder = await Folder.create({ name: 'Blog', parentId: root.id, userId }); + } + return blogFolder; + } + + async applyBlogFolderVisibility(userId, blogVisibility) { + const blogFolder = await this.ensureBlogFolder(userId); + const everyone = await ImageVisibilityType.findOne({ where: { description: 'everyone' } }); + if (!everyone) return; + // Clear existing visibilities for the Blog folder + await FolderImageVisibility.destroy({ where: { folderId: blogFolder.id } }); + if (blogVisibility === 'public') { + await FolderImageVisibility.create({ folderId: blogFolder.id, visibilityTypeId: everyone.id }); + } + // For non-public blogs we leave folder without 'everyone' (private by default) + return blogFolder; + } +} diff --git a/backend/services/emailService.js b/backend/services/emailService.js index 544597b..269d9f3 100644 --- a/backend/services/emailService.js +++ b/backend/services/emailService.js @@ -53,5 +53,19 @@ export const sendAnswerEmail = async (toEmail, answer, language) => { html: `

${ answer }

` }; + await transporter.sendMail(mailOptions); +}; + +export const sendBlogShareEmail = async (toEmail, blogUrl, blogTitle, senderName) => { + const subject = `yourPart: ${senderName} hat einen Blog geteilt`; + const text = `${senderName} hat den Blog "${blogTitle}" mit dir geteilt. Du kannst ihn hier ansehen: ${blogUrl}`; + const html = `

${senderName} hat den Blog "${blogTitle}" mit dir geteilt.

Hier ansehen: ${blogUrl}

`; + const mailOptions = { + from: process.env.SMTP_FROM, + to: toEmail, + subject, + text, + html + }; await transporter.sendMail(mailOptions); }; \ No newline at end of file diff --git a/backend/services/socialnetworkService.js b/backend/services/socialnetworkService.js index 5210c30..6952457 100644 --- a/backend/services/socialnetworkService.js +++ b/backend/services/socialnetworkService.js @@ -467,6 +467,33 @@ class SocialNetworkService extends BaseService { return imagePath; } + // Public variant used by blog: allow access if the image's folder is visible to 'everyone'. + async getImageFilePathPublicByHash(hash) { + const image = await Image.findOne({ where: { hash } }); + if (!image) { + throw new Error('Image not found'); + } + const folder = await Folder.findOne({ where: { id: image.folderId } }); + if (!folder) { + throw new Error('Folder not found'); + } + const everyone = await ImageVisibilityType.findOne({ where: { description: 'everyone' } }); + if (!everyone) { + throw new Error('Visibility type not found'); + } + const hasEveryone = await FolderImageVisibility.findOne({ where: { folderId: folder.id, visibilityTypeId: everyone.id } }); + if (!hasEveryone) { + const err = new Error('Access denied'); + err.status = 403; + throw err; + } + const imagePath = this.buildFilePath(image.hash, 'user'); + if (!fs.existsSync(imagePath)) { + throw new Error(`File "${imagePath}" not found`); + } + return imagePath; + } + async checkUserImageAccess(userId, imageId) { const image = await Image.findByPk(imageId); if (image.userId === userId) { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5c03670..3605289 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,10 +8,16 @@ "name": "frontend", "version": "1.0.0", "dependencies": { + "@tiptap/extension-color": "^2.14.0", + "@tiptap/extension-image": "^2.14.0", + "@tiptap/extension-text-align": "^2.14.0", + "@tiptap/extension-text-style": "^2.14.0", + "@tiptap/extension-underline": "^2.14.0", "@tiptap/starter-kit": "^2.14.0", "@tiptap/vue-3": "^2.14.0", "axios": "^1.7.2", "date-fns": "^3.6.0", + "dompurify": "^3.2.6", "dotenv": "^16.4.5", "mitt": "^3.0.1", "socket.io-client": "^4.8.1", @@ -969,6 +975,20 @@ "@tiptap/pm": "^2.7.0" } }, + "node_modules/@tiptap/extension-color": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-color/-/extension-color-2.14.0.tgz", + "integrity": "sha512-sY+eWIbkCMAwOGH7pQ1ZuNqkqMaaHE+TsJwA7bQ6VhI2gGhhqGjT/DfmJMUen8FSdzuPoWlgtuXXCeOO6FOduw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/extension-text-style": "^2.7.0" + } + }, "node_modules/@tiptap/extension-document": { "version": "2.14.0", "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.14.0.tgz", @@ -1081,6 +1101,19 @@ "@tiptap/pm": "^2.7.0" } }, + "node_modules/@tiptap/extension-image": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-2.14.0.tgz", + "integrity": "sha512-pYCUzZBgsxIvVGTzuW03cPz6PIrAo26xpoxqq4W090uMVoK0SgY5W5y0IqCdw4QyLkJ2/oNSFNc2EP9jVi1CcQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, "node_modules/@tiptap/extension-italic": { "version": "2.14.0", "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.14.0.tgz", @@ -1159,6 +1192,19 @@ "@tiptap/core": "^2.7.0" } }, + "node_modules/@tiptap/extension-text-align": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text-align/-/extension-text-align-2.14.0.tgz", + "integrity": "sha512-9Wth4sAq2lYVWvQA0Qy095fsnPEavBv1FKWzVEyurwEQB7ZQsf/MRGmCNFnUXXy12w1G9UOanS4KkJ4C64+Ccw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, "node_modules/@tiptap/extension-text-style": { "version": "2.14.0", "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.14.0.tgz", @@ -1172,6 +1218,19 @@ "@tiptap/core": "^2.7.0" } }, + "node_modules/@tiptap/extension-underline": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-2.14.0.tgz", + "integrity": "sha512-rlBasbwElFikaL5qPyp3OeoEBH2p9Dve0K6liqIWF4i9cECH2Bm53y2S0enVEe01hmgQEWmoYK+fq67rxr3XsQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, "node_modules/@tiptap/pm": { "version": "2.14.0", "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.14.0.tgz", @@ -1283,6 +1342,13 @@ "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", "license": "MIT" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@vitejs/plugin-vue": { "version": "5.2.4", "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", @@ -1612,6 +1678,15 @@ "node": ">=0.4.0" } }, + "node_modules/dompurify": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz", + "integrity": "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dotenv": { "version": "16.4.5", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", diff --git a/frontend/package.json b/frontend/package.json index ff93f8a..989bac3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,10 +8,16 @@ "preview": "vite preview" }, "dependencies": { + "@tiptap/extension-color": "^2.14.0", + "@tiptap/extension-image": "^2.14.0", + "@tiptap/extension-text-align": "^2.14.0", + "@tiptap/extension-text-style": "^2.14.0", + "@tiptap/extension-underline": "^2.14.0", "@tiptap/starter-kit": "^2.14.0", "@tiptap/vue-3": "^2.14.0", "axios": "^1.7.2", "date-fns": "^3.6.0", + "dompurify": "^3.2.6", "dotenv": "^16.4.5", "mitt": "^3.0.1", "socket.io-client": "^4.8.1", diff --git a/frontend/src/api/blogApi.js b/frontend/src/api/blogApi.js new file mode 100644 index 0000000..c98ffa4 --- /dev/null +++ b/frontend/src/api/blogApi.js @@ -0,0 +1,19 @@ +import apiClient from '@/utils/axios.js'; + +export const listBlogs = async () => (await apiClient.get('/api/blog/blogs')).data; +export const getBlog = async (id) => (await apiClient.get(`/api/blog/blogs/${id}`)).data; +export const listPosts = async (id, { page = 1, pageSize = 10 } = {}) => (await apiClient.get(`/api/blog/blogs/${id}/posts`, { params: { page, pageSize } })).data; + +export const createBlog = async (payload) => (await apiClient.post('/api/blog/blogs', payload)).data; +export const updateBlog = async (id, payload) => (await apiClient.put(`/api/blog/blogs/${id}`, payload)).data; +export const createPost = async (id, payload) => (await apiClient.post(`/api/blog/blogs/${id}/posts`, payload)).data; +export const listBlogImages = async (id) => (await apiClient.get(`/api/blog/blogs/${id}/images`)).data; +export const uploadBlogImage = async (id, file, meta = {}) => { + const formData = new FormData(); + formData.append('image', file); + if (meta.title) formData.append('title', meta.title); + if (meta.description) formData.append('description', meta.description); + return (await apiClient.post(`/api/blog/blogs/${id}/images`, formData, { headers: { 'Content-Type': 'multipart/form-data' } })).data; +}; +export const shareBlog = async (id, payload) => (await apiClient.post(`/api/blog/blogs/${id}/share`, payload)).data; + \ No newline at end of file diff --git a/frontend/src/i18n/index.js b/frontend/src/i18n/index.js index a269243..f73b9a8 100644 --- a/frontend/src/i18n/index.js +++ b/frontend/src/i18n/index.js @@ -15,6 +15,7 @@ import enSocialNetwork from './locales/en/socialnetwork.json'; import enFriends from './locales/en/friends.json'; import enFalukant from './locales/en/falukant.json'; import enPasswordReset from './locales/en/passwordReset.json'; +import enBlog from './locales/en/blog.json'; import deGeneral from './locales/de/general.json'; import deHeader from './locales/de/header.json'; @@ -30,6 +31,7 @@ import deSocialNetwork from './locales/de/socialnetwork.json'; import deFriends from './locales/de/friends.json'; import deFalukant from './locales/de/falukant.json'; import dePasswordReset from './locales/de/passwordReset.json'; +import deBlog from './locales/de/blog.json'; const messages = { en: { @@ -47,6 +49,7 @@ const messages = { ...enSocialNetwork, ...enFriends, ...enFalukant, + ...enBlog, }, de: { 'Ok': 'Ok', @@ -64,6 +67,7 @@ const messages = { ...deSocialNetwork, ...deFriends, ...deFalukant, + ...deBlog, } }; diff --git a/frontend/src/i18n/locales/de/blog.json b/frontend/src/i18n/locales/de/blog.json new file mode 100644 index 0000000..a7f353b --- /dev/null +++ b/frontend/src/i18n/locales/de/blog.json @@ -0,0 +1,11 @@ +{ + "blog": { + "posts": "Beiträge", + "noPosts": "Keine Beiträge.", + "newPost": "Neuen Beitrag verfassen", + "title": "Titel", + "publish": "Veröffentlichen", + "pickImage": "Bild auswählen", + "uploadImage": "Bild hochladen" + } +} diff --git a/frontend/src/i18n/locales/de/navigation.json b/frontend/src/i18n/locales/de/navigation.json index 454a929..a8b2426 100644 --- a/frontend/src/i18n/locales/de/navigation.json +++ b/frontend/src/i18n/locales/de/navigation.json @@ -16,6 +16,7 @@ }, "m-socialnetwork": { "guestbook": "Gästebuch", + "blog": "Blog", "usersearch": "Benutzersuche", "forum": "Forum", "gallery": "Galerie", diff --git a/frontend/src/i18n/locales/en/blog.json b/frontend/src/i18n/locales/en/blog.json new file mode 100644 index 0000000..fe623a9 --- /dev/null +++ b/frontend/src/i18n/locales/en/blog.json @@ -0,0 +1,11 @@ +{ + "blog": { + "posts": "Posts", + "noPosts": "No posts.", + "newPost": "Write new post", + "title": "Title", + "publish": "Publish", + "pickImage": "Pick an image", + "uploadImage": "Upload image" + } +} diff --git a/frontend/src/i18n/locales/en/navigation.json b/frontend/src/i18n/locales/en/navigation.json index 67630d0..f1a8986 100644 --- a/frontend/src/i18n/locales/en/navigation.json +++ b/frontend/src/i18n/locales/en/navigation.json @@ -1,11 +1,79 @@ { - "home": "Home", - "about": "About", - "services": "Services", - "team": "Team", - "company": "Company", - "consulting": "Consulting", - "development": "Development", - "mailbox": "Mailbox", - "logout": "Logout" + "navigation": { + "home": "Home", + "logout": "Logout", + "friends": "Friends", + "socialnetwork": "Meeting point", + "chats": "Chats", + "falukant": "Falukant", + "minigames": "Mini games", + "settings": "Settings", + "administration": "Administration", + "m-chats": { + "multiChat": "Multiuser chat", + "randomChat": "Random single chat", + "eroticChat": "Erotic chat" + }, + "m-socialnetwork": { + "guestbook": "Guestbook", + "blog": "Blog", + "usersearch": "User search", + "forum": "Forum", + "gallery": "Gallery", + "blockedUsers": "Blocked users", + "oneTimeInvitation": "One-time invitations", + "diary": "Diary", + "erotic": "Erotic", + "m-erotic": { + "pictures": "Pictures", + "videos": "Videos" + } + }, + "m-settings": { + "homepage": "Homepage", + "account": "Account", + "personal": "Personal", + "view": "Appearance", + "flirt": "Flirt", + "interests": "Interests", + "notifications": "Notifications", + "sexuality": "Sexuality" + }, + "m-administration": { + "contactrequests": "Contact requests", + "useradministration": "User administration", + "forum": "Forum", + "userrights": "User rights", + "interests": "Interests", + "falukant": "Falukant", + "m-falukant": { + "logentries": "Log entries", + "edituser": "Edit user", + "database": "Database" + } + }, + "m-friends": { + "manageFriends": "Manage friends", + "chat": "Chat", + "profile": "Profile" + }, + "m-falukant": { + "create": "Create", + "overview": "Overview", + "towns": "Towns", + "directors": "Directors", + "factory": "Factory", + "family": "Family", + "house": "House", + "darknet": "Underground", + "reputation": "Reputation", + "moneyhistory": "Money flow", + "nobility": "Social status", + "politics": "Politics", + "education": "Education", + "health": "Health", + "bank": "Bank", + "church": "Church" + } + } } \ No newline at end of file diff --git a/frontend/src/router/blogRoutes.js b/frontend/src/router/blogRoutes.js new file mode 100644 index 0000000..fb4bafc --- /dev/null +++ b/frontend/src/router/blogRoutes.js @@ -0,0 +1,13 @@ +import BlogListView from '@/views/blog/BlogListView.vue'; +import BlogView from '@/views/blog/BlogView.vue'; +import BlogEditorView from '@/views/blog/BlogEditorView.vue'; + +export default [ + { path: '/blogs/create', name: 'BlogCreate', component: BlogEditorView, meta: { requiresAuth: true } }, + { path: '/blogs/:id/edit', name: 'BlogEdit', component: BlogEditorView, props: true, meta: { requiresAuth: true } }, + // Slug-only route first so it doesn't get captured by the :id route + { path: '/blogs/:slug', name: 'BlogSlug', component: BlogView, props: route => ({ slug: route.params.slug }) }, + // Id-constrained route (numeric id only) with optional slug for canonical links + { path: '/blogs/:id(\\d+)/:slug?', name: 'Blog', component: BlogView, props: true }, + { path: '/blogs', name: 'BlogList', component: BlogListView }, +]; diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 88dddc9..df0b348 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -6,6 +6,7 @@ import socialRoutes from './socialRoutes'; import settingsRoutes from './settingsRoutes'; import adminRoutes from './adminRoutes'; import falukantRoutes from './falukantRoutes'; +import blogRoutes from './blogRoutes'; const routes = [ { @@ -18,6 +19,7 @@ const routes = [ ...settingsRoutes, ...adminRoutes, ...falukantRoutes, + ...blogRoutes, ]; const router = createRouter({ diff --git a/frontend/src/views/blog/BlogEditorView.vue b/frontend/src/views/blog/BlogEditorView.vue new file mode 100644 index 0000000..0ef16f9 --- /dev/null +++ b/frontend/src/views/blog/BlogEditorView.vue @@ -0,0 +1,201 @@ + + + + + diff --git a/frontend/src/views/blog/BlogListView.vue b/frontend/src/views/blog/BlogListView.vue new file mode 100644 index 0000000..de47a58 --- /dev/null +++ b/frontend/src/views/blog/BlogListView.vue @@ -0,0 +1,29 @@ + + + diff --git a/frontend/src/views/blog/BlogView.vue b/frontend/src/views/blog/BlogView.vue new file mode 100644 index 0000000..8f560f2 --- /dev/null +++ b/frontend/src/views/blog/BlogView.vue @@ -0,0 +1,109 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/blog/components/RichTextEditor.vue b/frontend/src/views/blog/components/RichTextEditor.vue new file mode 100644 index 0000000..7a51359 --- /dev/null +++ b/frontend/src/views/blog/components/RichTextEditor.vue @@ -0,0 +1,124 @@ + + + + + diff --git a/frontend/src/views/social/ForumTopicView.vue b/frontend/src/views/social/ForumTopicView.vue index d1748ac..f887fde 100644 --- a/frontend/src/views/social/ForumTopicView.vue +++ b/frontend/src/views/social/ForumTopicView.vue @@ -14,7 +14,7 @@
- +
@@ -47,6 +47,8 @@ export default { this.editor = new Editor({ extensions: [StarterKit], content: '', + editable: true, + editorProps: { attributes: { class: 'pm-root' } }, }); }, beforeUnmount() { @@ -126,13 +128,24 @@ export default { .editor-container { margin-top: 1rem; border: 1px solid #ccc; - padding: 10px; - min-height: 200px; + padding: 0; + min-height: 260px; background-color: white; } .editor { - min-height: 150px; + min-height: 260px; outline: none; + cursor: text; } +.editor :deep(.ProseMirror) { + min-height: 260px; + outline: none; + padding: 10px; + box-sizing: border-box; + width: 100%; +} +.editor :deep(.ProseMirror p) { margin: 0 0 .6rem; } +.editor :deep(.ProseMirror p:first-child) { margin-top: 0; } +.editor :deep(.ProseMirror-focused) { outline: 2px solid rgba(100,150,255,.35); } diff --git a/frontend/src/views/social/ForumView.vue b/frontend/src/views/social/ForumView.vue index 29a558c..a02747d 100644 --- a/frontend/src/views/social/ForumView.vue +++ b/frontend/src/views/social/ForumView.vue @@ -16,7 +16,7 @@
- +