traxxx-web/src/stashes.js

404 lines
10 KiB
JavaScript
Executable File

import config from 'config';
import { CronJob } from 'cron';
import { knexOwner as knex } from './knex.js';
import { indexApi } from './manticore.js';
import { HttpError } from './errors.js';
import slugify from './utils/slugify.js';
import initLogger from './logger.js';
const logger = initLogger();
const lastViewRefresh = {
actors: 0,
stashes: 0,
};
export function curateStash(stash, assets = {}) {
if (!stash) {
return null;
}
const curatedStash = {
id: stash.id,
name: stash.name,
slug: stash.slug,
isPrimary: stash.primary,
public: stash.public,
createdAt: stash.created_at,
stashedScenes: stash.stashed_scenes ?? null,
stashedMovies: stash.stashed_movies ?? null,
stashedActors: stash.stashed_actors ?? null,
user: assets.user ? {
id: assets.user.id,
username: assets.user.username,
avatar: `/media/avatars/${assets.user.id}_${assets.user.username}.png`,
createdAt: assets.user.created_at,
} : null,
};
return curatedStash;
}
function curateStashEntry(stash, user) {
const curatedStashEntry = {
user_id: user?.id || undefined,
name: stash.name || undefined,
slug: slugify(stash.name) || undefined,
public: stash.public ?? false,
};
return curatedStashEntry;
}
function verifyStashAccess(stash, sessionUser) {
if (!stash || (!stash.public && stash.user_id !== sessionUser?.id)) {
throw new HttpError('This stash does not exist, or you are not allowed access.', 404);
}
}
export async function fetchStashById(stashId, sessionUser) {
const stash = await knex('stashes')
.where('id', stashId)
.first();
verifyStashAccess(stash, sessionUser);
return curateStash(stash);
}
export async function fetchStashByUsernameAndSlug(username, stashSlug, sessionUser) {
const user = await knex('users').where('username', username).first();
if (!user) {
throw new HttpError('This user does not exist.', 404);
}
const stash = await knex('stashes')
.select('stashes.*', 'stashes_meta.*')
.leftJoin('stashes_meta', 'stashes_meta.stash_id', 'stashes.id')
.where('slug', stashSlug)
.where('user_id', user.id)
.first();
verifyStashAccess(stash, sessionUser);
return curateStash(stash, { user });
}
export async function fetchStashes(domain, itemId, sessionUser) {
const stashes = await knex(`stashes_${domain}s`)
.select('stashes.*')
.leftJoin('stashes', 'stashes.id', `stashes_${domain}s.stash_id`)
.where({
[`${domain}_id`]: itemId,
user_id: sessionUser.id,
});
return stashes.map((stash) => curateStash(stash));
}
function verifyStashName(stash) {
if (!stash.name) {
throw new HttpError('Stash name required', 400);
}
if (stash.name.length < config.stashes.nameLength[0]) {
throw new HttpError('Stash name is too short', 400);
}
if (stash.name.length > config.stashes.nameLength[1]) {
throw new HttpError('Stash name is too long', 400);
}
if (!config.stashes.namePattern.test(stash.name)) {
throw new HttpError('Stash name contains invalid characters', 400);
}
}
export async function refreshView(domain = 'stashes') {
// throttle view refreshes
if (new Date() - lastViewRefresh[domain] > config.stashes.viewRefreshCooldowns[domain] * 60000) {
lastViewRefresh[domain] = new Date();
logger.verbose(`Refreshing ${domain} view`);
await knex.schema.refreshMaterializedView(`${domain}_meta`);
await knex.schema.refreshMaterializedView('stashes_meta');
return true;
}
logger.debug(`Skipping ${domain} view refresh`);
return false;
}
export async function createStash(newStash, sessionUser) {
if (!sessionUser) {
throw new HttpError('You are not authenthicated', 401);
}
verifyStashName(newStash);
try {
const stash = await knex('stashes')
.insert(curateStashEntry(newStash, sessionUser))
.returning('*');
return curateStash(stash);
} catch (error) {
if (error.routine === '_bt_check_unique') {
throw new HttpError('Stash name should be unique', 409);
}
throw error;
}
}
export async function updateStash(stashId, updatedStash, sessionUser) {
if (!sessionUser) {
throw new HttpError('You are not authenthicated', 401);
}
if (updatedStash.name) {
verifyStashName(updatedStash);
}
try {
const stash = await knex('stashes')
.where({
id: stashId,
user_id: sessionUser.id,
})
.update(curateStashEntry(updatedStash))
.returning('*');
if (!stash) {
throw new HttpError('You are not authorized to modify this stash', 403);
}
return curateStash(stash);
} catch (error) {
if (error.routine === '_bt_check_unique') {
throw new HttpError('Stash name should be unique', 409);
}
throw error;
}
}
export async function removeStash(stashId, sessionUser) {
if (!sessionUser) {
throw new HttpError('You are not authenthicated', 401);
}
const removed = await knex('stashes')
.where({
id: stashId,
user_id: sessionUser.id,
primary: false,
})
.delete();
if (removed === 0) {
throw new HttpError('Unable to remove this stash', 400);
}
}
export async function stashActor(actorId, stashId, sessionUser) {
const stash = await fetchStashById(stashId, sessionUser);
const [stashed] = await knex('stashes_actors')
.insert({
stash_id: stash.id,
actor_id: actorId,
})
.returning(['id', 'created_at']);
await indexApi.replace({
index: 'actors_stashed',
id: stashed.id,
doc: {
actor_id: actorId,
user_id: sessionUser.id,
stash_id: stashId,
created_at: Math.round(stashed.created_at.getTime() / 1000),
},
});
logger.verbose(`${sessionUser.username} (${sessionUser.id}) stashed actor ${actorId} to stash ${stash.id} (${stash.name})`);
refreshView('actors');
return fetchStashes('actor', actorId, sessionUser);
}
export async function unstashActor(actorId, stashId, sessionUser) {
await knex
.from('stashes_actors AS deletable')
.where('deletable.actor_id', actorId)
.where('deletable.stash_id', stashId)
.whereExists(knex('stashes_actors') // verify user owns this stash, complimentary to row-level security
.leftJoin('stashes', 'stashes.id', 'stashes_actors.stash_id')
.where('stashes_actors.stash_id', knex.raw('deletable.stash_id'))
.where('stashes.user_id', sessionUser.id))
.delete();
try {
await indexApi.callDelete({
index: 'actors_stashed',
query: {
bool: {
must: [
{ equals: { actor_id: actorId } },
{ equals: { stash_id: stashId } },
{ equals: { user_id: sessionUser.id } },
],
},
},
});
} catch (error) {
console.log(error);
}
logger.verbose(`${sessionUser.username} (${sessionUser.id}) unstashed actor ${actorId} from stash ${stashId}`);
refreshView('actors');
return fetchStashes('actor', actorId, sessionUser);
}
export async function stashScene(sceneId, stashId, sessionUser) {
const stash = await fetchStashById(stashId, sessionUser);
const [stashed] = await knex('stashes_scenes')
.insert({
stash_id: stash.id,
scene_id: sceneId,
})
.returning(['id', 'created_at']);
await indexApi.replace({
index: 'scenes_stashed',
id: stashed.id,
doc: {
// ...doc.replace.doc,
scene_id: sceneId,
user_id: sessionUser.id,
stash_id: stashId,
created_at: Math.round(stashed.created_at.getTime() / 1000),
},
});
logger.verbose(`${sessionUser.username} (${sessionUser.id}) stashed scene ${sceneId} to stash ${stash.id} (${stash.name})`);
refreshView('scenes');
return fetchStashes('scene', sceneId, sessionUser);
}
export async function unstashScene(sceneId, stashId, sessionUser) {
await knex
.from('stashes_scenes AS deletable')
.where('deletable.scene_id', sceneId)
.where('deletable.stash_id', stashId)
.whereExists(knex('stashes_scenes') // verify user owns this stash, complimentary to row-level security
.leftJoin('stashes', 'stashes.id', 'stashes_scenes.stash_id')
.where('stashes_scenes.stash_id', knex.raw('deletable.stash_id'))
.where('stashes.user_id', sessionUser.id))
.delete();
await indexApi.callDelete({
index: 'scenes_stashed',
query: {
bool: {
must: [
{ equals: { scene_id: sceneId } },
{ equals: { stash_id: stashId } },
{ equals: { user_id: sessionUser.id } },
],
},
},
});
logger.verbose(`${sessionUser.username} (${sessionUser.id}) unstashed scene ${sceneId} from stash ${stashId}`);
refreshView('scenes');
return fetchStashes('scene', sceneId, sessionUser);
}
export async function stashMovie(movieId, stashId, sessionUser) {
const stash = await fetchStashById(stashId, sessionUser);
const [stashed] = await knex('stashes_movies')
.insert({
stash_id: stash.id,
movie_id: movieId,
})
.returning(['id', 'created_at']);
await indexApi.replace({
index: 'movies_stashed',
id: stashed.id,
doc: {
movie_id: movieId,
user_id: sessionUser.id,
stash_id: stashId,
created_at: Math.round(stashed.created_at.getTime() / 1000),
},
});
logger.verbose(`${sessionUser.username} (${sessionUser.id}) stashed movie ${movieId} to stash ${stash.id} (${stash.name})`);
refreshView('movies');
return fetchStashes('movie', movieId, sessionUser);
}
export async function unstashMovie(movieId, stashId, sessionUser) {
await knex
.from('stashes_movies AS deletable')
.where('deletable.movie_id', movieId)
.where('deletable.stash_id', stashId)
.whereExists(knex('stashes_movies') // verify user owns this stash, complimentary to row-level security
.leftJoin('stashes', 'stashes.id', 'stashes_movies.stash_id')
.where('stashes_movies.stash_id', knex.raw('deletable.stash_id'))
.where('stashes.user_id', sessionUser.id))
.delete();
await indexApi.callDelete({
index: 'movies_stashed',
query: {
bool: {
must: [
{ equals: { movie_id: movieId } },
{ equals: { stash_id: stashId } },
{ equals: { user_id: sessionUser.id } },
],
},
},
});
logger.verbose(`${sessionUser.username} (${sessionUser.id}) unstashed movie ${movieId} from stash ${stashId}`);
refreshView('movies');
return fetchStashes('movie', movieId, sessionUser);
}
CronJob.from({
cronTime: config.stashes.viewRefreshCron,
async onTick() {
logger.verbose('Updating stash views');
await refreshView('scenes');
await refreshView('actors');
await refreshView('movies');
await refreshView('stashes');
},
start: true,
runOnInit: true,
});