diff --git a/backend/controllers/socialnetworkController.js b/backend/controllers/socialnetworkController.js index 9c483bf..93304d6 100644 --- a/backend/controllers/socialnetworkController.js +++ b/backend/controllers/socialnetworkController.js @@ -16,13 +16,16 @@ class SocialNetworkController { this.getFoldersByUsername = this.getFoldersByUsername.bind(this); this.deleteFolder = this.deleteFolder.bind(this); this.getAdultFolders = this.getAdultFolders.bind(this); + this.getAdultFoldersByUsername = this.getAdultFoldersByUsername.bind(this); this.createAdultFolder = this.createAdultFolder.bind(this); this.getAdultFolderImageList = this.getAdultFolderImageList.bind(this); this.uploadAdultImage = this.uploadAdultImage.bind(this); this.getAdultImageByHash = this.getAdultImageByHash.bind(this); this.changeAdultImage = this.changeAdultImage.bind(this); this.listEroticVideos = this.listEroticVideos.bind(this); + this.getEroticVideosByUsername = this.getEroticVideosByUsername.bind(this); this.uploadEroticVideo = this.uploadEroticVideo.bind(this); + this.changeEroticVideo = this.changeEroticVideo.bind(this); this.getEroticVideoByHash = this.getEroticVideoByHash.bind(this); this.reportEroticContent = this.reportEroticContent.bind(this); this.createGuestbookEntry = this.createGuestbookEntry.bind(this); @@ -157,8 +160,8 @@ class SocialNetworkController { try { const userId = req.headers.userid; const { imageId } = req.params; - const { title, visibilities } = req.body; - const folderId = await this.socialNetworkService.changeImage(userId, imageId, title, visibilities); + const { title, visibilities, selectedUsers } = req.body; + const folderId = await this.socialNetworkService.changeImage(userId, imageId, title, visibilities, selectedUsers); console.log('--->', folderId); res.status(201).json(await this.socialNetworkService.getFolderImageList(userId, folderId)); } catch (error) { @@ -208,6 +211,21 @@ class SocialNetworkController { } } + async getAdultFoldersByUsername(req, res) { + try { + const requestingUserId = req.headers.userid; + const { username } = req.params; + const folders = await this.socialNetworkService.getAdultFoldersByUsername(username, requestingUserId); + if (!folders) { + return res.status(404).json({ error: 'No folders found or access denied.' }); + } + res.status(200).json(folders); + } catch (error) { + console.error('Error in getAdultFoldersByUsername:', error); + res.status(error.status || 500).json({ error: error.message }); + } + } + async createAdultFolder(req, res) { try { const userId = req.headers.userid; @@ -267,8 +285,8 @@ class SocialNetworkController { try { const userId = req.headers.userid; const { imageId } = req.params; - const { title, visibilities } = req.body; - const folderId = await this.socialNetworkService.changeAdultImage(userId, imageId, title, visibilities); + const { title, visibilities, selectedUsers } = req.body; + const folderId = await this.socialNetworkService.changeAdultImage(userId, imageId, title, visibilities, selectedUsers); res.status(201).json(await this.socialNetworkService.getAdultFolderImageList(userId, folderId)); } catch (error) { console.error('Error in changeAdultImage:', error); @@ -287,6 +305,18 @@ class SocialNetworkController { } } + async getEroticVideosByUsername(req, res) { + try { + const userId = req.headers.userid; + const { username } = req.params; + const videos = await this.socialNetworkService.getEroticVideosByUsername(username, userId); + res.status(200).json(videos); + } catch (error) { + console.error('Error in getEroticVideosByUsername:', error); + res.status(error.status || 500).json({ error: error.message }); + } + } + async uploadEroticVideo(req, res) { try { const userId = req.headers.userid; @@ -300,6 +330,18 @@ class SocialNetworkController { } } + async changeEroticVideo(req, res) { + try { + const userId = req.headers.userid; + const { videoId } = req.params; + const updatedVideo = await this.socialNetworkService.changeEroticVideo(userId, videoId, req.body); + res.status(200).json(updatedVideo); + } catch (error) { + console.error('Error in changeEroticVideo:', error); + res.status(error.status || 500).json({ error: error.message }); + } + } + async getEroticVideoByHash(req, res) { try { const userId = req.headers.userid; diff --git a/backend/migrations/20260327000000-add-erotic-video-visibility.cjs b/backend/migrations/20260327000000-add-erotic-video-visibility.cjs new file mode 100644 index 0000000..36ff3b1 --- /dev/null +++ b/backend/migrations/20260327000000-add-erotic-video-visibility.cjs @@ -0,0 +1,89 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable( + { schema: 'community', tableName: 'erotic_video_image_visibility' }, + { + id: { + type: Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true, + allowNull: false, + }, + erotic_video_id: { + type: Sequelize.INTEGER, + allowNull: false, + references: { + model: { schema: 'community', tableName: 'erotic_video' }, + key: 'id', + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE', + }, + visibility_type_id: { + type: Sequelize.INTEGER, + allowNull: false, + references: { + model: { schema: 'type', tableName: 'image_visibility' }, + key: 'id', + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE', + }, + } + ); + + await queryInterface.createTable( + { schema: 'community', tableName: 'erotic_video_visibility_user' }, + { + id: { + type: Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true, + allowNull: false, + }, + erotic_video_id: { + type: Sequelize.INTEGER, + allowNull: false, + references: { + model: { schema: 'community', tableName: 'erotic_video' }, + key: 'id', + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE', + }, + user_id: { + type: Sequelize.INTEGER, + allowNull: false, + references: { + model: { schema: 'community', tableName: 'user' }, + key: 'id', + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE', + }, + } + ); + + await queryInterface.sequelize.query(` + INSERT INTO community.erotic_video_image_visibility (erotic_video_id, visibility_type_id) + SELECT ev.id, iv.id + FROM community.erotic_video ev + CROSS JOIN type.image_visibility iv + WHERE iv.description = 'adults' + AND NOT EXISTS ( + SELECT 1 + FROM community.erotic_video_image_visibility eviv + WHERE eviv.erotic_video_id = ev.id + AND eviv.visibility_type_id = iv.id + ) + `); + }, + + async down(queryInterface) { + await queryInterface.dropTable({ schema: 'community', tableName: 'erotic_video_visibility_user' }); + await queryInterface.dropTable({ schema: 'community', tableName: 'erotic_video_image_visibility' }); + }, +}; diff --git a/backend/models/associations.js b/backend/models/associations.js index 77e031a..978a693 100644 --- a/backend/models/associations.js +++ b/backend/models/associations.js @@ -25,6 +25,8 @@ import ImageVisibilityUser from './community/image_visibility_user.js'; import FolderImageVisibility from './community/folder_image_visibility.js'; import ImageImageVisibility from './community/image_image_visibility.js'; import FolderVisibilityUser from './community/folder_visibility_user.js'; +import EroticVideoImageVisibility from './community/erotic_video_image_visibility.js'; +import EroticVideoVisibilityUser from './community/erotic_video_visibility_user.js'; import GuestbookEntry from './community/guestbook.js'; import Forum from './forum/forum.js'; import Title from './forum/title.js'; @@ -242,6 +244,17 @@ export default function setupAssociations() { otherKey: 'imageId' }); + EroticVideo.belongsToMany(ImageVisibilityType, { + through: EroticVideoImageVisibility, + foreignKey: 'eroticVideoId', + otherKey: 'visibilityTypeId' + }); + ImageVisibilityType.belongsToMany(EroticVideo, { + through: EroticVideoImageVisibility, + foreignKey: 'visibilityTypeId', + otherKey: 'eroticVideoId' + }); + Folder.belongsToMany(ImageVisibilityUser, { through: FolderVisibilityUser, foreignKey: 'folderId', @@ -253,6 +266,19 @@ export default function setupAssociations() { otherKey: 'folderId' }); + EroticVideo.belongsToMany(User, { + through: EroticVideoVisibilityUser, + foreignKey: 'eroticVideoId', + otherKey: 'userId', + as: 'selectedVisibilityUsers' + }); + User.belongsToMany(EroticVideo, { + through: EroticVideoVisibilityUser, + foreignKey: 'userId', + otherKey: 'eroticVideoId', + as: 'visibleEroticVideos' + }); + // Guestbook related associations User.hasMany(GuestbookEntry, { foreignKey: 'recipientId', as: 'receivedEntries' }); User.hasMany(GuestbookEntry, { foreignKey: 'senderId', as: 'sentEntries' }); diff --git a/backend/models/community/erotic_video_image_visibility.js b/backend/models/community/erotic_video_image_visibility.js new file mode 100644 index 0000000..7c0a43f --- /dev/null +++ b/backend/models/community/erotic_video_image_visibility.js @@ -0,0 +1,26 @@ +import { sequelize } from '../../utils/sequelize.js'; +import { DataTypes } from 'sequelize'; + +const EroticVideoImageVisibility = sequelize.define('erotic_video_image_visibility', { + id: { + type: DataTypes.INTEGER, + autoIncrement: true, + primaryKey: true, + allowNull: false + }, + eroticVideoId: { + type: DataTypes.INTEGER, + allowNull: false + }, + visibilityTypeId: { + type: DataTypes.INTEGER, + allowNull: false + } +}, { + tableName: 'erotic_video_image_visibility', + timestamps: false, + underscored: true, + schema: 'community' +}); + +export default EroticVideoImageVisibility; diff --git a/backend/models/community/erotic_video_visibility_user.js b/backend/models/community/erotic_video_visibility_user.js new file mode 100644 index 0000000..cd567f1 --- /dev/null +++ b/backend/models/community/erotic_video_visibility_user.js @@ -0,0 +1,26 @@ +import { sequelize } from '../../utils/sequelize.js'; +import { DataTypes } from 'sequelize'; + +const EroticVideoVisibilityUser = sequelize.define('erotic_video_visibility_user', { + id: { + type: DataTypes.INTEGER, + autoIncrement: true, + primaryKey: true, + allowNull: false + }, + eroticVideoId: { + type: DataTypes.INTEGER, + allowNull: false + }, + userId: { + type: DataTypes.INTEGER, + allowNull: false + } +}, { + tableName: 'erotic_video_visibility_user', + timestamps: false, + underscored: true, + schema: 'community' +}); + +export default EroticVideoVisibilityUser; diff --git a/backend/models/index.js b/backend/models/index.js index cfbc061..a19f5f0 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -25,6 +25,8 @@ import ImageVisibilityUser from './community/image_visibility_user.js'; import FolderImageVisibility from './community/folder_image_visibility.js'; import ImageImageVisibility from './community/image_image_visibility.js'; import FolderVisibilityUser from './community/folder_visibility_user.js'; +import EroticVideoImageVisibility from './community/erotic_video_image_visibility.js'; +import EroticVideoVisibilityUser from './community/erotic_video_visibility_user.js'; import GuestbookEntry from './community/guestbook.js'; import DiaryHistory from './community/diary_history.js'; import Diary from './community/diary.js'; @@ -179,6 +181,8 @@ const models = { FolderImageVisibility, ImageImageVisibility, FolderVisibilityUser, + EroticVideoImageVisibility, + EroticVideoVisibilityUser, GuestbookEntry, DiaryHistory, Diary, diff --git a/backend/routers/socialnetworkRouter.js b/backend/routers/socialnetworkRouter.js index 385fc96..6840675 100644 --- a/backend/routers/socialnetworkRouter.js +++ b/backend/routers/socialnetworkRouter.js @@ -17,12 +17,15 @@ router.get('/folder/:folderId', socialNetworkController.getFolderImageList); router.post('/images', upload.single('image'), socialNetworkController.uploadImage); router.post('/erotic/folders/:folderId', socialNetworkController.createAdultFolder); router.get('/erotic/folders', socialNetworkController.getAdultFolders); +router.get('/profile/erotic/folders/:username', socialNetworkController.getAdultFoldersByUsername); +router.get('/profile/erotic/videos/:username', socialNetworkController.getEroticVideosByUsername); router.get('/erotic/folder/:folderId', socialNetworkController.getAdultFolderImageList); router.post('/erotic/images', upload.single('image'), socialNetworkController.uploadAdultImage); router.put('/erotic/images/:imageId', socialNetworkController.changeAdultImage); router.get('/erotic/image/:hash', socialNetworkController.getAdultImageByHash); router.get('/erotic/videos', socialNetworkController.listEroticVideos); router.post('/erotic/videos', upload.single('video'), socialNetworkController.uploadEroticVideo); +router.put('/erotic/videos/:videoId', socialNetworkController.changeEroticVideo); router.get('/erotic/video/:hash', socialNetworkController.getEroticVideoByHash); router.post('/erotic/report', socialNetworkController.reportEroticContent); router.get('/images/:imageId', socialNetworkController.getImage); diff --git a/backend/services/socialnetworkService.js b/backend/services/socialnetworkService.js index 54d479e..8895b79 100644 --- a/backend/services/socialnetworkService.js +++ b/backend/services/socialnetworkService.js @@ -13,6 +13,8 @@ import EroticContentReport from '../models/community/erotic_content_report.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 EroticVideoImageVisibility from '../models/community/erotic_video_image_visibility.js'; +import EroticVideoVisibilityUser from '../models/community/erotic_video_visibility_user.js'; import { v4 as uuidv4 } from 'uuid'; import fs from 'fs'; import fsPromises from 'fs/promises'; @@ -74,6 +76,372 @@ class SocialNetworkService extends BaseService { } } + parseSelectedUsers(selectedUsers) { + if (!selectedUsers) return []; + if (Array.isArray(selectedUsers)) { + return selectedUsers.map(value => String(value || '').trim()).filter(Boolean); + } + if (typeof selectedUsers === 'string') { + try { + const parsed = JSON.parse(selectedUsers); + if (Array.isArray(parsed)) { + return parsed.map(value => String(value || '').trim()).filter(Boolean); + } + } catch (error) { + // Fallback to comma-separated values below. + } + return selectedUsers + .split(',') + .map(value => value.trim()) + .filter(Boolean); + } + return []; + } + + async getActiveFriendIds(userId) { + const friendships = await Friendship.findAll({ + where: { + accepted: true, + denied: false, + withdrawn: false, + [Op.or]: [ + { user1Id: userId }, + { user2Id: userId } + ] + } + }); + return friendships.map(friendship => ( + friendship.user1Id === userId ? friendship.user2Id : friendship.user1Id + )); + } + + async areUsersFriends(userId, otherUserId) { + if (!userId || !otherUserId) return false; + const friendship = await Friendship.findOne({ + where: { + accepted: true, + denied: false, + withdrawn: false, + [Op.or]: [ + { user1Id: userId, user2Id: otherUserId }, + { user1Id: otherUserId, user2Id: userId } + ] + } + }); + return Boolean(friendship); + } + + async resolveSelectedUserIds(ownerId, selectedUsers, { adultOnly = false } = {}) { + const usernames = [...new Set(this.parseSelectedUsers(selectedUsers))]; + if (!usernames.length) { + return []; + } + + const users = await User.findAll({ + where: { + [Op.or]: usernames.map(username => ({ + username: { + [Op.iLike]: username + } + })) + }, + attributes: ['id', 'username'] + }); + + const matchedUsers = []; + for (const requestedName of usernames) { + const user = users.find(candidate => ( + String(candidate.username || '').toLowerCase() === requestedName.toLowerCase() + )); + if (!user) { + throw new Error(`User "${requestedName}" not found`); + } + if (user.id === ownerId) { + continue; + } + if (adultOnly) { + const access = await this.getAdultAccessState(user.id); + if (!access.adultAccessEnabled) { + throw new Error(`User "${user.username}" is not approved for the adult area`); + } + } + matchedUsers.push(user.id); + } + + return [...new Set(matchedUsers)]; + } + + async saveFolderSelectedUsers(folderId, selectedUsers, ownerId, { adultOnly = false } = {}) { + await FolderVisibilityUser.destroy({ where: { folderId } }); + const selectedUserIds = await this.resolveSelectedUserIds(ownerId, selectedUsers, { adultOnly }); + for (const userId of selectedUserIds) { + await FolderVisibilityUser.create({ folderId, visibilityUserId: userId }); + } + } + + async saveImageSelectedUsers(imageId, selectedUsers, ownerId, { adultOnly = false } = {}) { + await ImageVisibilityUser.destroy({ where: { imageId } }); + const selectedUserIds = await this.resolveSelectedUserIds(ownerId, selectedUsers, { adultOnly }); + for (const userId of selectedUserIds) { + await ImageVisibilityUser.create({ imageId, userId }); + } + } + + async getFolderSelectedUsernames(folderId) { + const selectedUserLinks = await FolderVisibilityUser.findAll({ where: { folderId } }); + if (!selectedUserLinks.length) return []; + const users = await User.findAll({ + where: { id: selectedUserLinks.map(link => link.visibilityUserId) }, + attributes: ['id', 'username'] + }); + return users.map(user => user.username).sort((a, b) => a.localeCompare(b)); + } + + async getImageSelectedUsernames(imageId) { + const selectedUsers = await ImageVisibilityUser.findAll({ where: { imageId } }); + if (!selectedUsers.length) return []; + const users = await User.findAll({ + where: { id: selectedUsers.map(entry => entry.userId) }, + attributes: ['id', 'username'] + }); + return users.map(user => user.username).sort((a, b) => a.localeCompare(b)); + } + + async saveEroticVideoSelectedUsers(videoId, selectedUsers, ownerId, { adultOnly = false } = {}) { + await EroticVideoVisibilityUser.destroy({ where: { eroticVideoId: videoId } }); + const selectedUserIds = await this.resolveSelectedUserIds(ownerId, selectedUsers, { adultOnly }); + for (const userId of selectedUserIds) { + await EroticVideoVisibilityUser.create({ eroticVideoId: videoId, userId }); + } + } + + async getEroticVideoSelectedUsernames(videoId) { + const selectedUsers = await EroticVideoVisibilityUser.findAll({ where: { eroticVideoId: videoId } }); + if (!selectedUsers.length) return []; + const users = await User.findAll({ + where: { id: selectedUsers.map(entry => entry.userId) }, + attributes: ['id', 'username'] + }); + return users.map(user => user.username).sort((a, b) => a.localeCompare(b)); + } + + async saveEroticVideoVisibilities(videoId, visibilities) { + let normalizedVisibilities = visibilities; + if (typeof normalizedVisibilities === 'string') { + normalizedVisibilities = JSON.parse(normalizedVisibilities); + } + if (!Array.isArray(normalizedVisibilities) || !normalizedVisibilities.length) { + throw new Error('Invalid visibilities provided'); + } + + await EroticVideoImageVisibility.destroy({ where: { eroticVideoId: videoId } }); + for (const visibility of normalizedVisibilities) { + const visibilityTypeId = typeof visibility === 'object' ? visibility.id : visibility; + await EroticVideoImageVisibility.create({ eroticVideoId: videoId, visibilityTypeId }); + } + } + + async getEroticVideoVisibilityEntries(videoId) { + return await ImageVisibilityType.findAll({ + include: [{ + model: EroticVideo, + where: { id: videoId }, + attributes: [], + through: { attributes: [] } + }] + }); + } + + async enrichEroticVideoVisibilityMetadata(videos) { + const enrichedVideos = []; + for (const videoRecord of videos) { + const video = videoRecord.get ? videoRecord.get() : { ...videoRecord }; + const visibilities = await this.getEroticVideoVisibilityEntries(video.id); + video.visibilities = visibilities.map(entry => ({ id: entry.id, description: entry.description })); + video.selectedUsers = await this.getEroticVideoSelectedUsernames(video.id); + enrichedVideos.push(video); + } + return enrichedVideos; + } + + async enrichImageVisibilityMetadata(images) { + const enrichedImages = []; + for (const imageRecord of images) { + const image = imageRecord.get ? imageRecord.get() : { ...imageRecord }; + const visibilities = await ImageVisibilityType.findAll({ + include: [{ + model: Image, + where: { id: image.id }, + attributes: [], + through: { attributes: [] } + }] + }); + image.visibilities = visibilities.map(entry => ({ id: entry.id, description: entry.description })); + image.selectedUsers = await this.getImageSelectedUsernames(image.id); + enrichedImages.push(image); + } + return enrichedImages; + } + + async canRequesterAccessAdultFolder(folder, requesterId) { + if (!folder || !requesterId) return false; + if (folder.userId === requesterId) { + return true; + } + + const adultAccess = await this.getAdultAccessState(requesterId); + if (!adultAccess.adultAccessEnabled) { + return false; + } + + const folderVisibilities = await ImageVisibilityType.findAll({ + include: [{ + model: Folder, + where: { id: folder.id }, + attributes: [], + through: { attributes: [] } + }] + }); + const descriptions = folderVisibilities.map(entry => entry.description); + if (!descriptions.length) { + return false; + } + + if (descriptions.includes('adults') || descriptions.includes('everyone')) { + return true; + } + + if ((descriptions.includes('friends') || descriptions.includes('friends-and-adults')) && + await this.areUsersFriends(folder.userId, requesterId)) { + return true; + } + + if (descriptions.includes('selected-users')) { + const selectedLink = await FolderVisibilityUser.findOne({ + where: { + folderId: folder.id, + visibilityUserId: requesterId + } + }); + if (selectedLink) { + return true; + } + } + + return false; + } + + async canRequesterAccessAdultImage(image, requesterId) { + if (!image || !requesterId) return false; + if (image.userId === requesterId) { + return true; + } + if (image.isModeratedHidden) { + return false; + } + + const folder = await Folder.findOne({ + where: { + id: image.folderId, + userId: image.userId, + isAdultArea: true + } + }); + if (!folder) { + return false; + } + + const folderAccess = await this.canRequesterAccessAdultFolder(folder, requesterId); + if (!folderAccess) { + return false; + } + + const adultAccess = await this.getAdultAccessState(requesterId); + if (!adultAccess.adultAccessEnabled) { + return false; + } + + const imageVisibilities = await ImageVisibilityType.findAll({ + include: [{ + model: Image, + where: { id: image.id }, + attributes: [], + through: { attributes: [] } + }] + }); + const descriptions = imageVisibilities.map(entry => entry.description); + if (!descriptions.length) { + return true; + } + + if (descriptions.includes('adults') || descriptions.includes('everyone')) { + return true; + } + + if ((descriptions.includes('friends') || descriptions.includes('friends-and-adults')) && + await this.areUsersFriends(image.userId, requesterId)) { + return true; + } + + if (descriptions.includes('selected-users')) { + const selectedLink = await ImageVisibilityUser.findOne({ + where: { + imageId: image.id, + userId: requesterId + } + }); + if (selectedLink) { + return true; + } + } + + return false; + } + + async canRequesterAccessEroticVideo(video, requesterId) { + if (!video || !requesterId) return false; + if (video.userId === requesterId) { + return true; + } + if (video.isModeratedHidden) { + return false; + } + + const adultAccess = await this.getAdultAccessState(requesterId); + if (!adultAccess.adultAccessEnabled) { + return false; + } + + const videoVisibilities = await this.getEroticVideoVisibilityEntries(video.id); + const descriptions = videoVisibilities.map(entry => entry.description); + if (!descriptions.length) { + return false; + } + + if (descriptions.includes('adults') || descriptions.includes('everyone')) { + return true; + } + + if ((descriptions.includes('friends') || descriptions.includes('friends-and-adults')) && + await this.areUsersFriends(video.userId, requesterId)) { + return true; + } + + if (descriptions.includes('selected-users')) { + const selectedLink = await EroticVideoVisibilityUser.findOne({ + where: { + eroticVideoId: video.id, + userId: requesterId + } + }); + if (selectedLink) { + return true; + } + } + + return false; + } + async resolveEroticTarget(targetType, targetId) { if (targetType === 'image') { const image = await Image.findOne({ @@ -240,6 +608,9 @@ class SocialNetworkService extends BaseService { visibilityTypeId: visibilityId }); } + await this.saveFolderSelectedUsers(newFolder.id, data.selectedUsers || data.selectedUsernames || [], user.id, { + adultOnly: isAdultArea + }); return newFolder; } @@ -270,6 +641,7 @@ class SocialNetworkService extends BaseService { const children = await this.getSubFolders(folder.id, userId, isAdultArea); const visibilityTypeIds = folder.image_visibility_types.map(v => v.id); folder.setDataValue('visibilityTypeIds', visibilityTypeIds); + folder.setDataValue('selectedUsers', await this.getFolderSelectedUsernames(folder.id)); folder.setDataValue('children', children); folder.setDataValue('image_visibility_types', undefined); } @@ -286,7 +658,7 @@ class SocialNetworkService extends BaseService { } }); if (!folder) throw new Error('Folder not found'); - return await Image.findAll({ + const images = await Image.findAll({ where: { folderId: folder.id, isAdultContent: false @@ -295,6 +667,7 @@ class SocialNetworkService extends BaseService { ['title', 'asc'] ] }); + return this.enrichImageVisibilityMetadata(images); } async uploadImage(hashedId, file, formData) { @@ -302,6 +675,7 @@ class SocialNetworkService extends BaseService { const processedImageName = await this.processAndUploadUserImage(file, 'user'); const newImage = await this.createImageRecord(formData, userId, file, processedImageName, { isAdultContent: false }); await this.saveImageVisibilities(newImage.id, formData.visibility); + await this.saveImageSelectedUsers(newImage.id, formData.selectedUsers || formData.selectedUsernames || [], userId); return newImage; } @@ -406,7 +780,11 @@ class SocialNetworkService extends BaseService { } async loadUserByName(userName) { - return await User.findOne({ username: userName}); + return await User.findOne({ + where: { + username: userName + } + }); } validateFolderData(data) { @@ -601,27 +979,78 @@ class SocialNetworkService extends BaseService { const children = await this.getSubFolders(rootFolder.id, userId, true); const data = rootFolder.get(); data.visibilityTypeIds = data.image_visibility_types.map(v => v.id); + data.selectedUsers = await this.getFolderSelectedUsernames(rootFolder.id); delete data.image_visibility_types; data.children = children; return data; } + async getAdultFoldersByUsername(username, hashedUserId) { + const requestingUserId = await this.requireAdultAreaAccessByHash(hashedUserId); + const owner = await this.loadUserByName(username); + if (!owner) { + throw new Error('User not found'); + } + + const ownerRoot = await Folder.findOne({ + where: { + userId: owner.id, + isAdultArea: true, + name: 'Erotik' + }, + include: [{ + model: ImageVisibilityType, + through: { model: FolderImageVisibility }, + attributes: ['id'] + }] + }); + if (!ownerRoot) { + return null; + } + + if (!(await this.canRequesterAccessAdultFolder(ownerRoot, requestingUserId))) { + const error = new Error('Adult folder access denied'); + error.status = 403; + throw error; + } + + const children = await this.getAccessibleAdultFolders(ownerRoot.id, owner.id, requestingUserId); + const rootFolder = ownerRoot.get(); + rootFolder.visibilityTypeIds = ownerRoot.image_visibility_types.map(v => v.id); + rootFolder.selectedUsers = await this.getFolderSelectedUsernames(ownerRoot.id); + delete rootFolder.image_visibility_types; + rootFolder.children = children; + return rootFolder; + } + async getAdultFolderImageList(hashedId, folderId) { const userId = await this.requireAdultAreaAccessByHash(hashedId); const folder = await Folder.findOne({ - where: { id: folderId, userId, isAdultArea: true } + where: { id: folderId, isAdultArea: true } }); if (!folder) { throw new Error('Folder not found'); } - return await Image.findAll({ + if (!(await this.canRequesterAccessAdultFolder(folder, userId))) { + const error = new Error('Access denied'); + error.status = 403; + throw error; + } + const images = await Image.findAll({ where: { folderId: folder.id, isAdultContent: true, - userId + userId: folder.userId }, order: [['title', 'asc']] }); + const visibleImages = []; + for (const image of images) { + if (await this.canRequesterAccessAdultImage(image, userId)) { + visibleImages.push(image); + } + } + return this.enrichImageVisibilityMetadata(visibleImages); } async createAdultFolder(hashedId, data, folderId) { @@ -650,6 +1079,9 @@ class SocialNetworkService extends BaseService { const processedImageName = await this.processAndUploadUserImage(file, 'erotic'); const newImage = await this.createImageRecord(formData, userId, file, processedImageName, { isAdultContent: true }); await this.saveImageVisibilities(newImage.id, formData.visibility); + await this.saveImageSelectedUsers(newImage.id, formData.selectedUsers || formData.selectedUsernames || [], userId, { + adultOnly: true + }); return newImage; } @@ -658,13 +1090,17 @@ class SocialNetworkService extends BaseService { const image = await Image.findOne({ where: { hash, - userId, isAdultContent: true } }); if (!image) { throw new Error('Image not found'); } + if (!(await this.canRequesterAccessAdultImage(image, userId))) { + const error = new Error('Access denied'); + error.status = 403; + throw error; + } if (image.isModeratedHidden) { throw new Error('Image hidden by moderation'); } @@ -677,10 +1113,33 @@ class SocialNetworkService extends BaseService { async listEroticVideos(hashedId) { const userId = await this.requireAdultAreaAccessByHash(hashedId); - return await EroticVideo.findAll({ + const videos = await EroticVideo.findAll({ where: { userId }, order: [['createdAt', 'DESC']] }); + return this.enrichEroticVideoVisibilityMetadata(videos); + } + + async getEroticVideosByUsername(username, hashedId) { + const requestingUserId = await this.requireAdultAreaAccessByHash(hashedId); + const owner = await this.loadUserByName(username); + if (!owner) { + throw new Error('User not found'); + } + + const videos = await EroticVideo.findAll({ + where: { userId: owner.id }, + order: [['createdAt', 'DESC']] + }); + + const visibleVideos = []; + for (const video of videos) { + if (await this.canRequesterAccessEroticVideo(video, requestingUserId)) { + visibleVideos.push(video); + } + } + + return this.enrichEroticVideoVisibilityMetadata(visibleVideos); } async uploadEroticVideo(hashedId, file, formData) { @@ -698,7 +1157,7 @@ class SocialNetworkService extends BaseService { const filePath = this.buildFilePath(fileName, 'erotic-video'); await this.saveFile(file.buffer, filePath); - return await EroticVideo.create({ + const video = await EroticVideo.create({ title: formData.title || file.originalname, description: formData.description || null, originalFileName: file.originalname, @@ -706,16 +1165,33 @@ class SocialNetworkService extends BaseService { mimeType: file.mimetype, userId }); + + const visibility = formData.visibility || JSON.stringify( + (await this.getPossibleImageVisibilities()) + .filter(entry => entry.description === 'adults') + .map(entry => entry.id) + ); + await this.saveEroticVideoVisibilities(video.id, visibility); + await this.saveEroticVideoSelectedUsers(video.id, formData.selectedUsers || formData.selectedUsernames || [], userId, { + adultOnly: true + }); + + return video; } async getEroticVideoFilePath(hashedId, hash) { const userId = await this.requireAdultAreaAccessByHash(hashedId); const video = await EroticVideo.findOne({ - where: { hash, userId } + where: { hash } }); if (!video) { throw new Error('Video not found'); } + if (!(await this.canRequesterAccessEroticVideo(video, userId))) { + const error = new Error('Access denied'); + error.status = 403; + throw error; + } if (video.isModeratedHidden) { throw new Error('Video hidden by moderation'); } @@ -726,6 +1202,29 @@ class SocialNetworkService extends BaseService { return { filePath: videoPath, mimeType: video.mimeType }; } + async changeEroticVideo(hashedUserId, videoId, payload) { + const userId = await this.requireAdultAreaAccessByHash(hashedUserId); + const video = await EroticVideo.findOne({ + where: { + id: videoId, + userId + } + }); + if (!video) { + throw new Error('Video not found'); + } + + await video.update({ + title: payload.title || video.title, + description: payload.description ?? video.description + }); + await this.saveEroticVideoVisibilities(videoId, payload.visibilities); + await this.saveEroticVideoSelectedUsers(videoId, payload.selectedUsers || [], userId, { + adultOnly: true + }); + return video; + } + async createEroticContentReport(hashedId, payload) { const reporterId = await this.requireAdultAreaAccessByHash(hashedId); const targetType = String(payload.targetType || '').trim().toLowerCase(); @@ -807,7 +1306,7 @@ class SocialNetworkService extends BaseService { }); } - async changeImage(hashedUserId, imageId, title, visibilities) { + async changeImage(hashedUserId, imageId, title, visibilities, selectedUsers = []) { const userId = await this.checkUserAccess(hashedUserId); await this.checkUserImageAccess(userId, imageId); const image = await Image.findOne({ where: { id: imageId, isAdultContent: false } }); @@ -819,10 +1318,11 @@ class SocialNetworkService extends BaseService { for (const visibility of visibilities) { await ImageImageVisibility.create({ imageId, visibilityTypeId: visibility.id }); } + await this.saveImageSelectedUsers(imageId, selectedUsers, userId); return image.folderId; } - async changeAdultImage(hashedUserId, imageId, title, visibilities) { + async changeAdultImage(hashedUserId, imageId, title, visibilities, selectedUsers = []) { const userId = await this.requireAdultAreaAccessByHash(hashedUserId); const image = await Image.findOne({ where: { @@ -839,9 +1339,38 @@ class SocialNetworkService extends BaseService { for (const visibility of visibilities) { await ImageImageVisibility.create({ imageId, visibilityTypeId: visibility.id }); } + await this.saveImageSelectedUsers(imageId, selectedUsers, userId, { + adultOnly: true + }); return image.folderId; } + async getAccessibleAdultFolders(parentId, ownerUserId, requestingUserId) { + const folders = await Folder.findAll({ + where: { parentId, userId: ownerUserId, isAdultArea: true }, + include: [{ + model: ImageVisibilityType, + through: { model: FolderImageVisibility }, + attributes: ['id'] + }], + order: [['name', 'asc']] + }); + + const result = []; + for (const folderRecord of folders) { + if (!(await this.canRequesterAccessAdultFolder(folderRecord, requestingUserId))) { + continue; + } + const folder = folderRecord.get(); + folder.visibilityTypeIds = folderRecord.image_visibility_types.map(v => v.id); + folder.selectedUsers = await this.getFolderSelectedUsernames(folder.id); + delete folder.image_visibility_types; + folder.children = await this.getAccessibleAdultFolders(folder.id, ownerUserId, requestingUserId); + result.push(folder); + } + return result; + } + async getFoldersByUsername(username, hashedUserId) { const user = await this.loadUserByName(username); if (!user) { diff --git a/frontend/src/dialogues/socialnetwork/CreateFolderDialog.vue b/frontend/src/dialogues/socialnetwork/CreateFolderDialog.vue index 3e830d3..1202561 100644 --- a/frontend/src/dialogues/socialnetwork/CreateFolderDialog.vue +++ b/frontend/src/dialogues/socialnetwork/CreateFolderDialog.vue @@ -30,6 +30,15 @@ +