Merge branch 'main' into feature-rate-limiting

This commit is contained in:
rijkvanzanten
2020-09-08 12:31:57 -04:00
248 changed files with 3387 additions and 34724 deletions

7669
api/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "directus",
"version": "9.0.0-alpha.25",
"version": "9.0.0-alpha.33",
"license": "GPL-3.0-only",
"homepage": "https://github.com/directus/next#readme",
"description": "Directus is a real-time API and App dashboard for managing SQL database content.",
@@ -64,7 +64,7 @@
"example.env"
],
"dependencies": {
"@directus/app": "^9.0.0-alpha.25",
"@directus/app": "^9.0.0-alpha.33",
"@directus/format-title": "^3.2.0",
"@slynova/flydrive": "^1.0.2",
"@slynova/flydrive-gcs": "^1.0.2",
@@ -94,9 +94,10 @@
"js-yaml": "^3.14.0",
"jsonwebtoken": "^8.5.1",
"knex": "^0.21.4",
"knex-schema-inspector": "0.0.9",
"knex-schema-inspector": "0.0.11",
"liquidjs": "^9.14.1",
"lodash": "^4.17.19",
"macos-release": "^2.4.1",
"ms": "^2.1.2",
"nanoid": "^3.1.12",
"nodemailer": "^6.4.11",
@@ -116,7 +117,7 @@
"mysql": "^2.18.1",
"oracledb": "^5.0.0",
"sqlite3": "^5.0.0",
"pg": "^8.3.2",
"pg": "^8.3.3",
"redis": "^3.0.2"
},
"devDependencies": {
@@ -164,5 +165,5 @@
"prettier --write"
]
},
"gitHead": "c22537b6d0dd7dc6cc651a9200a68f6b060353d4"
"gitHead": "a7d3952ec3b812ee1eec2f1c7b9f44186cbe0498"
}

View File

