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