Added functional stash button on scene tiles.

This commit is contained in:
DebaucheryLibrarian 2024-03-03 02:33:35 +01:00
parent 082d4fc154
commit f56e22230b
13 changed files with 657 additions and 73 deletions

View File

@ -78,6 +78,7 @@
<VDropdown <VDropdown
v-if="user" v-if="user"
:triggers="['click']" :triggers="['click']"
:prevent-overflow="true"
> >
<div class="userpanel"> <div class="userpanel">
<img <img
@ -90,12 +91,22 @@
<div class="menu"> <div class="menu">
<a <a
:href="`/user/${user.username}`" :href="`/user/${user.username}`"
class="menu-header" class="menu-header ellipsis"
>{{ user.username }}</a> >{{ user.username }}</a>
<ul class="menu-list nolist"> <ul class="menu-list nolist">
<li class="menu-item">
<a
:href="`/user/${user.username}`"
class="menu-button nolink"
>
<Icon icon="vcard" />
View profile
</a>
</li>
<li <li
class="menu-item logout" class="menu-button menu-item logout"
@click="logout" @click="logout"
><Icon icon="exit2" />Log out</li> ><Icon icon="exit2" />Log out</li>
</ul> </ul>
@ -201,9 +212,9 @@ async function logout() {
height: 2rem; height: 2rem;
display: flex; display: flex;
align-items: center; align-items: center;
border: solid 1px var(--shadow-weak-20);
border-radius: 1rem; border-radius: 1rem;
background: var(--background); background: var(--background-dark-10);
box-shadow: inset 0 0 3px var(--shadow-weak-40);
.input { .input {
padding: .5rem 0 .5rem 1rem; padding: .5rem 0 .5rem 1rem;
@ -225,7 +236,10 @@ async function logout() {
} }
&.focused { &.focused {
/*
border: solid 1px var(--primary-light-10); border: solid 1px var(--primary-light-10);
*/
box-shadow: inset 0 0 3px var(--shadow-weak-30);
.icon { .icon {
fill: var(--primary); fill: var(--primary);
@ -258,25 +272,32 @@ async function logout() {
text-decoration: none; text-decoration: none;
} }
.menu {
overflow: hidden;
}
.menu-header { .menu-header {
display: flex; display: flex;
justify-content: center;
padding: .75rem 1rem; padding: .75rem 1rem;
border-bottom: solid 1px var(--shadow-weak-30); border-bottom: solid 1px var(--shadow-weak-30);
color: var(--shadow-strong-30); color: var(--shadow-strong-30);
text-decoration: none; text-decoration: none;
text-align: center;
font-weight: bold; font-weight: bold;
} }
.menu-item { .menu-item {
display: block;
}
.menu-button {
display: flex; display: flex;
align-items: center; align-items: center;
padding: .5rem; padding: .5rem .5rem .5rem .75rem;
.icon { .icon {
fill: var(--shadow); fill: var(--shadow);
margin-right: .5rem; margin-right: .75rem;
transform: translateY(-1px);
} }
&:hover { &:hover {
@ -286,8 +307,6 @@ async function logout() {
} }
.logout { .logout {
color: var(--error);
.icon { .icon {
fill: var(--error); fill: var(--error);
} }

View File

@ -1,18 +1,34 @@
<template> <template>
<div class="tile"> <div class="tile">
<Link <div class="poster-container">
:href="`/scene/${scene.id}/${scene.slug}`" <Link
target="_blank" :href="`/scene/${scene.id}/${scene.slug}`"
class="poster" target="_blank"
> class="poster"
<img
v-if="scene.poster"
:src="scene.poster.isS3 ? `https://cdndev.traxxx.me/${scene.poster.thumbnail}` : `/media/${scene.poster.thumbnail}`"
:style="{ 'background-image': scene.poster.isS3 ? `url(https://cdndev.traxxx.me/${scene.poster.lazy})` : `url(/media/${scene.poster.lazy})` }"
loading="lazy"
class="thumbnail"
> >
</Link> <img
v-if="scene.poster"
:src="scene.poster.isS3 ? `https://cdndev.traxxx.me/${scene.poster.thumbnail}` : `/media/${scene.poster.thumbnail}`"
:style="{ 'background-image': scene.poster.isS3 ? `url(https://cdndev.traxxx.me/${scene.poster.lazy})` : `url(/media/${scene.poster.lazy})` }"
loading="lazy"
class="thumbnail"
>
</Link>
<Icon
v-show="favorited"
icon="heart7"
class="heart favorited"
@click.native.stop="unstash"
/>
<Icon
v-show="!favorited"
icon="heart8"
class="heart"
@click.native.stop="stash"
/>
</div>
<div class="meta"> <div class="meta">
<div class="channel"> <div class="channel">
@ -80,14 +96,42 @@
</template> </template>
<script setup> <script setup>
import { ref, inject } from 'vue';
import { format } from 'date-fns'; import { format } from 'date-fns';
defineProps({ import { post, del } from '#/src/api.js';
import Icon from '../icon/icon.vue';
const props = defineProps({
scene: { scene: {
type: Object, type: Object,
default: null, default: null,
}, },
}); });
const pageContext = inject('pageContext');
const user = pageContext.user;
const favorited = ref(props.scene.stashes.some((sceneStash) => sceneStash.primary));
async function stash() {
try {
favorited.value = true;
await post(`/stashes/${user.primaryStash.id}/scenes`, { sceneId: props.scene.id });
} catch (error) {
favorited.value = false;
}
}
async function unstash() {
try {
favorited.value = false;
await del(`/stashes/${user.primaryStash.id}/scenes/${props.scene.id}`);
} catch (error) {
favorited.value = true;
}
}
</script> </script>
<style scoped> <style scoped>
@ -100,9 +144,17 @@ defineProps({
&:hover { &:hover {
box-shadow: 0 0 3px var(--shadow-weak-20); box-shadow: 0 0 3px var(--shadow-weak-20);
.heart {
fill: var(--text-light);
}
} }
} }
.poster-container {
position: relative;
}
.poster { .poster {
display: block; display: block;
height: 14rem; height: 14rem;
@ -118,6 +170,26 @@ defineProps({
background-position: center; background-position: center;
} }
.icon.heart {
width: 2rem;
height: 1.5rem;
position: absolute;
top: 0;
right: 0;
padding: .5rem .5rem 1rem 1rem;
fill: var(--highlight-strong-10);
filter: drop-shadow(0 0 3px var(--shadow));
&:hover {
cursor: pointer;
fill: var(--primary);
}
&.favorited {
fill: var(--primary);
}
}
.meta { .meta {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;

View File

@ -7,7 +7,7 @@ export async function onBeforeRender(pageContext) {
page: Number(pageContext.routeParams.page) || 1, page: Number(pageContext.routeParams.page) || 1,
limit: Number(pageContext.urlParsed.search.limit) || 30, limit: Number(pageContext.urlParsed.search.limit) || 30,
aggregate: false, aggregate: false,
}); }, pageContext.user);
return { return {
pageContext: { pageContext: {

View File

@ -9,6 +9,34 @@
<h2 class="username">{{ profile.username }}</h2> <h2 class="username">{{ profile.username }}</h2>
</div> </div>
<ul class="stashes nolist">
<li
v-for="stash in profile.stashes"
:key="`stash-${stash.id}`"
>
<a
:href="`/stash/${profile.username}/${stash.slug}`"
class="stash nolink"
>
<div class="stash-name">
{{ stash.name }}
<Icon
v-if="stash.primary"
icon="heart7"
class="primary"
/>
</div>
<div class="stash-counts">
<div class="stash-count"><Icon icon="clapboard-play" />{{ abbreviateNumber(stash.stashedScenes) }}</div>
<div class="stash-count"><Icon icon="movie" />{{ abbreviateNumber(stash.stashedMovies) }}</div>
<div class="stash-count"><Icon icon="star" />{{ abbreviateNumber(stash.stashedActors) }}</div>
</div>
</a>
</li>
</ul>
</div> </div>
</template> </template>
@ -17,6 +45,12 @@ import { inject } from 'vue';
const pageContext = inject('pageContext'); const pageContext = inject('pageContext');
const profile = pageContext.pageProps.profile; const profile = pageContext.pageProps.profile;
function abbreviateNumber(number) {
return number.toLocaleString('en-US', { notation: 'compact' });
}
console.log(profile.stashes);
</script> </script>
<style scoped> <style scoped>
@ -38,4 +72,49 @@ const profile = pageContext.pageProps.profile;
border-radius: .25rem; border-radius: .25rem;
margin-right: 1rem; margin-right: 1rem;
} }
.stashes {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr));
padding: 1rem;
}
.stash {
width: 100%;
background: var(--background);
box-shadow: 0 0 3px var(--shadow-weak-30);
&:hover {
box-shadow: 0 0 3px var(--shadow-weak-20);
}
}
.stash-name {
display: flex;
justify-content: space-between;
padding: .5rem;
border-bottom: solid 1px var(--shadow-weak-30);
font-weight: bold;
.icon.primary {
fill: var(--primary);
}
}
.stash-counts {
display: flex;
justify-content: space-between;
}
.stash-count {
display: inline-flex;
align-items: center;
padding: .5rem;
font-size: .9rem;
.icon {
margin-right: .5rem;
fill: var(--shadow);
}
}
</style> </style>

View File

@ -5,7 +5,7 @@ import fs from 'fs/promises';
import { createAvatar } from '@dicebear/core'; import { createAvatar } from '@dicebear/core';
import { shapes } from '@dicebear/collection'; import { shapes } from '@dicebear/collection';
import knex from './knex.js'; import { knexOwner as knex } from './knex.js';
import { curateUser, fetchUser } from './users.js'; import { curateUser, fetchUser } from './users.js';
import { HttpError } from './errors.js'; import { HttpError } from './errors.js';
@ -38,7 +38,7 @@ async function generateAvatar(user) {
export async function login(credentials) { export async function login(credentials) {
if (!config.auth.login) { 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(), { const user = await fetchUser(credentials.username.trim(), {
@ -46,6 +46,8 @@ export async function login(credentials) {
raw: true, raw: true,
}); });
console.log('login user', user);
if (!user) { if (!user) {
throw new HttpError('Username or password incorrect', 401); throw new HttpError('Username or password incorrect', 401);
} }
@ -60,12 +62,13 @@ export async function login(credentials) {
await generateAvatar(user); await generateAvatar(user);
} }
// fetched the raw user for password verification, don't return directly to user
return curateUser(user); return curateUser(user);
} }
export async function signup(credentials) { export async function signup(credentials) {
if (!config.auth.signup) { 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(); const curatedUsername = credentials.username.trim();

View File

@ -1,7 +1,16 @@
import config from 'config'; import config from 'config';
import knex from 'knex'; 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', client: 'pg',
connection: config.database.owner, connection: config.database.owner,
pool: config.database.pool, pool: config.database.pool,
@ -9,3 +18,5 @@ export default knex({
asyncStackTraces: process.env.NODE_ENV === 'development', asyncStackTraces: process.env.NODE_ENV === 'development',
// debug: process.env.NODE_ENV === 'development', // debug: process.env.NODE_ENV === 'development',
}); });
export default knexQuery;

View File

@ -1,11 +1,12 @@
import config from 'config'; import config from 'config';
import knex from './knex.js'; import { knexOwner as knex } from './knex.js';
import { searchApi } from './manticore.js'; import { searchApi } from './manticore.js';
import { HttpError } from './errors.js'; import { HttpError } from './errors.js';
import { fetchActorsById, curateActor, sortActorsByGender } from './actors.js'; import { fetchActorsById, curateActor, sortActorsByGender } from './actors.js';
import { fetchTagsById } from './tags.js'; import { fetchTagsById } from './tags.js';
import { fetchEntitiesById } from './entities.js'; import { fetchEntitiesById } from './entities.js';
import { curateStash } from './stashes.js';
function curateMedia(media) { function curateMedia(media) {
if (!media) { if (!media) {
@ -66,14 +67,15 @@ function curateScene(rawScene, assets) {
})), })),
poster: curateMedia(assets.poster), poster: curateMedia(assets.poster),
photos: assets.photos.map((photo) => curateMedia(photo)), photos: assets.photos.map((photo) => curateMedia(photo)),
stashes: assets.stashes?.map((stash) => curateStash(stash)) || [],
createdBatchId: rawScene.created_batch_id, createdBatchId: rawScene.created_batch_id,
updatedBatchId: rawScene.updated_batch_id, updatedBatchId: rawScene.updated_batch_id,
}; };
} }
export async function fetchScenesById(sceneIds) { export async function fetchScenesById(sceneIds, reqUser) {
const [scenes, channels, actors, directors, tags, posters, photos] = await Promise.all([ const [scenes, channels, actors, directors, tags, posters, photos, stashes] = await Promise.all([
knex('releases').whereIn('id', sceneIds), knex('releases').whereIn('releases.id', sceneIds),
knex('releases') knex('releases')
.select('channels.*', 'networks.id as network_id', 'networks.slug as network_slug', 'networks.name as network_name', 'networks.type as network_type') .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) .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'), 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', '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 birth_countries', 'birth_countries.alpha2', 'actors_meta.birth_country_alpha2')
.leftJoin('countries as residence_countries', 'residence_countries.alpha2', 'actors_meta.residence_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'), .leftJoin('actors as directors', 'directors.id', 'releases_directors.director_id'),
knex('releases_tags') knex('releases_tags')
.select('id', 'slug', 'name', 'release_id') .select('id', 'slug', 'name', 'release_id')
.leftJoin('tags', 'tags.id', 'releases_tags.tag_id')
.whereNotNull('tags.id') .whereNotNull('tags.id')
.whereIn('release_id', sceneIds) .whereIn('release_id', sceneIds)
.leftJoin('tags', 'tags.id', 'releases_tags.tag_id')
.orderBy('priority', 'desc'), .orderBy('priority', 'desc'),
knex('releases_posters') knex('releases_posters')
.whereIn('release_id', sceneIds) .whereIn('release_id', sceneIds)
@ -114,6 +116,12 @@ export async function fetchScenesById(sceneIds) {
knex('releases_photos') knex('releases_photos')
.whereIn('release_id', sceneIds) .whereIn('release_id', sceneIds)
.leftJoin('media', 'media.id', 'releases_photos.media_id'), .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) => { return sceneIds.map((sceneId) => {
@ -129,6 +137,7 @@ export async function fetchScenesById(sceneIds) {
const sceneTags = tags.filter((tag) => tag.release_id === sceneId); const sceneTags = tags.filter((tag) => tag.release_id === sceneId);
const scenePoster = posters.find((poster) => poster.release_id === sceneId); const scenePoster = posters.find((poster) => poster.release_id === sceneId);
const scenePhotos = photos.filter((photo) => photo.release_id === sceneId); const scenePhotos = photos.filter((photo) => photo.release_id === sceneId);
const sceneStashes = stashes.filter((stash) => stash.scene_id === sceneId);
return curateScene(scene, { return curateScene(scene, {
channel: sceneChannel, channel: sceneChannel,
@ -137,6 +146,7 @@ export async function fetchScenesById(sceneIds) {
tags: sceneTags, tags: sceneTags,
poster: scenePoster, poster: scenePoster,
photos: scenePhotos, photos: scenePhotos,
stashes: sceneStashes,
}); });
}).filter(Boolean); }).filter(Boolean);
} }
@ -294,7 +304,7 @@ function countAggregations(buckets) {
return Object.fromEntries(buckets.map((bucket) => [bucket.key, { count: bucket.doc_count }])); 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 options = curateOptions(rawOptions);
const { query, sort } = buildQuery(filters); const { query, sort } = buildQuery(filters);
@ -302,6 +312,8 @@ export async function fetchScenes(filters, rawOptions) {
console.log('options', options); console.log('options', options);
console.log('query', query.bool.must); console.log('query', query.bool.must);
console.log('request user', reqUser);
console.time('manticore'); console.time('manticore');
const result = await searchApi.search({ const result = await searchApi.search({
@ -329,8 +341,6 @@ export async function fetchScenes(filters, rawOptions) {
console.timeEnd('manticore'); console.timeEnd('manticore');
console.log('hits', result.hits.hits.length);
const actorCounts = options.aggregateActors && countAggregations(result.aggregations?.actorIds?.buckets); const actorCounts = options.aggregateActors && countAggregations(result.aggregations?.actorIds?.buckets);
const tagCounts = options.aggregateTags && countAggregations(result.aggregations?.tagIds?.buckets); const tagCounts = options.aggregateTags && countAggregations(result.aggregations?.tagIds?.buckets);
const channelCounts = options.aggregateChannels && countAggregations(result.aggregations?.channelIds?.buckets); const channelCounts = options.aggregateChannels && countAggregations(result.aggregations?.channelIds?.buckets);
@ -346,7 +356,7 @@ export async function fetchScenes(filters, rawOptions) {
console.timeEnd('fetch aggregations'); console.timeEnd('fetch aggregations');
const sceneIds = result.hits.hits.map((hit) => Number(hit._id)); const sceneIds = result.hits.hits.map((hit) => Number(hit._id));
const scenes = await fetchScenesById(sceneIds); const scenes = await fetchScenesById(sceneIds, reqUser);
return { return {
scenes, scenes,

227
src/stashes.js Executable file
View File

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

View File

@ -1,12 +1,13 @@
import knex from './knex.js'; import { knexOwner as knex } from './knex.js';
// import { curateStash } from './stashes.js'; import { curateStash } from './stashes.js';
import { HttpError } from './errors.js';
export function curateUser(user) { export function curateUser(user, assets = {}) {
if (!user) { if (!user) {
return null; return null;
} }
const ability = [...(user.role_abilities || []), ...(user.abilities || [])]; const curatedStashes = assets.stashes?.filter(Boolean).map((stash) => curateStash(stash)) || [];
const curatedUser = { const curatedUser = {
id: user.id, id: user.id,
@ -14,39 +15,48 @@ export function curateUser(user) {
email: user.email, email: user.email,
emailVerified: user.email_verified, emailVerified: user.email_verified,
identityVerified: user.identity_verified, identityVerified: user.identity_verified,
ability,
avatar: `/media/avatars/${user.id}_${user.username}.png`, avatar: `/media/avatars/${user.id}_${user.username}.png`,
createdAt: user.created_at, createdAt: user.created_at,
// stashes: user.stashes?.filter(Boolean).map((stash) => curateStash(stash)) || [], stashes: curatedStashes,
primaryStash: curatedStashes.find((stash) => stash.primary),
}; };
return curatedUser; 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 = {}) { export async function fetchUser(userId, options = {}) {
const user = await knex('users') 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')) .select(knex.raw('users.*, users_roles.abilities as role_abilities'))
.modify((builder) => { .modify((builder) => 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());
}
}
})
.leftJoin('users_roles', 'users_roles.role', 'users.role') .leftJoin('users_roles', 'users_roles.role', 'users.role')
.leftJoin('stashes', 'stashes.user_id', 'users.id')
.groupBy('users.id', 'users_roles.role') .groupBy('users.id', 'users_roles.role')
.first(); .first();
if (!user) {
throw HttpError(`User '${userId}' not found`, 404);
}
if (options.raw) { if (options.raw) {
return user; 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 });
} }

75
src/utils/slugify.js Executable file
View File

@ -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;
}

View File

@ -1,6 +1,5 @@
/* eslint-disable no-param-reassign */ /* eslint-disable no-param-reassign */
import { login, signup } from '../auth.js'; import { login, signup } from '../auth.js';
import { fetchUser } from '../users.js';
export async function setUserApi(req, res, next) { export async function setUserApi(req, res, next) {
if (req.session.user) { 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) { export async function signupApi(req, res) {
const user = await signup(req.body); const user = await signup(req.body);

View File

@ -37,6 +37,18 @@ import {
signupApi, signupApi,
} from './auth.js'; } from './auth.js';
import {
createStashApi,
removeStashApi,
stashActorApi,
stashSceneApi,
stashMovieApi,
unstashActorApi,
unstashSceneApi,
unstashMovieApi,
updateStashApi,
} from './stashes.js';
import initLogger from '../logger.js'; import initLogger from '../logger.js';
const logger = initLogger(); const logger = initLogger();
@ -97,6 +109,19 @@ export default async function initServer() {
// USERS // USERS
router.post('/api/users', signupApi); 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 // SCENES
router.get('/api/scenes', fetchScenesApi); router.get('/api/scenes', fetchScenesApi);

65
src/web/stashes.js Executable file
View File

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