diff --git a/backend/controllers/adminController.js b/backend/controllers/adminController.js index 9329e7a..5369c37 100644 --- a/backend/controllers/adminController.js +++ b/backend/controllers/adminController.js @@ -29,6 +29,12 @@ class AdminController { this.getUser = this.getUser.bind(this); this.getUsers = this.getUsers.bind(this); this.updateUser = this.updateUser.bind(this); + this.getAdultVerificationRequests = this.getAdultVerificationRequests.bind(this); + this.setAdultVerificationStatus = this.setAdultVerificationStatus.bind(this); + this.getAdultVerificationDocument = this.getAdultVerificationDocument.bind(this); + this.getEroticModerationReports = this.getEroticModerationReports.bind(this); + this.applyEroticModerationAction = this.applyEroticModerationAction.bind(this); + this.getEroticModerationPreview = this.getEroticModerationPreview.bind(this); // Rights this.listRightTypes = this.listRightTypes.bind(this); @@ -119,6 +125,97 @@ class AdminController { } } + async getAdultVerificationRequests(req, res) { + try { + const { userid: requester } = req.headers; + const { status = 'pending' } = req.query; + const result = await AdminService.getAdultVerificationRequests(requester, status); + res.status(200).json(result); + } catch (error) { + const status = error.message === 'noaccess' ? 403 : 500; + res.status(status).json({ error: error.message }); + } + } + + async setAdultVerificationStatus(req, res) { + const schema = Joi.object({ + status: Joi.string().valid('approved', 'rejected', 'pending').required() + }); + const { error, value } = schema.validate(req.body || {}); + if (error) { + return res.status(400).json({ error: error.details[0].message }); + } + try { + const { userid: requester } = req.headers; + const { id } = req.params; + const result = await AdminService.setAdultVerificationStatus(requester, id, value.status); + res.status(200).json(result); + } catch (err) { + const status = err.message === 'noaccess' ? 403 : (['notfound', 'notadult', 'wrongstatus', 'missingparamtype'].includes(err.message) ? 400 : 500); + res.status(status).json({ error: err.message }); + } + } + + async getAdultVerificationDocument(req, res) { + try { + const { userid: requester } = req.headers; + const { id } = req.params; + const result = await AdminService.getAdultVerificationDocument(requester, id); + res.setHeader('Content-Type', result.mimeType); + res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(result.originalName)}"`); + res.sendFile(result.filePath); + } catch (err) { + const status = err.message === 'noaccess' ? 403 : (['notfound', 'norequest', 'nofile'].includes(err.message) ? 404 : 500); + res.status(status).json({ error: err.message }); + } + } + + async getEroticModerationReports(req, res) { + try { + const { userid: requester } = req.headers; + const { status = 'open' } = req.query; + const result = await AdminService.getEroticModerationReports(requester, status); + res.status(200).json(result); + } catch (error) { + const status = error.message === 'noaccess' ? 403 : 500; + res.status(status).json({ error: error.message }); + } + } + + async applyEroticModerationAction(req, res) { + const schema = Joi.object({ + action: Joi.string().valid('dismiss', 'hide_content', 'restore_content', 'delete_content', 'block_uploads', 'revoke_access').required(), + note: Joi.string().allow('', null).max(2000).optional() + }); + const { error, value } = schema.validate(req.body || {}); + if (error) { + return res.status(400).json({ error: error.details[0].message }); + } + try { + const { userid: requester } = req.headers; + const { id } = req.params; + const result = await AdminService.applyEroticModerationAction(requester, Number(id), value.action, value.note || null); + res.status(200).json(result); + } catch (err) { + const status = err.message === 'noaccess' ? 403 : (['notfound', 'targetnotfound', 'wrongaction'].includes(err.message) ? 400 : 500); + res.status(status).json({ error: err.message }); + } + } + + async getEroticModerationPreview(req, res) { + try { + const { userid: requester } = req.headers; + const { type, targetId } = req.params; + const result = await AdminService.getEroticModerationPreview(requester, type, Number(targetId)); + res.setHeader('Content-Type', result.mimeType); + res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(result.originalName)}"`); + res.sendFile(result.filePath); + } catch (err) { + const status = err.message === 'noaccess' ? 403 : (['notfound', 'nofile', 'wrongtype'].includes(err.message) ? 404 : 500); + res.status(status).json({ error: err.message }); + } + } + // --- Rights --- async listRightTypes(req, res) { try { @@ -523,6 +620,7 @@ class AdminController { title: Joi.string().min(1).max(255).required(), roomTypeId: Joi.number().integer().required(), isPublic: Joi.boolean().required(), + isAdultOnly: Joi.boolean().allow(null), genderRestrictionId: Joi.number().integer().allow(null), minAge: Joi.number().integer().min(0).allow(null), maxAge: Joi.number().integer().min(0).allow(null), @@ -534,7 +632,7 @@ class AdminController { if (error) { return res.status(400).json({ error: error.details[0].message }); } - const room = await AdminService.updateRoom(req.params.id, value); + const room = await AdminService.updateRoom(userId, req.params.id, value); res.status(200).json(room); } catch (error) { console.log(error); @@ -553,6 +651,7 @@ class AdminController { title: Joi.string().min(1).max(255).required(), roomTypeId: Joi.number().integer().required(), isPublic: Joi.boolean().required(), + isAdultOnly: Joi.boolean().allow(null), genderRestrictionId: Joi.number().integer().allow(null), minAge: Joi.number().integer().min(0).allow(null), maxAge: Joi.number().integer().min(0).allow(null), @@ -579,7 +678,7 @@ class AdminController { if (!userId || !(await AdminService.hasUserAccess(userId, 'chatrooms'))) { return res.status(403).json({ error: 'Keine Berechtigung.' }); } - await AdminService.deleteRoom(req.params.id); + await AdminService.deleteRoom(userId, req.params.id); res.sendStatus(204); } catch (error) { console.log(error); diff --git a/backend/controllers/chatController.js b/backend/controllers/chatController.js index 6f8b537..db6dddd 100644 --- a/backend/controllers/chatController.js +++ b/backend/controllers/chatController.js @@ -172,7 +172,9 @@ class ChatController { async getRoomList(req, res) { // Öffentliche Räume für Chat-Frontend try { - const rooms = await chatService.getRoomList(); + const { userid: hashedUserId } = req.headers; + const adultOnly = String(req.query.adultOnly || '').toLowerCase() === 'true'; + const rooms = await chatService.getRoomList(hashedUserId, { adultOnly }); res.status(200).json(rooms); } catch (error) { res.status(500).json({ error: error.message }); diff --git a/backend/controllers/navigationController.js b/backend/controllers/navigationController.js index 8f422dc..74068e0 100644 --- a/backend/controllers/navigationController.js +++ b/backend/controllers/navigationController.js @@ -258,6 +258,14 @@ const menuStructure = { visible: ["mainadmin", "useradministration"], path: "/admin/users" }, + adultverification: { + visible: ["mainadmin", "useradministration"], + path: "/admin/users/adult-verification" + }, + eroticmoderation: { + visible: ["mainadmin", "useradministration"], + path: "/admin/users/erotic-moderation" + }, userstatistics: { visible: ["mainadmin"], path: "/admin/users/statistics" @@ -343,7 +351,14 @@ class NavigationController { return age; } - async filterMenu(menu, rights, age, userId) { + normalizeAdultVerificationStatus(value) { + if (['pending', 'approved', 'rejected'].includes(value)) { + return value; + } + return 'none'; + } + + async filterMenu(menu, rights, age, userId, adultVerificationStatus = 'none') { const filteredMenu = {}; try { const hasFalukantAccount = await this.hasFalukantAccount(userId); @@ -357,8 +372,17 @@ class NavigationController { || (value.visible.includes('hasfalukantaccount') && hasFalukantAccount)) { const { visible, ...itemWithoutVisible } = value; filteredMenu[key] = { ...itemWithoutVisible }; + if ( + value.visible.includes("over18") + && age >= 18 + && adultVerificationStatus !== 'approved' + && (value.path || value.action || value.view) + ) { + filteredMenu[key].disabled = true; + filteredMenu[key].disabledReasonKey = 'socialnetwork.erotic.lockedShort'; + } if (value.children) { - filteredMenu[key].children = await this.filterMenu(value.children, rights, age, userId); + filteredMenu[key].children = await this.filterMenu(value.children, rights, age, userId, adultVerificationStatus); } } } @@ -385,20 +409,29 @@ class NavigationController { required: false }] }); - const userBirthdateParams = await UserParam.findAll({ + const userParams = await UserParam.findAll({ where: { userId: user.id }, include: [ { model: UserParamType, as: 'paramType', - where: { description: 'birthdate' } + where: { description: ['birthdate', 'adult_verification_status'] } } ] }); - const birthDate = userBirthdateParams.length > 0 ? userBirthdateParams[0].value : (new Date()).toDateString(); + let birthDate = (new Date()).toDateString(); + let adultVerificationStatus = 'none'; + for (const param of userParams) { + if (param.paramType?.description === 'birthdate' && param.value) { + birthDate = param.value; + } + if (param.paramType?.description === 'adult_verification_status') { + adultVerificationStatus = this.normalizeAdultVerificationStatus(param.value); + } + } const age = this.calculateAge(birthDate); const rights = userRights.map(ur => ur.rightType?.title).filter(Boolean); - const filteredMenu = await this.filterMenu(menuStructure, rights, age, user.id); + const filteredMenu = await this.filterMenu(menuStructure, rights, age, user.id, adultVerificationStatus); // Vokabeltrainer: Sprachen werden im Frontend dynamisch geladen (wie Forum) // Keine children mehr, da das Menü nur 2 Ebenen unterstützt diff --git a/backend/controllers/settingsController.js b/backend/controllers/settingsController.js index 7e41316..e24d54d 100644 --- a/backend/controllers/settingsController.js +++ b/backend/controllers/settingsController.js @@ -217,6 +217,25 @@ class SettingsController { res.status(500).json({ error: 'Internal server error' }); } } + + async submitAdultVerificationRequest(req, res) { + try { + const hashedUserId = req.headers.userid; + const note = req.body?.note || ''; + const file = req.file || null; + const result = await settingsService.submitAdultVerificationRequest(hashedUserId, { note }, file); + res.status(200).json(result); + } catch (error) { + console.error('Error submitting adult verification request:', error); + const status = [ + 'User not found', + 'Adult verification can only be requested by adult users', + 'No verification document provided', + 'Unsupported verification document type' + ].includes(error.message) ? 400 : 500; + res.status(status).json({ error: error.message }); + } + } } export default SettingsController; diff --git a/backend/controllers/socialnetworkController.js b/backend/controllers/socialnetworkController.js index dd5de06..9c483bf 100644 --- a/backend/controllers/socialnetworkController.js +++ b/backend/controllers/socialnetworkController.js @@ -15,6 +15,16 @@ class SocialNetworkController { this.changeImage = this.changeImage.bind(this); this.getFoldersByUsername = this.getFoldersByUsername.bind(this); this.deleteFolder = this.deleteFolder.bind(this); + this.getAdultFolders = this.getAdultFolders.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.uploadEroticVideo = this.uploadEroticVideo.bind(this); + this.getEroticVideoByHash = this.getEroticVideoByHash.bind(this); + this.reportEroticContent = this.reportEroticContent.bind(this); this.createGuestbookEntry = this.createGuestbookEntry.bind(this); this.getGuestbookEntries = this.getGuestbookEntries.bind(this); this.deleteGuestbookEntry = this.deleteGuestbookEntry.bind(this); @@ -187,6 +197,138 @@ class SocialNetworkController { } } + async getAdultFolders(req, res) { + try { + const userId = req.headers.userid; + const folders = await this.socialNetworkService.getAdultFolders(userId); + res.status(200).json(folders); + } catch (error) { + console.error('Error in getAdultFolders:', error); + res.status(error.status || 500).json({ error: error.message }); + } + } + + async createAdultFolder(req, res) { + try { + const userId = req.headers.userid; + const folderData = req.body; + const { folderId } = req.params; + const folder = await this.socialNetworkService.createAdultFolder(userId, folderData, folderId); + res.status(201).json(folder); + } catch (error) { + console.error('Error in createAdultFolder:', error); + res.status(error.status || 500).json({ error: error.message }); + } + } + + async getAdultFolderImageList(req, res) { + try { + const userId = req.headers.userid; + const { folderId } = req.params; + const images = await this.socialNetworkService.getAdultFolderImageList(userId, folderId); + res.status(200).json(images); + } catch (error) { + console.error('Error in getAdultFolderImageList:', error); + res.status(error.status || 500).json({ error: error.message }); + } + } + + async uploadAdultImage(req, res) { + try { + const userId = req.headers.userid; + const file = req.file; + const formData = req.body; + const image = await this.socialNetworkService.uploadAdultImage(userId, file, formData); + res.status(201).json(image); + } catch (error) { + console.error('Error in uploadAdultImage:', error); + res.status(error.status || 500).json({ error: error.message }); + } + } + + async getAdultImageByHash(req, res) { + try { + const userId = req.headers.userid; + const { hash } = req.params; + const filePath = await this.socialNetworkService.getAdultImageFilePath(userId, hash); + res.sendFile(filePath, err => { + if (err) { + console.error('Error sending adult file:', err); + res.status(500).json({ error: 'Error sending file' }); + } + }); + } catch (error) { + console.error('Error in getAdultImageByHash:', error); + res.status(error.status || 403).json({ error: error.message || 'Access denied or image not found' }); + } + } + + async changeAdultImage(req, res) { + 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); + res.status(201).json(await this.socialNetworkService.getAdultFolderImageList(userId, folderId)); + } catch (error) { + console.error('Error in changeAdultImage:', error); + res.status(error.status || 403).json({ error: error.message || 'Access denied or image not found' }); + } + } + + async listEroticVideos(req, res) { + try { + const userId = req.headers.userid; + const videos = await this.socialNetworkService.listEroticVideos(userId); + res.status(200).json(videos); + } catch (error) { + console.error('Error in listEroticVideos:', error); + res.status(error.status || 500).json({ error: error.message }); + } + } + + async uploadEroticVideo(req, res) { + try { + const userId = req.headers.userid; + const file = req.file; + const formData = req.body; + const video = await this.socialNetworkService.uploadEroticVideo(userId, file, formData); + res.status(201).json(video); + } catch (error) { + console.error('Error in uploadEroticVideo:', error); + res.status(error.status || 500).json({ error: error.message }); + } + } + + async getEroticVideoByHash(req, res) { + try { + const userId = req.headers.userid; + const { hash } = req.params; + const { filePath, mimeType } = await this.socialNetworkService.getEroticVideoFilePath(userId, hash); + res.type(mimeType); + res.sendFile(filePath, err => { + if (err) { + console.error('Error sending adult video:', err); + res.status(500).json({ error: 'Error sending file' }); + } + }); + } catch (error) { + console.error('Error in getEroticVideoByHash:', error); + res.status(error.status || 403).json({ error: error.message || 'Access denied or video not found' }); + } + } + + async reportEroticContent(req, res) { + try { + const userId = req.headers.userid; + const result = await this.socialNetworkService.createEroticContentReport(userId, req.body || {}); + res.status(201).json(result); + } catch (error) { + console.error('Error in reportEroticContent:', error); + res.status(error.status || 400).json({ error: error.message }); + } + } + async createGuestbookEntry(req, res) { try { const { htmlContent, recipientName } = req.body; diff --git a/backend/migrations/20260326000000-add-adult-area-to-gallery.cjs b/backend/migrations/20260326000000-add-adult-area-to-gallery.cjs new file mode 100644 index 0000000..b51c1be --- /dev/null +++ b/backend/migrations/20260326000000-add-adult-area-to-gallery.cjs @@ -0,0 +1,31 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn( + { schema: 'community', tableName: 'folder' }, + 'is_adult_area', + { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + ); + + await queryInterface.addColumn( + { schema: 'community', tableName: 'image' }, + 'is_adult_content', + { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + ); + }, + + async down(queryInterface) { + await queryInterface.removeColumn({ schema: 'community', tableName: 'image' }, 'is_adult_content'); + await queryInterface.removeColumn({ schema: 'community', tableName: 'folder' }, 'is_adult_area'); + }, +}; diff --git a/backend/migrations/20260326001000-create-erotic-video.cjs b/backend/migrations/20260326001000-create-erotic-video.cjs new file mode 100644 index 0000000..e70dd24 --- /dev/null +++ b/backend/migrations/20260326001000-create-erotic-video.cjs @@ -0,0 +1,63 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable( + { schema: 'community', tableName: 'erotic_video' }, + { + id: { + type: Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true, + allowNull: false, + }, + title: { + type: Sequelize.STRING, + allowNull: false, + }, + description: { + type: Sequelize.TEXT, + allowNull: true, + }, + original_file_name: { + type: Sequelize.STRING, + allowNull: false, + }, + hash: { + type: Sequelize.STRING, + allowNull: false, + unique: true, + }, + mime_type: { + type: Sequelize.STRING, + allowNull: false, + }, + user_id: { + type: Sequelize.INTEGER, + allowNull: false, + references: { + model: { schema: 'community', tableName: 'user' }, + key: 'id', + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE', + }, + created_at: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'), + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'), + }, + } + ); + }, + + async down(queryInterface) { + await queryInterface.dropTable({ schema: 'community', tableName: 'erotic_video' }); + }, +}; diff --git a/backend/migrations/20260326002000-add-is-adult-only-to-chat-room.cjs b/backend/migrations/20260326002000-add-is-adult-only-to-chat-room.cjs new file mode 100644 index 0000000..86b7dfe --- /dev/null +++ b/backend/migrations/20260326002000-add-is-adult-only-to-chat-room.cjs @@ -0,0 +1,20 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn( + { schema: 'chat', tableName: 'room' }, + 'is_adult_only', + { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + ); + }, + + async down(queryInterface) { + await queryInterface.removeColumn({ schema: 'chat', tableName: 'room' }, 'is_adult_only'); + }, +}; diff --git a/backend/migrations/20260326003000-add-adult-content-moderation.cjs b/backend/migrations/20260326003000-add-adult-content-moderation.cjs new file mode 100644 index 0000000..37d42bb --- /dev/null +++ b/backend/migrations/20260326003000-add-adult-content-moderation.cjs @@ -0,0 +1,95 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn( + { schema: 'community', tableName: 'image' }, + 'is_moderated_hidden', + { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false + } + ).catch(() => {}); + + await queryInterface.addColumn( + { schema: 'community', tableName: 'erotic_video' }, + 'is_moderated_hidden', + { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false + } + ).catch(() => {}); + + await queryInterface.createTable( + { schema: 'community', tableName: 'erotic_content_report' }, + { + id: { + type: Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true, + allowNull: false + }, + reporter_id: { + type: Sequelize.INTEGER, + allowNull: false, + references: { model: { schema: 'community', tableName: 'user' }, key: 'id' }, + onDelete: 'CASCADE' + }, + target_type: { + type: Sequelize.STRING(20), + allowNull: false + }, + target_id: { + type: Sequelize.INTEGER, + allowNull: false + }, + reason: { + type: Sequelize.STRING(80), + allowNull: false + }, + note: { + type: Sequelize.TEXT, + allowNull: true + }, + status: { + type: Sequelize.STRING(20), + allowNull: false, + defaultValue: 'open' + }, + action_taken: { + type: Sequelize.STRING(40), + allowNull: true + }, + handled_by: { + type: Sequelize.INTEGER, + allowNull: true, + references: { model: { schema: 'community', tableName: 'user' }, key: 'id' }, + onDelete: 'SET NULL' + }, + handled_at: { + type: Sequelize.DATE, + allowNull: true + }, + created_at: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.literal('NOW()') + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.literal('NOW()') + } + } + ).catch(() => {}); + }, + + async down(queryInterface) { + await queryInterface.dropTable({ schema: 'community', tableName: 'erotic_content_report' }).catch(() => {}); + await queryInterface.removeColumn({ schema: 'community', tableName: 'erotic_video' }, 'is_moderated_hidden').catch(() => {}); + await queryInterface.removeColumn({ schema: 'community', tableName: 'image' }, 'is_moderated_hidden').catch(() => {}); + } +}; diff --git a/backend/models/associations.js b/backend/models/associations.js index 7237e09..77e031a 100644 --- a/backend/models/associations.js +++ b/backend/models/associations.js @@ -18,6 +18,8 @@ import UserParamVisibilityType from './type/user_param_visibility.js'; import UserParamVisibility from './community/user_param_visibility.js'; import Folder from './community/folder.js'; import Image from './community/image.js'; +import EroticVideo from './community/erotic_video.js'; +import EroticContentReport from './community/erotic_content_report.js'; import ImageVisibilityType from './type/image_visibility.js'; import ImageVisibilityUser from './community/image_visibility_user.js'; import FolderImageVisibility from './community/folder_image_visibility.js'; @@ -209,6 +211,14 @@ export default function setupAssociations() { Image.belongsTo(User, { foreignKey: 'userId' }); User.hasMany(Image, { foreignKey: 'userId' }); + EroticVideo.belongsTo(User, { foreignKey: 'userId', as: 'owner' }); + User.hasMany(EroticVideo, { foreignKey: 'userId', as: 'eroticVideos' }); + + EroticContentReport.belongsTo(User, { foreignKey: 'reporterId', as: 'reporter' }); + User.hasMany(EroticContentReport, { foreignKey: 'reporterId', as: 'eroticContentReports' }); + EroticContentReport.belongsTo(User, { foreignKey: 'handledBy', as: 'moderator' }); + User.hasMany(EroticContentReport, { foreignKey: 'handledBy', as: 'handledEroticContentReports' }); + // Image visibility associations Folder.belongsToMany(ImageVisibilityType, { through: FolderImageVisibility, diff --git a/backend/models/chat/room.js b/backend/models/chat/room.js index c79e44d..1b638aa 100644 --- a/backend/models/chat/room.js +++ b/backend/models/chat/room.js @@ -20,6 +20,10 @@ const Room = sequelize.define('Room', { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true}, + isAdultOnly: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false}, genderRestrictionId: { type: DataTypes.INTEGER, allowNull: true}, diff --git a/backend/models/community/erotic_content_report.js b/backend/models/community/erotic_content_report.js new file mode 100644 index 0000000..98092c5 --- /dev/null +++ b/backend/models/community/erotic_content_report.js @@ -0,0 +1,64 @@ +import { Model, DataTypes } from 'sequelize'; +import { sequelize } from '../../utils/sequelize.js'; + +class EroticContentReport extends Model {} + +EroticContentReport.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + reporterId: { + type: DataTypes.INTEGER, + allowNull: false, + field: 'reporter_id' + }, + targetType: { + type: DataTypes.STRING, + allowNull: false, + field: 'target_type' + }, + targetId: { + type: DataTypes.INTEGER, + allowNull: false, + field: 'target_id' + }, + reason: { + type: DataTypes.STRING, + allowNull: false + }, + note: { + type: DataTypes.TEXT, + allowNull: true + }, + status: { + type: DataTypes.STRING, + allowNull: false, + defaultValue: 'open' + }, + actionTaken: { + type: DataTypes.STRING, + allowNull: true, + field: 'action_taken' + }, + handledBy: { + type: DataTypes.INTEGER, + allowNull: true, + field: 'handled_by' + }, + handledAt: { + type: DataTypes.DATE, + allowNull: true, + field: 'handled_at' + } +}, { + sequelize, + modelName: 'EroticContentReport', + tableName: 'erotic_content_report', + schema: 'community', + timestamps: true, + underscored: true +}); + +export default EroticContentReport; diff --git a/backend/models/community/erotic_video.js b/backend/models/community/erotic_video.js new file mode 100644 index 0000000..821632c --- /dev/null +++ b/backend/models/community/erotic_video.js @@ -0,0 +1,55 @@ +import { Model, DataTypes } from 'sequelize'; +import { sequelize } from '../../utils/sequelize.js'; + +class EroticVideo extends Model {} + +EroticVideo.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + title: { + type: DataTypes.STRING, + allowNull: false + }, + description: { + type: DataTypes.TEXT, + allowNull: true + }, + originalFileName: { + type: DataTypes.STRING, + allowNull: false, + field: 'original_file_name' + }, + hash: { + type: DataTypes.STRING, + allowNull: false, + unique: true + }, + mimeType: { + type: DataTypes.STRING, + allowNull: false, + field: 'mime_type' + }, + isModeratedHidden: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + field: 'is_moderated_hidden' + }, + userId: { + type: DataTypes.INTEGER, + allowNull: false, + field: 'user_id' + } +}, { + sequelize, + modelName: 'EroticVideo', + tableName: 'erotic_video', + schema: 'community', + timestamps: true, + underscored: true +}); + +export default EroticVideo; diff --git a/backend/models/community/folder.js b/backend/models/community/folder.js index 955499a..c7f1286 100644 --- a/backend/models/community/folder.js +++ b/backend/models/community/folder.js @@ -6,6 +6,11 @@ const Folder = sequelize.define('folder', { name: { type: DataTypes.STRING, allowNull: false}, + isAdultArea: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false + }, parentId: { type: DataTypes.INTEGER, allowNull: true diff --git a/backend/models/community/image.js b/backend/models/community/image.js index 589a29c..9b11d8a 100644 --- a/backend/models/community/image.js +++ b/backend/models/community/image.js @@ -6,6 +6,16 @@ const Image = sequelize.define('image', { title: { type: DataTypes.STRING, allowNull: false}, + isAdultContent: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false + }, + isModeratedHidden: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false + }, description: { type: DataTypes.TEXT, allowNull: true}, diff --git a/backend/models/index.js b/backend/models/index.js index 6095739..cfbc061 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -18,6 +18,8 @@ import UserParamVisibilityType from './type/user_param_visibility.js'; import UserParamVisibility from './community/user_param_visibility.js'; import Folder from './community/folder.js'; import Image from './community/image.js'; +import EroticVideo from './community/erotic_video.js'; +import EroticContentReport from './community/erotic_content_report.js'; import ImageVisibilityType from './type/image_visibility.js'; import ImageVisibilityUser from './community/image_visibility_user.js'; import FolderImageVisibility from './community/folder_image_visibility.js'; @@ -170,6 +172,8 @@ const models = { UserParamVisibility, Folder, Image, + EroticVideo, + EroticContentReport, ImageVisibilityType, ImageVisibilityUser, FolderImageVisibility, diff --git a/backend/routers/adminRouter.js b/backend/routers/adminRouter.js index 6964f8f..ebf8984 100644 --- a/backend/routers/adminRouter.js +++ b/backend/routers/adminRouter.js @@ -19,6 +19,12 @@ router.delete('/chat/rooms/:id', authenticate, adminController.deleteRoom); router.get('/users/search', authenticate, adminController.searchUsers); router.get('/users/statistics', authenticate, adminController.getUserStatistics); router.get('/users/batch', authenticate, adminController.getUsers); +router.get('/users/adult-verification', authenticate, adminController.getAdultVerificationRequests); +router.get('/users/:id/adult-verification/document', authenticate, adminController.getAdultVerificationDocument); +router.put('/users/:id/adult-verification', authenticate, adminController.setAdultVerificationStatus); +router.get('/users/erotic-moderation', authenticate, adminController.getEroticModerationReports); +router.get('/users/erotic-moderation/preview/:type/:targetId', authenticate, adminController.getEroticModerationPreview); +router.put('/users/erotic-moderation/:id', authenticate, adminController.applyEroticModerationAction); router.get('/users/:id', authenticate, adminController.getUser); router.put('/users/:id', authenticate, adminController.updateUser); diff --git a/backend/routers/chatRouter.js b/backend/routers/chatRouter.js index c52ab69..40acac9 100644 --- a/backend/routers/chatRouter.js +++ b/backend/routers/chatRouter.js @@ -14,7 +14,7 @@ router.post('/exit', chatController.removeUser); router.post('/initOneToOne', authenticate, chatController.initOneToOne); router.post('/oneToOne/sendMessage', authenticate, chatController.sendOneToOneMessage); // Neue Route zum Senden einer Nachricht router.get('/oneToOne/messageHistory', authenticate, chatController.getOneToOneMessageHistory); // Neue Route zum Abrufen der Nachrichtengeschichte -router.get('/rooms', chatController.getRoomList); +router.get('/rooms', authenticate, chatController.getRoomList); router.get('/room-create-options', authenticate, chatController.getRoomCreateOptions); router.get('/my-rooms', authenticate, chatController.getOwnRooms); router.delete('/my-rooms/:id', authenticate, chatController.deleteOwnRoom); diff --git a/backend/routers/settingsRouter.js b/backend/routers/settingsRouter.js index ee3cc6f..21cf125 100644 --- a/backend/routers/settingsRouter.js +++ b/backend/routers/settingsRouter.js @@ -1,9 +1,11 @@ import { Router } from 'express'; import SettingsController from '../controllers/settingsController.js'; import { authenticate } from '../middleware/authMiddleware.js'; +import multer from 'multer'; const router = Router(); const settingsController = new SettingsController(); +const upload = multer(); router.post('/filter', authenticate, settingsController.filterSettings.bind(settingsController)); router.post('/update', authenticate, settingsController.updateSetting.bind(settingsController)); @@ -21,5 +23,6 @@ router.get('/visibilities', authenticate, settingsController.getVisibilities.bin router.post('/update-visibility', authenticate, settingsController.updateVisibility.bind(settingsController)); router.get('/llm', authenticate, settingsController.getLlmSettings.bind(settingsController)); router.post('/llm', authenticate, settingsController.saveLlmSettings.bind(settingsController)); +router.post('/adult-verification/request', authenticate, upload.single('document'), settingsController.submitAdultVerificationRequest.bind(settingsController)); export default router; diff --git a/backend/routers/socialnetworkRouter.js b/backend/routers/socialnetworkRouter.js index a0e6322..385fc96 100644 --- a/backend/routers/socialnetworkRouter.js +++ b/backend/routers/socialnetworkRouter.js @@ -15,6 +15,16 @@ router.post('/folders/:folderId', socialNetworkController.createFolder); router.get('/folders', socialNetworkController.getFolders); 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('/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.get('/erotic/video/:hash', socialNetworkController.getEroticVideoByHash); +router.post('/erotic/report', socialNetworkController.reportEroticContent); router.get('/images/:imageId', socialNetworkController.getImage); router.put('/images/:imageId', socialNetworkController.changeImage); router.get('/imagevisibilities', socialNetworkController.getImageVisibilityTypes); diff --git a/backend/services/adminService.js b/backend/services/adminService.js index 762a901..8cedd09 100644 --- a/backend/services/adminService.js +++ b/backend/services/adminService.js @@ -24,12 +24,44 @@ import BranchType from "../models/falukant/type/branch.js"; import RegionDistance from "../models/falukant/data/region_distance.js"; import Room from '../models/chat/room.js'; import UserParam from '../models/community/user_param.js'; +import Image from '../models/community/image.js'; +import EroticVideo from '../models/community/erotic_video.js'; +import EroticContentReport from '../models/community/erotic_content_report.js'; import TitleOfNobility from "../models/falukant/type/title_of_nobility.js"; import { sequelize } from '../utils/sequelize.js'; import npcCreationJobService from './npcCreationJobService.js'; import { v4 as uuidv4 } from 'uuid'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); class AdminService { + removeEroticStorageFile(type, hash) { + if (!hash) { + return; + } + const storageFolder = type === 'image' ? 'erotic' : 'erotic-video'; + const filePath = path.join(__dirname, '..', 'images', storageFolder, hash); + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } + } + + calculateAgeFromBirthdate(birthdate) { + if (!birthdate) return null; + const today = new Date(); + const birthDateObj = new Date(birthdate); + let age = today.getFullYear() - birthDateObj.getFullYear(); + const monthDiff = today.getMonth() - birthDateObj.getMonth(); + if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDateObj.getDate())) { + age--; + } + return age; + } + async hasUserAccess(userId, section) { const userRights = await UserRight.findAll({ include: [{ @@ -232,6 +264,359 @@ class AdminService { }); } + async getAdultVerificationRequests(userId, status = 'pending') { + if (!(await this.hasUserAccess(userId, 'useradministration'))) { + throw new Error('noaccess'); + } + + const users = await User.findAll({ + attributes: ['id', 'hashedId', 'username', 'active'], + include: [ + { + model: UserParam, + as: 'user_params', + required: false, + include: [ + { + model: UserParamType, + as: 'paramType', + where: { description: ['birthdate', 'adult_verification_status'] } + } + ] + } + ], + order: [['username', 'ASC']] + }); + + const rows = users.map((user) => { + const birthdateParam = user.user_params.find((param) => param.paramType?.description === 'birthdate'); + const statusParam = user.user_params.find((param) => param.paramType?.description === 'adult_verification_status'); + const requestParam = user.user_params.find((param) => param.paramType?.description === 'adult_verification_request'); + const age = this.calculateAgeFromBirthdate(birthdateParam?.value); + const verificationStatus = ['pending', 'approved', 'rejected'].includes(statusParam?.value) + ? statusParam.value + : 'none'; + let verificationRequest = null; + try { + verificationRequest = requestParam?.value ? JSON.parse(requestParam.value) : null; + } catch { + verificationRequest = null; + } + return { + id: user.hashedId, + username: user.username, + active: !!user.active, + age, + adultVerificationStatus: verificationStatus, + adultVerificationRequest: verificationRequest + }; + }).filter((row) => row.age !== null && row.age >= 18); + + if (status === 'all') { + return rows; + } + + return rows.filter((row) => row.adultVerificationStatus === status); + } + + async setAdultVerificationStatus(requesterId, targetHashedId, status) { + if (!(await this.hasUserAccess(requesterId, 'useradministration'))) { + throw new Error('noaccess'); + } + if (!['approved', 'rejected', 'pending'].includes(status)) { + throw new Error('wrongstatus'); + } + + const user = await User.findOne({ + where: { hashedId: targetHashedId }, + attributes: ['id'] + }); + if (!user) { + throw new Error('notfound'); + } + + const birthdateParam = await UserParam.findOne({ + where: { userId: user.id }, + include: [{ + model: UserParamType, + as: 'paramType', + where: { description: 'birthdate' } + }] + }); + const age = this.calculateAgeFromBirthdate(birthdateParam?.value); + if (age === null || age < 18) { + throw new Error('notadult'); + } + + const paramType = await UserParamType.findOne({ + where: { description: 'adult_verification_status' } + }); + if (!paramType) { + throw new Error('missingparamtype'); + } + + const existing = await UserParam.findOne({ + where: { userId: user.id, paramTypeId: paramType.id } + }); + + if (existing) { + await existing.update({ value: status }); + } else { + await UserParam.create({ + userId: user.id, + paramTypeId: paramType.id, + value: status + }); + } + + return { success: true }; + } + + async getAdultVerificationDocument(requesterId, targetHashedId) { + if (!(await this.hasUserAccess(requesterId, 'useradministration'))) { + throw new Error('noaccess'); + } + + const user = await User.findOne({ + where: { hashedId: targetHashedId }, + attributes: ['id', 'username'] + }); + if (!user) { + throw new Error('notfound'); + } + + const requestParam = await UserParam.findOne({ + where: { userId: user.id }, + include: [{ + model: UserParamType, + as: 'paramType', + where: { description: 'adult_verification_request' } + }] + }); + if (!requestParam?.value) { + throw new Error('norequest'); + } + + let requestData; + try { + requestData = JSON.parse(requestParam.value); + } catch { + throw new Error('norequest'); + } + + const filePath = path.join(__dirname, '..', 'images', 'adult-verification', requestData.fileName); + if (!fs.existsSync(filePath)) { + throw new Error('nofile'); + } + + return { + filePath, + mimeType: requestData.mimeType || 'application/octet-stream', + originalName: requestData.originalName || `${user.username}-verification` + }; + } + + async ensureUserParam(userId, description, value) { + const paramType = await UserParamType.findOne({ + where: { description } + }); + if (!paramType) { + throw new Error('missingparamtype'); + } + const existing = await UserParam.findOne({ + where: { userId, paramTypeId: paramType.id } + }); + if (existing) { + await existing.update({ value }); + return existing; + } + return await UserParam.create({ + userId, + paramTypeId: paramType.id, + value + }); + } + + async getEroticModerationReports(userId, status = 'open') { + if (!(await this.hasUserAccess(userId, 'useradministration'))) { + throw new Error('noaccess'); + } + + const where = status === 'all' ? {} : { status }; + const reports = await EroticContentReport.findAll({ + where, + include: [ + { + model: User, + as: 'reporter', + attributes: ['id', 'hashedId', 'username'] + }, + { + model: User, + as: 'moderator', + attributes: ['id', 'hashedId', 'username'], + required: false + } + ], + order: [['createdAt', 'DESC']] + }); + + const rows = []; + for (const report of reports) { + let target = null; + if (report.targetType === 'image') { + target = await Image.findByPk(report.targetId, { + attributes: ['id', 'title', 'hash', 'userId', 'isModeratedHidden'] + }); + } else if (report.targetType === 'video') { + target = await EroticVideo.findByPk(report.targetId, { + attributes: ['id', 'title', 'hash', 'userId', 'isModeratedHidden'] + }); + } + const owner = target ? await User.findByPk(target.userId, { + attributes: ['hashedId', 'username'] + }) : null; + rows.push({ + id: report.id, + targetType: report.targetType, + targetId: report.targetId, + reason: report.reason, + note: report.note, + status: report.status, + actionTaken: report.actionTaken, + handledAt: report.handledAt, + createdAt: report.createdAt, + reporter: report.reporter, + moderator: report.moderator, + target: target ? { + id: target.id, + title: target.title, + hash: target.hash, + isModeratedHidden: !!target.isModeratedHidden + } : null, + owner + }); + } + + return rows; + } + + async applyEroticModerationAction(requesterId, reportId, action, note = null) { + if (!(await this.hasUserAccess(requesterId, 'useradministration'))) { + throw new Error('noaccess'); + } + if (!['dismiss', 'hide_content', 'restore_content', 'delete_content', 'block_uploads', 'revoke_access'].includes(action)) { + throw new Error('wrongaction'); + } + + const moderator = await User.findOne({ + where: { hashedId: requesterId }, + attributes: ['id'] + }); + const report = await EroticContentReport.findByPk(reportId); + if (!report) { + throw new Error('notfound'); + } + + let target = null; + if (report.targetType === 'image') { + target = await Image.findByPk(report.targetId); + } else if (report.targetType === 'video') { + target = await EroticVideo.findByPk(report.targetId); + } + + if (action === 'dismiss') { + await report.update({ + status: 'dismissed', + actionTaken: 'dismiss', + handledBy: moderator?.id || null, + handledAt: new Date(), + note: note ?? report.note + }); + return { success: true }; + } + + if (!target) { + throw new Error('targetnotfound'); + } + + if (action === 'hide_content') { + await target.update({ isModeratedHidden: true }); + } else if (action === 'restore_content') { + await target.update({ isModeratedHidden: false }); + } else if (action === 'delete_content') { + this.removeEroticStorageFile(report.targetType, target.hash); + await target.destroy(); + } else if (action === 'block_uploads') { + await this.ensureUserParam(target.userId, 'adult_upload_blocked', 'true'); + } else if (action === 'revoke_access') { + await this.ensureUserParam(target.userId, 'adult_verification_status', 'rejected'); + } + + await report.update({ + status: 'actioned', + actionTaken: action, + handledBy: moderator?.id || null, + handledAt: new Date(), + note: note ?? report.note + }); + + return { success: true }; + } + + async getEroticModerationPreview(requesterId, targetType, targetId) { + if (!(await this.hasUserAccess(requesterId, 'useradministration'))) { + throw new Error('noaccess'); + } + + if (targetType === 'image') { + const target = await Image.findByPk(targetId, { + attributes: ['hash', 'originalFileName'] + }); + if (!target) { + throw new Error('notfound'); + } + const filePath = path.join(__dirname, '..', 'images', 'erotic', target.hash); + if (!fs.existsSync(filePath)) { + throw new Error('nofile'); + } + return { + filePath, + mimeType: this.getMimeTypeFromName(target.originalFileName) || 'image/jpeg', + originalName: target.originalFileName || target.hash + }; + } + + if (targetType === 'video') { + const target = await EroticVideo.findByPk(targetId, { + attributes: ['hash', 'mimeType', 'originalFileName'] + }); + if (!target) { + throw new Error('notfound'); + } + const filePath = path.join(__dirname, '..', 'images', 'erotic-video', target.hash); + if (!fs.existsSync(filePath)) { + throw new Error('nofile'); + } + return { + filePath, + mimeType: target.mimeType || this.getMimeTypeFromName(target.originalFileName) || 'application/octet-stream', + originalName: target.originalFileName || target.hash + }; + } + + throw new Error('wrongtype'); + } + + getMimeTypeFromName(fileName) { + const lower = String(fileName || '').toLowerCase(); + if (lower.endsWith('.png')) return 'image/png'; + if (lower.endsWith('.webp')) return 'image/webp'; + if (lower.endsWith('.gif')) return 'image/gif'; + if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg'; + return null; + } + async getFalukantUserById(userId, hashedId) { if (!(await this.hasUserAccess(userId, 'falukantusers'))) { throw new Error('noaccess'); @@ -682,6 +1067,7 @@ class AdminService { 'title', 'roomTypeId', 'isPublic', + 'isAdultOnly', 'genderRestrictionId', 'minAge', 'maxAge', @@ -1312,4 +1698,4 @@ class AdminService { } } -export default new AdminService(); \ No newline at end of file +export default new AdminService(); diff --git a/backend/services/chatService.js b/backend/services/chatService.js index fb6f757..d129425 100644 --- a/backend/services/chatService.js +++ b/backend/services/chatService.js @@ -2,6 +2,8 @@ import { v4 as uuidv4 } from 'uuid'; import amqp from 'amqplib/callback_api.js'; import User from '../models/community/user.js'; import Room from '../models/chat/room.js'; +import UserParam from '../models/community/user_param.js'; +import UserParamType from '../models/type/user_param.js'; const RABBITMQ_URL = process.env.AMQP_URL || 'amqp://localhost'; const QUEUE = 'oneToOne_messages'; @@ -166,17 +168,69 @@ class ChatService { (chat.user1Id === user2HashId && chat.user2Id === user1HashId) ); } + + calculateAge(birthdate) { + const birthDate = new Date(birthdate); + const ageDifMs = Date.now() - birthDate.getTime(); + const ageDate = new Date(ageDifMs); + return Math.abs(ageDate.getUTCFullYear() - 1970); + } + + normalizeAdultVerificationStatus(value) { + if (!value) return 'none'; + const normalized = String(value).trim().toLowerCase(); + return ['pending', 'approved', 'rejected'].includes(normalized) ? normalized : 'none'; + } + + async getAdultAccessState(hashedUserId) { + if (!hashedUserId) { + return { isAdult: false, adultVerificationStatus: 'none', adultAccessEnabled: false }; + } + + const user = await User.findOne({ where: { hashedId: hashedUserId }, attributes: ['id'] }); + if (!user) { + throw new Error('user_not_found'); + } + + const params = await UserParam.findAll({ + where: { userId: user.id }, + include: [{ + model: UserParamType, + as: 'paramType', + where: { description: ['birthdate', 'adult_verification_status'] } + }] + }); + + const birthdateParam = params.find(param => param.paramType?.description === 'birthdate'); + const statusParam = params.find(param => param.paramType?.description === 'adult_verification_status'); + const age = birthdateParam ? this.calculateAge(birthdateParam.value) : 0; + const adultVerificationStatus = this.normalizeAdultVerificationStatus(statusParam?.value); + return { + isAdult: age >= 18, + adultVerificationStatus, + adultAccessEnabled: age >= 18 && adultVerificationStatus === 'approved' + }; + } - async getRoomList() { + async getRoomList(hashedUserId, { adultOnly = false } = {}) { // Nur öffentliche Räume, keine sensiblen Felder const { default: Room } = await import('../models/chat/room.js'); const { default: RoomType } = await import('../models/chat/room_type.js'); + const where = { isPublic: true, isAdultOnly: Boolean(adultOnly) }; + + if (adultOnly) { + const adultAccess = await this.getAdultAccessState(hashedUserId); + if (!adultAccess.adultAccessEnabled) { + return []; + } + } + return Room.findAll({ attributes: [ - 'id', 'title', 'roomTypeId', 'isPublic', 'genderRestrictionId', + 'id', 'title', 'roomTypeId', 'isPublic', 'isAdultOnly', 'genderRestrictionId', 'minAge', 'maxAge', 'friendsOfOwnerOnly', 'requiredUserRightId' ], - where: { isPublic: true }, + where, include: [ { model: RoomType, as: 'roomType' } ] @@ -215,7 +269,7 @@ class ChatService { return Room.findAll({ where: { ownerId: user.id }, - attributes: ['id', 'title', 'isPublic', 'roomTypeId', 'ownerId'], + attributes: ['id', 'title', 'isPublic', 'isAdultOnly', 'roomTypeId', 'ownerId'], order: [['title', 'ASC']] }); } diff --git a/backend/services/falukantService.js b/backend/services/falukantService.js index d12e8d7..dd43d38 100644 --- a/backend/services/falukantService.js +++ b/backend/services/falukantService.js @@ -76,6 +76,13 @@ import ProductWeatherEffect from '../models/falukant/type/product_weather_effect import WeatherType from '../models/falukant/type/weather.js'; import ReputationActionType from '../models/falukant/type/reputation_action.js'; import ReputationActionLog from '../models/falukant/log/reputation_action.js'; +import { + productionPieceCost, + productionCostTotal, + effectiveWorthPercent, + KNOWLEDGE_PRICE_FLOOR, + calcRegionalSellPriceSync, +} from '../utils/falukant/falukantProductEconomy.js'; function calcAge(birthdate) { const b = new Date(birthdate); b.setHours(0, 0); @@ -97,54 +104,6 @@ async function getBranchOrFail(userId, branchId) { return branch; } -const PRODUCTION_COST_BASE = 6.0; -const PRODUCTION_COST_PER_PRODUCT_CATEGORY = 1.0; -const PRODUCTION_HEADROOM_DISCOUNT_PER_STEP = 0.035; -const PRODUCTION_HEADROOM_DISCOUNT_CAP = 0.14; - -function productionPieceCost(certificate, category) { - const c = Math.max(1, Number(category) || 1); - const cert = Math.max(1, Number(certificate) || 1); - const raw = PRODUCTION_COST_BASE + (c * PRODUCTION_COST_PER_PRODUCT_CATEGORY); - const headroom = Math.max(0, cert - c); - const discount = Math.min( - headroom * PRODUCTION_HEADROOM_DISCOUNT_PER_STEP, - PRODUCTION_HEADROOM_DISCOUNT_CAP - ); - return raw * (1 - discount); -} - -function productionCostTotal(quantity, category, certificate) { - const q = Math.min(100, Math.max(1, Number(quantity) || 1)); - return q * productionPieceCost(certificate, category); -} - -/** Regionaler Nachfragewert: sehr niedrige Werte aus der DB sonst unspielbar; Decke nach oben bei 100. */ -function effectiveWorthPercent(worthPercent) { - const w = Number(worthPercent); - if (Number.isNaN(w)) return 75; - return Math.min(100, Math.max(75, w)); -} - -/** Untergrenze für den Wissens-Multiplikator auf den Basispreis (0 = minAnteil, 100 = voller Basispreis). */ -const KNOWLEDGE_PRICE_FLOOR = 0.7; - -function calcSellPrice(product, knowledgeFactor = 0) { - const max = product.sellCost; - const min = max * KNOWLEDGE_PRICE_FLOOR; - return min + (max - min) * (knowledgeFactor / 100); -} - -/** Synchrone Preisberechnung, wenn worthPercent bereits bekannt ist (kein DB-Zugriff). */ -function calcRegionalSellPriceSync(product, knowledgeFactor, worthPercent) { - if (product.sellCost === null || product.sellCost === undefined) return null; - const w = effectiveWorthPercent(worthPercent); - const basePrice = product.sellCost * (w / 100); - const min = basePrice * KNOWLEDGE_PRICE_FLOOR; - const max = basePrice; - return min + (max - min) * (knowledgeFactor / 100); -} - const POLITICAL_OFFICE_RANKS = { assessor: 1, councillor: 1, diff --git a/backend/services/settingsService.js b/backend/services/settingsService.js index 10ad35a..97e66d9 100644 --- a/backend/services/settingsService.js +++ b/backend/services/settingsService.js @@ -12,6 +12,13 @@ import UserParamVisibilityType from '../models/type/user_param_visibility.js'; import UserParamVisibility from '../models/community/user_param_visibility.js'; import { encrypt } from '../utils/encryption.js'; import { sequelize } from '../utils/sequelize.js'; +import fsPromises from 'fs/promises'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { v4 as uuidv4 } from 'uuid'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); /** Wie UserParam.value-Setter: bei Verschlüsselungsfehler leeren String speichern, nicht crashen. */ function encryptUserParamValue(plain) { @@ -24,6 +31,90 @@ function encryptUserParamValue(plain) { } class SettingsService extends BaseService{ + parseAdultVerificationRequest(value) { + if (!value) return null; + try { + return JSON.parse(value); + } catch { + return null; + } + } + + normalizeAdultVerificationStatus(value) { + if (['pending', 'approved', 'rejected'].includes(value)) { + return value; + } + return 'none'; + } + + async getAdultAccessStateByUserId(userId) { + const userParams = await this.getUserParams(userId, ['birthdate', 'adult_verification_status', 'adult_verification_request']); + let birthdate = null; + let adultVerificationStatus = 'none'; + let adultVerificationRequest = null; + for (const param of userParams) { + if (param.paramType.description === 'birthdate') { + birthdate = param.value; + } + if (param.paramType.description === 'adult_verification_status') { + adultVerificationStatus = this.normalizeAdultVerificationStatus(param.value); + } + if (param.paramType.description === 'adult_verification_request') { + adultVerificationRequest = this.parseAdultVerificationRequest(param.value); + } + } + const age = birthdate ? this.calculateAge(birthdate) : null; + const isAdult = age !== null && age >= 18; + return { + age, + isAdult, + adultVerificationStatus: isAdult ? adultVerificationStatus : 'none', + adultVerificationRequest: isAdult ? adultVerificationRequest : null, + adultAccessEnabled: isAdult && adultVerificationStatus === 'approved' + }; + } + + buildAdultVerificationFilePath(fileName) { + return path.join(__dirname, '..', 'images', 'adult-verification', fileName); + } + + async saveAdultVerificationDocument(file) { + const allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf']; + if (!file || !file.buffer) { + throw new Error('No verification document provided'); + } + if (!allowedMimeTypes.includes(file.mimetype)) { + throw new Error('Unsupported verification document type'); + } + + const ext = path.extname(file.originalname || '').toLowerCase(); + const safeExt = ext && ext.length <= 8 ? ext : (file.mimetype === 'application/pdf' ? '.pdf' : '.bin'); + const fileName = `${uuidv4()}${safeExt}`; + const filePath = this.buildAdultVerificationFilePath(fileName); + await fsPromises.mkdir(path.dirname(filePath), { recursive: true }); + await fsPromises.writeFile(filePath, file.buffer); + return { fileName, filePath }; + } + + async upsertUserParam(userId, description, value) { + const paramType = await UserParamType.findOne({ where: { description } }); + if (!paramType) { + throw new Error(`Missing user param type: ${description}`); + } + const existingParam = await UserParam.findOne({ + where: { userId, paramTypeId: paramType.id } + }); + if (existingParam) { + await existingParam.update({ value }); + return existingParam; + } + return UserParam.create({ + userId, + paramTypeId: paramType.id, + value + }); + } + async getUserParams(userId, paramDescriptions) { return await UserParam.findAll({ where: { userId }, @@ -299,10 +390,13 @@ class SettingsService extends BaseService{ email = null; } + const adultAccess = await this.getAdultAccessStateByUserId(user.id); + return { username: user.username, email: email, - showinsearch: user.searchable + showinsearch: user.searchable, + ...adultAccess }; } catch (error) { console.error('Error getting account settings:', error); @@ -317,6 +411,8 @@ class SettingsService extends BaseService{ throw new Error('User not found'); } + const adultAccess = await this.getAdultAccessStateByUserId(user.id); + // Update username if provided if (settings.username !== undefined) { await user.update({ username: settings.username }); @@ -332,6 +428,17 @@ class SettingsService extends BaseService{ await user.update({ searchable: settings.showinsearch }); } + if (settings.requestAdultVerification) { + if (!adultAccess.isAdult) { + throw new Error('Adult verification can only be requested by adult users'); + } + + const normalizedValue = adultAccess.adultVerificationStatus === 'approved' + ? 'approved' + : 'pending'; + await this.upsertUserParam(user.id, 'adult_verification_status', normalizedValue); + } + // Update password if provided and not empty if (settings.newpassword && settings.newpassword.trim() !== '') { if (!settings.oldpassword || settings.oldpassword.trim() === '') { @@ -357,6 +464,34 @@ class SettingsService extends BaseService{ } } + async submitAdultVerificationRequest(hashedUserId, { note }, file) { + const user = await this.getUserByHashedId(hashedUserId); + if (!user) { + throw new Error('User not found'); + } + const adultAccess = await this.getAdultAccessStateByUserId(user.id); + if (!adultAccess.isAdult) { + throw new Error('Adult verification can only be requested by adult users'); + } + if (!file) { + throw new Error('No verification document provided'); + } + + const savedFile = await this.saveAdultVerificationDocument(file); + const requestPayload = { + fileName: savedFile.fileName, + originalName: file.originalname, + mimeType: file.mimetype, + note: note || '', + submittedAt: new Date().toISOString() + }; + + await this.upsertUserParam(user.id, 'adult_verification_request', JSON.stringify(requestPayload)); + await this.upsertUserParam(user.id, 'adult_verification_status', adultAccess.adultVerificationStatus === 'approved' ? 'approved' : 'pending'); + + return requestPayload; + } + async getVisibilities() { return UserParamVisibilityType.findAll(); } diff --git a/backend/services/socialnetworkService.js b/backend/services/socialnetworkService.js index 6952457..54d479e 100644 --- a/backend/services/socialnetworkService.js +++ b/backend/services/socialnetworkService.js @@ -8,6 +8,8 @@ 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 EroticVideo from '../models/community/erotic_video.js'; +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'; @@ -30,6 +32,150 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); class SocialNetworkService extends BaseService { + normalizeAdultVerificationStatus(value) { + if (!value) return 'none'; + const normalized = String(value).trim().toLowerCase(); + return ['pending', 'approved', 'rejected'].includes(normalized) ? normalized : 'none'; + } + + async getAdultAccessState(userId) { + const params = await this.getUserParams(userId, ['birthdate', 'adult_verification_status', 'adult_upload_blocked']); + const birthdateParam = params.find(param => param.paramType.description === 'birthdate'); + const statusParam = params.find(param => param.paramType.description === 'adult_verification_status'); + const uploadBlockedParam = params.find(param => param.paramType.description === 'adult_upload_blocked'); + const age = birthdateParam ? this.calculateAge(birthdateParam.value) : 0; + const adultVerificationStatus = this.normalizeAdultVerificationStatus(statusParam?.value); + return { + age, + isAdult: age >= 18, + adultVerificationStatus, + adultAccessEnabled: age >= 18 && adultVerificationStatus === 'approved', + adultUploadBlocked: String(uploadBlockedParam?.value).toLowerCase() === 'true' + }; + } + + async requireAdultAreaAccessByHash(hashedId) { + const userId = await this.checkUserAccess(hashedId); + const adultAccess = await this.getAdultAccessState(userId); + if (!adultAccess.adultAccessEnabled) { + const error = new Error('Adult area access denied'); + error.status = 403; + throw error; + } + return userId; + } + + async ensureAdultUploadsAllowed(userId) { + const adultAccess = await this.getAdultAccessState(userId); + if (adultAccess.adultUploadBlocked) { + const error = new Error('Adult uploads are blocked'); + error.status = 403; + throw error; + } + } + + async resolveEroticTarget(targetType, targetId) { + if (targetType === 'image') { + const image = await Image.findOne({ + where: { + id: targetId, + isAdultContent: true + } + }); + if (!image) { + throw new Error('Target not found'); + } + return { targetType, target: image, ownerId: image.userId }; + } + if (targetType === 'video') { + const video = await EroticVideo.findByPk(targetId); + if (!video) { + throw new Error('Target not found'); + } + return { targetType, target: video, ownerId: video.userId }; + } + throw new Error('Unsupported target type'); + } + + async ensureRootFolder(userId) { + let rootFolder = await Folder.findOne({ + where: { parentId: null, userId, isAdultArea: false }, + include: [{ + model: ImageVisibilityType, + through: { model: FolderImageVisibility }, + attributes: ['id'], + }] + }); + if (rootFolder) { + return 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, + isAdultArea: false + }); + if (visibility) { + await FolderImageVisibility.create({ + folderId: rootFolder.id, + visibilityTypeId: visibility.id + }); + } + return await Folder.findOne({ + where: { id: rootFolder.id }, + include: [{ + model: ImageVisibilityType, + through: { model: FolderImageVisibility }, + attributes: ['id'], + }] + }); + } + + async ensureAdultRootFolder(userId) { + const rootFolder = await this.ensureRootFolder(userId); + let adultRoot = await Folder.findOne({ + where: { + parentId: rootFolder.id, + userId, + isAdultArea: true, + name: 'Erotik' + }, + include: [{ + model: ImageVisibilityType, + through: { model: FolderImageVisibility }, + attributes: ['id'], + }] + }); + if (adultRoot) { + return adultRoot; + } + + const adultsVisibility = await ImageVisibilityType.findOne({ where: { description: 'adults' } }); + adultRoot = await Folder.create({ + name: 'Erotik', + parentId: rootFolder.id, + userId, + isAdultArea: true + }); + if (adultsVisibility) { + await FolderImageVisibility.create({ + folderId: adultRoot.id, + visibilityTypeId: adultsVisibility.id + }); + } + return await Folder.findOne({ + where: { id: adultRoot.id }, + include: [{ + model: ImageVisibilityType, + through: { model: FolderImageVisibility }, + attributes: ['id'], + }] + }); + } + async searchUsers({ hashedUserId, username, ageFrom, ageTo, genders }) { const whereClause = this.buildSearchWhereClause(username); const user = await this.loadUserByHash(hashedUserId); @@ -49,15 +195,19 @@ class SocialNetworkService extends BaseService { return this.constructUserProfile(user, requestingUserId); } - async createFolder(hashedUserId, data, folderId) { + async createFolder(hashedUserId, data, folderId, options = {}) { await this.checkUserAccess(hashedUserId); const user = await this.loadUserByHash(hashedUserId); if (!user) { throw new Error('User not found'); } + const isAdultArea = Boolean(options.isAdultArea); + if (isAdultArea) { + await this.requireAdultAreaAccessByHash(hashedUserId); + } console.log('given data', data, folderId); const parentFolder = data.parentId ? await Folder.findOne({ - where: { id: data.parentId, userId: user.id } + where: { id: data.parentId, userId: user.id, isAdultArea } }) : null; if (data.parentId && !parentFolder) { throw new Error('Parent folder not found'); @@ -68,11 +218,12 @@ class SocialNetworkService extends BaseService { newFolder = await Folder.create({ parentId: parentFolder.id || null, userId: user.id, - name: data.name + name: data.name, + isAdultArea }); } else { newFolder = await Folder.findOne({ - where: { id: folderId, userId: user.id } + where: { id: folderId, userId: user.id, isAdultArea } }); if (!newFolder) { throw new Error('Folder not found or user does not own the folder'); @@ -94,38 +245,8 @@ class SocialNetworkService extends BaseService { 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); + const rootFolder = await this.ensureRootFolder(userId); + const children = await this.getSubFolders(rootFolder.id, userId, false); rootFolder = rootFolder.get(); rootFolder.visibilityTypeIds = rootFolder.image_visibility_types.map(v => v.id); delete rootFolder.image_visibility_types; @@ -133,9 +254,9 @@ class SocialNetworkService extends BaseService { return rootFolder; } - async getSubFolders(parentId, userId) { + async getSubFolders(parentId, userId, isAdultArea = false) { const folders = await Folder.findAll({ - where: { parentId, userId }, + where: { parentId, userId, isAdultArea }, include: [{ model: ImageVisibilityType, through: { model: FolderImageVisibility }, @@ -146,7 +267,7 @@ class SocialNetworkService extends BaseService { ] }); for (const folder of folders) { - const children = await this.getSubFolders(folder.id, userId); + 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('children', children); @@ -160,13 +281,15 @@ class SocialNetworkService extends BaseService { const folder = await Folder.findOne({ where: { id: folderId, - userId + userId, + isAdultArea: false } }); if (!folder) throw new Error('Folder not found'); return await Image.findAll({ where: { - folderId: folder.id + folderId: folder.id, + isAdultContent: false }, order: [ ['title', 'asc'] @@ -176,13 +299,13 @@ class SocialNetworkService extends BaseService { 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); + 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); return newImage; } - async processAndUploadUserImage(file) { + async processAndUploadUserImage(file, storageType = 'user') { try { const img = sharp(file.buffer); const metadata = await img.metadata(); @@ -199,7 +322,7 @@ class SocialNetworkService extends BaseService { withoutEnlargement: true }); const newFileName = this.generateUniqueFileName(file.originalname); - const filePath = this.buildFilePath(newFileName, 'user'); + const filePath = this.buildFilePath(newFileName, storageType); await resizedImg.toFile(filePath); return newFileName; } catch (error) { @@ -231,7 +354,7 @@ class SocialNetworkService extends BaseService { } } - async createImageRecord(formData, userId, file, fileName) { + async createImageRecord(formData, userId, file, fileName, options = {}) { try { return await Image.create({ title: formData.title, @@ -240,6 +363,7 @@ class SocialNetworkService extends BaseService { hash: fileName, folderId: formData.folderId, userId: userId, + isAdultContent: Boolean(options.isAdultContent), }); } catch (error) { throw new Error(`Failed to create image record: ${error.message}`); @@ -271,6 +395,7 @@ class SocialNetworkService extends BaseService { async getImage(imageId) { const image = await Image.findByPk(imageId); if (!image) throw new Error('Image not found'); + if (image.isAdultContent) throw new Error('Access denied'); await this.checkUserAccess(image.userId); return image; } @@ -455,6 +580,9 @@ class SocialNetworkService extends BaseService { if (!image) { throw new Error('Image not found'); } + if (image.isAdultContent) { + throw new Error('Access denied'); + } const userId = await this.checkUserAccess(hashedUserId); const hasAccess = await this.checkUserImageAccess(userId, image.id); if (!hasAccess) { @@ -467,6 +595,178 @@ class SocialNetworkService extends BaseService { return imagePath; } + async getAdultFolders(hashedId) { + const userId = await this.requireAdultAreaAccessByHash(hashedId); + const rootFolder = await this.ensureAdultRootFolder(userId); + const children = await this.getSubFolders(rootFolder.id, userId, true); + const data = rootFolder.get(); + data.visibilityTypeIds = data.image_visibility_types.map(v => v.id); + delete data.image_visibility_types; + data.children = children; + return data; + } + + async getAdultFolderImageList(hashedId, folderId) { + const userId = await this.requireAdultAreaAccessByHash(hashedId); + const folder = await Folder.findOne({ + where: { id: folderId, userId, isAdultArea: true } + }); + if (!folder) { + throw new Error('Folder not found'); + } + return await Image.findAll({ + where: { + folderId: folder.id, + isAdultContent: true, + userId + }, + order: [['title', 'asc']] + }); + } + + async createAdultFolder(hashedId, data, folderId) { + await this.requireAdultAreaAccessByHash(hashedId); + if (!data.parentId) { + const userId = await this.checkUserAccess(hashedId); + const adultRoot = await this.ensureAdultRootFolder(userId); + data.parentId = adultRoot.id; + } + return this.createFolder(hashedId, data, folderId, { isAdultArea: true }); + } + + async uploadAdultImage(hashedId, file, formData) { + const userId = await this.requireAdultAreaAccessByHash(hashedId); + await this.ensureAdultUploadsAllowed(userId); + const folder = await Folder.findOne({ + where: { + id: formData.folderId, + userId, + isAdultArea: true + } + }); + if (!folder) { + throw new Error('Folder not found'); + } + 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); + return newImage; + } + + async getAdultImageFilePath(hashedId, hash) { + const userId = await this.requireAdultAreaAccessByHash(hashedId); + const image = await Image.findOne({ + where: { + hash, + userId, + isAdultContent: true + } + }); + if (!image) { + throw new Error('Image not found'); + } + if (image.isModeratedHidden) { + throw new Error('Image hidden by moderation'); + } + const imagePath = this.buildFilePath(image.hash, 'erotic'); + if (!fs.existsSync(imagePath)) { + throw new Error(`File "${imagePath}" not found`); + } + return imagePath; + } + + async listEroticVideos(hashedId) { + const userId = await this.requireAdultAreaAccessByHash(hashedId); + return await EroticVideo.findAll({ + where: { userId }, + order: [['createdAt', 'DESC']] + }); + } + + async uploadEroticVideo(hashedId, file, formData) { + const userId = await this.requireAdultAreaAccessByHash(hashedId); + await this.ensureAdultUploadsAllowed(userId); + if (!file) { + throw new Error('Video file is required'); + } + const allowedMimeTypes = ['video/mp4', 'video/webm', 'video/ogg', 'video/quicktime']; + if (!allowedMimeTypes.includes(file.mimetype)) { + throw new Error('Unsupported video format'); + } + + const fileName = this.generateUniqueFileName(file.originalname); + const filePath = this.buildFilePath(fileName, 'erotic-video'); + await this.saveFile(file.buffer, filePath); + + return await EroticVideo.create({ + title: formData.title || file.originalname, + description: formData.description || null, + originalFileName: file.originalname, + hash: fileName, + mimeType: file.mimetype, + userId + }); + } + + async getEroticVideoFilePath(hashedId, hash) { + const userId = await this.requireAdultAreaAccessByHash(hashedId); + const video = await EroticVideo.findOne({ + where: { hash, userId } + }); + if (!video) { + throw new Error('Video not found'); + } + if (video.isModeratedHidden) { + throw new Error('Video hidden by moderation'); + } + const videoPath = this.buildFilePath(video.hash, 'erotic-video'); + if (!fs.existsSync(videoPath)) { + throw new Error(`File "${videoPath}" not found`); + } + return { filePath: videoPath, mimeType: video.mimeType }; + } + + async createEroticContentReport(hashedId, payload) { + const reporterId = await this.requireAdultAreaAccessByHash(hashedId); + const targetType = String(payload.targetType || '').trim().toLowerCase(); + const targetId = Number(payload.targetId); + const reason = String(payload.reason || '').trim().toLowerCase(); + const note = payload.note ? String(payload.note).trim() : null; + + if (!['image', 'video'].includes(targetType) || !Number.isInteger(targetId) || targetId <= 0) { + throw new Error('Invalid report target'); + } + if (!['suspected_minor', 'non_consensual', 'violence', 'harassment', 'spam', 'other'].includes(reason)) { + throw new Error('Invalid report reason'); + } + + const { ownerId } = await this.resolveEroticTarget(targetType, targetId); + if (ownerId === reporterId) { + throw new Error('Own content cannot be reported'); + } + + const existingOpen = await EroticContentReport.findOne({ + where: { + reporterId, + targetType, + targetId, + status: 'open' + } + }); + if (existingOpen) { + return existingOpen; + } + + return await EroticContentReport.create({ + reporterId, + targetType, + targetId, + reason, + note, + status: 'open' + }); + } + // 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 } }); @@ -510,7 +810,7 @@ class SocialNetworkService extends BaseService { 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 } }); + const image = await Image.findOne({ where: { id: imageId, isAdultContent: false } }); if (!image) { throw new Error('image not found') } @@ -522,13 +822,33 @@ class SocialNetworkService extends BaseService { return image.folderId; } + async changeAdultImage(hashedUserId, imageId, title, visibilities) { + const userId = await this.requireAdultAreaAccessByHash(hashedUserId); + const image = await Image.findOne({ + where: { + id: imageId, + userId, + isAdultContent: true + } + }); + if (!image) { + throw new Error('image not found'); + } + await image.update({ 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 } }); + let rootFolder = await Folder.findOne({ where: { parentId: null, userId: user.id, isAdultArea: false } }); if (!rootFolder) { return null; } @@ -542,9 +862,9 @@ class SocialNetworkService extends BaseService { const folderIdString = String(folderId); const requestingUserIdString = String(requestingUserId); const requestingUser = await User.findOne({ where: { id: requestingUserIdString } }); - const isAdult = this.isUserAdult(requestingUser.id); + const isAdult = await this.isUserAdult(requestingUser.id); const accessibleFolders = await Folder.findAll({ - where: { parentId: folderIdString }, + where: { parentId: folderIdString, isAdultArea: false }, include: [ { model: ImageVisibilityType, diff --git a/backend/sql/add_adult_area_to_gallery.sql b/backend/sql/add_adult_area_to_gallery.sql new file mode 100644 index 0000000..032d9d9 --- /dev/null +++ b/backend/sql/add_adult_area_to_gallery.sql @@ -0,0 +1,5 @@ +ALTER TABLE community.folder +ADD COLUMN IF NOT EXISTS is_adult_area BOOLEAN NOT NULL DEFAULT FALSE; + +ALTER TABLE community.image +ADD COLUMN IF NOT EXISTS is_adult_content BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/backend/sql/add_adult_content_moderation.sql b/backend/sql/add_adult_content_moderation.sql new file mode 100644 index 0000000..be4cac7 --- /dev/null +++ b/backend/sql/add_adult_content_moderation.sql @@ -0,0 +1,36 @@ +ALTER TABLE community.image +ADD COLUMN IF NOT EXISTS is_moderated_hidden BOOLEAN NOT NULL DEFAULT FALSE; + +ALTER TABLE community.erotic_video +ADD COLUMN IF NOT EXISTS is_moderated_hidden BOOLEAN NOT NULL DEFAULT FALSE; + +CREATE TABLE IF NOT EXISTS community.erotic_content_report ( + id SERIAL PRIMARY KEY, + reporter_id INTEGER NOT NULL REFERENCES community."user"(id) ON DELETE CASCADE, + target_type VARCHAR(20) NOT NULL, + target_id INTEGER NOT NULL, + reason VARCHAR(80) NOT NULL, + note TEXT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'open', + action_taken VARCHAR(40) NULL, + handled_by INTEGER NULL REFERENCES community."user"(id) ON DELETE SET NULL, + handled_at TIMESTAMP WITH TIME ZONE NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS erotic_content_report_status_idx +ON community.erotic_content_report (status, created_at DESC); + +CREATE INDEX IF NOT EXISTS erotic_content_report_target_idx +ON community.erotic_content_report (target_type, target_id); + +INSERT INTO type.user_param (description, datatype, settings_id, order_id, min_age) +SELECT 'adult_upload_blocked', 'bool', st.id, 999, 18 +FROM type.settings st +WHERE st.name = 'account' + AND NOT EXISTS ( + SELECT 1 + FROM type.user_param upt + WHERE upt.description = 'adult_upload_blocked' + ); diff --git a/backend/sql/add_adult_verification_user_param.sql b/backend/sql/add_adult_verification_user_param.sql new file mode 100644 index 0000000..f631963 --- /dev/null +++ b/backend/sql/add_adult_verification_user_param.sql @@ -0,0 +1,21 @@ +-- Erotikbereich: Sichtbar ab 18, nutzbar erst nach Moderatorfreigabe + +INSERT INTO type.user_param (description, datatype, settings_id, order_id, immutable, min_age, gender, unit) +SELECT 'adult_verification_status', 'string', s.id, 910, false, 18, NULL, NULL +FROM type.settings s +WHERE s.name = 'account' + AND NOT EXISTS ( + SELECT 1 + FROM type.user_param p + WHERE p.description = 'adult_verification_status' + ); + +INSERT INTO type.user_param (description, datatype, settings_id, order_id, immutable, min_age, gender, unit) +SELECT 'adult_verification_request', 'string', s.id, 911, false, 18, NULL, NULL +FROM type.settings s +WHERE s.name = 'account' + AND NOT EXISTS ( + SELECT 1 + FROM type.user_param p + WHERE p.description = 'adult_verification_request' + ); diff --git a/backend/sql/add_is_adult_only_to_chat_room.sql b/backend/sql/add_is_adult_only_to_chat_room.sql new file mode 100644 index 0000000..e9a0f87 --- /dev/null +++ b/backend/sql/add_is_adult_only_to_chat_room.sql @@ -0,0 +1,2 @@ +ALTER TABLE chat.room +ADD COLUMN IF NOT EXISTS is_adult_only BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/backend/sql/balance_carrot_product.sql b/backend/sql/balance_carrot_product.sql index be91ec6..f13c90a 100644 --- a/backend/sql/balance_carrot_product.sql +++ b/backend/sql/balance_carrot_product.sql @@ -1,5 +1,7 @@ --- Karotte: Debug-Tempo und Preis an gleiche Basis wie andere Kat.-1-Waren (siehe initializeFalukantPredefines.js). --- Sicher für alle Installationen: nur production_time ohne optionale Spalten. +-- Karotte: Tempo und Preis wie andere Kat.-1-Waren (sell_cost 11). +-- Invariante (siehe backend/utils/falukant/falukantProductEconomy.js): bei Zertifikat=Kategorie und +-- 100 % Wissen muss sell_cost mindestens ceil(Stückkosten * 100 / 75) sein (Kat. 1 → min. 10). +-- Nach manuell zu niedrigem sell_cost (z. B. Erlös ~3) ausführen. BEGIN; @@ -7,32 +9,12 @@ UPDATE falukant_type.product SET production_time = 2 WHERE label_tr = 'carrot'; -COMMIT; - --- Optional (wenn Migration mit original_sell_cost läuft): in derselben Session ausführen -/* +-- Basispreis angleichen (ohne Steuer-Aufschreibung; ggf. danach update_product_sell_costs.sql) UPDATE falukant_type.product -SET original_sell_cost = 6 +SET sell_cost = 11 WHERE label_tr = 'carrot'; -WITH RECURSIVE ancestors AS ( - SELECT id AS start_id, id, parent_id, tax_percent FROM falukant_data.region - UNION ALL - SELECT a.start_id, r.id, r.parent_id, r.tax_percent - FROM falukant_data.region r - JOIN ancestors a ON r.id = a.parent_id -), totals AS ( - SELECT start_id, COALESCE(SUM(tax_percent), 0) AS total FROM ancestors GROUP BY start_id -), mm AS ( - SELECT COALESCE(MAX(total), 0) AS max_total FROM totals -) -UPDATE falukant_type.product p -SET sell_cost = CEIL(p.original_sell_cost * ( - CASE WHEN (1 - mm.max_total / 100) <= 0 THEN 1 ELSE (1 / (1 - mm.max_total / 100)) END -)) -FROM mm -WHERE p.label_tr = 'carrot' AND p.original_sell_cost IS NOT NULL; -*/ +COMMIT; --- Ohne original_sell_cost: grob sell_cost = 6 (wie Milch/Brot; ggf. anpassen) --- UPDATE falukant_type.product SET sell_cost = 6 WHERE label_tr = 'carrot'; +-- Optional: Spalte original_sell_cost mitpflegen, falls ihr die MAX-STRATEGY aus update_product_sell_costs.sql nutzt +-- UPDATE falukant_type.product SET original_sell_cost = 11 WHERE label_tr = 'carrot'; diff --git a/backend/sql/create_erotic_video.sql b/backend/sql/create_erotic_video.sql new file mode 100644 index 0000000..6ff5eaf --- /dev/null +++ b/backend/sql/create_erotic_video.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS community.erotic_video ( + id SERIAL PRIMARY KEY, + title VARCHAR(255) NOT NULL, + description TEXT NULL, + original_file_name VARCHAR(255) NOT NULL, + hash VARCHAR(255) NOT NULL UNIQUE, + mime_type VARCHAR(255) NOT NULL, + user_id INTEGER NOT NULL REFERENCES community."user"(id) ON UPDATE CASCADE ON DELETE CASCADE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/backend/sql/rebalance_product_certificates.sql b/backend/sql/rebalance_product_certificates.sql index 704a609..8df0cf1 100644 --- a/backend/sql/rebalance_product_certificates.sql +++ b/backend/sql/rebalance_product_certificates.sql @@ -4,7 +4,7 @@ UPDATE falukant_type.product SET sell_cost = CASE label_tr WHEN 'wheat' THEN 11 WHEN 'grain' THEN 11 - WHEN 'carrot' THEN 8 + WHEN 'carrot' THEN 11 WHEN 'fish' THEN 11 WHEN 'meat' THEN 11 WHEN 'leather' THEN 11 diff --git a/backend/utils/falukant/falukantProductEconomy.js b/backend/utils/falukant/falukantProductEconomy.js new file mode 100644 index 0000000..609d073 --- /dev/null +++ b/backend/utils/falukant/falukantProductEconomy.js @@ -0,0 +1,96 @@ +/** + * Zentrale Produktions- und Preisformeln (muss mit der Spielwirtschaft konsistent bleiben). + * Wird von falukantService und der Produkt-Initialisierung genutzt. + * + * Mindest-Erlös (Ertrags-Tabelle, Branch): bei 100 % Produktwissen ist der Verkaufspreis + * das obere Ende der Spanne = basePrice = sellCost * (effectiveWorth/100), mit + * effectiveWorth >= 75 (siehe effectiveWorthPercent in falukantService). + * Engster Fall für Gewinn/Stück: Zertifikat = Produktkategorie (kein Headroom-Rabatt auf + * Stückkosten) und regionale Nachfrage am Boden (75 %). + */ + +export const PRODUCTION_COST_BASE = 6.0; +export const PRODUCTION_COST_PER_PRODUCT_CATEGORY = 1.0; +export const PRODUCTION_HEADROOM_DISCOUNT_PER_STEP = 0.035; +export const PRODUCTION_HEADROOM_DISCOUNT_CAP = 0.14; + +export function productionPieceCost(certificate, category) { + const c = Math.max(1, Number(category) || 1); + const cert = Math.max(1, Number(certificate) || 1); + const raw = PRODUCTION_COST_BASE + (c * PRODUCTION_COST_PER_PRODUCT_CATEGORY); + const headroom = Math.max(0, cert - c); + const discount = Math.min( + headroom * PRODUCTION_HEADROOM_DISCOUNT_PER_STEP, + PRODUCTION_HEADROOM_DISCOUNT_CAP + ); + return raw * (1 - discount); +} + +export function productionCostTotal(quantity, category, certificate) { + const q = Math.min(100, Math.max(1, Number(quantity) || 1)); + return q * productionPieceCost(certificate, category); +} + +export function effectiveWorthPercent(worthPercent) { + const w = Number(worthPercent); + if (Number.isNaN(w)) return 75; + return Math.min(100, Math.max(75, w)); +} + +/** Untergrenze für den Wissens-Multiplikator auf den regionalen Basispreis. */ +export const KNOWLEDGE_PRICE_FLOOR = 0.7; + +export function calcSellPrice(product, knowledgeFactor = 0) { + const max = product.sellCost; + const min = max * KNOWLEDGE_PRICE_FLOOR; + return min + (max - min) * (knowledgeFactor / 100); +} + +export function calcRegionalSellPriceSync(product, knowledgeFactor, worthPercent) { + if (product.sellCost === null || product.sellCost === undefined) return null; + const w = effectiveWorthPercent(worthPercent); + const basePrice = product.sellCost * (w / 100); + const min = basePrice * KNOWLEDGE_PRICE_FLOOR; + const max = basePrice; + return min + (max - min) * (knowledgeFactor / 100); +} + +/** Untergrenze für worthPercent nach effectiveWorthPercent (75–100). */ +export const EFFECTIVE_WORTH_PERCENT_MIN = 75; + +/** + * Minimaler ganzzahliger Basis-sell_cost (vor Steuer-/Regions-Faktoren in der DB), + * sodass bei Zertifikat = Produktkategorie, 100 % Wissen und 75 % Nachfrage + * der Erlös pro Stück >= Stückkosten (kein struktureller Verlust in der Ertrags-Tabelle). + */ +export function minBaseSellCostForTightProduction(category) { + const c = Math.max(1, Number(category) || 1); + const cost = productionPieceCost(c, c); + return Math.ceil((cost * 100) / EFFECTIVE_WORTH_PERCENT_MIN); +} + +/** + * Prüft Vordefinierungen; meldet Abweichungen nur per warn (kein Throw), damit Deployments + * mit alter DB nicht brechen — Balance-Anpassung erfolgt bewusst im Code/SQL. + */ +export function validateProductBaseSellCosts(products, { warn = console.warn } = {}) { + const issues = []; + for (const p of products) { + const min = minBaseSellCostForTightProduction(p.category); + if (Number(p.sellCost) < min) { + issues.push({ + labelTr: p.labelTr, + category: p.category, + sellCost: p.sellCost, + minRequired: min, + }); + } + } + if (issues.length && typeof warn === 'function') { + warn( + '[falukantProductEconomy] sell_cost unter Mindestbedarf (Zertifikat=Kategorie, 100% Wissen, 75% Nachfrage):', + issues + ); + } + return issues; +} diff --git a/backend/utils/falukant/initializeFalukantPredefines.js b/backend/utils/falukant/initializeFalukantPredefines.js index 614fd19..f36f40a 100644 --- a/backend/utils/falukant/initializeFalukantPredefines.js +++ b/backend/utils/falukant/initializeFalukantPredefines.js @@ -6,6 +6,7 @@ import FalukantStockType from "../../models/falukant/type/stock.js"; import TitleOfNobility from "../../models/falukant/type/title_of_nobility.js"; import TitleRequirement from "../../models/falukant/type/title_requirement.js"; import { sequelize } from "../sequelize.js"; +import { validateProductBaseSellCosts } from "./falukantProductEconomy.js"; export const initializeFalukantPredefines = async () => { await initializeFalukantFirstnames(); @@ -250,7 +251,7 @@ async function initializeFalukantProducts() { const baseProducts = [ { labelTr: 'wheat', category: 1, productionTime: 2, sellCost: 11 }, { labelTr: 'grain', category: 1, productionTime: 2, sellCost: 11 }, - { labelTr: 'carrot', category: 1, productionTime: 2, sellCost: 8 }, + { labelTr: 'carrot', category: 1, productionTime: 2, sellCost: 11 }, { labelTr: 'fish', category: 1, productionTime: 2, sellCost: 11 }, { labelTr: 'meat', category: 1, productionTime: 2, sellCost: 11 }, { labelTr: 'leather', category: 1, productionTime: 2, sellCost: 11 }, @@ -282,6 +283,8 @@ async function initializeFalukantProducts() { { labelTr: 'ox', category: 5, productionTime: 5, sellCost: 78 }, ]; + validateProductBaseSellCosts(baseProducts); + const productsToInsert = baseProducts.map(p => ({ ...p, sellCostMinNeutral: Math.ceil(p.sellCost * factorMin), diff --git a/backend/utils/initializeTypes.js b/backend/utils/initializeTypes.js index c2aa97e..5ca2db2 100644 --- a/backend/utils/initializeTypes.js +++ b/backend/utils/initializeTypes.js @@ -46,6 +46,9 @@ const initializeTypes = async () => { willChildren: { type: 'bool', setting: 'flirt', minAge: 14 }, smokes: { type: 'singleselect', setting: 'flirt', minAge: 14}, drinks: { type: 'singleselect', setting: 'flirt', minAge: 14 }, + adult_verification_status: { type: 'string', setting: 'account', minAge: 18 }, + adult_verification_request: { type: 'string', setting: 'account', minAge: 18 }, + adult_upload_blocked: { type: 'bool', setting: 'account', minAge: 18 }, llm_settings: { type: 'string', setting: 'languageAssistant' }, llm_api_key: { type: 'string', setting: 'languageAssistant' }, }; diff --git a/docs/ADULT_SOCIAL_EROTIC_CONCEPT.md b/docs/ADULT_SOCIAL_EROTIC_CONCEPT.md new file mode 100644 index 0000000..ec399b1 --- /dev/null +++ b/docs/ADULT_SOCIAL_EROTIC_CONCEPT.md @@ -0,0 +1,406 @@ +# yourPart: Konzept für den Erotikbereich + +## 1. Ausgangspunkt + +Im Menü ist die klare Trennung bereits vorgesehen: + +- `Social Network -> Galerie` +- `Social Network -> Erotik -> Bilder` +- `Social Network -> Erotik -> Videos` + +Zusätzlich existiert im Chat-Umfeld bereits die Idee `Erotikchat`. + +Damit sollte der 18+-Bereich **nicht** als bloßer Filter der normalen Galerie gedacht werden, sondern als **eigener Social-Bereich für Erwachsene**. + +## 2. Zielbild + +Der Erotikbereich ist ein eigener, abgegrenzter Teil des Social Networks für volljährige Nutzer. + +Wichtig für den Zugang: + +- **Erotik -> Bilder** +- **Erotik -> Videos** +- später zusätzlich **Erotik -> Chat** oder klar markierte 18+-Chaträume + +Der Erotikbereich soll: + +- ab **18 Jahren** im Menü sichtbar sein +- aber erst nach **Moderatorfreigabe** wirklich nutzbar sein +- technisch und visuell **klar vom normalen Social-Bereich getrennt** sein +- nicht versehentlich in allgemeine Feeds, Galerien oder Raumlisten hineinlaufen +- serverseitig abgesichert sein + +Wichtig: + +- **nicht** die gesamte Plattform wird auf Erwachsene beschränkt +- **nicht** das gesamte Social Network wird auf Erwachsene beschränkt +- ausschließlich die Module unter `Social Network -> Erotik -> ...` sind volljährigen Nutzern vorbehalten +- normale Bereiche wie Suche, Forum, Galerie, Freunde, Tagebuch und Chat bleiben davon getrennt + +## 3. Bestand heute + +Vorhanden: + +- Menüstruktur für `Erotik -> Bilder` und `Erotik -> Videos` in [navigationController.js](/mnt/share/torsten/Programs/YourPart3/backend/controllers/navigationController.js) +- Navigationstexte in [navigation.json](/mnt/share/torsten/Programs/YourPart3/frontend/src/i18n/locales/de/navigation.json) +- normale Galerie in [GalleryView.vue](/mnt/share/torsten/Programs/YourPart3/frontend/src/views/social/GalleryView.vue) +- Mehrraum-Chat in [MultiChatDialog.vue](/mnt/share/torsten/Programs/YourPart3/frontend/src/dialogues/chat/MultiChatDialog.vue) +- vorhandene Erwachsenensichtbarkeiten in der Galerie (`adults`, `friends-and-adults`) + +Noch nicht fertig: + +- echte Moderationsfreischaltung für Erwachsene +- eigene Erotik-Bilderansicht +- eigenes Erotik-Video-Modul +- 18+-Chatanbindung +- harte serverseitige Sperren für nicht berechtigte Nutzer +- Moderation speziell für Adult-Inhalte + +## 4. Grundentscheidung + +Erotik wird als **eigener Bereich** modelliert, nicht als Untermenge der Standard-Galerie. + +Das bedeutet: + +- normale Galerie bleibt normaler Social-Bereich +- Erotik-Bilder sind ein eigenes Modul +- Erotik-Videos sind ein eigenes Modul +- späterer Erotik-Chat ist ebenfalls ein eigenes Modul oder klar abgegrenzte Raumgruppe + +Vorteile: + +- klare UX +- weniger Vermischung +- einfachere Berechtigungslogik +- sauberere Moderation +- spätere Erweiterung auf Videos ohne Umbau + +## 5. Zugangsmodell + +## 5.1 Volljährigkeit + +Nur Nutzer mit: + +- `Alter >= 18` + +dürfen den Erotikbereich überhaupt sehen. + +## 5.2 Moderationsfreigabe + +Zusätzlich braucht es eine echte Freischaltung: + +- `adultVerificationStatus = none | pending | approved | rejected` + +Dabei gilt: + +- volljährig allein reicht nicht für die Nutzung +- erst `approved` schaltet Bilder, Videos und später Chats frei +- die Freigabe erfolgt durch Moderation auf Basis eines Nachweises + +## 5.3 Sicht im Menü + +Empfehlung: + +- unter 18: `Erotik` erscheint gar nicht +- ab 18 ohne Freigabe: `Erotik` erscheint, die Unterpunkte sind sichtbar, aber gesperrt +- ab 18 mit `pending`: sichtbar, weiterhin gesperrt +- ab 18 mit `approved`: normal nutzbar +- ab 18 mit `rejected`: sichtbar, aber weiter gesperrt mit Hinweis auf erneute Anfrage + +Alle anderen Social-Network-Bereiche bleiben unverändert erreichbar, sofern ihre eigenen Altersregeln nichts anderes verlangen. + +## 6. Fachmodell + +## 6.1 Nutzer + +Benötigte logische Zustände: + +- `isAdult` +- `adultVerificationStatus` +- optional später zusätzlich `adultModeEnabled` als freiwilliger Opt-in nach Freigabe + +`isAdult` sollte aus vorhandenen Altersdaten abgeleitet werden, nicht frei gesetzt. + +## 6.2 Erotik-Bilder + +Eigenes Inhaltsmodell: + +- Bild gehört zum Erotikbereich +- zusätzlich Sichtbarkeit wie bisher möglich + +Empfohlene Felder: + +- `isAdultContent` oder `contentRating = adult` +- optional `adultVisibility` + +Wichtig: + +- das ist mehr als bloß `adults` als Sichtbarkeit +- wir müssen auch fachlich erkennen können, dass der Inhalt **zum Erotikbereich** gehört + +## 6.3 Erotik-Videos + +Eigenes Inhaltsmodell analog zu Bildern: + +- Video gehört zum Erotikbereich +- eigenes Metadatenmodell +- später Vorschaubild, Dauer, Format, Moderationsstatus + +Empfohlene Felder: + +- `isAdultContent` +- `processingStatus` +- `thumbnail` + +## 6.4 Erotik-Chat + +Für Chat reicht fachlich: + +- bestehender Raumtyp `chat` +- plus Flag `isAdultOnly` + +Optional zusätzlich: + +- Raumtyp `erotic_chat` + +## 7. Module + +## 7.1 Erotik -> Bilder + +Eigene View: + +- zeigt nur Inhalte aus dem Erotikbereich +- kein Vermischen mit normaler Galerie + +Funktionen: + +- hochladen +- organisieren +- ansehen +- Sichtbarkeit steuern +- melden + +Regeln: + +- keine Ausgabe an nicht berechtigte Nutzer +- keine Thumbnails für nicht berechtigte Nutzer +- Direktaufruf serverseitig blocken + +## 7.2 Erotik -> Videos + +Eigene View: + +- separat von Bildern +- gleiche Erwachsenensperren + +Funktionen: + +- Video-Upload +- Videoliste +- Vorschau +- Wiedergabe +- melden + +Erste Ausbaustufe: + +- einfache Liste +- keine komplexe Transcoding- oder Streaminglogik nötig, falls noch nicht vorhanden + +## 7.3 Erotik -> Chat + +Nicht zwingend sofort als eigener Menüpunkt nötig, aber fachlich vorbereiten. + +Variante A: + +- eigener Menüpunkt `Erotikchat` + +Variante B: + +- innerhalb des Mehrraum-Chats klar abgetrennte `18+`-Raumgruppe + +Empfehlung: + +- später eigener Einstieg oder klarer Erwachsenentab +- nicht bloß unauffällige Räume in der normalen Liste + +## 8. Galerie- und Videologik + +## 8.1 Keine Vermischung + +Normale Galerie: + +- zeigt keine Adult-Inhalte + +Erotik-Bilder: + +- zeigen nur Adult-Bilder + +Erotik-Videos: + +- zeigen nur Adult-Videos + +## 8.2 Uploadregeln + +Nur erlaubt für: + +- `isAdult = true` +- `adultVerificationStatus = approved` + +Beim Upload muss der Bereich eindeutig sein: + +- normales Bild +- Erotik-Bild +- normales Video +- Erotik-Video + +## 8.3 Vorschaulogik + +Nicht berechtigte Nutzer dürfen: + +- weder Originaldateien +- noch Vorschaubilder +- noch Metadatenlisten + +erhalten. + +## 9. Chatlogik + +## 9.1 Raumlistenfilter + +Nicht berechtigte Nutzer: + +- sehen keine Adult-Räume + +Berechtigte Nutzer: + +- sehen Adult-Räume in klarer Erwachsenengruppe + +## 9.2 Beitritt + +Server prüft beim Join: + +- Nutzer volljährig +- Moderation hat den Bereich freigeschaltet +- Raum `isAdultOnly` + +## 9.3 Random Chat + +Erste Version: + +- kein erotischer Random Chat + +Begründung: + +- höheres Missbrauchsrisiko +- kompliziertere Consent- und Moderationslage + +## 10. Moderation + +Adult-Bereich braucht eigene Moderationslogik. + +## 10.1 Meldegründe + +- Minderjährigkeitsverdacht +- unerlaubte Inhalte +- Belästigung +- Druck / Nötigung +- Gewalt-/Missbrauchskontext +- Spam / Scam + +## 10.2 Adminsicht + +Admins brauchen: + +- Adult-Kennzeichnung an Bildern +- Adult-Kennzeichnung an Videos +- Adult-Kennzeichnung an Räumen +- schnelle Sperrung einzelner Inhalte +- schnelle Sperrung von Uploadrechten + +## 10.3 Nulltoleranz + +Nicht erlaubt: + +- Minderjährige oder minderjährig wirkende Sexualdarstellung +- Gewalt-/Missbrauchsdarstellung +- Umgehung von Altersgrenzen + +## 11. Technische Umsetzung + +## 11.1 Backend + +Benötigt: + +- Prüfung `isAdult` +- Prüfung `adultVerificationStatus` +- Filterung von Erotik-Menü/API-Daten +- getrennte Endpunkte oder klare Adult-Filter für Bilder +- eigenes Video-Modul oder klare Adult-Video-Endpunkte +- Chatraumfilter für `isAdultOnly` + +## 11.2 Frontend + +Benötigt: + +- Sicht auf Freischaltungsstatus und Anfrage +- eigene Views: + - `ErotikBilderView` + - `ErotikVideosView` +- klare Zugangshinweise bei gesperrtem Bereich +- später Adult-Chat-Einstieg + +## 11.3 Serverseitige Pflicht + +Wichtig: + +- Frontend-Sperren reichen nie aus +- jede Dateiausgabe und jeder Chatzugang muss serverseitig geprüft werden + +## 12. Umsetzungsphasen + +## Phase A1: Zugang + +- `isAdult` sauber ableiten +- `adultVerificationStatus = none | pending | approved | rejected` +- Einstellungs-UI und Freischaltungsansicht +- Menü ab 18 sichtbar, bis Freigabe gesperrt +- serverseitige Sperren für Adult-Routen + +## Phase A2: Erotik-Bilder + +- eigener Erotik-Bilderpfad +- Adult-Kennzeichnung für Bilder +- Upload- und Anzeige-Logik + +## Phase A3: Erotik-Videos + +- eigenes Videomodul +- Adult-Kennzeichnung für Videos +- Upload und Anzeige + +## Phase A4: Erotik-Chat + +- Adult-Raumflag +- Raumlistenfilter +- Join-Sperren +- klarer UI-Einstieg + +## Phase A5: Moderation + +- Meldegründe +- Adminsicht +- Sperrpfade + +## 13. Empfehlung für den Start + +Die erste sinnvolle, kontrollierbare Version ist: + +- `A1` Zugang +- `A2` Erotik-Bilder + +Danach: + +- `A3` Erotik-Videos +- `A4` Erotik-Chat + +So nutzt ihr die bereits vorhandene Menüstruktur sauber aus und baut nicht auf halbe Übergangslösungen wie bloße Galeriefilter. diff --git a/frontend/src/api/chatApi.js b/frontend/src/api/chatApi.js index 6454630..3882531 100644 --- a/frontend/src/api/chatApi.js +++ b/frontend/src/api/chatApi.js @@ -1,7 +1,7 @@ import apiClient from "@/utils/axios.js"; -export const fetchPublicRooms = async () => { - const response = await apiClient.get("/api/chat/rooms"); +export const fetchPublicRooms = async (options = {}) => { + const response = await apiClient.get("/api/chat/rooms", { params: options }); return response.data; // expecting array of { id, title, ... } }; diff --git a/frontend/src/components/AppNavigation.vue b/frontend/src/components/AppNavigation.vue index 4d7008f..e08ac46 100644 --- a/frontend/src/components/AppNavigation.vue +++ b/frontend/src/components/AppNavigation.vue @@ -11,9 +11,10 @@ v-for="(item, key) in menu" :key="key" class="mainmenuitem" - :class="{ 'mainmenuitem--active': isItemActive(item), 'mainmenuitem--expanded': isMainExpanded(key) }" + :class="{ 'mainmenuitem--active': isItemActive(item), 'mainmenuitem--expanded': isMainExpanded(key), 'mainmenuitem--disabled': item.disabled }" tabindex="0" role="button" + :title="item.disabled ? $t(item.disabledReasonKey || 'socialnetwork.erotic.lockedShort') : undefined" :aria-haspopup="hasTopLevelSubmenu(item) ? 'menu' : undefined" :aria-expanded="hasTopLevelSubmenu(item) ? String(isMainExpanded(key)) : undefined" @click="handleItem(item, $event, key)" @@ -35,7 +36,8 @@ :key="subkey" tabindex="0" role="menuitem" - :class="{ 'submenu1__item--expanded': isSubExpanded(`${key}:${subkey}`) }" + :class="{ 'submenu1__item--expanded': isSubExpanded(`${key}:${subkey}`), 'submenu-item--disabled': subitem.disabled }" + :title="subitem.disabled ? $t(subitem.disabledReasonKey || 'socialnetwork.erotic.lockedShort') : undefined" @click="handleSubItem(subitem, subkey, key, $event)" @keydown.enter.prevent="handleSubItem(subitem, subkey, key, $event)" @keydown.space.prevent="handleSubItem(subitem, subkey, key, $event)" @@ -109,6 +111,8 @@ :key="subsubkey" tabindex="0" role="menuitem" + :class="{ 'submenu-item--disabled': subsubitem.disabled }" + :title="subsubitem.disabled ? $t(subsubitem.disabledReasonKey || 'socialnetwork.erotic.lockedShort') : undefined" @click="handleItem(subsubitem, $event)" @keydown.enter.prevent="handleItem(subsubitem, $event)" @keydown.space.prevent="handleItem(subsubitem, $event)" @@ -357,14 +361,20 @@ export default { }, openMultiChat() { - // Räume können später dynamisch geladen werden, hier als Platzhalter ein Beispiel: - const exampleRooms = [ - { id: 1, title: 'Allgemein' }, - { id: 2, title: 'Rollenspiel' } - ]; const ref = this.$root.$refs.multiChatDialog; if (ref && typeof ref.open === 'function') { - ref.open(exampleRooms); + ref.open(); + } else if (ref?.$refs?.dialog && typeof ref.$refs.dialog.open === 'function') { + ref.$refs.dialog.open(); + } else { + console.error('MultiChatDialog nicht bereit oder ohne open()'); + } + }, + + openEroticChat() { + const ref = this.$root.$refs.multiChatDialog; + if (ref && typeof ref.open === 'function') { + ref.open(null, { adultOnly: true }); } else if (ref?.$refs?.dialog && typeof ref.$refs.dialog.open === 'function') { ref.$refs.dialog.open(); } else { @@ -452,6 +462,10 @@ export default { handleItem(item, event, key = null) { event.stopPropagation(); + if (item?.disabled) { + return; + } + if (key && this.hasTopLevelSubmenu(item)) { if (this.isMobileNav) { this.toggleMain(key); @@ -603,6 +617,18 @@ ul { border-color: rgba(248, 162, 43, 0.2); } +.mainmenuitem--disabled, +.submenu-item--disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.mainmenuitem--disabled:hover { + transform: none; + background-color: transparent; + border-color: transparent; +} + .mainmenuitem--active { background: rgba(255, 255, 255, 0.72); border-color: rgba(248, 162, 43, 0.22); diff --git a/frontend/src/components/falukant/RevenueSection.vue b/frontend/src/components/falukant/RevenueSection.vue index f535956..8d49835 100644 --- a/frontend/src/components/falukant/RevenueSection.vue +++ b/frontend/src/components/falukant/RevenueSection.vue @@ -16,7 +16,7 @@