Added stashes with experimental row security policies. Added tag photos.

This commit is contained in:
DebaucheryLibrarian 2021-03-14 04:54:43 +01:00
parent 816529b0ca
commit e371e9725a
58 changed files with 610 additions and 172 deletions

View File

@ -49,7 +49,7 @@ async function login() {
this.success = false;
try {
await this.$store.dispatch('login', {
const user = await this.$store.dispatch('login', {
username: this.username,
password: this.password,
});
@ -57,7 +57,7 @@ async function login() {
this.success = true;
setTimeout(() => {
this.$router.replace(this.$route.query.ref || { name: 'home' });
this.$router.replace(this.$route.query.ref || { name: 'user', params: { username: user.username } });
}, 1000);
} catch (error) {
this.error = error.message;

View File

@ -93,82 +93,7 @@
</div>
<template v-slot:tooltip>
<div class="menu">
<ul class="menu-items noselect">
<router-link
v-if="!me"
to="/login"
class="menu-item"
@click.stop
>
<Icon icon="enter2" />Log in
</router-link>
<li
v-if="me"
class="menu-username"
>{{ me.username }}</li>
<li
v-if="me"
class="menu-item"
@click.stop="$store.dispatch('logout')"
>
<Icon icon="enter2" />Log out
</li>
<li
v-show="!sfw"
class="menu-item"
@click.stop="setSfw(true)"
>
<Icon
icon="flower"
class="toggle noselect"
/>Safe mode
</li>
<li
v-show="sfw"
class="menu-item"
@click.stop="setSfw(false)"
>
<Icon
icon="fire"
class="toggle noselect"
/>Filth mode
</li>
<li
v-show="theme === 'light'"
class="menu-item"
@click.stop="setTheme('dark')"
>
<Icon
icon="moon"
class="toggle noselect"
/>Dark theme
</li>
<li
v-show="theme === 'dark'"
class="menu-item"
@click.stop="setTheme('light')"
>
<Icon
icon="sun"
class="toggle noselect"
/>Light theme
</li>
<li
class="menu-item"
@click="$emit('showFilters', true)"
>
<Icon icon="filter" />Filters
</li>
</ul>
</div>
<Menu />
</template>
</Tooltip>
@ -201,34 +126,14 @@
</template>
<script>
import { mapState } from 'vuex';
import Menu from './menu.vue';
import Search from './search.vue';
import logo from '../../img/logo.svg';
function sfw(state) {
return state.ui.sfw;
}
function theme(state) {
return state.ui.theme;
}
function me(state) {
return state.auth.user;
}
function setTheme(newTheme) {
this.$store.dispatch('setTheme', newTheme);
}
function setSfw(enabled) {
this.$store.dispatch('setSfw', enabled);
}
export default {
components: {
Menu,
Search,
},
emits: ['toggleSidebar', 'showFilters'],
@ -239,17 +144,6 @@ export default {
showFilters: false,
};
},
computed: {
...mapState({
sfw,
theme,
me,
}),
},
methods: {
setSfw,
setTheme,
},
};
</script>
@ -414,51 +308,6 @@ export default {
}
}
.menu-items {
list-style: none;
padding: 0;
margin: 0;
}
.menu-item {
display: flex;
padding: .75rem 1rem .75rem .75rem;
color: inherit;
text-decoration: none;
.icon {
fill: var(--darken);
margin: 0 1rem 0 0;
}
&.disabled {
color: var(--darken-weak);
cursor: default;
.icon {
fill: var(--darken-weak);
}
}
&:hover:not(.disabled) {
cursor: pointer;
color: var(--primary);
.icon {
fill: var(--primary);
}
}
}
.menu-username {
font-weight: bold;
color: var(--shadow-strong);
font-size: .9rem;
padding: .75rem 1rem;
border-bottom: solid 1px var(--shadow-hint);
text-align: center;
}
.search-compact {
display: none;
height: 100%;

View File

@ -0,0 +1,168 @@
<template>
<div class="menu">
<ul class="menu-items noselect">
<router-link
v-if="me"
:to="{ name: 'user', params: { username: me.username } }"
class="menu-username"
>{{ me.username }}</router-link>
<router-link
v-else
to="/login"
class="menu-item"
@click.stop
>
<Icon icon="enter2" />Log in
</router-link>
<li
v-if="me"
class="menu-item"
@click.stop="$store.dispatch('logout')"
>
<Icon icon="enter2" />Log out
</li>
<li
v-show="!sfw"
class="menu-item"
@click.stop="setSfw(true)"
>
<Icon
icon="flower"
class="toggle noselect"
/>Safe mode
</li>
<li
v-show="sfw"
class="menu-item"
@click.stop="setSfw(false)"
>
<Icon
icon="fire"
class="toggle noselect"
/>Filth mode
</li>
<li
v-show="theme === 'light'"
class="menu-item"
@click.stop="setTheme('dark')"
>
<Icon
icon="moon"
class="toggle noselect"
/>Dark theme
</li>
<li
v-show="theme === 'dark'"
class="menu-item"
@click.stop="setTheme('light')"
>
<Icon
icon="sun"
class="toggle noselect"
/>Light theme
</li>
<li
class="menu-item"
@click="$emit('showFilters', true)"
>
<Icon icon="filter" />Filters
</li>
</ul>
</div>
</template>
<script>
import { mapState } from 'vuex';
function sfw(state) {
return state.ui.sfw;
}
function theme(state) {
return state.ui.theme;
}
function me(state) {
return state.auth.user;
}
function setTheme(newTheme) {
this.$store.dispatch('setTheme', newTheme);
}
function setSfw(enabled) {
this.$store.dispatch('setSfw', enabled);
}
export default {
computed: {
...mapState({
sfw,
theme,
me,
}),
},
methods: {
setSfw,
setTheme,
},
};
</script>
<style lang="scss" scoped>
@import 'breakpoints';
.menu-items {
list-style: none;
padding: 0;
margin: 0;
}
.menu-item {
display: flex;
padding: .75rem 1rem .75rem .75rem;
color: inherit;
text-decoration: none;
.icon {
fill: var(--darken);
margin: 0 1rem 0 0;
}
&.disabled {
color: var(--darken-weak);
cursor: default;
.icon {
fill: var(--darken-weak);
}
}
&:hover:not(.disabled) {
cursor: pointer;
color: var(--primary);
.icon {
fill: var(--primary);
}
}
}
.menu-username {
display: block;
font-weight: bold;
color: var(--shadow-strong);
font-size: .9rem;
padding: .75rem 1rem;
border-bottom: solid 1px var(--shadow-hint);
text-align: center;
text-decoration: none;
}
</style>

View File

@ -73,7 +73,10 @@
>{{ release.entity.name }}</h3>
</a>
<span class="row">
<span
v-if="release.actors?.length > 0"
class="row"
>
<ul
class="actors nolist"
:title="release.actors.map(actor => actor.name).join(', ')"
@ -106,7 +109,7 @@
>{{ release.shootId }}</span>
<ul
v-if="release.tags.length > 0"
v-if="release.tags?.length > 0"
:title="release.tags.map(tag => tag.name).join(', ')"
class="tags nolist"
>

View File

@ -4,7 +4,7 @@
:class="{ new: release.isNew }"
>
<span
v-if="release.entity.type !== 'network' && !release.entity.independent && release.entity.parent"
v-if="release.entity && release.entity.type !== 'network' && !release.entity.independent && release.entity.parent"
class="site"
>
<router-link
@ -33,7 +33,7 @@
</span>
<router-link
v-else
v-else-if="release.entity"
:to="`/${release.entity.type}/${release.entity.slug}`"
class="site site-link"
>

View File

@ -0,0 +1,113 @@
<template>
<div
v-if="user"
class="user"
>
<div class="header">
<h2 class="username">{{ user.username }}</h2>
</div>
<section
v-if="stashes.length > 0"
class="section"
>
<h3 class="heading">Stashes</h3>
<ul class="stashes nolist">
<li
v-for="stash in stashes"
:key="stash.id"
>
<h4 class="stash-name">{{ stash.name }}</h4>
<ul class="stash nolist actors">
<li
v-for="item in stash.actors"
:key="item.id"
><Actor :actor="item.actor" /></li>
</ul>
<ul class="stash nolist scenes">
<li
v-for="item in stash.scenes"
:key="item.id"
><Scene :release="item.scene" /></li>
</ul>
</li>
</ul>
</section>
</div>
</template>
<script>
import Actor from '../actors/tile.vue';
import Scene from '../releases/scene-tile.vue';
async function mounted() {
this.user = await this.$store.dispatch('fetchMe');
this.stashes = await this.$store.dispatch('fetchUserStashes', this.user.id);
}
export default {
components: {
Actor,
Scene,
},
data() {
return {
user: this.$route.params.username === this.$store.state.auth.user?.username
? this.$store.state.auth.user
: null,
stashes: [],
};
},
mounted,
};
</script>
<style lang="scss" scoped>
.header {
padding: 1rem;
background: var(--profile);
}
.username {
margin: 0;
font-size: 1.5rem;
color: var(--text-light);
}
.section {
padding: 1rem;
margin: 0 0 1rem 0;
}
.heading {
color: var(--primary);
}
.stash {
margin: 0 0 1rem 0;
}
.stash-name {
color: var(--shadow-strong);
margin: 0 0 1rem 0;
}
.actors {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
grid-gap: .5rem;
flex-grow: 1;
flex-wrap: wrap;
}
.scenes {
width: 100%;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(22rem, 1fr));
grid-gap: .5rem;
box-sizing: border-box;
}
</style>

View File

@ -5,10 +5,14 @@ $breakpoint3: 1200px;
$breakpoint4: 1500px;
:root {
/* --primary: #ff886c; */
/*
--primary: #ff6c88;
--primary-strong: #ff4166;
--primary-faded: #ffdfee;
*/
--primary: #f28;
--primary-strong: #f90071;
--primary-faded: #ff4e9f;
--text-dark: #222;
--text-light: #fff;

View File

@ -125,9 +125,32 @@ function curateTag(tag) {
return curatedTag;
}
function curateStash(stash) {
const curatedStash = {
...stash,
};
if (stash.scenes) {
curatedStash.scenes = stash.scenes.map(item => ({
...item,
scene: curateRelease(item.scene),
}));
}
if (stash.actors) {
curatedStash.actors = stash.actors.map(item => ({
...item,
actor: curateActor(item.actor),
}));
}
return curatedStash;
}
export {
curateActor,
curateEntity,
curateRelease,
curateTag,
curateStash,
};

View File

@ -3,6 +3,7 @@ import { createRouter, createWebHistory } from 'vue-router';
import Home from '../components/home/home.vue';
import Login from '../components/auth/login.vue';
import Signup from '../components/auth/signup.vue';
import User from '../components/users/user.vue';
import Release from '../components/releases/release.vue';
import Entity from '../components/entities/entity.vue';
import Networks from '../components/networks/networks.vue';
@ -38,6 +39,11 @@ const routes = [
name: 'singup',
component: Signup,
},
{
path: '/user/:username',
name: 'user',
component: User,
},
{
path: '/updates',
redirect: {

View File

@ -0,0 +1,102 @@
import { graphql } from '../api';
import { curateStash } from '../curate';
function initStashesActions(_store, _router) {
async function fetchUserStashes(context, userId) {
const { stashes } = await graphql(`
query Stashes(
$userId: Int!
) {
stashes(
filter: {
userId: {
equalTo: $userId
}
}
) {
id
name
actors: stashesActors {
comment
actor {
id
name
slug
gender
age
ageFromBirth
dateOfBirth
birthCity
birthState
birthCountry: countryByBirthCountryAlpha2 {
alpha2
name
alias
}
avatar: avatarMedia {
id
path
thumbnail
lazy
}
}
}
scenes: stashesScenes {
comment
scene {
id
title
slug
url
date
actors: releasesActors {
actor {
id
name
slug
}
}
tags: releasesTags {
tag {
id
name
slug
}
}
entity {
id
name
slug
independent
parent {
id
name
slug
independent
}
}
poster: releasesPosterByReleaseId {
media {
path
thumbnail
lazy
isS3
}
}
}
}
}
}
`, {
userId,
});
return stashes.map(stash => curateStash(stash));
}
return {
fetchUserStashes,
};
}
export default initStashesActions;

View File

@ -0,0 +1 @@
export default {};

View File

@ -0,0 +1,13 @@
import state from './state';
import mutations from './mutations';
import actions from './actions';
function initStashesStore(store, router) {
return {
state,
mutations,
actions: actions(store, router),
};
}
export default initStashesStore;

View File

@ -0,0 +1 @@
export default {};

View File

@ -2,20 +2,24 @@ import Vuex from 'vuex';
import initUiStore from './ui/ui';
import initAuthStore from './auth/auth';
import initUsersStore from './users/users';
import initReleasesStore from './releases/releases';
import initEntitiesStore from './entities/entities';
import initActorsStore from './actors/actors';
import initTagsStore from './tags/tags';
import initStashesStore from './stashes/stashes';
function initStore(router) {
const store = new Vuex.Store();
store.registerModule('ui', initUiStore(store, router));
store.registerModule('auth', initAuthStore(store, router));
store.registerModule('users', initUsersStore(store, router));
store.registerModule('releases', initReleasesStore(store, router));
store.registerModule('entities', initEntitiesStore(store, router));
store.registerModule('actors', initActorsStore(store, router));
store.registerModule('tags', initTagsStore(store, router));
store.registerModule('stashes', initStashesStore(store, router));
return store;
}

View File

@ -0,0 +1,15 @@
import { get } from '../api';
function initUsersActions(_store, _router) {
async function fetchUser(context, username) {
const user = await get(`/users/${username}`);
return user;
}
return {
fetchUser,
};
}
export default initUsersActions;

View File

@ -0,0 +1 @@
export default {};

1
assets/js/users/state.js Normal file
View File

@ -0,0 +1 @@
export default {};

13
assets/js/users/users.js Normal file
View File

@ -0,0 +1,13 @@
import state from './state';
import mutations from './mutations';
import actions from './actions';
function initUsersStore(store, router) {
return {
state,
mutations,
actions: actions(store, router),
};
}
export default initUsersStore;

View File

@ -1,9 +1,17 @@
module.exports = {
database: {
host: '127.0.0.1',
user: 'user',
password: 'password',
database: 'traxxx',
owner: {
host: '127.0.0.1',
user: 'traxxx',
password: 'password',
database: 'traxxx',
},
query: {
host: '127.0.0.1',
user: 'visitor',
password: 'password',
database: 'traxxx',
},
},
web: {
host: '0.0.0.0',

View File

@ -3,6 +3,6 @@
const config = require('config');
module.exports = {
client: 'pg',
connection: config.database,
client: 'pg',
connection: config.database.owner,
};

View File

@ -1,3 +1,5 @@
const config = require('config');
exports.up = knex => Promise.resolve()
.then(() => knex.schema.createTable('countries', (table) => {
table.text('alpha2', 2)
@ -1047,12 +1049,58 @@ exports.up = knex => Promise.resolve()
.notNullable()
.defaultTo(knex.fn.now());
}))
.then(() => knex.schema.createTable('stashes', (table) => {
table.increments('id');
table.integer('user_id')
.references('id')
.inTable('users');
table.string('name')
.notNullable();
table.string('slug')
.notNullable();
table.boolean('public')
.notNullable()
.defaultTo(false);
table.datetime('created_at')
.notNullable()
.defaultTo(knex.fn.now());
}))
.then(() => knex.schema.createTable('stashes_scenes', (table) => {
table.integer('stash_id')
.notNullable()
.references('id')
.inTable('stashes');
table.integer('scene_id')
.notNullable()
.references('id')
.inTable('releases');
table.string('comment');
}))
.then(() => knex.schema.createTable('stashes_actors', (table) => {
table.integer('stash_id')
.notNullable()
.references('id')
.inTable('stashes');
table.integer('actor_id')
.notNullable()
.references('id')
.inTable('actors');
table.string('comment');
}))
// SEARCH
.then(() => { // eslint-disable-line arrow-body-style
// allow vim fold
return knex.raw(`
ALTER TABLE releases_search
ADD COLUMN document tsvector;
ALTER TABLE releases_search ADD COLUMN document tsvector;
`);
})
// INDEXES
@ -1070,6 +1118,10 @@ exports.up = knex => Promise.resolve()
.then(() => { // eslint-disable-line arrow-body-style
// allow vim fold
return knex.raw(`
CREATE FUNCTION current_user_id() RETURNS INTEGER AS $$
SELECT current_setting('user.id', true)::integer;
$$ LANGUAGE SQL STABLE;
/* We need both the release entries and their search ranking, and PostGraphile does not seem to allow virtual foreign keys on function results.
* Using a table as a proxy for the search results allows us to get both a reference to the releases table, and the ranking.
* A composite type does not seem to be compatible with PostGraphile's @sortable, and a view does not allow for many native constraints */
@ -1236,6 +1288,42 @@ exports.up = knex => Promise.resolve()
$$ LANGUAGE sql STABLE;
`);
})
// POLICIES
.then(() => { // eslint-disable-line arrow-body-style
// allow vim fold
return knex.raw(`
GRANT ALL ON ALL TABLES IN SCHEMA public TO :visitor;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO :visitor;
ALTER TABLE stashes ENABLE ROW LEVEL SECURITY;
ALTER TABLE stashes_scenes ENABLE ROW LEVEL SECURITY;
ALTER TABLE stashes_actors ENABLE ROW LEVEL SECURITY;
CREATE POLICY stashes_policy_select ON stashes FOR SELECT USING (stashes.user_id = current_user_id());
CREATE POLICY stashes_policy_update ON stashes FOR UPDATE USING (stashes.user_id = current_user_id());
CREATE POLICY stashes_policy_delete ON stashes FOR DELETE USING (stashes.user_id = current_user_id());
CREATE POLICY stashes_policy_insert ON stashes FOR INSERT WITH CHECK(true);
CREATE POLICY stashes_policy ON stashes_scenes
USING (EXISTS (
SELECT *
FROM stashes
WHERE stashes.id = stashes_scenes.stash_id
AND stashes.user_id = current_user_id()
));
CREATE POLICY stashes_policy ON stashes_actors
USING (EXISTS (
SELECT *
FROM stashes
WHERE stashes.id = stashes_actors.stash_id
AND stashes.user_id = current_user_id()
));
`, {
visitor: knex.raw(config.database.query.user),
password: knex.raw(config.database.query.password),
});
})
// VIEWS AND COMMENTS
.then(() => { // eslint-disable-line arrow-body-style
// allow vim fold
@ -1319,6 +1407,10 @@ exports.down = (knex) => { // eslint-disable-line arrow-body-style
DROP TABLE IF EXISTS entities_types CASCADE;
DROP TABLE IF EXISTS entities CASCADE;
DROP TABLE IF EXISTS stashes_scenes CASCADE;
DROP TABLE IF EXISTS stashes_actors CASCADE;
DROP TABLE IF EXISTS stashes CASCADE;
DROP TABLE IF EXISTS users CASCADE;
DROP TABLE IF EXISTS users_roles CASCADE;
@ -1338,6 +1430,12 @@ exports.down = (knex) => { // eslint-disable-line arrow-body-style
DROP FUNCTION IF EXISTS movies_tags;
DROP FUNCTION IF EXISTS movies_photos;
DROP POLICY IF EXISTS stashes_policy ON stashes;
DROP POLICY IF EXISTS stashes_policy ON stashes_scenes;
DROP POLICY IF EXISTS stashes_policy ON stashes_actors;
DROP FUNCTION IF EXISTS current_user_id;
DROP TABLE IF EXISTS releases_search_results;
`);
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 775 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 781 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 798 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 682 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 692 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1008 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 906 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

@ -71,6 +71,13 @@ async function signup(credentials) {
})
.returning('*');
await knex('stashes').insert({
user_id: user.id,
name: 'Favorites',
slug: 'favorites',
public: false,
});
return curateUser(user);
}

View File

@ -5,7 +5,7 @@ const knex = require('knex');
module.exports = knex({
client: 'pg',
connection: config.database,
connection: config.database.owner,
// performance overhead, don't use asyncStackTraces in production
asyncStackTraces: process.env.NODE_ENV === 'development',
// debug: process.env.NODE_ENV === 'development',

View File

@ -10,7 +10,13 @@ const PgOrderByRelatedPlugin = require('@graphile-contrib/pg-order-by-related');
const { ActorPlugins, SitePlugins, ReleasePlugins } = require('./plugins/plugins');
const connectionString = `postgres://${config.database.user}:${config.database.password}@${config.database.host}:5432/${config.database.database}`;
const connectionString = `postgres://${config.database.query.user}:${config.database.query.password}@${config.database.query.host}:5432/${config.database.query.database}`;
async function pgSettings(req) {
return {
'user.id': req.session.user?.id,
};
}
module.exports = postgraphile(
connectionString,
@ -36,5 +42,6 @@ module.exports = postgraphile(
...SitePlugins,
...ReleasePlugins,
],
pgSettings,
},
);

View File

@ -48,7 +48,6 @@ async function initServer() {
const router = Router();
const store = new KnexSessionStore({ knex });
app.use(pg);
app.set('view engine', 'ejs');
router.use('/media', express.static(config.media.path));
@ -61,6 +60,8 @@ async function initServer() {
router.use(bodyParser.json({ strict: false }));
router.use(session({ ...config.web.session, store }));
router.use(pg);
router.use((req, res, next) => {
req.session.safeId = req.session.safeId || nanoid();