@@ -70,7 +70,7 @@ export default async function init(options: Record<string, any>) {
name: 'Administrator',
icon: 'verified_user',
admin: true,
description: 'Initial role with complete access to the App and API',
description: 'Initial administrative role with unrestricted App/API access',
});
await db('directus_users').insert({

View File

@@ -10,6 +10,7 @@ import { InvalidPayloadException } from '../exceptions/invalid-payload';
import ms from 'ms';
import cookieParser from 'cookie-parser';
import env from '../env';
import UsersService from '../services/users';
const router = Router();
@@ -17,7 +18,7 @@ const loginSchema = Joi.object({
email: Joi.string().email().required(),
password: Joi.string().required(),
mode: Joi.string().valid('cookie', 'json'),
otp: Joi.string()
otp: Joi.string(),
});
router.post(
@@ -153,6 +154,55 @@ router.post(
})
);
router.post(
'/password/request',
asyncHandler(async (req, res) => {
if (!req.body.email) {
throw new InvalidPayloadException(`"email" field is required.`);
}
const accountability = {
ip: req.ip,
userAgent: req.get('user-agent'),
role: null,
};
const service = new UsersService({ accountability });
try {
await service.requestPasswordReset(req.body.email);
} catch {
// We don't want to give away what email addresses exist, so we'll always return a 200
// from this endpoint
} finally {
return res.status(200).end();
}
})
);
router.post(
'/password/reset',
asyncHandler(async (req, res) => {
if (!req.body.token) {
throw new InvalidPayloadException(`"token" field is required.`);
}
if (!req.body.password) {
throw new InvalidPayloadException(`"password" field is required.`);
}
const accountability = {
ip: req.ip,
userAgent: req.get('user-agent'),
role: null,
};
const service = new UsersService({ accountability });
await service.resetPassword(req.body.token, req.body.password);
return res.status(200).end();
})
);
router.use(
'/sso',
session({ secret: env.SECRET as string, saveUninitialized: false, resave: false })

View File

@@ -1,8 +1,14 @@
import { Router } from 'express';
import ServerService from '../services/server';
const router = Router();
router.get('/ping', (req, res) => res.send('pong'));
router.get('/info', (req, res) => res.json({ data: process.versions }));
router.get('/info', (req, res) => {
const service = new ServerService({ accountability: req.accountability });
const data = service.serverInfo();
res.json({ data });
});
export default router;

View File

@@ -43,7 +43,12 @@ export default async function runAST(ast: AST, query = ast.query) {
let dbQuery = database.select([...toplevelFields, ...tempFields]).from(ast.name);
// Query defaults
query.limit = query.limit || 100;
query.limit = typeof query.limit === 'number' ? query.limit : 100;
if (query.limit === -1) {
delete query.limit;
}
query.sort = query.sort || [{ column: primaryKeyField, order: 'asc' }];
await applyQuery(ast.name, dbQuery, query);
@@ -114,9 +119,9 @@ export default async function runAST(ast: AST, query = ast.query) {
* `n` items in total. This limit will then be re-applied in the stitching process
* down below
*/
if (batchQuery.limit) {
if (typeof batchQuery.limit === 'number') {
tempLimit = batchQuery.limit;
delete batchQuery.limit;
batchQuery.limit = -1;
}
}
@@ -163,7 +168,7 @@ export default async function runAST(ast: AST, query = ast.query) {
});
// Reapply LIMIT query on a per-record basis
if (tempLimit) {
if (typeof tempLimit === 'number') {
resultsForCurrentRecord = resultsForCurrentRecord.slice(0, tempLimit);
}

View File

@@ -4,6 +4,14 @@ tables:
collection:
type: string
primary: true
icon:
type: string
length: 30
note:
type: text
display_template:
type: string
length: 255
hidden:
type: boolean
nullable: false
@@ -12,25 +20,24 @@ tables:
type: boolean
nullable: false
default: false
icon:
type: string
length: 30
note:
type: text
translation:
type: json
display_template:
archive_field:
type: string
length: 64
archive_app_filter:
type: boolean
nullable: false
default: true
archive_value:
type: string
length: 255
unarchive_value:
type: string
length: 255
sort_field:
type: string
length: 64
soft_delete_field:
type: string
length: 64
soft_delete_value:
type: string
length: 255
directus_roles:
id:
@@ -335,7 +342,7 @@ tables:
directus_presets:
id:
increments: true
title:
bookmark:
type: string
length: 255
user:
@@ -359,13 +366,13 @@ tables:
length: 100
filters:
type: json
view_type:
layout:
type: string
length: 100
default: tabular
view_query:
layout_query:
type: json
view_options:
layout_options:
type: json
directus_relations:
@@ -538,6 +545,9 @@ rows:
- collection: directus_roles
- collection: directus_settings
- collection: directus_users
archive_field: status
archive_value: archived
unarchive_value: draft
- collection: directus_webhooks
directus_fields:
@@ -558,77 +568,168 @@ rows:
translation: null
note: null
data:
- collection: directus_collections
field: collection_divider
special: alias
interface: divider
options:
icon: box
title: Collection Setup
color: '#2F80ED'
locked: true
sort: 1
width: full
- collection: directus_collections
field: collection
interface: text-input
locked: true
options:
font: monospace
locked: true
readonly: true
sort: 1
sort: 2
width: half
- collection: directus_collections
field: icon
interface: icon
options:
locked: true
sort: 2
sort: 3
width: half
- collection: directus_collections
field: note
interface: text-input
locked: true
options:
placeholder: A description of this collection...
sort: 3
width: full
- collection: directus_collections
field: display_template
interface: text-input
locked: true
options:
placeholder: 'Reference title for items, eg: {{ first_name }} {{ last_name }}'
sort: 4
width: full
- collection: directus_collections
field: hidden
interface: toggle
field: display_template
interface: display-template
options:
collectionField: collection
locked: true
sort: 5
width: full
- collection: directus_collections
field: hidden
special: boolean
interface: toggle
options:
label: Hide within the App
sort: 5
special: boolean
locked: true
sort: 6
width: half
- collection: directus_collections
field: singleton
special: boolean
interface: toggle
locked: true
options:
label: Treat as single object
sort: 6
special: boolean
locked: true
sort: 7
width: half
- collection: directus_collections
field: translation
special: json
interface: repeater
locked: true
options:
template: '{{ locale }}'
template: '{{ translation }} ({{ locale }})'
fields:
- field: locale
name: Language
type: string
system:
interface: language
options:
limit: true
schema:
default_value: en-US
meta:
interface: system-language
width: half
- field: translation
name: Translation
type: string
system:
interface: text-input
meta:
interface: system-language
width: half
special: json
sort: 7
options:
placeholder: Enter a translation...
locked: true
sort: 8
width: full
- collection: directus_collections
field: archive_divider
special: alias
interface: divider
options:
icon: archive
title: Archive
color: '#2F80ED'
locked: true
sort: 9
width: full
- collection: directus_collections
field: archive_field
interface: field
options:
collectionField: collection
allowNone: true
placeholder: Choose a field...
locked: true
sort: 10
width: half
- collection: directus_collections
field: archive_app_filter
interface: toggle
special: boolean
options:
label: Enable App Archive Filter
locked: true
sort: 11
width: half
- collection: directus_collections
field: archive_value
interface: text-input
options:
font: monospace
iconRight: archive
placeholder: Value set when archiving...
locked: true
sort: 12
width: half
- collection: directus_collections
field: unarchive_value
interface: text-input
options:
font: monospace
iconRight: unarchive
placeholder: Value set when unarchiving...
locked: true
sort: 13
width: half
- collection: directus_collections
field: sort_divider
special: alias
interface: divider
options:
icon: sort
title: Sort
color: '#2F80ED'
locked: true
sort: 14
width: full
- collection: directus_collections
field: sort_field
interface: field
options:
collectionField: collection
placeholder: Choose a field...
typeAllowList:
- float
- decimal
- integer
allowNone: true
locked: true
sort: 15
width: half
- collection: directus_roles
field: id
@@ -1265,32 +1366,20 @@ rows:
width: full
- collection: directus_users
field: status
interface: status
interface: dropdown
locked: true
special: status
options:
statuses:
draft:
name: Draft
color: "#B0BEC5"
backgroundColor: "#ECEFF1"
invited:
name: Invited
color: "#FFFFFF"
backgroundColor: "#2F80ED"
active:
name: Active
color: "#FFFFFF"
backgroundColor: "#27AE60"
suspended:
name: Suspended
color: "#FFFFFF"
backgroundColor: "#F2994A"
deleted:
name: Deleted
color: "#FFFFFF"
backgroundColor: "#EB5757"
softDelete: true
choices:
- text: Draft
value: draft
- text: Invited
value: invited
- text: Active
value: active
- text: Suspended
value: suspended
- text: Archived
value: archived
sort: 16
width: half
- collection: directus_users
@@ -1418,7 +1507,7 @@ rows:
options:
icon: insert_drive_file
title: File Naming
color: '#B0BEC5'
color: '#2F80ED'
special: alias
sort: 6
width: full
@@ -1471,12 +1560,12 @@ rows:
locked: true
special: json
- collection: directus_presets
field: view_query
field: layout_query
hidden: true
locked: true
special: json
- collection: directus_presets
field: view_options
field: layout_options
hidden: true
locked: true
special: json
@@ -1547,7 +1636,7 @@ rows:
options:
icon: public
title: Public Pages
color: '#B0BEC5'
color: '#2F80ED'
special: alias
sort: 5
width: full
@@ -1584,7 +1673,7 @@ rows:
options:
icon: security
title: Security
color: '#B0BEC5'
color: '#2F80ED'
special: alias
sort: 9
width: full
@@ -1625,7 +1714,7 @@ rows:
options:
icon: storage
title: Files & Thumbnails
color: '#B0BEC5'
color: '#2F80ED'
special: alias
sort: 13
width: full
@@ -1709,7 +1798,7 @@ rows:
options:
icon: pending
title: Miscellaneous
color: '#B0BEC5'
color: '#2F80ED'
special: alias
sort: 16
width: full
@@ -1810,23 +1899,23 @@ rows:
directus_presets:
defaults:
title: null
bookmark: null
user: null
role: null
collection: null
search: null
filters: '[]'
view_type: tabular
view_query: null
view_options: null
layout: tabular
layout_query: null
layout_options: null
data:
- collection: directus_files
view_type: cards
view_query:
layout: cards
layout_query:
cards:
sort: -uploaded_on
view_options:
layout_options:
cards:
icon: insert_drive_file
title: '{{ title }}'
@@ -1835,8 +1924,8 @@ rows:
imageFit: crop
- collection: directus_users
view_type: cards
view_options:
layout: cards
layout_options:
cards:
icon: account_circle
title: '{{ first_name }} {{ last_name }}'
@@ -1844,8 +1933,8 @@ rows:
size: 4
- collection: directus_activity
view_type: tabular
view_query:
layout: tabular
layout_query:
tabular:
sort: -action_on
fields:
@@ -1853,7 +1942,7 @@ rows:
- collection
- action_on
- action_by
view_options:
layout_options:
tabular:
widths:
action: 100

View File

@@ -65,3 +65,13 @@ export async function sendInviteMail(email: string, url: string) {
const html = await liquidEngine.renderFile('user-invitation', { email, url, projectName });
await transporter.sendMail({ from: env.EMAIL_FROM, to: email, html: html });
}
export async function sendPasswordResetMail(email: string, url: string) {
/**
* @TODO pull this from directus_settings
*/
const projectName = 'directus';
const html = await liquidEngine.renderFile('password-reset', { email, url, projectName });
await transporter.sendMail({ from: env.EMAIL_FROM, to: email, html: html });
}

View File

@@ -0,0 +1,15 @@
{% layout "base" %}
{% block content %}
<p>You requested to reset your password. Please click the link below to reset your password:</p>
<p><a href="{{ url }}">{{ url }}</a></p>
{% comment %}
@TODO
Make this white-labeled
{% endcomment %}
<p>Love,<br>Directus</p>
{% endblock %}

View File

@@ -3,7 +3,7 @@
<p>You have been invited to {{ projectName }}. Please click the link below to join:</p>
<p><a href="url">{{ url }}</a></p>
<p><a href="{{ url }}">{{ url }}</a></p>
{% comment %}
@TODO

View File

@@ -4,9 +4,9 @@
*/
import { RequestHandler } from 'express';
import { Query, Sort, Filter } from '../types/query';
import { Meta } from '../types/meta';
import { Accountability, Query, Sort, Filter, Meta } from '../types';
import logger from '../logger';
import { parseFilter } from '../utils/parse-filter';
const sanitizeQuery: RequestHandler = (req, res, next) => {
req.sanitizedQuery = {};
@@ -16,10 +16,10 @@ const sanitizeQuery: RequestHandler = (req, res, next) => {
fields: sanitizeFields(req.query.fields) || ['*'],
};
if (req.query.limit) {
if (req.query.limit !== undefined) {
const limit = sanitizeLimit(req.query.limit);
if (limit) {
if (typeof limit === 'number') {
query.limit = limit;
}
}
@@ -29,7 +29,7 @@ const sanitizeQuery: RequestHandler = (req, res, next) => {
}
if (req.query.filter) {
query.filter = sanitizeFilter(req.query.filter);
query.filter = sanitizeFilter(req.query.filter, req.accountability || null);
}
if (req.query.limit == '-1') {
@@ -56,13 +56,6 @@ const sanitizeQuery: RequestHandler = (req, res, next) => {
query.search = req.query.search;
}
if (req.permissions) {
query.filter = {
...(query.filter || {}),
...(req.permissions.permissions || {}),
};
}
req.sanitizedQuery = query;
return next();
};
@@ -93,7 +86,7 @@ function sanitizeSort(rawSort: any) {
});
}
function sanitizeFilter(rawFilter: any) {
function sanitizeFilter(rawFilter: any, accountability: Accountability | null) {
let filters: Filter = rawFilter;
if (typeof rawFilter === 'string') {
@@ -104,16 +97,13 @@ function sanitizeFilter(rawFilter: any) {
}
}
/**
* @todo
* validate filter syntax?
*/
filters = parseFilter(filters, accountability);
return filters;
}
function sanitizeLimit(rawLimit: any) {
if (!rawLimit) return null;
if (rawLimit === undefined || rawLimit === null) return null;
return Number(rawLimit);
}
@@ -141,4 +131,6 @@ function sanitizeMeta(rawMeta: any) {
if (Array.isArray(rawMeta)) {
return rawMeta;
}
return [rawMeta];
}

View File

@@ -16,6 +16,7 @@ import { ForbiddenException, InvalidPayloadException } from '../exceptions';
import { uniq, merge } from 'lodash';
import generateJoi from '../utils/generate-joi';
import ItemsService from './items';
import { parseFilter } from '../utils/parse-filter';
export default class AuthorizationService {
knex: Knex;
@@ -64,8 +65,7 @@ export default class AuthorizationService {
}
validateFields(ast);
applyFilters(ast);
applyFilters(ast, this.accountability);
return ast;
@@ -126,7 +126,8 @@ export default class AuthorizationService {
}
function applyFilters(
ast: AST | NestedCollectionAST | FieldAST
ast: AST | NestedCollectionAST | FieldAST,
accountability: Accountability | null
): AST | NestedCollectionAST | FieldAST {
if (ast.type === 'collection') {
const collection = ast.name;
@@ -136,11 +137,12 @@ export default class AuthorizationService {
(permission) => permission.collection === collection
)!;
const parsedPermissions = parseFilter(permissions.permissions, accountability);
ast.query = {
...ast.query,
filter: {
...(ast.query.filter || {}),
...permissions.permissions,
_and: [ast.query.filter || {}, parsedPermissions],
},
};
@@ -155,7 +157,10 @@ export default class AuthorizationService {
ast.query.limit = permissions.limit;
}
ast.children = ast.children.map(applyFilters) as (NestedCollectionAST | FieldAST)[];
ast.children = ast.children.map((child) => applyFilters(child, accountability)) as (
| NestedCollectionAST
| FieldAST
)[];
}
return ast;
@@ -202,7 +207,7 @@ export default class AuthorizationService {
if (invalidKeys.length > 0) {
throw new ForbiddenException(
`You're not allowed to ${action} field "${invalidKeys[0]}" in collection "${collection}".`
)
);
}
}
}
@@ -228,7 +233,11 @@ export default class AuthorizationService {
}
}
async checkAccess(action: PermissionsAction, collection: string, pk: PrimaryKey | PrimaryKey[]) {
async checkAccess(
action: PermissionsAction,
collection: string,
pk: PrimaryKey | PrimaryKey[]
) {
const itemsService = new ItemsService(collection, { accountability: this.accountability });
try {
@@ -242,8 +251,11 @@ export default class AuthorizationService {
if (Array.isArray(pk) && result.length !== pk.length) throw '';
} catch {
throw new ForbiddenException(
`You're not allowed to ${action} item "${pk}" in collection "${collection}".`, {
collection, item: pk, action
`You're not allowed to ${action} item "${pk}" in collection "${collection}".`,
{
collection,
item: pk,
action,
}
);
}

View File

@@ -199,12 +199,27 @@ export default class CollectionsService {
const payload = data as Partial<Collection>;
if (!payload.meta) {
throw new InvalidPayloadException(`"system" key is required`);
throw new InvalidPayloadException(`"meta" key is required`);
}
return (await collectionItemsService.update(payload.meta!, key as any)) as
| string
| string[];
const keys = Array.isArray(key) ? key : [key];
for (const key of keys) {
const exists =
(await this.knex
.select('collection')
.from('directus_collections')
.where({ collection: key })
.first()) !== undefined;
if (exists) {
await collectionItemsService.update(payload.meta, key);
} else {
await collectionItemsService.create({ ...payload.meta, collection: key });
}
}
return key;
}
const payloads = Array.isArray(data) ? data : [data];
@@ -228,7 +243,10 @@ export default class CollectionsService {
throw new ForbiddenException('Only admins can perform this action.');
}
const fieldsService = new FieldsService({ knex: this.knex, accountability: this.accountability });
const fieldsService = new FieldsService({
knex: this.knex,
accountability: this.accountability,
});
const tablesInDatabase = await schemaInspector.tables();
@@ -256,10 +274,14 @@ export default class CollectionsService {
const isM2O = relation.many_collection === collection;
if (isM2O) {
await this.knex('directus_relations').delete().where({ many_collection: collection, many_field: relation.many_field });
await this.knex('directus_relations')
.delete()
.where({ many_collection: collection, many_field: relation.many_field });
await fieldsService.deleteField(relation.one_collection, relation.one_field);
} else {
await this.knex('directus_relations').update({ one_field: null }).where({ one_collection: collection, field: relation.one_field });
await this.knex('directus_relations')
.update({ one_field: null })
.where({ one_collection: collection, field: relation.one_field });
await fieldsService.deleteField(relation.many_collection, relation.many_field);
}
}

View File

@@ -40,9 +40,10 @@ export default class FieldsService {
if (collection) {
fields = (await nonAuthorizedItemsService.readByQuery({
filter: { collection: { _eq: collection } },
limit: -1,
})) as FieldMeta[];
} else {
fields = (await nonAuthorizedItemsService.readByQuery({})) as FieldMeta[];
fields = (await nonAuthorizedItemsService.readByQuery({ limit: -1 })) as FieldMeta[];
}
fields = (await this.payloadService.processValues('read', fields)) as FieldMeta[];
@@ -101,11 +102,16 @@ export default class FieldsService {
// Filter the result so we only return the fields you have read access to
if (this.accountability && this.accountability.admin !== true) {
const permissions = await this.knex.select('collection', 'fields').from('directus_permissions').where({ role: this.accountability.role, action: 'read' });
const permissions = await this.knex
.select('collection', 'fields')
.from('directus_permissions')
.where({ role: this.accountability.role, action: 'read' });
const allowedFieldsInCollection: Record<string, string[]> = {};
permissions.forEach((permission) => {
allowedFieldsInCollection[permission.collection] = (permission.fields || '').split(',');
allowedFieldsInCollection[permission.collection] = (permission.fields || '').split(
','
);
});
if (collection && allowedFieldsInCollection.hasOwnProperty(collection) === false) {
@@ -113,7 +119,8 @@ export default class FieldsService {
}
return result.filter((field) => {
if (allowedFieldsInCollection.hasOwnProperty(field.collection) === false) return false;
if (allowedFieldsInCollection.hasOwnProperty(field.collection) === false)
return false;
const allowedFields = allowedFieldsInCollection[field.collection];
if (allowedFields[0] === '*') return true;
return allowedFields.includes(field.field);
@@ -131,8 +138,9 @@ export default class FieldsService {
.where({
role: this.accountability.role,
collection,
action: 'read'
}).first();
action: 'read',
})
.first();
if (!permissions) throw new ForbiddenException();
if (permissions.fields !== '*') {
@@ -245,13 +253,23 @@ export default class FieldsService {
.from('directus_fields')
.where({ collection, field: field.field })
.first();
if (!record) throw new FieldNotFoundException(collection, field.field);
await this.itemsService.update({
...field.meta,
collection: collection,
field: field.field,
}, record.id);
if (record) {
await this.itemsService.update(
{
...field.meta,
collection: collection,
field: field.field,
},
record.id
);
} else {
await this.itemsService.create({
...field.meta,
collection: collection,
field: field.field,
});
}
}
return field.field;
@@ -281,10 +299,14 @@ export default class FieldsService {
const isM2O = relation.many_collection === collection && relation.many_field === field;
if (isM2O) {
await this.knex('directus_relations').delete().where({ many_collection: collection, many_field: field });
await this.knex('directus_relations')
.delete()
.where({ many_collection: collection, many_field: field });
await this.deleteField(relation.one_collection, relation.one_field);
} else {
await this.knex('directus_relations').update({ one_field: null }).where({ one_collection: collection, one_field: field });
await this.knex('directus_relations')
.update({ one_field: null })
.where({ one_collection: collection, one_field: field });
}
}
}

View File

@@ -17,7 +17,12 @@ import env from '../env';
type Action = 'create' | 'read' | 'update';
type Transformers = {
[type: string]: (action: Action, value: any, payload: Partial<Item>) => Promise<any>;
[type: string]: (
action: Action,
value: any,
payload: Partial<Item>,
accountability: Accountability | null
) => Promise<any>;
};
export default class PayloadService {
@@ -91,9 +96,33 @@ export default class PayloadService {
return value;
},
async conceal(action, value) {
if (action === 'read') return '**********';
if (action === 'read') return value ? '**********' : null;
return value;
}
},
async 'user-created'(action, value, payload, accountability) {
if (action === 'create') return accountability?.user || null;
return value;
},
async 'user-updated'(action, value, payload, accountability) {
if (action === 'update') return accountability?.user || null;
return value;
},
async 'role-created'(action, value, payload, accountability) {
if (action === 'create') return accountability?.role || null;
return value;
},
async 'role-updated'(action, value, payload, accountability) {
if (action === 'update') return accountability?.role || null;
return value;
},
async 'date-created'(action, value) {
if (action === 'create') return new Date();
return value;
},
async 'date-updated'(action, value) {
if (action === 'update') return new Date();
return value;
},
};
processValues(action: Action, payloads: Partial<Item>[]): Promise<Partial<Item>[]>;
@@ -124,7 +153,12 @@ export default class PayloadService {
processedPayload.map(async (record: any) => {
await Promise.all(
specialFieldsInCollection.map(async (field) => {
const newValue = await this.processField(field, record, action);
const newValue = await this.processField(
field,
record,
action,
this.accountability
);
if (newValue !== undefined) record[field.field] = newValue;
})
);
@@ -151,18 +185,18 @@ export default class PayloadService {
async processField(
field: Pick<FieldMeta, 'field' | 'special'>,
payload: Partial<Item>,
action: Action
action: Action,
accountability: Accountability | null
) {
if (!field.special) return payload[field.field];
const fieldSpecials = field.special.split(',').map(s => s.trim());
const fieldSpecials = field.special.split(',').map((s) => s.trim());
let value = clone(payload[field.field]);
for (const special of fieldSpecials) {
if (this.transformers.hasOwnProperty(special)) {
value = await this.transformers[special](action, value, payload);
value = await this.transformers[special](action, value, payload, accountability);
}
}
@@ -266,12 +300,19 @@ export default class PayloadService {
);
const toBeUpdated = relatedRecords.filter(
(record) => record.hasOwnProperty(relation.many_primary) === true && record.hasOwnProperty('$delete') === false
(record) =>
record.hasOwnProperty(relation.many_primary) === true &&
record.hasOwnProperty('$delete') === false
);
const toBeDeleted = relatedRecords
.filter(record => record.hasOwnProperty(relation.many_primary) === true && record.hasOwnProperty('$delete') && record.$delete === true)
.map(record => record[relation.many_primary]);
.filter(
(record) =>
record.hasOwnProperty(relation.many_primary) === true &&
record.hasOwnProperty('$delete') &&
record.$delete === true
)
.map((record) => record[relation.many_primary]);
await itemsService.create(toBeCreated);
await itemsService.update(toBeUpdated);
@@ -280,4 +321,3 @@ export default class PayloadService {
}
}
}
0

View File

@@ -0,0 +1,46 @@
import { AbstractServiceOptions, Accountability } from '../types';
import Knex from 'knex';
import database from '../database';
import os from 'os';
import { ForbiddenException } from '../exceptions';
// @ts-ignore
import { version } from '../../package.json';
import macosRelease from 'macos-release';
export default class ServerService {
knex: Knex;
accountability: Accountability | null;
constructor(options?: AbstractServiceOptions) {
this.knex = options?.knex || database;
this.accountability = options?.accountability || null;
}
serverInfo() {
if (this.accountability?.admin !== true) {
throw new ForbiddenException();
}
const osType = os.type() === 'Darwin' ? 'macOS' : os.type();
const osVersion =
osType === 'macOS'
? `${macosRelease().name} (${macosRelease().version})`
: os.release();
return {
directus: {
version,
},
node: {
version: process.versions.node,
uptime: Math.round(process.uptime()),
},
os: {
type: osType,
version: osVersion,
uptime: Math.round(os.uptime()),
totalmem: os.totalmem(),
},
};
}
}

View File

@@ -1,10 +1,10 @@
import AuthService from './authentication';
import ItemsService from './items';
import jwt from 'jsonwebtoken';
import { sendInviteMail } from '../mail';
import { sendInviteMail, sendPasswordResetMail } from '../mail';
import database from '../database';
import argon2 from 'argon2';
import { InvalidPayloadException } from '../exceptions';
import { InvalidPayloadException, ForbiddenException } from '../exceptions';
import { Accountability, PrimaryKey, Item, AbstractServiceOptions } from '../types';
import Knex from 'knex';
import env from '../env';
@@ -48,7 +48,7 @@ export default class UsersService extends ItemsService {
async inviteUser(email: string, role: string) {
await this.service.create({ email, role, status: 'invited' });
const payload = { email };
const payload = { email, scope: 'invite' };
const token = jwt.sign(payload, env.SECRET as string, { expiresIn: '7d' });
const acceptURL = env.PUBLIC_URL + '/admin/accept-invite?token=' + token;
@@ -56,9 +56,14 @@ export default class UsersService extends ItemsService {
}
async acceptInvite(token: string, password: string) {
const { email } = jwt.verify(token, env.SECRET as string) as { email: string };
const { email, scope } = jwt.verify(token, env.SECRET as string) as {
email: string;
scope: string;
};
const user = await database
if (scope !== 'invite') throw new ForbiddenException();
const user = await this.knex
.select('id', 'status')
.from('directus_users')
.where({ email })
@@ -70,13 +75,53 @@ export default class UsersService extends ItemsService {
const passwordHashed = await argon2.hash(password);
await database('directus_users')
await this.knex('directus_users')
.update({ password: passwordHashed, status: 'active' })
.where({ id: user.id });
}
async requestPasswordReset(email: string) {
const user = await this.knex.select('id').from('directus_users').where({ email }).first();
if (!user) throw new ForbiddenException();
const payload = { email, scope: 'password-reset' };
const token = jwt.sign(payload, env.SECRET as string, { expiresIn: '7d' });
const acceptURL = env.PUBLIC_URL + '/admin/reset-password?token=' + token;
await sendPasswordResetMail(email, acceptURL);
}
async resetPassword(token: string, password: string) {
const { email, scope } = jwt.verify(token, env.SECRET as string) as {
email: string;
scope: string;
};
if (scope !== 'password-reset') throw new ForbiddenException();
const user = await this.knex
.select('id', 'status')
.from('directus_users')
.where({ email })
.first();
if (!user || user.status !== 'active') {
throw new ForbiddenException();
}
const passwordHashed = await argon2.hash(password);
await this.knex('directus_users')
.update({ password: passwordHashed, status: 'active' })
.where({ id: user.id });
}
async enableTFA(pk: string) {
const user = await this.knex.select('tfa_secret').from('directus_users').where({ id: pk }).first();
const user = await this.knex
.select('tfa_secret')
.from('directus_users')
.where({ id: pk })
.first();
if (user?.tfa_secret !== null) {
throw new InvalidPayloadException('TFA Secret is already set for this user');

View File

@@ -11,7 +11,7 @@ export default async function applyQuery(collection: string, dbQuery: QueryBuild
dbQuery.orderBy(query.sort);
}
if (query.limit && !query.offset) {
if (typeof query.limit === 'number' && !query.offset) {
dbQuery.limit(query.limit);
}
@@ -109,14 +109,14 @@ export function applyFilter(dbQuery: QueryBuilder, filter: Filter) {
}
if (operator === '_empty') {
dbQuery.andWhere(query => {
dbQuery.andWhere((query) => {
query.whereNull(key);
query.orWhere(key, '=', '');
});
}
if (operator === '_nempty') {
dbQuery.andWhere(query => {
dbQuery.andWhere((query) => {
query.whereNotNull(key);
query.orWhere(key, '!=', '');
});

View File

@@ -0,0 +1,9 @@
import { transform, isPlainObject } from 'lodash';
export function deepMap(obj: Record<string, any>, iterator: Function, context?: Function) {
return transform(obj, function (result: any, val, key) {
result[key] = isPlainObject(val)
? deepMap(val, iterator, context)
: iterator.call(context, val, key, obj);
});
}

View File

@@ -56,7 +56,7 @@ const localTypeMap: Record<string, { type: typeof types[number]; useTimezone?: b
// Postgres
json: { type: 'json' },
uuid: { type: 'string' },
uuid: { type: 'uuid' },
int2: { type: 'integer' },
serial4: { type: 'integer' },
int4: { type: 'integer' },

View File

@@ -0,0 +1,12 @@
import { Filter, Accountability } from '../types';
import { deepMap } from './deep-map';
export function parseFilter(filter: Filter, accountability: Accountability | null) {
return deepMap(filter, (val: any) => {
if (val === '$NOW') return new Date();
if (val === '$CURRENT_USER') return accountability?.user || null;
if (val === '$CURRENT_ROLE') return accountability?.role || null;
return val;
});
}

View File

@@ -11,6 +11,6 @@
"lib": [
"es2019"
],
"skipLibCheck": true
"skipLibCheck": true,
}
}