traxxx-web/src/actors.js

1134 lines
32 KiB
JavaScript

import config from 'config';
import { differenceInYears } from 'date-fns';
import { unit } from 'mathjs';
import { MerkleJson } from 'merkle-json';
import moment from 'moment';
import omit from 'object.omit';
import convert from 'convert';
import unprint from 'unprint';
import initLogger from './logger.js';
import { knexOwner as knex, knexManticore } from './knex.js';
import redis from './redis.js';
import { utilsApi } from './manticore.js';
import { HttpError } from './errors.js';
import { fetchCountriesByAlpha2 } from './countries.js';
import { curateEntity } from './entities.js';
import { curateMedia } from './media.js';
import { curateStash } from './stashes.js';
import escape from '../utils/escape-manticore.js';
import slugify from '../utils/slugify.js';
import { curateRevision } from './revisions.js';
import { interpolateProfiles } from '../common/actors.mjs'; // eslint-disable-line import/namespace
import { resolvePlace } from '../common/geo.mjs'; // eslint-disable-line import/namespace
const logger = initLogger();
const mj = new MerkleJson();
const keyMap = {
avatar: 'avatar_media_id',
dateOfBirth: 'date_of_birth',
dateOfDeath: 'date_of_death',
originCountry: 'birth_country_alpha2',
originState: 'birth_state',
originCity: 'birth_city',
residenceCountry: 'residence_country_alpha2',
residenceState: 'residence_state',
residenceCity: 'residence_city',
hairColor: 'hair_color',
naturalBoobs: 'natural_boobs',
boobsVolume: 'boobs_volume',
boobsImplant: 'boobs_implant',
boobsPlacement: 'boobs_placement',
boobsIncision: 'boobs_incision',
boobsSurgeon: 'boobs_surgeon',
naturalButt: 'natural_butt',
buttVolume: 'butt_volume',
buttImplant: 'butt_implant',
naturalLips: 'natural_lips',
lipsVolume: 'lips_volume',
naturalLabia: 'natural_labia',
penisLength: 'penis_length',
penisGirth: 'penis_girth',
hasTattoos: 'has_tattoos',
hasPiercings: 'has_piercings',
isCircumcised: 'circumcised',
};
const socialsOrder = ['onlyfans', 'twitter', 'fansly', 'loyalfans', 'manyvids', 'pornhub', 'linktree', null];
export function curateActor(actor, context = {}) {
return {
id: actor.id,
slug: actor.slug,
name: actor.name,
gender: actor.gender,
age: actor.age,
ethnicity: actor.ethnicity,
entity: actor.entity && {
id: actor.entity.id,
slug: actor.entity.slug,
name: actor.entity.name,
},
...Object.fromEntries(Object.entries(keyMap).map(([key, entryKey]) => [key, actor[entryKey]])),
ageFromBirth: actor.date_of_birth && actor.date_of_birth.getFullYear() > 1
? differenceInYears(Date.now(), actor.date_of_birth)
: null,
ageAtDeath: actor.date_of_birth && actor.date_of_death && actor.date_of_birth.getFullYear() > 1
? differenceInYears(actor.date_of_death, actor.date_of_birth)
: null,
ageThen: context.sceneDate && actor.date_of_birth && actor.date_of_birth.getFullYear() > 1
? differenceInYears(context.sceneDate, actor.date_of_birth)
: null,
bust: actor.bust,
cup: actor.cup,
waist: actor.waist,
hip: actor.hip,
height: actor.height && {
metric: actor.height,
imperial: unit(actor.height, 'cm').splitUnit(['ft', 'in']).map((value) => Math.round(value.toNumber())),
},
weight: actor.weight && {
metric: actor.weight,
imperial: Math.round(unit(actor.weight, 'kg').toNumeric('lbs')),
},
penisLength: actor.penis_length && {
metric: actor.penis_length,
imperial: Math.round(unit(actor.penis_length, 'cm').toNumeric('in')),
},
penisGirth: actor.penis_girth && {
metric: actor.penis_girth,
imperial: Math.round(unit(actor.penis_girth, 'cm').toNumeric('in')),
},
eyes: actor.eyes,
tattoos: actor.tattoos,
piercings: actor.piercings,
origin: actor.birth_country_alpha2 && {
country: actor.birth_country_alpha2 && {
alpha2: actor.birth_country_alpha2,
name: actor.birth_country_name,
alias: actor.birth_country_alias,
},
city: actor.birth_city,
state: actor.birth_state,
},
residence: actor.residence_country_alpha2 && {
country: actor.residence_country_alpha2 && {
alpha2: actor.residence_country_alpha2,
name: actor.residence_country_name,
alias: actor.residence_country_alias,
},
city: actor.residence_city,
state: actor.residence_state,
},
agency: actor.agency,
avatar: curateMedia(actor.avatar),
socials: context.socials?.map((social) => ({
id: social.id,
url: social.url,
platform: social.platform,
handle: social.handle,
})).toSorted((socialA, socialB) => {
if (socialA.platform && !socialB.platform) {
return -1;
}
if (socialB.platform && !socialA.platform) {
return 1;
}
if (socialsOrder.includes(socialA.platform) && !socialsOrder.includes(socialB.platform)) {
return -1;
}
if (socialsOrder.includes(socialB.platform) && !socialsOrder.includes(socialA.platform)) {
return 1;
}
return socialsOrder.indexOf(socialA.platform) - socialsOrder.indexOf(socialB.platform);
}),
profiles: context.profiles?.map((profile) => ({
id: profile.id,
description: profile.description,
descriptionHash: profile.description_hash,
entity: curateEntity({ ...profile.entity, parent: profile.parent_entity }),
// avatars: profile.avatars.map((avatar) => curateMedia(avatar)),
})),
photos: context.photos
?.map((photo) => ({
...curateMedia(photo),
isAvatar: photo.id === actor.avatar?.id,
profileIds: photo.profile_ids,
}))
.toSorted((photoA, photoB) => photoB.isAvatar - photoA.isAvatar),
createdAt: actor.created_at,
updatedAt: actor.updated_at,
scenes: actor.scenes,
likes: actor.stashed,
stashes: context.stashes?.map((stash) => curateStash(stash)) || [],
alerts: {
only: context.alerts?.filter((alert) => alert.is_only).flatMap((alert) => alert.alert_ids) || [],
multi: context.alerts?.filter((alert) => !alert.is_only).flatMap((alert) => alert.alert_ids) || [],
},
...context.append?.[actor.id],
};
}
export function sortActorsByGender(actors, context = {}) {
if (!actors) {
return actors;
}
const alphaActors = actors.sort((actorA, actorB) => actorA.name.localeCompare(actorB.name, 'en'));
const genderActors = ['transsexual', 'female', undefined, null, 'male'].flatMap((gender) => alphaActors.filter((actor) => actor.gender === gender));
const titleSlug = slugify(context.title);
const titleActors = titleSlug ? genderActors.sort((actorA, actorB) => {
const actorASlug = actorA.slug.split('-')[0];
const actorBSlug = actorB.slug.split('-')[0];
if (titleSlug.includes(actorASlug) && !titleSlug.includes(actorBSlug)) {
return -1;
}
if (titleSlug.includes(actorBSlug) && !titleSlug.includes(actorASlug)) {
return 1;
}
return 0;
}) : alphaActors;
return titleActors;
}
export async function fetchActorsById(actorIds, options = {}, reqUser) {
const [actors, profiles, photos, socials, stashes, alerts] = await Promise.all([
knex('actors')
.select(
'actors.*',
'actors_meta.stashed',
knex.raw('row_to_json(avatars) as avatar'),
'birth_countries.alpha2 as birth_country_alpha2',
knex.raw('COALESCE(birth_countries.alias, birth_countries.name) as birth_country_name'),
'residence_countries.alpha2 as residence_country_alpha2',
knex.raw('COALESCE(residence_countries.alias, residence_countries.name) as residence_country_name'),
knex.raw('row_to_json(entities) as entity'),
)
.leftJoin('actors_meta', 'actors_meta.actor_id', 'actors.id')
.leftJoin('countries as birth_countries', 'birth_countries.alpha2', 'actors.birth_country_alpha2')
.leftJoin('countries as residence_countries', 'residence_countries.alpha2', 'actors.residence_country_alpha2')
.leftJoin('media as avatars', 'avatars.id', 'actors.avatar_media_id')
.leftJoin('entities', 'entities.id', 'actors.entity_id')
.whereIn('actors.id', actorIds)
.modify((builder) => {
if (options.order) {
builder.orderBy(...options.order);
}
}),
knex('actors_profiles')
.select(
'actors_profiles.*',
knex.raw('row_to_json(entities) as entity'),
knex.raw('row_to_json(parents) as parent_entity'),
// knex.raw('coalesce(json_agg(media) filter (where media.id is not null), \'[]\') as avatars'),
)
.leftJoin('actors', 'actors.id', 'actors_profiles.actor_id')
.leftJoin('entities', 'entities.id', 'actors_profiles.entity_id')
.leftJoin('entities as parents', 'parents.id', 'entities.parent_id')
.leftJoin('actors_avatars', 'actors_avatars.profile_id', 'actors_profiles.id')
// .leftJoin('media', 'media.id', 'actors_avatars.media_id')
.whereIn('actors_profiles.actor_id', actorIds)
.groupBy('actors_profiles.id', 'entities.id', 'parents.id'),
knex('actors_avatars')
.select(
'media.*',
'actors_avatars.actor_id',
knex.raw('json_agg(actors_avatars.profile_id) as profile_ids'),
)
.whereIn('actor_id', actorIds)
.leftJoin('media', 'media.id', 'actors_avatars.media_id')
.groupBy('media.id', 'actors_avatars.actor_id')
.orderBy(knex.raw('max(actors_avatars.created_at)'), 'desc'),
knex('actors_socials')
.whereIn('actor_id', actorIds),
reqUser
? knex('stashes_actors')
.leftJoin('stashes', 'stashes.id', 'stashes_actors.stash_id')
.where('stashes.user_id', reqUser.id)
.whereIn('stashes_actors.actor_id', actorIds)
: [],
reqUser
? knex('alerts_users_actors')
.where('user_id', reqUser.id)
.whereIn('actor_id', actorIds)
: [],
]);
if (options.order) {
return actors.map((actorEntry) => curateActor(actorEntry, {
stashes: stashes.filter((stash) => stash.actor_id === actorEntry.id),
alerts: alerts.filter((alert) => alert.actor_id === actorEntry.id),
append: options.append,
}));
}
const curatedActors = actorIds.map((actorId) => {
const actor = actors.find((actorEntry) => actorEntry.id === actorId);
if (!actor) {
console.warn(`Can't match actor ${actorId}`);
return null;
}
return curateActor(actor, {
stashes: stashes.filter((stash) => stash.actor_id === actor.id),
alerts: alerts.filter((alert) => alert.actor_id === actor.id),
profiles: profiles.filter((profile) => profile.actor_id === actor.id),
photos: photos.filter((photo) => photo.actor_id === actor.id),
socials: socials.filter((social) => social.actor_id === actor.id),
append: options.append,
});
}).filter(Boolean);
return curatedActors;
}
function curateOptions(options = {}) {
if (options.limit > 120) {
throw new HttpError('Limit must be <= 120', 400);
}
return {
page: options.page || 1,
limit: options.limit || 30,
aggregateCountries: true,
requireAvatar: options.requireAvatar || false,
order: [escape(options.order?.[0]) || 'name', escape(options.order?.[1]) || 'asc'],
};
}
async function queryManticoreSql(filters, options, _reqUser) {
const aggSize = config.database.manticore.maxAggregateSize;
const sqlQuery = knexManticore.raw(`
:query:
OPTION
max_matches=:maxMatches:,
max_query_time=:maxQueryTime:
:countriesFacet:;
show meta;
`, {
query: knexManticore(filters.stashId ? 'actors_stashed' : 'actors')
.modify((builder) => {
if (filters.stashId) {
builder.select(knex.raw(`
actors.id as id,
actors.slug,
actors.gender as gender,
actors.country as country,
actors.height as height,
actors.mass as mass,
actors.cup as cup,
actors.natural_boobs as natural_boobs,
actors.date_of_birth as date_of_birth,
actors.has_avatar as has_avatar,
actors.scenes as scenes,
actors.stashed as stashed,
created_at as stashed_at,
if(actors.date_of_birth, floor((now() - actors.date_of_birth) / 31556952), 0) as age,
weight() as _score
`));
builder
.innerJoin('actors', 'actors.id', 'actors_stashed.actor_id')
.where('stash_id', filters.stashId);
} else {
builder.select(knex.raw('*, weight() as _score'));
}
if (filters.query) {
if (filters.query.charAt(0) === '#') {
builder.where('id', Number(escape(filters.query.slice(1))));
} else {
builder.whereRaw('match(\'@name :query:\', actors)', { query: escape(filters.query) });
}
}
// attribute filters
['country'].forEach((attribute) => {
if (filters[attribute]) {
builder.where(attribute, filters[attribute]);
}
});
if (filters.gender === 'other') {
builder.whereNull('gender');
} else if (filters.gender) {
builder.where('gender', filters.gender);
}
if (filters.age) {
builder.select('if(date_of_birth, floor((now() - date_of_birth) / 31556952), 0) as age');
}
// range filters
['age', 'height'].forEach((attribute) => {
if (filters[attribute]) {
builder
.where(attribute, '>=', filters[attribute][0])
.where(attribute, '<=', filters[attribute][1]);
}
});
if (filters.weight) {
// weight is a reserved keyword in manticore
builder
.where('mass', '>=', filters.weight[0])
.where('mass', '<=', filters.weight[1]);
}
if (filters.dateOfBirth && filters.dobType === 'dateOfBirth') {
builder.where('date_of_birth', Math.floor(filters.dateOfBirth.getTime() / 1000));
}
if (filters.dateOfBirth && filters.dobType === 'birthday') {
const month = filters.dateOfBirth.getMonth() + 1;
const day = filters.dateOfBirth.getDate();
builder.select('month(date_of_birth) as month_of_birth, day(date_of_birth) as day_of_birth');
builder
.where('month_of_birth', month)
.where('day_of_birth', day);
}
if (filters.cup) {
builder.select(`regex(actors.cup, '^[${filters.cup[0]}-${filters.cup[1]}]') as cup_in_range`);
builder.where('cup_in_range', 1);
}
if (typeof filters.naturalBoobs === 'boolean') {
builder.where('natural_boobs', filters.naturalBoobs ? 2 : 1); // manticore boolean does not support null, so 0 = null, 1 = false (enhanced), 2 = true (natural)
}
if (filters.requireAvatar) {
builder.where('has_avatar', 1);
}
if (options.order?.[0] === 'name') {
builder.orderBy('actors.slug', options.order[1]);
} else if (options.order?.[0] === 'likes') {
builder.orderBy([
{ column: 'actors.stashed', order: options.order[1] },
{ column: 'actors.slug', order: 'asc' },
]);
} else if (options.order?.[0] === 'scenes') {
builder.orderBy([
{ column: 'actors.scenes', order: options.order[1] },
{ column: 'actors.slug', order: 'asc' },
]);
} else if (options.order?.[0] === 'results') {
builder.orderBy([
{ column: '_score', order: options.order[1] },
{ column: 'actors.stashed', order: 'desc' },
{ column: 'actors.slug', order: 'asc' },
]);
} else if (options.order?.[0] === 'stashed' && filters.stashId) {
builder.orderBy([
{ column: 'stashed_at', order: options.order[1] },
{ column: 'actors.slug', order: 'asc' },
]);
} else if (options.order) {
builder.orderBy([
{ column: `actors.${options.order[0]}`, order: options.order[1] },
{ column: 'actors.slug', order: 'asc' },
]);
} else {
builder.orderBy('actors.slug', 'asc');
}
})
.limit(options.limit)
.offset((options.page - 1) * options.limit)
.toString(),
// option threads=1 fixes actors, but drastically slows down performance, wait for fix
countriesFacet: options.aggregateCountries ? knex.raw('facet actors.country order by count(*) desc limit :aggSize', { aggSize }) : null,
maxMatches: config.database.manticore.maxMatches,
maxQueryTime: config.database.manticore.maxQueryTime,
}).toString();
// manticore does not seem to accept table.column syntax if 'table' is primary (yet?), crude work-around
const curatedSqlQuery = filters.stashId
? sqlQuery
: sqlQuery.replace(/actors\./g, '');
if (process.env.NODE_ENV === 'development') {
console.log(curatedSqlQuery);
}
const results = await utilsApi.sql(curatedSqlQuery);
const countries = results
.find((result) => (result.columns[0].country || result.columns[0]['actors.country']) && result.columns[1]['count(*)'])
?.data.map((row) => ({ key: row.country || row['actors.country'], doc_count: row['count(*)'] })).filter((country) => !!country.key)
|| [];
const total = Number(results.at(-1).data.find((entry) => entry.Variable_name === 'total_found').Value);
return {
actors: results[0].data,
total,
aggregations: {
countries,
},
};
}
export async function fetchActors(filters, rawOptions, reqUser) {
const options = curateOptions(rawOptions);
const result = await queryManticoreSql(filters, options, reqUser);
// console.log('result', result);
const actorIds = result.actors.map((actor) => Number(actor.id));
const [actors, countries] = await Promise.all([
fetchActorsById(actorIds, {}, reqUser),
fetchCountriesByAlpha2(result.aggregations.countries.map((bucket) => bucket.key)),
]);
return {
actors,
countries,
total: result.total,
limit: options.limit,
};
}
export async function fetchActorRevisions(revisionId, filters = {}, reqUser) {
const limit = filters.limit || 50;
const page = filters.page || 1;
const revisions = await knex('actors_revisions')
.select(
'actors_revisions.*',
'users.username as username',
'reviewers.username as reviewer_username',
)
.leftJoin('users', 'users.id', 'actors_revisions.user_id')
.leftJoin('users as reviewers', 'reviewers.id', 'actors_revisions.reviewed_by')
.modify((builder) => {
if (!['admin', 'editor'].includes(reqUser?.role) && !filters.userId && !filters.actorId) {
builder.where('user_id', reqUser.id);
}
if (filters.userId) {
if (!['admin', 'editor'].includes(reqUser?.role) && filters.userId !== reqUser.id) {
throw new HttpError('You are not permitted to view revisions from other users.', 403);
}
builder.where('actors_revisions.user_id', filters.userId);
}
if (revisionId) {
builder.where('actors_revisions.id', revisionId);
return;
}
if (filters.actorId) {
builder.where('actors_revisions.actor_id', filters.actorId);
}
if (filters.isFinalized === false) {
builder.whereNull('approved');
}
if (filters.isFinalized === true) {
builder.whereNotNull('approved');
}
})
.orderBy('created_at', 'desc')
.limit(limit)
.offset((page - 1) * limit);
const avatarIds = Array.from(new Set(revisions.flatMap((revision) => [revision.base.avatar, revision.deltas.find((delta) => delta.key === 'avatar')?.value]))).filter(Boolean);
const avatarEntries = await knex('media').whereIn('id', avatarIds);
const avatars = avatarEntries.map((avatar) => curateMedia(avatar));
const curatedRevisions = revisions.map((revision) => curateRevision(revision));
return {
revisions: curatedRevisions,
revision: revisionId && curatedRevisions.find((revision) => revision.id === revisionId),
avatars,
};
}
async function applyActorValueDelta(profileId, delta, trx) {
await knex('actors_profiles')
.where('id', profileId)
.update(keyMap[delta.key] || delta.key, delta.value)
.transacting(trx);
}
async function applyActorDirectDelta(actorId, delta, trx) {
await knex('actors')
.where('id', actorId)
.update(keyMap[delta.key] || delta.key, delta.value)
.modify((builder) => {
if (delta.key === 'name') {
builder.update('slug', slugify(delta.value));
}
})
.transacting(trx);
}
async function applyActorSocialsDelta(actorId, delta, trx) {
await knex('actors_socials')
.where('actor_id', actorId)
.delete()
.transacting(trx);
await knex('actors_socials')
.insert(delta.value.map((social) => ({
actor_id: actorId,
platform: social.platform,
handle: social.handle,
url: social.url,
verified_at: knex.fn.now(), // manual add implies verification
})))
.transacting(trx);
}
async function fetchMainProfile(actorId, wasCreated = false) {
const profileEntry = await knex('actors_profiles')
.where('actor_id', actorId)
.where('entity_id', null)
.first();
if (profileEntry) {
return profileEntry;
}
if (wasCreated) {
throw new HttpError('Failed to find or create main profile', 404);
}
await knex('actors_profiles').insert({
actor_id: actorId,
entity_id: null,
});
return fetchMainProfile(actorId, true);
}
async function applyActorRevision(revisionIds, reqUser) {
const revisions = await knex('actors_revisions')
.whereIn('id', revisionIds)
.whereNull('applied_at'); // should not re-apply revision that was already applied
await revisions.reduce(async (chain, revision) => {
await chain;
const mainProfile = await fetchMainProfile(revision.actor_id);
await knex.transaction(async (trx) => {
await Promise.all(revision.deltas.map(async (delta) => {
if ([
'gender',
'avatar',
'dateOfBirth',
'dateOfDeath',
'originCountry',
'originState',
'originCity',
'residenceCountry',
'residenceState',
'residenceCity',
'ethnicity',
'height',
'weight',
'bust',
'cup',
'waist',
'hip',
'naturalBoobs',
'boobsVolume',
'boobsImplant',
'boobsPlacement',
'boobsIncision',
'boobsSurgeon',
'naturalButt',
'buttVolume',
'buttImplant',
'naturalLips',
'lipsVolume',
'naturalLabia',
'penisLength',
'penisGirth',
'isCircumcised',
'hairColor',
'eyes',
'hasTattoos',
'tattoos',
'hasPiercings',
'piercings',
'agency',
].includes(delta.key)) {
return applyActorValueDelta(mainProfile.id, delta, trx);
}
if (delta.key === 'socials') {
return applyActorSocialsDelta(revision.actor_id, delta, trx);
}
if (delta.key === 'name' && reqUser.role === 'admin') {
return applyActorDirectDelta(revision.actor_id, delta, trx);
}
return null;
}));
await knex('actors_revisions')
.transacting(trx)
.where('id', revision.id)
.update('applied_at', knex.fn.now());
// await trx.commit();
}).catch(async (error) => {
logger.error(`Failed to apply revision ${revision.id} on actor ${revision.actor_id}: ${error.message}`);
});
}, Promise.resolve());
const actorIds = Array.from(new Set(revisions.map((revision) => revision.actor_id)));
await interpolateProfiles(actorIds, {
knex,
logger,
moment,
slugify,
omit,
}, { refreshView: false });
}
export async function reviewActorRevision(revisionId, isApproved, { feedback }, reqUser) {
if (!reqUser || reqUser.role === 'user') {
throw new HttpError('You are not permitted to approve revisions', 403);
}
if (typeof isApproved !== 'boolean') {
throw new HttpError('You must either approve or reject the revision', 400);
}
const updated = await knex('actors_revisions')
.where('id', revisionId)
.whereNull('approved') // don't rerun reviewed revision, must be forked into new revision instead
.whereNull('applied_at')
.update({
approved: isApproved,
reviewed_at: knex.fn.now(),
reviewed_by: reqUser.id,
feedback,
});
if (updated === 0) {
throw new HttpError('This revision was already reviewed', 409);
}
if (isApproved) {
await applyActorRevision([revisionId], reqUser);
}
}
const cupConversions = {
us: ['AA', 'A', 'B', 'C', 'D', ['DD', 'E'], ['DDD', 'F'], 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P'], // United States
uk: ['AA', 'A', 'B', 'C', 'D', 'DD', 'E', 'F', 'FF', 'G', 'GG', 'H', 'HH', 'J', 'JJ', 'K', 'KK'], // United Kingdom
eu: ['AA', 'A', 'B', 'C', 'D', 'E', 'F', 'G', ' H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P'], // Europe
jp: ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q'], // Japan
};
cupConversions.fr = cupConversions.eu; // France
cupConversions.it = cupConversions.uk; // Italy
cupConversions.au = cupConversions.uk; // Australia
// bra band sizes
const bustConversions = {
us: [28, 30, 32, 34, 36, 38, 40, 42, 44, 46, 48, 50, 52, 54, 56],
eu: [60, 65, 70, 75, 80, 85, 90, 95, 100, 105, 110, 115, 120, 125, 130],
fr: [75, 80, 85, 90, 95, 100, 105, 110, 115, 120, 125, 130, 135, 140, 145],
it: [0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13],
au: [6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 26, 28, 30, 32, 34],
};
bustConversions.uk = bustConversions.us;
bustConversions.jp = bustConversions.eu;
const conversions = {
cup: cupConversions,
bust: bustConversions,
};
// to US
function convertFigure(domain = 'cup', rawValue, units) {
if (!rawValue) {
return null;
}
const value = typeof rawValue === 'string'
? rawValue.toUpperCase()
: Number(rawValue);
if (!units || !cupConversions[units] || units === 'us') {
return value;
}
if (!conversions[domain]) {
if (['us', 'uk'].includes(units)) {
return value; // should already be in inches
}
return Math.round(convert(value, 'cm').to('inches'));
}
if (Number.isNaN(value)) {
return value;
}
const valueIndex = conversions[domain][units].findIndex((chartValue) => (Array.isArray(chartValue) // US uses both DD and E, and DDD and F
? chartValue.includes(value)
: value === chartValue));
const usValue = Array.isArray(conversions[domain].us[valueIndex])
? conversions[domain].us[valueIndex][0]
: conversions[domain].us[valueIndex];
return usValue;
}
function convertHeight(height, units) {
if (units === 'metric' || !Array.isArray(height)) {
return Number(height) || null;
}
if (height.length !== 2) {
return null;
}
// 12 inches in a foot
return Math.round(((height[0] * 12) + height[1]) * 2.54);
}
function convertWeight(weight, units) {
if (units === 'imperial') {
return Math.round(unit(weight, 'lbs').toNumeric('kg'));
}
return Number(weight) || null;
}
const platformsByHostname = Object.fromEntries(Object.entries(config.socials.urls).map(([platform, url]) => {
const { hostname, pathname } = new URL(url);
return [hostname, {
platform,
pathname: decodeURIComponent(pathname),
url,
}];
}));
function curateSocials(socials) {
return socials.map((social) => {
if (!social.handle && !social.url) {
throw new Error('No social handle or website URL specified');
}
if (social.handle && !social.platform) {
throw new Error('No platform specified for social handle');
}
if (social.handle && social.platform && /[\w-]+/.test(social.handle) && /[a-z]+/i.test(social.platform)) {
return {
platform: social.platform.toLowerCase(),
handle: social.handle,
};
}
if (social.url) {
const { hostname, pathname } = new URL(social.url);
const platform = platformsByHostname[hostname];
if (platform) {
const handle = pathname.match(new RegExp(platform.pathname.replace('{handle}', '([\\w-]+)')))?.[1];
if (handle) {
return {
platform: platform.platform,
handle,
};
}
}
return {
url: social.url,
};
}
throw new Error('Invalid social');
}).filter(Boolean);
}
function getBaseActor(actor) {
return Object.fromEntries(Object.entries(actor).map(([key, values]) => {
if ([
'scenes',
'likes',
'stashes',
'profiles',
].includes(key)) {
return null;
}
if ([
'socials',
].includes(key)) {
return [key, values];
}
if (values?.id) {
return [key, values.id];
}
if (values?.metric) {
return [key, values.metric];
}
if (Array.isArray(values)) {
return [key, values.map((value) => value?.hash || value?.id || value)];
}
return [key, values];
}).filter(Boolean));
}
function getDeltas(edits, baseActor, options) {
return Promise.all(Object.entries(edits).map(async ([key, value]) => {
if (baseActor[key] === value || typeof value === 'undefined') {
return null;
}
if (key === 'originCountry' && edits.originPlace) {
// place overrides country
return null;
}
if (key === 'residenceCountry' && edits.residencePlace) {
// place overrides country
return null;
}
if (['originPlace', 'residencePlace'].includes(key)) {
if (!value && !baseActor[key]) {
// don't pollute deltas if value is already unset
return null;
}
if (!value) {
return [
// { key: key.includes('origin') ? 'originCountry' : 'residenceCountry', value: null },
{ key: key.includes('origin') ? 'originState' : 'residenceState', value: null },
{ key: key.includes('origin') ? 'originCity' : 'residenceCity', value: null },
];
}
const resolvedLocation = await resolvePlace(value, {
knex,
redis,
logger,
slugify,
unprint,
}, {
userAgent: 'contact via https://traxxx.me/',
});
if (!resolvedLocation) {
throw new Error(`Failed to resolve ${key} ${value}`);
}
const countryKey = key.includes('origin') ? 'originCountry' : 'residenceCountry';
if (!resolvedLocation.country) {
return null;
}
return [
{
key: countryKey,
value: resolvedLocation.country,
comment: edits[countryKey] && edits[countryKey] !== resolvedLocation.country
? `${countryKey} overridden by resolved ${key}`
: null,
},
{
key: key.includes('origin') ? 'originState' : 'residenceState',
value: resolvedLocation.state || null, // explicitly unset to prevent outcomes like Los Angeles, Greenland
},
{
key: key.includes('origin') ? 'originCity' : 'residenceCity',
value: resolvedLocation.city || null,
},
];
}
if (key === 'socials') {
const convertedSocials = curateSocials(value);
const convertedUrls = value
.filter((social) => social.url && !convertedSocials.some((convertedSocial) => convertedSocial.url === social.url))
.map((social) => social.url);
const conversionComment = convertedUrls.length > 0
? `curated URLs ${convertedUrls.join(', ')} as social handles`
: null;
return {
key,
value: convertedSocials,
comment: conversionComment,
};
}
if (['cup', 'bust', 'waist', 'hip'].includes(key)) {
const convertedValue = convertFigure(key, value, options.figureUnits);
const conversionComment = !value || convertedValue === value
? null
: `${key} converted from ${value} ${options.figureUnits?.toUpperCase() || 'US'} to ${convertedValue} US`;
return {
key,
value: convertedValue,
comment: conversionComment,
};
}
if (['height'].includes(key)) {
const convertedValue = convertHeight(value, options.sizeUnits);
if (baseActor[key] === convertedValue) {
return null;
}
const conversionComment = !value || convertedValue === value
? null
: `${key} converted from ${value[0]} in ${value[1]} ft to ${convertedValue} cm`;
return {
key,
value: convertedValue,
comment: conversionComment,
};
}
if (['weight'].includes(key)) {
const convertedValue = convertWeight(value, options.sizeUnits);
if (baseActor[key] === convertedValue) {
return null;
}
const conversionComment = !value || convertedValue === value
? null
: `${key} converted from ${value} lbs to ${convertedValue} kg`;
return {
key,
value: convertedValue,
comment: conversionComment,
};
}
if (['penisLength', 'penisGirth'].includes(key) && options.penisUnits === 'imperial') {
const convertedValue = Math.round(convert(value, 'inches').to('cm'));
if (baseActor[key] === convertedValue) {
return null;
}
return {
key,
value: convertedValue,
comment: `${key} converted from ${value} inches to ${convertedValue} cm`,
};
}
if (Array.isArray(value)) {
const valueSet = new Set(value);
const baseSet = new Set(baseActor[key]);
if (valueSet.size === baseSet.size && baseActor[key].every((id) => valueSet.has(id))) {
return null;
}
return { key, value: Array.from(valueSet) };
}
return { key, value };
})).then((rawDeltas) => rawDeltas.flat().filter(Boolean));
}
export async function createActorRevision(actorId, {
edits,
comment,
apply,
...options
}, reqUser) {
const [
[actor],
openRevisions,
] = await Promise.all([
fetchActorsById([actorId], {
reqUser,
includeAssets: true,
includePartOf: true,
}),
knex('actors_revisions')
.where('user_id', reqUser.id)
.whereNull('approved'),
]);
if (!actor) {
throw new HttpError(`No actor with ID ${actorId} found to update`, 404);
}
if (openRevisions.length >= config.revisions.unapprovedLimit && reqUser.role !== 'admin') {
throw new HttpError(`You have ${config.revisions.unapprovedLimit} unapproved revisions, please wait for approval before submitting another revision.`, 429);
}
const baseActor = getBaseActor(actor);
const deltas = await getDeltas(edits, baseActor, options);
const deltaComments = deltas.map((delta) => delta.comment);
const curatedComment = [comment, ...deltaComments].filter(Boolean).join(' | ');
if (deltas.length === 0) {
throw new HttpError('No effective changes provided', 400);
}
const [revisionEntry] = await knex('actors_revisions')
.insert({
user_id: reqUser.id,
actor_id: actor.id,
base: JSON.stringify(baseActor),
deltas: JSON.stringify(deltas),
hash: mj.hash({
base: baseActor,
deltas,
}),
comment: curatedComment,
})
.returning('id');
if (['admin', 'editor'].includes(reqUser.role) && apply) {
// don't keep the editor waiting for the revision to apply
reviewActorRevision(revisionEntry.id, true, {}, reqUser);
}
}