feat: Implement blog and blog post models, routes, and services
- Added Blog and BlogPost models with necessary fields and relationships. - Created blogRouter for handling blog-related API endpoints including CRUD operations. - Developed BlogService for business logic related to blogs and posts, including sharing functionality. - Implemented API client methods for frontend to interact with blog-related endpoints. - Added internationalization support for blog-related text in English and German. - Created Vue components for blog editing, listing, and viewing, including a rich text editor for post content. - Enhanced user experience with form validations and dynamic visibility settings based on user input.
This commit is contained in:
@@ -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;
|
||||
|
||||
174
backend/controllers/blogController.js
Normal file
174
backend/controllers/blogController.js
Normal file
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -32,6 +32,10 @@ const menuStructure = {
|
||||
visible: ["all"],
|
||||
path: "/socialnetwork/guestbook"
|
||||
},
|
||||
blog: {
|
||||
visible: ["all"],
|
||||
path: "/blogs"
|
||||
},
|
||||
usersearch: {
|
||||
visible: ["all"],
|
||||
path: "/socialnetwork/search"
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
60
backend/models/community/blog.js
Normal file
60
backend/models/community/blog.js
Normal file
@@ -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;
|
||||
44
backend/models/community/blog_post.js
Normal file
44
backend/models/community/blog_post.js
Normal file
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
32
backend/routers/blogRouter.js
Normal file
32
backend/routers/blogRouter.js
Normal file
@@ -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;
|
||||
246
backend/services/blogService.js
Normal file
246
backend/services/blogService.js
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -53,5 +53,19 @@ export const sendAnswerEmail = async (toEmail, answer, language) => {
|
||||
html: `<p>${ answer }</p>`
|
||||
};
|
||||
|
||||
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 = `<p><strong>${senderName}</strong> hat den Blog "${blogTitle}" mit dir geteilt.</p><p>Hier ansehen: <a href="${blogUrl}">${blogUrl}</a></p>`;
|
||||
const mailOptions = {
|
||||
from: process.env.SMTP_FROM,
|
||||
to: toEmail,
|
||||
subject,
|
||||
text,
|
||||
html
|
||||
};
|
||||
await transporter.sendMail(mailOptions);
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user