From f56e22230b626a96d5f1f085c83cd268eee74164 Mon Sep 17 00:00:00 2001 From: DebaucheryLibrarian Date: Sun, 3 Mar 2024 02:33:35 +0100 Subject: [PATCH] Added functional stash button on scene tiles. --- components/header/header.vue | 39 ++++-- components/scenes/tile.vue | 98 +++++++++++-- pages/updates/+onBeforeRender.js | 2 +- pages/users/@username/+Page.vue | 79 +++++++++++ src/auth.js | 9 +- src/knex.js | 13 +- src/scenes.js | 32 +++-- src/stashes.js | 227 +++++++++++++++++++++++++++++++ src/users.js | 54 +++++--- src/utils/slugify.js | 75 ++++++++++ src/web/auth.js | 12 -- src/web/server.js | 25 ++++ src/web/stashes.js | 65 +++++++++ 13 files changed, 657 insertions(+), 73 deletions(-) create mode 100755 src/stashes.js create mode 100755 src/utils/slugify.js create mode 100755 src/web/stashes.js diff --git a/components/header/header.vue b/components/header/header.vue index 1d87ebc..9985496 100644 --- a/components/header/header.vue +++ b/components/header/header.vue @@ -78,6 +78,7 @@
{{ user.username }} @@ -201,9 +212,9 @@ async function logout() { height: 2rem; display: flex; align-items: center; - border: solid 1px var(--shadow-weak-20); border-radius: 1rem; - background: var(--background); + background: var(--background-dark-10); + box-shadow: inset 0 0 3px var(--shadow-weak-40); .input { padding: .5rem 0 .5rem 1rem; @@ -225,7 +236,10 @@ async function logout() { } &.focused { + /* border: solid 1px var(--primary-light-10); + */ + box-shadow: inset 0 0 3px var(--shadow-weak-30); .icon { fill: var(--primary); @@ -258,25 +272,32 @@ async function logout() { text-decoration: none; } +.menu { + overflow: hidden; +} + .menu-header { display: flex; - justify-content: center; padding: .75rem 1rem; border-bottom: solid 1px var(--shadow-weak-30); color: var(--shadow-strong-30); text-decoration: none; - text-align: center; font-weight: bold; } .menu-item { + display: block; +} + +.menu-button { display: flex; align-items: center; - padding: .5rem; + padding: .5rem .5rem .5rem .75rem; .icon { fill: var(--shadow); - margin-right: .5rem; + margin-right: .75rem; + transform: translateY(-1px); } &:hover { @@ -286,8 +307,6 @@ async function logout() { } .logout { - color: var(--error); - .icon { fill: var(--error); } diff --git a/components/scenes/tile.vue b/components/scenes/tile.vue index f161716..98259ac 100644 --- a/components/scenes/tile.vue +++ b/components/scenes/tile.vue @@ -1,18 +1,34 @@ diff --git a/src/auth.js b/src/auth.js index d363ecf..3d82d4a 100755 --- a/src/auth.js +++ b/src/auth.js @@ -5,7 +5,7 @@ import fs from 'fs/promises'; import { createAvatar } from '@dicebear/core'; import { shapes } from '@dicebear/collection'; -import knex from './knex.js'; +import { knexOwner as knex } from './knex.js'; import { curateUser, fetchUser } from './users.js'; import { HttpError } from './errors.js'; @@ -38,7 +38,7 @@ async function generateAvatar(user) { export async function login(credentials) { if (!config.auth.login) { - throw new HttpError('Authentication is disabled', 405); + throw new HttpError('Logins are currently disabled', 405); } const user = await fetchUser(credentials.username.trim(), { @@ -46,6 +46,8 @@ export async function login(credentials) { raw: true, }); + console.log('login user', user); + if (!user) { throw new HttpError('Username or password incorrect', 401); } @@ -60,12 +62,13 @@ export async function login(credentials) { await generateAvatar(user); } + // fetched the raw user for password verification, don't return directly to user return curateUser(user); } export async function signup(credentials) { if (!config.auth.signup) { - throw new HttpError('Authentication is disabled', 405); + throw new HttpError('Sign-ups are currently disabled', 405); } const curatedUsername = credentials.username.trim(); diff --git a/src/knex.js b/src/knex.js index 03f4f4a..6e04eda 100755 --- a/src/knex.js +++ b/src/knex.js @@ -1,7 +1,16 @@ import config from 'config'; import knex from 'knex'; -export default knex({ +export const knexQuery = knex({ + client: 'pg', + connection: config.database.query, + pool: config.database.pool, + // performance overhead, don't use asyncStackTraces in production + asyncStackTraces: process.env.NODE_ENV === 'development', + // debug: process.env.NODE_ENV === 'development', +}); + +export const knexOwner = knex({ client: 'pg', connection: config.database.owner, pool: config.database.pool, @@ -9,3 +18,5 @@ export default knex({ asyncStackTraces: process.env.NODE_ENV === 'development', // debug: process.env.NODE_ENV === 'development', }); + +export default knexQuery; diff --git a/src/scenes.js b/src/scenes.js index b7e27b0..808ae59 100644 --- a/src/scenes.js +++ b/src/scenes.js @@ -1,11 +1,12 @@ import config from 'config'; -import knex from './knex.js'; +import { knexOwner as knex } from './knex.js'; import { searchApi } from './manticore.js'; import { HttpError } from './errors.js'; import { fetchActorsById, curateActor, sortActorsByGender } from './actors.js'; import { fetchTagsById } from './tags.js'; import { fetchEntitiesById } from './entities.js'; +import { curateStash } from './stashes.js'; function curateMedia(media) { if (!media) { @@ -66,14 +67,15 @@ function curateScene(rawScene, assets) { })), poster: curateMedia(assets.poster), photos: assets.photos.map((photo) => curateMedia(photo)), + stashes: assets.stashes?.map((stash) => curateStash(stash)) || [], createdBatchId: rawScene.created_batch_id, updatedBatchId: rawScene.updated_batch_id, }; } -export async function fetchScenesById(sceneIds) { - const [scenes, channels, actors, directors, tags, posters, photos] = await Promise.all([ - knex('releases').whereIn('id', sceneIds), +export async function fetchScenesById(sceneIds, reqUser) { + const [scenes, channels, actors, directors, tags, posters, photos, stashes] = await Promise.all([ + knex('releases').whereIn('releases.id', sceneIds), knex('releases') .select('channels.*', 'networks.id as network_id', 'networks.slug as network_slug', 'networks.name as network_name', 'networks.type as network_type') .whereIn('releases.id', sceneIds) @@ -92,9 +94,9 @@ export async function fetchScenesById(sceneIds) { knex.raw('COALESCE(residence_countries.alias, residence_countries.name) as residence_country_name'), */ ) - .whereIn('release_id', sceneIds) .leftJoin('actors', 'actors.id', 'releases_actors.actor_id') - .leftJoin('actors_meta', 'actors_meta.actor_id', 'actors.id'), + .leftJoin('actors_meta', 'actors_meta.actor_id', 'actors.id') + .whereIn('release_id', sceneIds), /* .leftJoin('countries as birth_countries', 'birth_countries.alpha2', 'actors_meta.birth_country_alpha2') .leftJoin('countries as residence_countries', 'residence_countries.alpha2', 'actors_meta.residence_country_alpha2'), @@ -104,9 +106,9 @@ export async function fetchScenesById(sceneIds) { .leftJoin('actors as directors', 'directors.id', 'releases_directors.director_id'), knex('releases_tags') .select('id', 'slug', 'name', 'release_id') + .leftJoin('tags', 'tags.id', 'releases_tags.tag_id') .whereNotNull('tags.id') .whereIn('release_id', sceneIds) - .leftJoin('tags', 'tags.id', 'releases_tags.tag_id') .orderBy('priority', 'desc'), knex('releases_posters') .whereIn('release_id', sceneIds) @@ -114,6 +116,12 @@ export async function fetchScenesById(sceneIds) { knex('releases_photos') .whereIn('release_id', sceneIds) .leftJoin('media', 'media.id', 'releases_photos.media_id'), + reqUser + ? knex('stashes_scenes') + .leftJoin('stashes', 'stashes.id', 'stashes_scenes.stash_id') + .where('stashes.user_id', reqUser.id) + .whereIn('stashes_scenes.scene_id', sceneIds) + : [], ]); return sceneIds.map((sceneId) => { @@ -129,6 +137,7 @@ export async function fetchScenesById(sceneIds) { const sceneTags = tags.filter((tag) => tag.release_id === sceneId); const scenePoster = posters.find((poster) => poster.release_id === sceneId); const scenePhotos = photos.filter((photo) => photo.release_id === sceneId); + const sceneStashes = stashes.filter((stash) => stash.scene_id === sceneId); return curateScene(scene, { channel: sceneChannel, @@ -137,6 +146,7 @@ export async function fetchScenesById(sceneIds) { tags: sceneTags, poster: scenePoster, photos: scenePhotos, + stashes: sceneStashes, }); }).filter(Boolean); } @@ -294,7 +304,7 @@ function countAggregations(buckets) { return Object.fromEntries(buckets.map((bucket) => [bucket.key, { count: bucket.doc_count }])); } -export async function fetchScenes(filters, rawOptions) { +export async function fetchScenes(filters, rawOptions, reqUser) { const options = curateOptions(rawOptions); const { query, sort } = buildQuery(filters); @@ -302,6 +312,8 @@ export async function fetchScenes(filters, rawOptions) { console.log('options', options); console.log('query', query.bool.must); + console.log('request user', reqUser); + console.time('manticore'); const result = await searchApi.search({ @@ -329,8 +341,6 @@ export async function fetchScenes(filters, rawOptions) { console.timeEnd('manticore'); - console.log('hits', result.hits.hits.length); - const actorCounts = options.aggregateActors && countAggregations(result.aggregations?.actorIds?.buckets); const tagCounts = options.aggregateTags && countAggregations(result.aggregations?.tagIds?.buckets); const channelCounts = options.aggregateChannels && countAggregations(result.aggregations?.channelIds?.buckets); @@ -346,7 +356,7 @@ export async function fetchScenes(filters, rawOptions) { console.timeEnd('fetch aggregations'); const sceneIds = result.hits.hits.map((hit) => Number(hit._id)); - const scenes = await fetchScenesById(sceneIds); + const scenes = await fetchScenesById(sceneIds, reqUser); return { scenes, diff --git a/src/stashes.js b/src/stashes.js new file mode 100755 index 0000000..f4930e6 --- /dev/null +++ b/src/stashes.js @@ -0,0 +1,227 @@ +import config from 'config'; + +import { knexOwner as knex } from './knex.js'; +import { HttpError } from './errors.js'; +import slugify from './utils/slugify.js'; +import initLogger from './logger.js'; + +const logger = initLogger(); + +let lastActorsViewRefresh = 0; + +export function curateStash(stash) { + if (!stash) { + return null; + } + + const curatedStash = { + id: stash.id, + name: stash.name, + slug: stash.slug, + primary: stash.primary, + public: stash.public, + createdAt: stash.created_at, + stashedScenes: stash.stashed_scenes || null, + stashedMovies: stash.stashed_movies || null, + stashedActors: stash.stashed_actors || null, + }; + + return curatedStash; +} + +function curateStashEntry(stash, user) { + const curatedStashEntry = { + user_id: user.id, + name: stash.name, + slug: slugify(stash.name), + public: false, + }; + + return curatedStashEntry; +} + +export async function fetchStash(stashId, sessionUser) { + if (!sessionUser) { + throw new HttpError('You are not authenthicated', 401); + } + + const stash = await knex('stashes') + .where({ + id: stashId, + user_id: sessionUser.id, + }) + .first(); + + if (!stash) { + throw new HttpError('You are not authorized to access this stash', 403); + } + + return curateStash(stash); +} + +export async function fetchStashes(domain, itemId, sessionUser) { + const stashes = await knex(`stashes_${domain}s`) + .select('stashes.*') + .where({ + [`${domain}_id`]: itemId, + user_id: sessionUser.id, + }) + .leftJoin('stashes', 'stashes.id', `stashes_${domain}s.stash_id`); + + return stashes.map((stash) => curateStash(stash)); +} + +export async function createStash(newStash, sessionUser) { + if (!sessionUser) { + throw new HttpError('You are not authenthicated', 401); + } + + 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, newStash, sessionUser) { + if (!sessionUser) { + throw new HttpError('You are not authenthicated', 401); + } + + const stash = await knex('stashes') + .where({ + id: stashId, + user_id: sessionUser.id, + }) + .update(newStash) + .returning('*'); + + if (!stash) { + throw new HttpError('You are not authorized to modify this stash', 403); + } + + return curateStash(stash); +} + +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 refreshActorsView() { + if (new Date() - lastActorsViewRefresh > config.stashes.viewRefreshCooldown * 60000) { + // don't refresh actors view more than once an hour + lastActorsViewRefresh = new Date(); + + logger.debug('Refreshing actors view'); + + return knex.schema.refreshMaterializedView('actors_meta'); + } + + logger.silly('Skipping actors view refresh'); + + return false; +} + +export async function stashActor(actorId, stashId, sessionUser) { + const stash = await fetchStash(stashId, sessionUser); + + await knex('stashes_actors') + .insert({ + stash_id: stash.id, + actor_id: actorId, + }); + + refreshActorsView(); + + return fetchStashes('actor', actorId, sessionUser); +} + +export async function stashScene(sceneId, stashId, sessionUser) { + const stash = await fetchStash(stashId, sessionUser); + + await knex('stashes_scenes') + .insert({ + stash_id: stash.id, + scene_id: sceneId, + }); + + return fetchStashes('scene', sceneId, sessionUser); +} + +export async function stashMovie(movieId, stashId, sessionUser) { + const stash = await fetchStash(stashId, sessionUser); + + await knex('stashes_movies') + .insert({ + stash_id: stash.id, + movie_id: movieId, + }); + + return fetchStashes('movie', movieId, 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(); + + refreshActorsView(); + + return fetchStashes('actor', actorId, 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(); + + return fetchStashes('scene', sceneId, 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(); + + return fetchStashes('movie', movieId, sessionUser); +} diff --git a/src/users.js b/src/users.js index 1651356..ecdcd56 100755 --- a/src/users.js +++ b/src/users.js @@ -1,12 +1,13 @@ -import knex from './knex.js'; -// import { curateStash } from './stashes.js'; +import { knexOwner as knex } from './knex.js'; +import { curateStash } from './stashes.js'; +import { HttpError } from './errors.js'; -export function curateUser(user) { +export function curateUser(user, assets = {}) { if (!user) { return null; } - const ability = [...(user.role_abilities || []), ...(user.abilities || [])]; + const curatedStashes = assets.stashes?.filter(Boolean).map((stash) => curateStash(stash)) || []; const curatedUser = { id: user.id, @@ -14,39 +15,48 @@ export function curateUser(user) { email: user.email, emailVerified: user.email_verified, identityVerified: user.identity_verified, - ability, avatar: `/media/avatars/${user.id}_${user.username}.png`, createdAt: user.created_at, - // stashes: user.stashes?.filter(Boolean).map((stash) => curateStash(stash)) || [], + stashes: curatedStashes, + primaryStash: curatedStashes.find((stash) => stash.primary), }; return curatedUser; } +function whereUser(builder, userId, options = {}) { + if (typeof userId === 'number') { + builder.where('users.id', userId); + } + + if (typeof userId === 'string') { + builder.where(knex.raw('lower(users.username)'), userId.toLowerCase()); + + if (options.email) { + builder.orWhere(knex.raw('lower(users.email)'), userId.toLowerCase()); + } + } +} + export async function fetchUser(userId, options = {}) { const user = await knex('users') - .select(knex.raw('users.*, users_roles.abilities as role_abilities, COALESCE(json_agg(stashes ORDER BY stashes.created_at) FILTER (WHERE stashes.id IS NOT NULL), \'[]\') as stashes')) - .modify((builder) => { - if (typeof userId === 'number') { - builder.where('users.id', userId); - } - - if (typeof userId === 'string') { - builder.where(knex.raw('lower(users.username)'), userId.toLowerCase()); - - if (options.email) { - builder.orWhere(knex.raw('lower(users.email)'), userId.toLowerCase()); - } - } - }) + .select(knex.raw('users.*, users_roles.abilities as role_abilities')) + .modify((builder) => whereUser(builder, userId, options)) .leftJoin('users_roles', 'users_roles.role', 'users.role') - .leftJoin('stashes', 'stashes.user_id', 'users.id') .groupBy('users.id', 'users_roles.role') .first(); + if (!user) { + throw HttpError(`User '${userId}' not found`, 404); + } + if (options.raw) { return user; } - return curateUser(user); + const stashes = await knex('stashes') + .where('user_id', user.id) + .leftJoin('stashes_meta', 'stashes_meta.stash_id', 'stashes.id'); + + return curateUser(user, { stashes }); } diff --git a/src/utils/slugify.js b/src/utils/slugify.js new file mode 100755 index 0000000..1b28d5e --- /dev/null +++ b/src/utils/slugify.js @@ -0,0 +1,75 @@ +const substitutes = { + à: 'a', + á: 'a', + ä: 'a', + å: 'a', + ã: 'a', + æ: 'ae', + ç: 'c', + è: 'e', + é: 'e', + ë: 'e', + ẽ: 'e', + ì: 'i', + í: 'i', + ï: 'i', + ĩ: 'i', + ǹ: 'n', + ń: 'n', + ñ: 'n', + ò: 'o', + ó: 'o', + ö: 'o', + õ: 'o', + ø: 'o', + œ: 'oe', + ß: 'ss', + ù: 'u', + ú: 'u', + ü: 'u', + ũ: 'u', + ỳ: 'y', + ý: 'y', + ÿ: 'y', + ỹ: 'y', +}; + +export default function slugify(strings, delimiter = '-', { + encode = false, + removeAccents = true, + removePunctuation = false, + limit = 1000, +} = {}) { + if (!strings || (typeof strings !== 'string' && !Array.isArray(strings))) { + return strings; + } + + const slugComponents = [] + .concat(strings) + .filter(Boolean) + .flatMap((string) => string + .trim() + .toLowerCase() + .replace(removePunctuation && /[.,:;'"_-]/g, '') + .match(/[A-Za-zÀ-ÖØ-öø-ÿ0-9]+/g)); + + if (!slugComponents) { + return ''; + } + + const slug = slugComponents.reduce((acc, component, index) => { + const accSlug = `${acc}${index > 0 ? delimiter : ''}${component}`; + + if (accSlug.length < limit) { + if (removeAccents) { + return accSlug.replace(/[à-ÿ]/g, (match) => substitutes[match] || ''); + } + + return accSlug; + } + + return acc; + }, ''); + + return encode ? encodeURI(slug) : slug; +} diff --git a/src/web/auth.js b/src/web/auth.js index c38554f..b3dd317 100755 --- a/src/web/auth.js +++ b/src/web/auth.js @@ -1,6 +1,5 @@ /* eslint-disable no-param-reassign */ import { login, signup } from '../auth.js'; -import { fetchUser } from '../users.js'; export async function setUserApi(req, res, next) { if (req.session.user) { @@ -27,17 +26,6 @@ export async function logoutApi(req, res) { }); } -export async function fetchMeApi(req, res) { - if (req.session.user) { - req.session.user = await fetchUser(req.session.user.id, false, req.session.user); - - res.send(req.session.user); - return; - } - - res.status(401).send(); -} - export async function signupApi(req, res) { const user = await signup(req.body); diff --git a/src/web/server.js b/src/web/server.js index cb93847..eac5b1c 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -37,6 +37,18 @@ import { signupApi, } from './auth.js'; +import { + createStashApi, + removeStashApi, + stashActorApi, + stashSceneApi, + stashMovieApi, + unstashActorApi, + unstashSceneApi, + unstashMovieApi, + updateStashApi, +} from './stashes.js'; + import initLogger from '../logger.js'; const logger = initLogger(); @@ -97,6 +109,19 @@ export default async function initServer() { // USERS router.post('/api/users', signupApi); + // STASHES + router.post('/api/stashes', createStashApi); + router.patch('/api/stashes/:stashId', updateStashApi); + router.delete('/api/stashes/:stashId', removeStashApi); + + router.post('/api/stashes/:stashId/actors', stashActorApi); + router.post('/api/stashes/:stashId/scenes', stashSceneApi); + router.post('/api/stashes/:stashId/movies', stashMovieApi); + + router.delete('/api/stashes/:stashId/actors/:actorId', unstashActorApi); + router.delete('/api/stashes/:stashId/scenes/:sceneId', unstashSceneApi); + router.delete('/api/stashes/:stashId/movies/:movieId', unstashMovieApi); + // SCENES router.get('/api/scenes', fetchScenesApi); diff --git a/src/web/stashes.js b/src/web/stashes.js new file mode 100755 index 0000000..3a0e967 --- /dev/null +++ b/src/web/stashes.js @@ -0,0 +1,65 @@ +import { + createStash, + removeStash, + stashActor, + stashScene, + stashMovie, + unstashActor, + unstashScene, + unstashMovie, + updateStash, +} from '../stashes.js'; + +export async function createStashApi(req, res) { + const stash = await createStash(req.body, req.session.user); + + res.send(stash); +} + +export async function updateStashApi(req, res) { + const stash = await updateStash(req.params.stashId, req.body, req.session.user); + + res.send(stash); +} + +export async function removeStashApi(req, res) { + await removeStash(req.params.stashId, req.session.user); + + res.status(204).send(); +} + +export async function stashActorApi(req, res) { + const stashes = await stashActor(req.body.actorId, req.params.stashId, req.user); + + res.send(stashes); +} + +export async function stashSceneApi(req, res) { + const stashes = await stashScene(req.body.sceneId, req.params.stashId, req.user); + + res.send(stashes); +} + +export async function stashMovieApi(req, res) { + const stashes = await stashMovie(req.body.movieId, req.params.stashId, req.user); + + res.send(stashes); +} + +export async function unstashActorApi(req, res) { + const stashes = await unstashActor(req.params.actorId, req.params.stashId, req.user); + + res.send(stashes); +} + +export async function unstashSceneApi(req, res) { + const stashes = await unstashScene(req.params.sceneId, req.params.stashId, req.user); + + res.send(stashes); +} + +export async function unstashMovieApi(req, res) { + const stashes = await unstashMovie(req.params.movieId, req.params.stashId, req.user); + + res.send(stashes); +}