mirror of
https://github.com/directus/directus.git
synced 2026-01-25 19:38:25 -05:00
Merge branch 'main' into feature-rate-limiting
This commit is contained in:
7669
api/package-lock.json
generated
7669
api/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
15
api/src/mail/templates/password-reset.liquid
Normal file
15
api/src/mail/templates/password-reset.liquid
Normal 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 %}
|
||||
@@ -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
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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, '!=', '');
|
||||
});
|
||||
|
||||
9
api/src/utils/deep-map.ts
Normal file
9
api/src/utils/deep-map.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
12
api/src/utils/parse-filter.ts
Normal file
12
api/src/utils/parse-filter.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
@@ -11,6 +11,6 @@
|
||||
"lib": [
|
||||
"es2019"
|
||||
],
|
||||
"skipLibCheck": true
|
||||
"skipLibCheck": true,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user