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.

This commit is contained in:
Torsten Schulz (local)
2026-03-27 09:14:54 +01:00
parent f93687c753
commit 3e6c09ab29
73 changed files with 4459 additions and 197 deletions

View File

@@ -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);

View File

@@ -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 });

View File

@@ -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

View File

@@ -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;

View File

@@ -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;

View File

@@ -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');
},
};

View File

@@ -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' });
},
};

View File

@@ -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');
},
};

View File

@@ -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(() => {});
}
};

View File

@@ -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,

View File

@@ -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},

View File

@@ -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;

View File

@@ -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;

View File

@@ -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

View File

@@ -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},

View File

@@ -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,

View File

@@ -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);

View File

@@ -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);

View File

@@ -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;

View File

@@ -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);

View File

@@ -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();
export default new AdminService();

View File

@@ -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']]
});
}

View File

@@ -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,

View File

@@ -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();
}

View File

@@ -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,

View File

@@ -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;

View File

@@ -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'
);

View File

@@ -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'
);

View File

@@ -0,0 +1,2 @@
ALTER TABLE chat.room
ADD COLUMN IF NOT EXISTS is_adult_only BOOLEAN NOT NULL DEFAULT FALSE;

View File

@@ -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';

View File

@@ -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
);

View File

@@ -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

View File

@@ -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 (75100). */
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;
}

View File

@@ -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),

View File

@@ -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' },
};