Expanded edit fields. Added revision history to scene and user pages.

This commit is contained in:
2024-10-06 02:45:56 +02:00
parent 8bf9e22b39
commit 8f843f321d
57 changed files with 1664 additions and 156 deletions

View File

@@ -218,7 +218,11 @@ async function queryManticoreSql(filters, options, _reqUser) {
}
if (filters.query) {
builder.whereRaw('match(\'@name :query:\', actors)', { query: escape(filters.query) });
if (filters.query.charAt(0) === '#') {
builder.where('id', Number(escape(filters.query.slice(1))));
} else {
builder.whereRaw('match(\'@name :query:\', actors)', { query: escape(filters.query) });
}
}
// attribute filters

View File

@@ -59,6 +59,7 @@ export async function login(credentials, userIp) {
await knex('users')
.update('last_login', 'NOW()')
.update('last_ip', userIp)
.where('id', user.id);
logger.verbose(`Login from '${user.username}' (${user.id}, ${userIp})`);

View File

@@ -1,11 +1,13 @@
import config from 'config';
import util from 'util'; /* eslint-disable-line no-unused-vars */
import { MerkleJson } from 'merkle-json';
import { knexQuery as knex, knexOwner, knexManticore } from './knex.js';
import { utilsApi } from './manticore.js';
import { HttpError } from './errors.js';
import { fetchActorsById, curateActor, sortActorsByGender } from './actors.js';
import { fetchTagsById } from './tags.js';
import { fetchMoviesById } from './movies.js';
import { fetchEntitiesById } from './entities.js';
import { curateStash } from './stashes.js';
import { curateMedia } from './media.js';
@@ -14,6 +16,7 @@ import promiseProps from '../utils/promise-props.js';
import initLogger from './logger.js';
const logger = initLogger();
const mj = new MerkleJson();
function getWatchUrl(scene) {
if (scene.url) {
@@ -600,59 +603,169 @@ export async function fetchScenes(filters, rawOptions, reqUser) {
};
}
async function applySceneValueDelta(sceneId, delta, trx) {
console.log('value delta', delta);
function curateRevision(revision) {
return {
id: revision.id,
sceneId: revision.scene_id,
base: revision.base,
deltas: revision.deltas,
hash: revision.hash,
comment: revision.comment,
user: revision.user_id && {
id: revision.user_id,
username: revision.username,
},
review: typeof revision.approved === 'boolean' ? {
isApproved: revision.approved,
userId: revision.reviewed_by,
username: revision.reviewer_username,
reviewedAt: revision.reviewed_at,
} : null,
appliedAt: revision.applied_at,
failed: revision.failed,
createdAt: revision.created_at,
};
}
export async function fetchSceneRevisions(revisionId, filters = {}, reqUser) {
const limit = filters.limit || 50;
const page = filters.page || 1;
const revisions = await knexOwner('scenes_revisions')
.select(
'scenes_revisions.*',
'users.username as username',
'reviewers.username as reviewer_username',
)
.leftJoin('users', 'users.id', 'scenes_revisions.user_id')
.leftJoin('users as reviewers', 'reviewers.id', 'scenes_revisions.reviewed_by')
.modify((builder) => {
if (reqUser?.role !== 'admin' && !filters.userId && !filters.sceneId) {
builder.where('user_id', reqUser.id);
}
if (filters.userId) {
if (reqUser?.role !== 'admin' && filters.userId !== reqUser.id) {
throw new HttpError('You are not permitted to view revisions from other users.', 403);
}
builder.where('scenes_revisions.user_id', filters.userId);
}
if (revisionId) {
builder.where('scenes_revisions.id', revisionId);
return;
}
if (filters.sceneId) {
builder.where('scenes_revisions.scene_id', filters.sceneId);
}
console.log(filters);
if (filters.isFinalized === false) {
builder.whereNull('approved');
}
if (filters.isFinalized === true) {
builder.whereNotNull('approved');
}
})
.orderBy('created_at', 'desc')
.limit(limit)
.offset((page - 1) * limit);
const actorIds = Array.from(new Set(revisions.flatMap((revision) => [...revision.base.actors, ...(revision.deltas.find((delta) => delta.key === 'actors')?.value || [])])));
const tagIds = Array.from(new Set(revisions.flatMap((revision) => [...revision.base.tags, ...(revision.deltas.find((delta) => delta.key === 'tags')?.value || [])])));
const movieIds = Array.from(new Set(revisions.flatMap((revision) => [...revision.base.movies, ...(revision.deltas.find((delta) => delta.key === 'movies')?.value || [])])));
const [actors, tags, movies] = await Promise.all([
fetchActorsById(actorIds),
fetchTagsById(tagIds),
fetchMoviesById(movieIds),
]);
const curatedRevisions = revisions.map((revision) => curateRevision(revision));
return {
revisions: curatedRevisions,
revision: revisionId && curatedRevisions.find((revision) => revision.id === revisionId),
actors,
tags,
movies,
};
}
const keyMap = {
productionDate: 'production_date',
};
async function applySceneValueDelta(sceneId, delta, trx) {
return knexOwner('releases')
.where('id', sceneId)
.update(delta.key, delta.value)
.update(keyMap[delta.key] || delta.key, delta.value)
.transacting(trx);
}
async function applySceneActorsDelta(sceneId, delta, trx) {
console.log('actors delta', delta);
await knexOwner('releases_actors')
.where('release_id', sceneId)
.delete()
.transacting(trx);
await knexOwner('releases_actors')
.insert(delta.value.map((actorId) => ({
release_id: sceneId,
actor_id: actorId,
})))
.transacting(trx);
if (delta.value.length > 0) {
await knexOwner('releases_actors')
.insert(delta.value.map((actorId) => ({
release_id: sceneId,
actor_id: actorId,
})))
.transacting(trx);
}
}
async function applySceneTagsDelta(sceneId, delta, trx) {
console.log('tags delta', delta);
// don't remove unidentified tags
await knexOwner('releases_tags')
.where('release_id', sceneId)
.whereNotNull('tag_id')
.delete()
.transacting(trx);
await knexOwner('releases_tags')
.insert(delta.value.map((tagId) => ({
release_id: sceneId,
tag_id: tagId,
source: 'editor',
})))
.transacting(trx);
if (delta.value.length > 0) {
await knexOwner('releases_tags')
.insert(delta.value.map((tagId) => ({
release_id: sceneId,
tag_id: tagId,
source: 'editor',
})))
.transacting(trx);
}
}
async function applySceneRevision(sceneIds) {
async function applySceneMoviesDelta(sceneId, delta, trx) {
await knexOwner('movies_scenes')
.where('scene_id', sceneId)
.delete()
.transacting(trx);
if (delta.value.length > 0) {
await knexOwner('movies_scenes')
.insert(delta.value.map((movieId) => ({
scene_id: sceneId,
movie_id: movieId,
})))
.transacting(trx);
}
}
async function applySceneRevision(revisionIds) {
const revisions = await knexOwner('scenes_revisions')
.whereIn('scene_id', sceneIds)
.whereNull('applied_at');
.whereIn('id', revisionIds)
.whereNull('applied_at'); // should not re-apply revision that was already applied
await revisions.reduce(async (chain, revision) => {
await chain;
console.log('revision', revision);
await knexOwner.transaction(async (trx) => {
await revision.deltas.map(async (delta) => {
if ([
@@ -660,10 +773,10 @@ async function applySceneRevision(sceneIds) {
'description',
'date',
'duration',
'production_date',
'production_location',
'production_city',
'production_state',
'productionDate',
'productionLocation',
'productionCity',
'productionState',
].includes(delta.key)) {
return applySceneValueDelta(revision.scene_id, delta, trx);
}
@@ -676,6 +789,10 @@ async function applySceneRevision(sceneIds) {
return applySceneTagsDelta(revision.scene_id, delta, trx);
}
if (delta.key === 'movies') {
return applySceneMoviesDelta(revision.scene_id, delta, trx);
}
return null;
});
@@ -690,20 +807,44 @@ async function applySceneRevision(sceneIds) {
}, Promise.resolve());
}
const keyMap = {
productionDate: 'production_date',
};
export async function reviewSceneRevision(revisionId, isApproved, { feedback }, reqUser) {
if (!reqUser || reqUser.role === 'user') {
throw new HttpError('You are not permitted to approve revisions', 403);
}
export async function createSceneRevision(sceneId, { edits, comment }, reqUser) {
if (typeof isApproved !== 'boolean') {
throw new HttpError('You must either approve or reject the revision', 400);
}
await knexOwner('scenes_revisions')
.where('id', revisionId)
.whereRaw('approved is not true') // don't rerun approved and applied revision, must be forked into new revision instead
.whereNull('applied_at')
.update({
approved: isApproved,
reviewed_at: knex.fn.now(),
reviewed_by: reqUser.id,
feedback,
});
if (isApproved) {
await applySceneRevision([revisionId]);
}
}
export async function createSceneRevision(sceneId, { edits, comment, apply }, reqUser) {
const [
[scene],
openRevisions,
] = await Promise.all([
fetchScenesById([sceneId], { reqUser, includeAssets: true }),
fetchScenesById([sceneId], {
reqUser,
includeAssets: true,
includePartOf: true,
}),
knexOwner('scenes_revisions')
.where('user_id', reqUser.id)
.whereNull('approved_by')
.whereNot('failed', true),
.whereNull('approved'),
]);
if (!scene) {
@@ -754,25 +895,28 @@ export async function createSceneRevision(sceneId, { edits, comment }, reqUser)
}
}
return {
key: keyMap[key] || key,
value,
};
return { key, value };
}).filter(Boolean);
if (deltas.length === 0) {
throw new HttpError('No effective changes provided', 400);
}
await knexOwner('scenes_revisions').insert({
user_id: reqUser.id,
scene_id: scene.id,
base: JSON.stringify(baseScene),
deltas: JSON.stringify(deltas),
comment,
});
const [revisionEntry] = await knexOwner('scenes_revisions')
.insert({
user_id: reqUser.id,
scene_id: scene.id,
base: JSON.stringify(baseScene),
deltas: JSON.stringify(deltas),
hash: mj.hash({
base: baseScene,
deltas,
}),
comment,
})
.returning('id');
if (['admin', 'editor'].includes(reqUser.role)) {
await applySceneRevision([scene.id]);
if (['admin', 'editor'].includes(reqUser.role) && apply) {
await reviewSceneRevision(revisionEntry.id, true, {}, reqUser);
}
}

View File

@@ -1,3 +1,4 @@
import config from 'config';
import { parse } from 'yaml';
import { knexOwner as knex } from './knex.js';
@@ -128,3 +129,22 @@ export async function removeTemplate(templateId, reqUser) {
.where('user_id', reqUser.id)
.delete();
}
export async function createBan(ban, reqUser) {
console.log(ban);
if (reqUser.role !== 'admin') {
throw new HttpError('You do not have sufficient privileges to set a ban', 403);
}
const targetUser = ban.userId && await knex('users').where('id', ban.userId).first();
const curatedBan = {
user_id: ban.userId,
username: ban.username,
ip: ban.banIp && targetUser.last_ip,
expires_at: knex.raw('now() + make_interval(mins => :minutes)', { minutes: config.bans.defaultExpiry }),
};
await knex('bans').insert(curatedBan);
}

View File

@@ -20,6 +20,11 @@ function getIp(req) {
? ip.slice(ip.lastIndexOf(':') + 1)
: ip;
if (!unmappedIp) {
console.log('failed unmapped ip', ip, unmappedIp);
return null;
}
// ensure IP is in expanded notation for consistency and matching
const expandedIp = unmappedIp.includes(':')
? new IPCIDR(`${ip}/128`) // IPv6

View File

@@ -1,9 +1,12 @@
import Router from 'express-promise-router';
import { stringify } from '@brillout/json-serializer/stringify'; /* eslint-disable-line import/extensions */
import {
fetchScenes,
fetchScenesById,
fetchSceneRevisions,
createSceneRevision,
reviewSceneRevision,
} from '../scenes.js';
import { parseActorIdentifier } from '../query.js';
@@ -48,7 +51,7 @@ export async function curateScenesQuery(query) {
};
}
export async function fetchScenesApi(req, res) {
async function fetchScenesApi(req, res) {
const {
scenes,
aggYears,
@@ -203,11 +206,9 @@ export async function fetchScenesGraphql(query, req) {
};
}
export async function fetchSceneApi(req, res) {
async function fetchSceneApi(req, res) {
const [scene] = await fetchScenesById([Number(req.params.sceneId)], { reqUser: req.user });
console.log(req.params.sceneId, scene);
if (!scene) {
throw new HttpError(`No scene with ID ${req.params.sceneId} found`, 404);
}
@@ -228,8 +229,30 @@ export async function fetchScenesByIdGraphql(query, req) {
return scenes[0];
}
export async function createSceneRevisionApi(req, res) {
await createSceneRevision(Number(req.params.sceneId), req.body, req.user);
async function fetchSceneRevisionsApi(req, res) {
const revisions = await fetchSceneRevisions(Number(req.params.revisionId) || null, req.query, req.user);
res.send(revisions);
}
async function createSceneRevisionApi(req, res) {
await createSceneRevision(Number(req.body.sceneId), req.body, req.user);
res.status(204).send();
}
async function reviewSceneRevisionApi(req, res) {
await reviewSceneRevision(Number(req.params.revisionId), req.body.isApproved, req.body, req.user);
res.status(204).send();
}
export const scenesRouter = Router();
scenesRouter.get('/api/scenes', fetchScenesApi);
scenesRouter.get('/api/scenes/:sceneId', fetchSceneApi);
scenesRouter.get('/api/revisions', fetchSceneRevisionsApi);
scenesRouter.get('/api/revisions/:revisionId', fetchSceneRevisionsApi);
scenesRouter.post('/api/revisions', createSceneRevisionApi);
scenesRouter.post('/api/revisions/:revisionId/reviews', reviewSceneRevisionApi);

View File

@@ -13,11 +13,7 @@ import redis from '../redis.js';
import errorHandler from './error.js';
import consentHandler from './consent.js';
import {
fetchScenesApi,
fetchSceneApi,
createSceneRevisionApi,
} from './scenes.js';
import { scenesRouter } from './scenes.js';
import { fetchActorsApi } from './actors.js';
import { fetchMoviesApi } from './movies.js';
@@ -39,25 +35,8 @@ import {
flushUserKeysApi,
} from './auth.js';
import {
fetchUserApi,
fetchUserTemplatesApi,
createTemplateApi,
removeTemplateApi,
} from './users.js';
import {
fetchUserStashesApi,
createStashApi,
removeStashApi,
stashActorApi,
stashSceneApi,
stashMovieApi,
unstashActorApi,
unstashSceneApi,
unstashMovieApi,
updateStashApi,
} from './stashes.js';
import { router as userRouter } from './users.js';
import { router as stashesRouter } from './stashes.js';
import {
fetchAlertsApi,
@@ -145,32 +124,12 @@ export default async function initServer() {
router.delete('/api/session', logoutApi);
// USERS
router.get('/api/users/:userId', fetchUserApi);
router.post('/api/users', signupApi);
router.get('/api/users/:userId/notifications', fetchNotificationsApi);
router.patch('/api/users/:userId/notifications', updateNotificationsApi);
router.patch('/api/users/:userId/notifications/:notificationId', updateNotificationApi);
// STASHES
router.get('/api/users/:userId/stashes', fetchUserStashesApi);
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);
// SUMMARY TEMPLATES
router.get('/api/users/:userId/templates', fetchUserTemplatesApi);
router.post('/api/templates', createTemplateApi);
router.delete('/api/templates/:templateId', removeTemplateApi);
// API KEYS
router.get('/api/me/keys', fetchUserKeysApi);
router.post('/api/keys', createKeyApi);
@@ -182,10 +141,9 @@ export default async function initServer() {
router.post('/api/alerts', createAlertApi);
router.delete('/api/alerts/:alertId', removeAlertApi);
// SCENES
router.get('/api/scenes', fetchScenesApi);
router.get('/api/scenes/:sceneId', fetchSceneApi);
router.patch('/api/scenes/:sceneId', createSceneRevisionApi);
router.use(userRouter);
router.use(stashesRouter);
router.use(scenesRouter);
// ACTORS
router.get('/api/actors', fetchActorsApi);

View File

@@ -1,3 +1,5 @@
import Router from 'express-promise-router';
import {
fetchUserStashes,
createStash,
@@ -70,3 +72,18 @@ export async function unstashMovieApi(req, res) {
res.send(stashes);
}
export const router = Router();
router.get('/api/users/:userId/stashes', fetchUserStashesApi);
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);

View File

@@ -1,3 +1,4 @@
import Router from 'express-promise-router';
import { stringify } from '@brillout/json-serializer/stringify'; /* eslint-disable-line import/extensions */
import {
@@ -5,28 +6,45 @@ import {
fetchUserTemplates,
createTemplate,
removeTemplate,
createBan,
} from '../users.js';
export async function fetchUserApi(req, res) {
async function fetchUserApi(req, res) {
const user = await fetchUser(req.params.userId, {}, req.user);
res.send(stringify(user));
}
export async function fetchUserTemplatesApi(req, res) {
async function fetchUserTemplatesApi(req, res) {
const templates = await fetchUserTemplates(req.user);
res.send(templates);
}
export async function createTemplateApi(req, res) {
async function createTemplateApi(req, res) {
const template = await createTemplate(req.body, req.user);
res.send(stringify(template));
}
export async function removeTemplateApi(req, res) {
async function removeTemplateApi(req, res) {
await removeTemplate(req.params.templateId, req.user);
res.status(204).send();
}
async function createBanApi(req, res) {
await createBan(req.body, req.user);
res.status(204).send();
}
export const router = Router();
router.get('/api/users/:userId', fetchUserApi);
router.get('/api/users/:userId/templates', fetchUserTemplatesApi);
router.post('/api/templates', createTemplateApi);
router.delete('/api/templates/:templateId', removeTemplateApi);
router.post('/api/bans', createBanApi);