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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user