diff --git a/assets/css/style.css b/assets/css/style.css index 48130a4..6cab288 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -29,5 +29,5 @@ body { .heading { margin: 0 0 1rem 0; - color: var(--primary-light-20); + color: var(--primary); } diff --git a/assets/img/icons/backspace2.svg b/assets/img/icons/backspace2.svg new file mode 100755 index 0000000..ff5e840 --- /dev/null +++ b/assets/img/icons/backspace2.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/img/icons/database-remove.svg b/assets/img/icons/database-remove.svg new file mode 100755 index 0000000..672dec1 --- /dev/null +++ b/assets/img/icons/database-remove.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/img/icons/folder-remove.svg b/assets/img/icons/folder-remove.svg new file mode 100755 index 0000000..740062e --- /dev/null +++ b/assets/img/icons/folder-remove.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/img/icons/history.svg b/assets/img/icons/history.svg new file mode 100755 index 0000000..85a554b --- /dev/null +++ b/assets/img/icons/history.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/img/icons/stack-cancel.svg b/assets/img/icons/stack-cancel.svg new file mode 100755 index 0000000..49bcc1b --- /dev/null +++ b/assets/img/icons/stack-cancel.svg @@ -0,0 +1,4 @@ + + + + diff --git a/config/default.cjs b/config/default.cjs index 1afb7c8..4045aa5 100755 --- a/config/default.cjs +++ b/config/default.cjs @@ -23,7 +23,6 @@ module.exports = { maxQueryTime: 10000, }, timeout: 5000, - graphiql: false, pool: { min: 0, max: 20, @@ -64,6 +63,12 @@ module.exports = { usernameLength: [2, 24], usernamePattern: /^[a-zA-Z0-9_-]+$/, }, + apiAccess: { + graphqlEnabled: true, + keySize: 24, // bytes + keyLimit: 5, // max keys per user + keyCooldown: 1, // minutes between key generation + }, psa: { text: 'Welcome to traxxx!', // html enabled type: 'notice', // notice, alert diff --git a/pages/auth/keys/+Page.vue b/pages/auth/keys/+Page.vue new file mode 100644 index 0000000..f606b3b --- /dev/null +++ b/pages/auth/keys/+Page.vue @@ -0,0 +1,271 @@ + + + + + diff --git a/pages/auth/keys/+onBeforeRender.js b/pages/auth/keys/+onBeforeRender.js new file mode 100644 index 0000000..68b1638 --- /dev/null +++ b/pages/auth/keys/+onBeforeRender.js @@ -0,0 +1,14 @@ +import { fetchUserKeys } from '#/src/auth.js'; + +export async function onBeforeRender(pageContext) { + const keys = await fetchUserKeys(pageContext.user); + + return { + pageContext: { + title: 'API keys', + pageProps: { + keys, + }, + }, + }; +} diff --git a/src/actors.js b/src/actors.js index 4e6e54e..fa7267b 100644 --- a/src/actors.js +++ b/src/actors.js @@ -26,7 +26,7 @@ export function curateActor(actor, context = {}) { cup: actor.cup, waist: actor.waist, hip: actor.hip, - naturalBoobs: actor.naturalBoobs, + naturalBoobs: actor.natural_boobs, height: actor.height && { metric: actor.height, imperial: unit(actor.height, 'cm').splitUnit(['ft', 'in']).map((value) => Math.round(value.toNumber())), @@ -36,7 +36,7 @@ export function curateActor(actor, context = {}) { imperial: Math.round(unit(actor.weight, 'kg').toNumeric('lbs')), }, eyes: actor.eyes, - hairColor: actor.hairColor, + hairColor: actor.hair_color, hasTattoos: actor.has_tattoos, tattoos: actor.tattoos, hasPiercings: actor.has_piercings, diff --git a/src/api.js b/src/api.js index 74e6848..d8684d3 100644 --- a/src/api.js +++ b/src/api.js @@ -79,7 +79,7 @@ export async function post(path, data, options = {}) { try { const res = await fetch(`/api${path}${getQuery(options.query)}`, { method: 'POST', - body: JSON.stringify(data), + body: data && JSON.stringify(data), ...postHeaders, }); @@ -95,8 +95,6 @@ export async function post(path, data, options = {}) { return body; } - console.log(body.statusMessage); - showFeedback(false, options, body.statusMessage); throw new Error(body.statusMessage); } catch (error) { @@ -109,7 +107,7 @@ export async function patch(path, data, options = {}) { try { const res = await fetch(`/api${path}${getQuery(options.query)}`, { method: 'PATCH', - body: JSON.stringify(data), + body: data && JSON.stringify(data), ...postHeaders, }); @@ -136,7 +134,7 @@ export async function del(path, options = {}) { try { const res = await fetch(`/api${path}${getQuery(options.query)}`, { method: 'DELETE', - body: JSON.stringify(options.data), + body: options.data && JSON.stringify(options.data), ...postHeaders, }); diff --git a/src/auth.js b/src/auth.js index e42a21e..c64ccd3 100755 --- a/src/auth.js +++ b/src/auth.js @@ -4,13 +4,17 @@ import crypto from 'crypto'; import fs from 'fs/promises'; import { createAvatar } from '@dicebear/core'; import { shapes } from '@dicebear/collection'; +import { faker } from '@faker-js/faker'; import { knexOwner as knex } from './knex.js'; +import redis from './redis.js'; import { curateUser, fetchUser } from './users.js'; import { HttpError } from './errors.js'; -import initLogger from './logger.js'; +import slugify from '../utils/slugify.js'; +import initLogger, { initAccessLogger } from './logger.js'; const logger = initLogger(); +const accessLogger = initAccessLogger(); const scrypt = util.promisify(crypto.scrypt); async function verifyPassword(password, storedPassword) { @@ -138,3 +142,100 @@ export async function signup(credentials, userIp) { return fetchUser(userId); } + +function curateKey(key) { + return { + id: key.id, + identifier: key.identifier, + lastUsedAt: key.last_used_at, + lastUsedIp: key.last_used_ip, + createdAt: key.created_at, + }; +} + +export async function fetchUserKeys(reqUser) { + const keys = await knex('users_keys') + .where('user_id', reqUser.id) + .orderBy('created_at', 'asc'); + + return keys.map((key) => curateKey(key)); +} + +export async function verifyKey(userId, key, req) { + if (!key || !userId) { + throw new HttpError('The API credentials are not provided.', 401); + } + + const hashedKey = (await scrypt(key, '', 64)).toString('hex'); // salt redundant for randomly generated key + + const storedKey = await knex('users_keys') + .where('user_id', userId) + .where('key', hashedKey) + .first(); + + if (!storedKey) { + throw new HttpError('The API credentials are invalid.', 401); + } + + accessLogger.access({ + userId, + identifier: storedKey.identifier, + ip: req.userIp, + }); + + knex('users_keys') + .where('id', storedKey.id) + .update('last_used_at', knex.raw('now()')) + .update('last_used_ip', req.userIp) + .then(() => { + // no need to wait for this + }); +} + +export async function createKey(reqUser) { + const cooldownKey = `traxxx:key_create_cooldown:${reqUser.id}`; + + if (reqUser.role !== 'admin' && await redis.exists(cooldownKey)) { + throw new HttpError(`You can only create a new API key once every ${config.apiAccess.keyCooldown} minutes.`, 429); + } + + const keys = await fetchUserKeys(reqUser); + + if (keys.length >= config.apiAccess.keyLimit) { + throw new HttpError(`You can only hold ${config.apiAccess.keyLimit} API keys at one time. Please remove a key.`, 400); + } + + const key = crypto.randomBytes(config.apiAccess.keySize).toString('base64url'); + const hashedKey = (await scrypt(key, '', 64)).toString('hex'); // salt redundant for randomly generated key + + const identifier = slugify([faker.word.adjective(), faker.animal[faker.animal.type()]()]); + + const [newKey] = await knex('users_keys') + .insert({ + user_id: reqUser.id, + key: hashedKey, + identifier, + }) + .returning('*'); + + await redis.set(cooldownKey, identifier); + await redis.expire(cooldownKey, config.apiAccess.keyCooldown * 60); + + return { + ...curateKey(newKey), + key, + }; +} + +export async function removeUserKey(reqUser, identifier) { + await knex('users_keys') + .where('user_id', reqUser.id) + .where('identifier', identifier) + .delete(); +} + +export async function flushUserKeys(reqUser) { + await knex('users_keys') + .where('user_id', reqUser.id) + .delete(); +} diff --git a/src/logger.js b/src/logger.js index cc7a47b..f216282 100755 --- a/src/logger.js +++ b/src/logger.js @@ -34,3 +34,20 @@ export default function initLogger(customLabel) { ], }); } + +export function initAccessLogger() { + return winston.createLogger({ + level: 'access', + levels: { + access: 0, + }, + format: winston.format.printf((data) => JSON.stringify({ ...data.message, timestamp: new Date() })), + transports: [ + new winston.transports.DailyRotateFile({ + datePattern: 'YYYY-MM-DD', + filename: path.join('log', 'access_%DATE%.log'), + level: 'access', + }), + ], + }); +} diff --git a/src/users.js b/src/users.js index d24be66..12ef70d 100755 --- a/src/users.js +++ b/src/users.js @@ -27,6 +27,7 @@ export function curateUser(user, _assets = {}) { emailVerified: user.email_verified, identityVerified: user.identity_verified, avatar: `/media/avatars/${user.id}_${user.username}.png`, + role: user.role, createdAt: user.created_at, }; diff --git a/src/web/actors.js b/src/web/actors.js index be3e98f..83fb6e9 100644 --- a/src/web/actors.js +++ b/src/web/actors.js @@ -46,7 +46,7 @@ export const actorsSchema = ` query: String limit: Int! = 30 page: Int! = 1 - order: [String] + order: [String!] ): ActorsResult actor( @@ -54,7 +54,7 @@ export const actorsSchema = ` ): Actor actorsById( - ids: [Int]! + ids: [Int!]! ): [Actor] } @@ -70,7 +70,7 @@ export const actorsSchema = ` } type ActorsResult { - nodes: [Actor] + nodes: [Actor!]! total: Int } @@ -81,6 +81,8 @@ export const actorsSchema = ` gender: String dateOfBirth: Date age: Int + ageFromBirth: Int + ageThen: Int origin: Location residence: Location height: Int diff --git a/src/web/auth.js b/src/web/auth.js index 3564cef..f407ebf 100755 --- a/src/web/auth.js +++ b/src/web/auth.js @@ -1,7 +1,16 @@ /* eslint-disable no-param-reassign */ +import { stringify } from '@brillout/json-serializer/stringify'; /* eslint-disable-line import/extensions */ import IPCIDR from 'ip-cidr'; -import { login, signup } from '../auth.js'; +import { + login, + signup, + fetchUserKeys, + createKey, + removeUserKey, + flushUserKeys, +} from '../auth.js'; + import { fetchUser } from '../users.js'; function getIp(req) { @@ -68,4 +77,28 @@ export async function signupApi(req, res) { req.session.user = user; res.send(user); } + +export async function fetchUserKeysApi(req, res) { + const keys = await fetchUserKeys(req.user); + + res.send(stringify(keys)); +} + +export async function createKeyApi(req, res) { + const key = await createKey(req.user); + + res.send(stringify(key)); +} + +export async function removeUserKeyApi(req, res) { + await removeUserKey(req.user, req.params.keyIdentifier); + + res.status(204).send(); +} + +export async function flushUserKeysApi(req, res) { + await flushUserKeys(req.user); + + res.status(204).send(); +} /* eslint-enable no-param-reassign */ diff --git a/src/web/entities.js b/src/web/entities.js index 5a1d8ae..58ae03e 100644 --- a/src/web/entities.js +++ b/src/web/entities.js @@ -37,7 +37,7 @@ export const entitiesSchema = ` } type EntitiesResult { - nodes: [Entity] + nodes: [Entity!]! } type Entity { @@ -47,7 +47,7 @@ export const entitiesSchema = ` url: String type: String parent: Entity - children: [Entity] + children: [Entity!]! } `; diff --git a/src/web/graphql.js b/src/web/graphql.js index 13b7885..01699d0 100644 --- a/src/web/graphql.js +++ b/src/web/graphql.js @@ -1,3 +1,4 @@ +import config from 'config'; import { format } from 'date-fns'; import { @@ -24,6 +25,8 @@ import { fetchActorsByIdGraphql, } from './actors.js'; +import { verifyKey } from '../auth.js'; + const schema = buildSchema(` type Query { movies( @@ -61,6 +64,13 @@ const DateScalar = new GraphQLScalarType({ }); export async function graphqlApi(req, res) { + if (!config.apiAccess.graphqlEnabled) { + res.status(404).send(); + return; + } + + await verifyKey(req.headers['api-user'], req.headers['api-key'], req); + const data = await graphql({ schema, source: req.body.query, diff --git a/src/web/scenes.js b/src/web/scenes.js index e88cb2c..1a49884 100644 --- a/src/web/scenes.js +++ b/src/web/scenes.js @@ -75,9 +75,9 @@ export const scenesSchema = ` scenes( query: String scope: String - entities: [String] - actorIds: [String] - tags: [String] + entities: [String!] + actorIds: [String!] + tags: [String!] limit: Int! = 30 page: Int! = 1 ): ReleasesResult @@ -87,16 +87,16 @@ export const scenesSchema = ` ): Release scenesById( - ids: [Int]! + ids: [Int!]! ): [Release] } type ReleasesAggregate { - actors: [Actor] + actors: [Actor!] } type ReleasesResult { - nodes: [Release] + nodes: [Release!]! total: Int aggregates: ReleasesAggregate } @@ -112,13 +112,13 @@ export const scenesSchema = ` shootId: Int channel: Entity network: Entity - actors: [Actor] - tags: [Tag] + actors: [Actor!]! + tags: [Tag!]! poster: Media trailer: Media - photos: [Media] - covers: [Media] - movies: [Release] + photos: [Media!]! + covers: [Media!]! + movies: [Release!]! } type Tag { diff --git a/src/web/server.js b/src/web/server.js index c1ba0b2..174141e 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -28,6 +28,10 @@ import { loginApi, logoutApi, signupApi, + fetchUserKeysApi, + createKeyApi, + removeUserKeyApi, + flushUserKeysApi, } from './auth.js'; import { @@ -162,6 +166,12 @@ export default async function initServer() { router.post('/api/templates', createTemplateApi); router.delete('/api/templates/:templateId', removeTemplateApi); + // API KEYS + router.get('/api/me/keys', fetchUserKeysApi); + router.post('/api/keys', createKeyApi); + router.delete('/api/me/keys/:keyIdentifier', removeUserKeyApi); + router.delete('/api/me/keys', flushUserKeysApi); + // ALERTS router.get('/api/alerts', fetchAlertsApi); router.post('/api/alerts', createAlertApi); @@ -182,7 +192,10 @@ export default async function initServer() { // TAGS router.get('/api/tags', fetchTagsApi); - router.post('/graphql', graphqlApi); + if (config.apiAccess.graphqlEnabled) { + router.post('/graphql', graphqlApi); + } + router.use(consentHandler); router.use((req, res, next) => {