mirror of
https://github.com/directus/directus.git
synced 2026-01-30 02:07:57 -05:00
Merge branch 'main' into feature-rate-limiting
This commit is contained in:
@@ -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,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
46
api/src/services/server.ts
Normal file
46
api/src/services/server.ts
Normal 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(),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user