import BaseService from './BaseService.js'; import { Op } from 'sequelize'; 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 UserParamVisibility from '../models/community/user_param_visibility.js'; import UserParamVisibilityType from '../models/type/user_param_visibility.js'; 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 ImageImageVisibility from '../models/community/image_image_visibility.js'; import { v4 as uuidv4 } from 'uuid'; import fs from 'fs'; import fsPromises from 'fs/promises'; import path from 'path'; import { fileURLToPath } from 'url'; import FolderVisibilityUser from '../models/community/folder_visibility_user.js'; import ImageVisibilityUser from '../models/community/image_visibility_user.js'; import GuestbookEntry from '../models/community/guestbook.js'; import { JSDOM } from 'jsdom'; import DOMPurify from 'dompurify'; import sharp from 'sharp'; import Diary from '../models/community/diary.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); class SocialNetworkService extends BaseService { async searchUsers({ username, ageFrom, ageTo, genders }) { const whereClause = this.buildSearchWhereClause(username); const users = await User.findAll({ where: whereClause, include: this.getUserParamsInclude() }); return this.filterUsersByCriteria(users, ageFrom, ageTo, genders); } async getProfile(hashedUserId, requestingUserId) { await this.checkUserAccess(requestingUserId); const user = await this.fetchUserProfile(hashedUserId); if (!user) return null; return this.constructUserProfile(user, requestingUserId); } async createFolder(hashedUserId, data, folderId) { await this.checkUserAccess(hashedUserId); const user = await this.loadUserByHash(hashedUserId); if (!user) { throw new Error('User not found'); } const parentFolder = data.parentId ? await Folder.findOne({ where: { id: data.parentId, userId: user.id } }) : null; if (data.parentId && !parentFolder) { throw new Error('Parent folder not found'); } let newFolder; if (folderId === 0) { newFolder = await Folder.create({ parentId: data.parentId || null, userId: user.id, name: data.name }); } else { newFolder = await Folder.findOne({ where: { id: folderId, userId: user.id } }); if (!newFolder) { throw new Error('Folder not found or user does not own the folder'); } newFolder.name = data.name; await newFolder.save(); } await FolderImageVisibility.destroy({ where: { folderId: newFolder.id } }); for (const visibilityId of data.visibilities) { await FolderImageVisibility.create({ folderId: newFolder.id, visibilityTypeId: visibilityId }); } return newFolder; } async getFolders(hashedId) { const userId = await this.checkUserAccess(hashedId); let rootFolder = await Folder.findOne({ where: { parentId: null, userId }, include: [{ model: ImageVisibilityType, through: { model: FolderImageVisibility }, attributes: ['id'], }] }); if (!rootFolder) { const user = await User.findOne({ where: { id: userId } }); const visibility = await ImageVisibilityType.findOne({ where: { description: 'everyone' } }); rootFolder = await Folder.create({ name: user.username, parentId: null, userId }); await FolderImageVisibility.create({ folderId: rootFolder.id, visibilityTypeId: visibility.id }); rootFolder = await Folder.findOne({ where: { id: rootFolder.id }, include: [{ model: ImageVisibilityType, through: { model: FolderImageVisibility }, attributes: ['id'], }] }); } const children = await this.getSubFolders(rootFolder.id, userId); rootFolder = rootFolder.get(); rootFolder.visibilityTypeIds = rootFolder.image_visibility_types.map(v => v.id); delete rootFolder.image_visibility_types; rootFolder.children = children; return rootFolder; } async getSubFolders(parentId, userId) { const folders = await Folder.findAll({ where: { parentId, userId }, include: [{ model: ImageVisibilityType, through: { model: FolderImageVisibility }, attributes: ['id'], }], order: [ ['name', 'asc'] ] }); for (const folder of folders) { const children = await this.getSubFolders(folder.id, userId); const visibilityTypeIds = folder.image_visibility_types.map(v => v.id); folder.setDataValue('visibilityTypeIds', visibilityTypeIds); folder.setDataValue('children', children); folder.setDataValue('image_visibility_types', undefined); } return folders.map(folder => folder.get()); } async getFolderImageList(hashedId, folderId) { const userId = await this.checkUserAccess(hashedId); const folder = await Folder.findOne({ where: { id: folderId, userId } }); if (!folder) throw new Error('Folder not found'); return await Image.findAll({ where: { folderId: folder.id }, order: [ ['title', 'asc'] ] }); } async uploadImage(hashedId, file, formData) { const userId = await this.getUserId(hashedId); const processedImageName = await this.processAndUploadUserImage(file); const newImage = await this.createImageRecord(formData, userId, file, processedImageName); await this.saveImageVisibilities(newImage.id, formData.visibility); return newImage; } async processAndUploadUserImage(file) { try { const img = sharp(file.buffer); const metadata = await img.metadata(); if (!metadata || !['jpeg', 'png', 'webp', 'gif'].includes(metadata.format)) { throw new Error('File is not a valid image'); } if (metadata.width < 75 || metadata.height < 75) { throw new Error('Image dimensions are too small. Minimum size is 75x75 pixels.'); } const resizedImg = img.resize({ width: Math.min(metadata.width, 500), height: Math.min(metadata.height, 500), fit: sharp.fit.inside, withoutEnlargement: true }); const newFileName = this.generateUniqueFileName(file.originalname); const filePath = this.buildFilePath(newFileName, 'user'); await resizedImg.toFile(filePath); return newFileName; } catch (error) { throw new Error(`Failed to process image: ${error.message}`); } } async getUserId(hashedId) { return await this.checkUserAccess(hashedId); } generateUniqueFileName(originalFileName) { const uniqueHash = uuidv4(); return uniqueHash; } buildFilePath(fileName, type) { const basePath = path.join(__dirname, '..', 'images', type); return path.join(basePath, fileName); } async saveFile(buffer, filePath) { try { await fsPromises.mkdir(path.dirname(filePath), { recursive: true }); await fsPromises.writeFile(filePath, buffer); } catch (error) { throw new Error(`Failed to save file: ${error.message}`); } } async createImageRecord(formData, userId, file, fileName) { try { return await Image.create({ title: formData.title, description: formData.description || null, originalFileName: file.originalname, hash: fileName, folderId: formData.folderId, userId: userId, }); } catch (error) { throw new Error(`Failed to create image record: ${error.message}`); } } async saveImageVisibilities(imageId, visibilities) { if (typeof visibilities === 'string') { visibilities = JSON.parse(visibilities); } if (!visibilities || !Array.isArray(visibilities)) { throw new Error('Invalid visibilities provided'); } try { const visibilityPromises = visibilities.map(visibilityId => { return ImageImageVisibility.create({ imageId: imageId, visibilityTypeId: visibilityId }); }); await Promise.all(visibilityPromises); } catch (error) { throw new Error(`Failed to save image visibilities: ${error.message}`); } } async getImage(imageId) { const image = await Image.findByPk(imageId); if (!image) throw new Error('Image not found'); await this.checkUserAccess(image.userId); return image; } async loadUserByHash(hashedId) { return await User.findOne({ hashedId }); } async loadUserByName(userName) { return await User.findOne({ username: userName}); } validateFolderData(data) { if (!data.name || typeof data.name !== 'string') throw new Error('Invalid folder data: Name is required'); } validateImageData(imageData) { if (!imageData.url || typeof imageData.url !== 'string') throw new Error('Invalid image data: URL is required'); } buildSearchWhereClause(username) { const whereClause = { active: true, searchable: true }; if (username) { whereClause.username = { [Op.iLike]: `%${username}%` }; } return whereClause; } getUserParamsInclude() { return [ { model: UserParam, as: 'user_params', include: [{ model: UserParamType, as: 'paramType', required: true }] } ]; } filterUsersByCriteria(users, ageFrom, ageTo, genders) { const results = []; for (const user of users) { const userDetails = this.extractUserDetails(user); if (this.isUserValid(userDetails, ageFrom, ageTo, genders)) { results.push(userDetails); } } return results; } extractUserDetails(user) { const birthdateParam = user.user_params.find(param => param.paramType.description === 'birthdate'); const genderParam = user.user_params.find(param => param.paramType.description === 'gender'); const age = birthdateParam ? this.calculateAge(birthdateParam.value) : null; const gender = genderParam ? this.getGenderValue(genderParam.value) : null; return { id: user.hashedId, username: user.username, email: user.email, gender, age }; } async getGenderValue(genderId) { const genderValue = await UserParamValue.findOne({ where: { id: genderId } }); return genderValue ? genderValue.value : null; } isUserValid(userDetails, ageFrom, ageTo, genders) { const { age, gender } = userDetails; const isWithinAgeRange = (!ageFrom || age >= ageFrom) && (!ageTo || age <= ageTo); const isGenderValid = !genders || !genders.length || (gender && genders.includes(gender)); return isWithinAgeRange && isGenderValid && age >= 14; } async fetchUserProfile(hashedUserId) { return await User.findOne({ where: { hashedId: hashedUserId, active: true, searchable: true }, include: [ { model: UserParam, as: 'user_params', include: [ { model: UserParamType, as: 'paramType' }, { model: UserParamVisibility, as: 'param_visibilities', include: [{ model: UserParamVisibilityType, as: 'visibility_type' }] } ], order: [['order_id', 'asc']] } ] }); } async constructUserProfile(user, hashedUserId) { const userParams = {}; const requestingUser = await this.loadUserByHash(hashedUserId); if (!requestingUser) { throw new Error('User not found'); } const requestingUserAge = await this.getUserAge(requestingUser.id); for (const param of user.user_params) { const visibility = param.param_visibilities?.[0]?.visibility_type?.description || 'Invisible'; if (visibility === 'Invisible') continue; if (this.isVisibleToUser(visibility, requestingUserAge)) { userParams[param.paramType.description] = { type: param.paramType.datatype, value: await this.getParamValue(param) }; } } return { username: user.username, registrationDate: user.registrationDate, params: userParams }; } async getUserAge(userId) { const params = await this.getUserParams(userId, ['birthdate']); const birthdateParam = params.find(param => param.paramType.description === 'birthdate'); return birthdateParam ? this.calculateAge(birthdateParam.value) : 0; } isVisibleToUser(visibility, requestingUserAge) { return visibility === 'All' || (visibility === 'FriendsAndAdults' && requestingUserAge >= 18) || (visibility === 'AdultsOnly' && requestingUserAge >= 18); } async getParamValue(param) { let paramValue = param.value; try { const parsedValue = JSON.parse(paramValue); if (Array.isArray(parsedValue)) { paramValue = await Promise.all(parsedValue.map(value => this.getValueFromDatabase(value, param.paramTypeId))); } else if (/^\d+$/.test(paramValue)) { paramValue = await this.getValueFromDatabase(paramValue, param.paramTypeId); } } catch (e) { } return paramValue; } async getValueFromDatabase(value, paramTypeId) { const userParamValue = await UserParamValue.findOne({ where: { id: parseInt(value, 10), userParamTypeId: paramTypeId } }); return userParamValue ? userParamValue.value : value; } async getPossibleImageVisibilities() { return await ImageVisibilityType.findAll(); } async getImageFilePath(hashedUserId, hash) { const image = await Image.findOne({ where: { hash } }); if (!image) { throw new Error('Image not found'); } const userId = await this.checkUserAccess(hashedUserId); const hasAccess = await this.checkUserImageAccess(userId, image.id); if (!hasAccess) { throw new Error('Access denied'); } 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) { return true; } const accessRules = await ImageImageVisibility.findAll({ where: { imageId } }); return accessRules.some(rule => { return false; }); } async changeImage(hashedUserId, imageId, title, visibilities) { const userId = await this.checkUserAccess(hashedUserId); await this.checkUserImageAccess(userId, imageId); const image = await Image.findOne({ where: { id: imageId } }); if (!image) { throw new Error('image not found') } await image.update({ title: title }); await ImageImageVisibility.destroy({ where: { imageId } }); for (const visibility of visibilities) { await ImageImageVisibility.create({ imageId, visibilityTypeId: visibility.id }); } return image.folderId; } async getFoldersByUsername(username, hashedUserId) { const user = await this.loadUserByName(username); if (!user) { throw new Error('User not found'); } const requestingUserId = await this.checkUserAccess(hashedUserId); let rootFolder = await Folder.findOne({ where: { parentId: null, userId: user.id } }); if (!rootFolder) { return null; } const accessibleFolders = await this.getAccessibleFolders(rootFolder.id, requestingUserId); rootFolder = rootFolder.get(); rootFolder.children = accessibleFolders; return rootFolder; } async getAccessibleFolders(folderId, requestingUserId) { const folderIdString = String(folderId); const requestingUserIdString = String(requestingUserId); const requestingUser = await User.findOne({ where: { id: requestingUserIdString } }); const isAdult = this.isUserAdult(requestingUser.id); const accessibleFolders = await Folder.findAll({ where: { parentId: folderIdString }, include: [ { model: ImageVisibilityType, through: { model: FolderImageVisibility }, where: { [Op.or]: [ { description: 'everyone' }, { description: 'adults', ...(isAdult ? {} : { [Op.not]: null }) }, { description: 'friends-and-adults', ...(isAdult ? {} : { [Op.not]: null }) } ] }, required: false }, { model: ImageVisibilityUser, through: { model: FolderVisibilityUser }, where: { user_id: requestingUserIdString }, required: false } ] }); const folderList = []; for (let folder of accessibleFolders) { const children = await this.getAccessibleFolders(folder.id, requestingUserIdString); folder = folder.get(); folder.children = children; folderList.push(folder); } return folderList; } async deleteFolder(hashedUserId, folderId) { const userId = await this.checkUserAccess(hashedUserId); const folder = await Folder.findOne({ where: { id: folderId, userId } }); if (!folder) { throw new Error('Folder not found or access denied'); } await FolderImageVisibility.destroy({ where: { folderId: folder.id } }); await folder.destroy(); return true; } async createGuestbookEntry(hashedSenderId, recipientName, htmlContent, image) { const sender = await this.loadUserByHash(hashedSenderId); if (!sender) { throw new Error('Sender not found'); } const recipient = await this.loadUserByName(recipientName); if (!recipient) { throw new Error('Recipient not found'); } const sanitizedContent = this.sanitizeHtml(htmlContent); let uploadedImage = null; if (image) { uploadedImage = await this.processAndUploadGuestbookImage(image); } const entry = await GuestbookEntry.create({ senderId: sender.id, recipientId: recipient.id, senderUsername: sender.username, contentHtml: sanitizedContent, imageUrl: uploadedImage }); return entry; } sanitizeHtml(htmlContent) { const window = new JSDOM('').window; const purify = DOMPurify(window); const cleanHtml = purify.sanitize(htmlContent, { ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'ul', 'ol', 'li', 'br'], ALLOWED_ATTR: ['href', 'title'], FORBID_TAGS: ['script', 'style', 'iframe', 'img'], ALLOWED_URI_REGEXP: /^https?:\/\//, }); return cleanHtml; } async processAndUploadGuestbookImage(image) { try { const img = sharp(image.buffer); const metadata = await img.metadata(); if (!metadata || !['jpeg', 'png', 'webp', 'gif'].includes(metadata.format)) { throw new Error('File is not a valid image'); } if (metadata.width < 20 || metadata.height < 20) { throw new Error('Image dimensions are too small. Minimum size is 20x20 pixels.'); } const resizedImg = img.resize({ width: Math.min(metadata.width, 500), height: Math.min(metadata.height, 500), fit: sharp.fit.inside, withoutEnlargement: true }); const newFileName = this.generateUniqueFileName(image.originalname); const filePath = this.buildFilePath(newFileName, 'guestbook'); await resizedImg.toFile(filePath); return newFileName; } catch (error) { throw new Error(`Failed to process image: ${error.message}`); } } async getGuestbookEntries(hashedUserId, username, page = 1) { const pageSize = 20; const offset = (page - 1) * pageSize; this.checkUserAccess(hashedUserId); const user = await this.loadUserByName(username); if (!user) { throw new Error('User not found'); } const entries = await GuestbookEntry.findAndCountAll({ where: { recipientId: user.id }, include: [ { model: User, as: 'sender', attributes: ['username'] }, ], limit: pageSize, offset: offset, order: [['createdAt', 'DESC']] }); const resultList = entries.rows.map(entry => ({ id: entry.id, sender: entry.sender ? entry.sender.username : entry.senderUsername, contentHtml: entry.contentHtml, withImage: entry.imageUrl !== null, createdAt: entry.createdAt })); return { entries: resultList, currentPage: page, totalPages: Math.ceil(entries.count / pageSize), }; } async getGuestbookImageFilePath(hashedUserId, guestbookOwnerName, entryId) { await this.checkUserAccess(hashedUserId); const guestbookOwner = await this.loadUserByName(guestbookOwnerName); if (!guestbookOwner) { throw new Error('usernotfound'); } const entry = await GuestbookEntry.findOne({ where: { id: entryId, recipientId: guestbookOwner.id }, }); if (!entry) { throw new Error('entrynotfound'); } if (!entry.imageUrl) { console.log(entry); throw new Error('entryhasnoimage'); } console.log(`Image URL: ${entry.imageUrl}`); const imagePath = this.buildFilePath(entry.imageUrl, 'guestbook'); if (!fs.existsSync(imagePath)) { throw new Error(imagenotfound); } return imagePath; } async deleteGuestbookEntry(hashedUserId, entryId) { const user = await this.loadUserByHash(hashedUserId); if (!user) { throw new Error('User not found'); } const entry = await GuestbookEntry.findOne({ where: { id: entryId } }); if (!entry) { throw new Error('Entry not found'); } if (entry.senderId !== user.id && entry.recipientId !== user.id) { throw new Error('Not authorized to delete this entry'); } await entry.destroy(); } async createDiaryEntry(hashedUserId, text) { const userId = await this.checkUserAccess(hashedUserId); const newEntry = await Diary.create({ userId: userId, text: text, createdAt: new Date(), updatedAt: new Date(), }); return newEntry; } async updateDiaryEntry(diaryEntryId, hashedUserId, newText) { const userId = await this.checkUserAccess(hashedUserId); const existingEntry = await Diary.findOne({ where: { id: diaryEntryId, userId: userId } }); if (!existingEntry) { throw new Error('Diary entry not found or unauthorized access'); } existingEntry.text = newText; existingEntry.updatedAt = new Date(); await existingEntry.save(); return existingEntry; } async deleteDiaryEntry(diaryEntryId, hashedUserId) { const userId = await this.checkUserAccess(hashedUserId); const entryToDelete = await Diary.findOne({ where: { id: diaryEntryId, userId: userId } }); if (!entryToDelete) { throw new Error('Diary entry not found or unauthorized access'); } await entryToDelete.destroy(); return true; } async getDiaryEntries(hashedUserId, page) { const userId = await this.checkUserAccess(hashedUserId); const entries = await Diary.findAndCountAll({ where: { userId: userId }, order: [['createdAt', 'DESC']], offset: (page - 1) * 20, limit: 20, }); return { entries: entries.rows, totalPages: Math.ceil(entries.count / 20) }; } } export default SocialNetworkService;