From 3e6c09ab29724d5a802d83f8826aaa61fab0b172 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Fri, 27 Mar 2026 09:14:54 +0100 Subject: [PATCH] Add adult verification and erotic moderation features: Implement new routes and controller methods for managing adult verification requests, status updates, and document retrieval. Introduce erotic moderation actions and reports, enhancing administrative capabilities. Update chat and navigation controllers to support adult content filtering and access control. Enhance user parameter handling for adult verification status and requests, improving overall user experience and compliance. --- backend/controllers/adminController.js | 103 ++++- backend/controllers/chatController.js | 4 +- backend/controllers/navigationController.js | 45 +- backend/controllers/settingsController.js | 19 + .../controllers/socialnetworkController.js | 142 ++++++ ...260326000000-add-adult-area-to-gallery.cjs | 31 ++ .../20260326001000-create-erotic-video.cjs | 63 +++ ...6002000-add-is-adult-only-to-chat-room.cjs | 20 + ...326003000-add-adult-content-moderation.cjs | 95 ++++ backend/models/associations.js | 10 + backend/models/chat/room.js | 4 + .../models/community/erotic_content_report.js | 64 +++ backend/models/community/erotic_video.js | 55 +++ backend/models/community/folder.js | 5 + backend/models/community/image.js | 10 + backend/models/index.js | 4 + backend/routers/adminRouter.js | 6 + backend/routers/chatRouter.js | 2 +- backend/routers/settingsRouter.js | 3 + backend/routers/socialnetworkRouter.js | 10 + backend/services/adminService.js | 388 +++++++++++++++- backend/services/chatService.js | 62 ++- backend/services/falukantService.js | 55 +-- backend/services/settingsService.js | 137 +++++- backend/services/socialnetworkService.js | 420 +++++++++++++++--- backend/sql/add_adult_area_to_gallery.sql | 5 + backend/sql/add_adult_content_moderation.sql | 36 ++ .../sql/add_adult_verification_user_param.sql | 21 + .../sql/add_is_adult_only_to_chat_room.sql | 2 + backend/sql/balance_carrot_product.sql | 36 +- backend/sql/create_erotic_video.sql | 11 + .../sql/rebalance_product_certificates.sql | 2 +- .../utils/falukant/falukantProductEconomy.js | 96 ++++ .../falukant/initializeFalukantPredefines.js | 5 +- backend/utils/initializeTypes.js | 3 + docs/ADULT_SOCIAL_EROTIC_CONCEPT.md | 406 +++++++++++++++++ frontend/src/api/chatApi.js | 4 +- frontend/src/components/AppNavigation.vue | 42 +- .../components/falukant/RevenueSection.vue | 31 +- frontend/src/dialogues/admin/RoomDialog.vue | 10 +- .../src/dialogues/chat/MultiChatDialog.vue | 13 +- .../socialnetwork/CreateFolderDialog.vue | 17 +- frontend/src/i18n/locales/de/admin.json | 83 +++- frontend/src/i18n/locales/de/chat.json | 3 +- frontend/src/i18n/locales/de/falukant.json | 3 +- frontend/src/i18n/locales/de/navigation.json | 4 +- frontend/src/i18n/locales/de/settings.json | 29 +- .../src/i18n/locales/de/socialnetwork.json | 68 ++- frontend/src/i18n/locales/en/admin.json | 83 +++- frontend/src/i18n/locales/en/chat.json | 3 +- frontend/src/i18n/locales/en/falukant.json | 21 + frontend/src/i18n/locales/en/navigation.json | 4 +- frontend/src/i18n/locales/en/settings.json | 29 +- .../src/i18n/locales/en/socialnetwork.json | 68 ++- frontend/src/i18n/locales/es/admin.json | 81 ++++ frontend/src/i18n/locales/es/chat.json | 1 + frontend/src/i18n/locales/es/falukant.json | 3 +- frontend/src/i18n/locales/es/navigation.json | 2 + frontend/src/i18n/locales/es/settings.json | 29 +- .../src/i18n/locales/es/socialnetwork.json | 65 +++ frontend/src/router/adminRoutes.js | 14 + frontend/src/router/index.js | 27 +- frontend/src/router/socialRoutes.js | 21 + .../src/views/admin/AdultVerificationView.vue | 258 +++++++++++ frontend/src/views/admin/ChatRoomsView.vue | 2 + .../src/views/admin/EroticModerationView.vue | 244 ++++++++++ frontend/src/views/falukant/BranchView.vue | 5 +- frontend/src/views/settings/AccountView.vue | 60 ++- .../src/views/social/EroticAccessView.vue | 201 +++++++++ .../social/EroticMediaPlaceholderView.vue | 116 +++++ .../src/views/social/EroticPicturesView.vue | 361 +++++++++++++++ .../src/views/social/EroticVideosView.vue | 269 +++++++++++ frontend/src/views/social/GalleryView.vue | 2 + 73 files changed, 4459 insertions(+), 197 deletions(-) create mode 100644 backend/migrations/20260326000000-add-adult-area-to-gallery.cjs create mode 100644 backend/migrations/20260326001000-create-erotic-video.cjs create mode 100644 backend/migrations/20260326002000-add-is-adult-only-to-chat-room.cjs create mode 100644 backend/migrations/20260326003000-add-adult-content-moderation.cjs create mode 100644 backend/models/community/erotic_content_report.js create mode 100644 backend/models/community/erotic_video.js create mode 100644 backend/sql/add_adult_area_to_gallery.sql create mode 100644 backend/sql/add_adult_content_moderation.sql create mode 100644 backend/sql/add_adult_verification_user_param.sql create mode 100644 backend/sql/add_is_adult_only_to_chat_room.sql create mode 100644 backend/sql/create_erotic_video.sql create mode 100644 backend/utils/falukant/falukantProductEconomy.js create mode 100644 docs/ADULT_SOCIAL_EROTIC_CONCEPT.md create mode 100644 frontend/src/views/admin/AdultVerificationView.vue create mode 100644 frontend/src/views/admin/EroticModerationView.vue create mode 100644 frontend/src/views/social/EroticAccessView.vue create mode 100644 frontend/src/views/social/EroticMediaPlaceholderView.vue create mode 100644 frontend/src/views/social/EroticPicturesView.vue create mode 100644 frontend/src/views/social/EroticVideosView.vue 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 @@ {{ $t('falukant.branch.revenue.perMinute') }} {{ $t('falukant.branch.revenue.profitAbsolute') }} {{ $t('falukant.branch.revenue.profitPerMinute') }} - Bessere Preise + {{ $t('falukant.branch.revenue.betterPrices') }} @@ -29,12 +29,20 @@ {{ calculateProductProfit(product).perMinute }}
- - {{ city.regionName }} - ({{ formatPrice(city.price) }}) - +
@@ -188,7 +196,14 @@ .price-cities { display: flex; flex-wrap: wrap; - gap: 0.3em; + align-items: baseline; + gap: 0.15em 0.35em; + line-height: 1.35; + min-width: 0; + } + .city-price-sep { + color: #666; + user-select: none; } .city-price { padding: 0.2em 0.4em; diff --git a/frontend/src/dialogues/admin/RoomDialog.vue b/frontend/src/dialogues/admin/RoomDialog.vue index a213b2a..f9aaa5b 100644 --- a/frontend/src/dialogues/admin/RoomDialog.vue +++ b/frontend/src/dialogues/admin/RoomDialog.vue @@ -17,6 +17,10 @@ {{ $t('admin.chatrooms.isPublic') }} +