diff --git a/api/src/controllers/files.ts b/api/src/controllers/files.ts index d33beb55e5..15e154fd66 100644 --- a/api/src/controllers/files.ts +++ b/api/src/controllers/files.ts @@ -12,6 +12,7 @@ import url from 'url'; import path from 'path'; import useCollection from '../middleware/use-collection'; import { respond } from '../middleware/respond'; +import { toArray } from '../utils/to-array'; const router = express.Router(); @@ -32,7 +33,7 @@ const multipartHandler = asyncHandler(async (req, res, next) => { * the row in directus_files async during the upload of the actual file. */ - let disk: string = (env.STORAGE_LOCATIONS as string).split(',')[0].trim(); + let disk: string = toArray(env.STORAGE_LOCATIONS)[0]; let payload: Partial = {}; let fileCount = 0; @@ -155,7 +156,7 @@ router.post( const payload = { filename_download: filename, - storage: (env.STORAGE_LOCATIONS as string).split(',')[0].trim(), + storage: toArray(env.STORAGE_LOCATIONS)[0], type: fileResponse.headers['content-type'], title: formatTitle(filename), ...(req.body.data || {}), diff --git a/api/src/controllers/server.ts b/api/src/controllers/server.ts index d6fb0f688f..0f20b5fd7c 100644 --- a/api/src/controllers/server.ts +++ b/api/src/controllers/server.ts @@ -20,12 +20,12 @@ router.get('/ping', (req, res) => res.send('pong')); router.get( '/info', - (req, res, next) => { + asyncHandler(async (req, res, next) => { const service = new ServerService({ accountability: req.accountability }); - const data = service.serverInfo(); + const data = await service.serverInfo(); res.locals.payload = { data }; return next(); - }, + }), respond ); diff --git a/api/src/database/seeds/02-rows/02-permissions.yaml b/api/src/database/seeds/02-rows/02-permissions.yaml deleted file mode 100644 index 4b32d9ede6..0000000000 --- a/api/src/database/seeds/02-rows/02-permissions.yaml +++ /dev/null @@ -1,17 +0,0 @@ -table: directus_permissions - -defaults: - role: null - collection: null - action: null - permissions: null - validation: null - presets: null - fields: null - limit: null - -data: - - collection: directus_settings - action: read - permissions: {} - fields: 'project_name,project_logo,project_color,public_foreground,public_background,public_note,custom_css' diff --git a/api/src/database/seeds/02-rows/03-presets.yaml b/api/src/database/seeds/02-rows/02-presets.yaml similarity index 100% rename from api/src/database/seeds/02-rows/03-presets.yaml rename to api/src/database/seeds/02-rows/02-presets.yaml diff --git a/api/src/database/seeds/02-rows/04-relations.yaml b/api/src/database/seeds/02-rows/03-relations.yaml similarity index 100% rename from api/src/database/seeds/02-rows/04-relations.yaml rename to api/src/database/seeds/02-rows/03-relations.yaml diff --git a/api/src/env.ts b/api/src/env.ts index 520e6c2e7e..d1cf1d1520 100644 --- a/api/src/env.ts +++ b/api/src/env.ts @@ -63,11 +63,6 @@ function processValues(env: Record) { if (value === 'false') env[key] = false; if (value === 'null') env[key] = null; if (isNaN(value) === false && value.length > 0) env[key] = Number(value); - if (typeof value === 'string' && value.includes(',')) - env[key] = value - .split(',') - .map((val) => val.trim()) - .filter((val) => val); } return env; diff --git a/api/src/mail/index.ts b/api/src/mail/index.ts index 565a1db276..70944e3579 100644 --- a/api/src/mail/index.ts +++ b/api/src/mail/index.ts @@ -60,18 +60,28 @@ export async function sendInviteMail(email: string, url: string) { /** * @TODO pull this from directus_settings */ - const projectName = 'directus'; + const projectName = 'Directus'; const html = await liquidEngine.renderFile('user-invitation', { email, url, projectName }); - await transporter.sendMail({ from: env.EMAIL_FROM, to: email, html: html }); + await transporter.sendMail({ + from: env.EMAIL_FROM, + to: email, + html: html, + subject: `[${projectName}] You've been invited`, + }); } export async function sendPasswordResetMail(email: string, url: string) { /** * @TODO pull this from directus_settings */ - const projectName = 'directus'; + const projectName = 'Directus'; const html = await liquidEngine.renderFile('password-reset', { email, url, projectName }); - await transporter.sendMail({ from: env.EMAIL_FROM, to: email, html: html }); + await transporter.sendMail({ + from: env.EMAIL_FROM, + to: email, + html: html, + subject: `[${projectName}] Password Reset Request`, + }); } diff --git a/api/src/services/authorization.ts b/api/src/services/authorization.ts index 0b526b4385..44427a05d2 100644 --- a/api/src/services/authorization.ts +++ b/api/src/services/authorization.ts @@ -17,22 +17,25 @@ import { ForbiddenException, FailedValidationException } from '../exceptions'; import { uniq, merge, flatten } from 'lodash'; import generateJoi from '../utils/generate-joi'; import { ItemsService } from './items'; +import { PayloadService } from './payload'; import { parseFilter } from '../utils/parse-filter'; import { toArray } from '../utils/to-array'; export class AuthorizationService { knex: Knex; accountability: Accountability | null; + payloadService: PayloadService; constructor(options?: AbstractServiceOptions) { this.knex = options?.knex || database; this.accountability = options?.accountability || null; + this.payloadService = new PayloadService('directus_permissions', { knex: this.knex }); } async processAST(ast: AST, action: PermissionsAction = 'read'): Promise { const collectionsRequested = getCollectionsFromAST(ast); - const permissionsForCollections = await this.knex + let permissionsForCollections = await this.knex .select('*') .from('directus_permissions') .where({ action, role: this.accountability?.role }) @@ -41,6 +44,11 @@ export class AuthorizationService { collectionsRequested.map(({ collection }) => collection) ); + permissionsForCollections = (await this.payloadService.processValues( + 'read', + permissionsForCollections + )) as Permission[]; + // If the permissions don't match the collections, you don't have permission to read all of them const uniqueCollectionsRequestedCount = uniq( collectionsRequested.map(({ collection }) => collection) @@ -111,7 +119,7 @@ export class AuthorizationService { (permission) => permission.collection === collection )!; - const allowedFields = permissions.fields?.split(',') || []; + const allowedFields = permissions.fields || []; for (const childNode of ast.children) { if (childNode.type !== 'field') { @@ -213,21 +221,26 @@ export class AuthorizationService { permissions: {}, validation: {}, limit: null, - fields: '*', + fields: ['*'], presets: {}, }; } else { permission = await this.knex - .select('*') + .select('*') .from('directus_permissions') .where({ action, collection, role: this.accountability?.role || null }) .first(); + permission = (await this.payloadService.processValues( + 'read', + permission as Item + )) as Permission; + // Check if you have permission to access the fields you're trying to acces if (!permission) throw new ForbiddenException(); - const allowedFields = permission.fields?.split(',') || []; + const allowedFields = permission.fields || []; if (allowedFields.includes('*') === false) { for (const payload of payloads) { @@ -260,13 +273,16 @@ export class AuthorizationService { .from('directus_fields') .where({ collection, field: column.name }) .first(); + const specials = (field?.special || '').split(','); + const hasGenerateSpecial = [ 'uuid', 'date-created', 'role-created', 'user-created', ].some((name) => specials.includes(name)); + const isRequired = column.is_nullable === false && column.has_auto_increment === false && diff --git a/api/src/services/graphql.ts b/api/src/services/graphql.ts index d3f54fdee2..1cc3864f8a 100644 --- a/api/src/services/graphql.ts +++ b/api/src/services/graphql.ts @@ -18,7 +18,6 @@ import { GraphQLInputObjectType, ObjectFieldNode, GraphQLID, - ValueNode, FieldNode, GraphQLFieldConfigMap, GraphQLInt, @@ -26,11 +25,9 @@ import { StringValueNode, BooleanValueNode, ArgumentNode, - GraphQLScalarType, GraphQLBoolean, ObjectValueNode, GraphQLUnionType, - GraphQLUnionTypeConfig, } from 'graphql'; import { getGraphQLType } from '../utils/get-graphql-type'; import { RelationsService } from './relations'; @@ -65,7 +62,7 @@ export class GraphQLService { this.knex = options?.knex || database; this.fieldsService = new FieldsService(options); this.collectionsService = new CollectionsService(options); - this.relationsService = new RelationsService({ knex: this.knex }); + this.relationsService = new RelationsService(options); } args = { @@ -138,6 +135,7 @@ export class GraphQLService { const relatedIsSystem = relationForField.one_collection!.startsWith( 'directus_' ); + const relatedType = relatedIsSystem ? schema[relationForField.one_collection!.substring(9)].type : schema.items[relationForField.one_collection!].type; diff --git a/api/src/services/meta.ts b/api/src/services/meta.ts index 9764dcdcee..fad8c127b8 100644 --- a/api/src/services/meta.ts +++ b/api/src/services/meta.ts @@ -40,7 +40,7 @@ export class MetaService { const dbQuery = database(collection).count('*', { as: 'count' }); if (query.filter) { - applyFilter(dbQuery, query.filter, collection); + await applyFilter(dbQuery, query.filter, collection); } const records = await dbQuery; diff --git a/api/src/services/relations.ts b/api/src/services/relations.ts index c8c17a182f..9b08c1b964 100644 --- a/api/src/services/relations.ts +++ b/api/src/services/relations.ts @@ -25,7 +25,9 @@ export class RelationsService extends ItemsService { | ParsedRelation | ParsedRelation[] | null; + const filteredResults = await this.filterForbidden(results); + return filteredResults; } @@ -58,6 +60,7 @@ export class RelationsService extends ItemsService { this.accountability?.role || null, 'read' ); + const allowedFields = await this.permissionsService.getAllowedFields( this.accountability?.role || null, 'read' diff --git a/api/src/services/server.ts b/api/src/services/server.ts index 8c24228ae9..f243647f25 100644 --- a/api/src/services/server.ts +++ b/api/src/services/server.ts @@ -2,7 +2,6 @@ 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'; @@ -16,31 +15,57 @@ export class ServerService { this.accountability = options?.accountability || null; } - serverInfo() { - if (this.accountability?.admin !== true) { - throw new ForbiddenException(); - } + async serverInfo() { + const info: Record = {}; - const osType = os.type() === 'Darwin' ? 'macOS' : os.type(); - const osVersion = - osType === 'macOS' - ? `${macosRelease().name} (${macosRelease().version})` - : os.release(); + const projectInfo = await this.knex + .select( + 'project_name', + 'project_logo', + 'project_color', + 'public_foreground', + 'public_background', + 'public_note', + 'custom_css' + ) + .from('directus_settings') + .first(); - return { - directus: { + info.project = projectInfo + ? { + project_name: projectInfo.project_name, + project_logo: projectInfo.project_logo, + project_color: projectInfo.project_color, + public_foreground: projectInfo.public_foreground, + public_background: projectInfo.public_background, + public_note: projectInfo.public_note, + custom_css: projectInfo.custom_css, + } + : null; + + if (this.accountability?.admin === true) { + const osType = os.type() === 'Darwin' ? 'macOS' : os.type(); + + const osVersion = + osType === 'macOS' + ? `${macosRelease().name} (${macosRelease().version})` + : os.release(); + + info.directus = { version, - }, - node: { + }; + info.node = { version: process.versions.node, uptime: Math.round(process.uptime()), - }, - os: { + }; + info.os = { type: osType, version: osVersion, uptime: Math.round(os.uptime()), totalmem: os.totalmem(), - }, - }; + }; + } + + return info; } } diff --git a/api/src/types/permissions.ts b/api/src/types/permissions.ts index bbd4ec01c8..40f93ac8ad 100644 --- a/api/src/types/permissions.ts +++ b/api/src/types/permissions.ts @@ -9,5 +9,5 @@ export type Permission = { validation: Record; limit: number | null; presets: Record | null; - fields: string | null; + fields: string[] | null; }; diff --git a/api/src/utils/apply-query.ts b/api/src/utils/apply-query.ts index d0ead782c3..13f3f6ad93 100644 --- a/api/src/utils/apply-query.ts +++ b/api/src/utils/apply-query.ts @@ -1,7 +1,7 @@ import { QueryBuilder } from 'knex'; import { Query, Filter } from '../types'; import database, { schemaInspector } from '../database'; -import { clone } from 'lodash'; +import { clone, isPlainObject } from 'lodash'; export default async function applyQuery(collection: string, dbQuery: QueryBuilder, query: Query) { if (query.filter) { @@ -46,116 +46,244 @@ export default async function applyQuery(collection: string, dbQuery: QueryBuild } } -export async function applyFilter(dbQuery: QueryBuilder, filter: Filter, collection: string) { - for (const [key, value] of Object.entries(filter)) { - if (key === '_or') { - value.forEach((subFilter: Record) => { - dbQuery.orWhere((subQuery) => applyFilter(subQuery, subFilter, collection)); - }); +export async function applyFilter(rootQuery: QueryBuilder, rootFilter: Filter, collection: string) { + const relations = await database.select('*').from('directus_relations'); - continue; + addWhereClauses(rootQuery, rootFilter, collection); + addJoins(rootQuery, rootFilter, collection); + + function addWhereClauses(dbQuery: QueryBuilder, filter: Filter, collection: string) { + for (const [key, value] of Object.entries(filter)) { + if (key === '_or') { + /** @NOTE these callback functions aren't called until Knex runs the query */ + dbQuery.orWhere((subQuery) => { + value.forEach((subFilter: Record) => { + addWhereClauses(subQuery, subFilter, collection); + }); + }); + + continue; + } + + if (key === '_and') { + /** @NOTE these callback functions aren't called until Knex runs the query */ + dbQuery.andWhere((subQuery) => { + value.forEach((subFilter: Record) => { + addWhereClauses(subQuery, subFilter, collection); + }); + }); + + continue; + } + + const filterPath = getFilterPath(key, value); + const { operator: filterOperator, value: filterValue } = getOperation(key, value); + + if (filterPath.length > 1) { + const columnName = getWhereColumn(filterPath, collection); + applyFilterToQuery(columnName, filterOperator, filterValue); + } else { + applyFilterToQuery(`${collection}.${filterPath[0]}`, filterOperator, filterValue); + } } - if (key === '_and') { - value.forEach((subFilter: Record) => { - dbQuery.andWhere((subQuery) => applyFilter(subQuery, subFilter, collection)); - }); + function applyFilterToQuery(key: string, operator: string, compareValue: any) { + if (operator === '_eq') { + dbQuery.where({ [key]: compareValue }); + } - continue; + if (operator === '_neq') { + dbQuery.whereNot({ [key]: compareValue }); + } + + if (operator === '_contains') { + dbQuery.where(key, 'like', `%${compareValue}%`); + } + + if (operator === '_ncontains') { + dbQuery.where(key, 'like', `%${compareValue}%`); + } + + if (operator === '_gt') { + dbQuery.where(key, '>', compareValue); + } + + if (operator === '_gte') { + dbQuery.where(key, '>=', compareValue); + } + + if (operator === '_lt') { + dbQuery.where(key, '<', compareValue); + } + + if (operator === '_lte') { + dbQuery.where(key, '<=', compareValue); + } + + if (operator === '_in') { + let value = compareValue; + if (typeof value === 'string') value = value.split(','); + + dbQuery.whereIn(key, value as string[]); + } + + if (operator === '_nin') { + let value = compareValue; + if (typeof value === 'string') value = value.split(','); + + dbQuery.whereNotIn(key, value as string[]); + } + + if (operator === '_null') { + dbQuery.whereNull(key); + } + + if (operator === '_nnull') { + dbQuery.whereNotNull(key); + } + + if (operator === '_empty') { + dbQuery.andWhere((query) => { + query.whereNull(key); + query.orWhere(key, '=', ''); + }); + } + + if (operator === '_nempty') { + dbQuery.andWhere((query) => { + query.whereNotNull(key); + query.orWhere(key, '!=', ''); + }); + } + + if (operator === '_between') { + let value = compareValue; + if (typeof value === 'string') value = value.split(','); + + dbQuery.whereBetween(key, value); + } + + if (operator === '_nbetween') { + let value = compareValue; + if (typeof value === 'string') value = value.split(','); + + dbQuery.whereNotBetween(key, value); + } } - const filterPath = getFilterPath(key, value); - const { operator: filterOperator, value: filterValue } = getOperation(key, value); + function getWhereColumn(path: string[], collection: string) { + path = clone(path); - const column = - filterPath.length > 1 - ? await applyJoins(dbQuery, filterPath, collection) - : `${collection}.${filterPath[0]}`; + let columnName = ''; - applyFilterToQuery(column, filterOperator, filterValue); + followRelation(path); + + return columnName; + + function followRelation(pathParts: string[], parentCollection: string = collection) { + const relation = relations.find((relation) => { + return ( + (relation.many_collection === parentCollection && + relation.many_field === pathParts[0]) || + (relation.one_collection === parentCollection && + relation.one_field === pathParts[0]) + ); + }); + + if (!relation) return; + + const isM2O = + relation.many_collection === parentCollection && + relation.many_field === pathParts[0]; + + pathParts.shift(); + + const parent = isM2O ? relation.one_collection! : relation.many_collection; + + if (pathParts.length === 1) { + columnName = `${parent}.${pathParts[0]}`; + } + + if (pathParts.length) { + followRelation(pathParts, parent); + } + } + } } - function applyFilterToQuery(key: string, operator: string, compareValue: any) { - if (operator === '_eq') { - dbQuery.where({ [key]: compareValue }); + /** + * @NOTE Yes this is very similar in structure and functionality as the other loop. However, + * due to the order of execution that Knex has in the nested andWhere / orWhere structures, + * joins that are added in there aren't added in time + */ + function addJoins(dbQuery: QueryBuilder, filter: Filter, collection: string) { + for (const [key, value] of Object.entries(filter)) { + if (key === '_or') { + value.forEach((subFilter: Record) => { + addJoins(dbQuery, subFilter, collection); + }); + + continue; + } + + if (key === '_and') { + value.forEach((subFilter: Record) => { + addJoins(dbQuery, subFilter, collection); + }); + + continue; + } + + const filterPath = getFilterPath(key, value); + + if (filterPath.length > 1) { + addJoin(filterPath, collection); + } } - if (operator === '_neq') { - dbQuery.whereNot({ [key]: compareValue }); - } + function addJoin(path: string[], collection: string) { + path = clone(path); - if (operator === '_contains') { - dbQuery.where(key, 'like', `%${compareValue}%`); - } + followRelation(path); - if (operator === '_ncontains') { - dbQuery.where(key, 'like', `%${compareValue}%`); - } + function followRelation(pathParts: string[], parentCollection: string = collection) { + const relation = relations.find((relation) => { + return ( + (relation.many_collection === parentCollection && + relation.many_field === pathParts[0]) || + (relation.one_collection === parentCollection && + relation.one_field === pathParts[0]) + ); + }); - if (operator === '_gt') { - dbQuery.where(key, '>', compareValue); - } + if (!relation) return; - if (operator === '_gte') { - dbQuery.where(key, '>=', compareValue); - } + const isM2O = + relation.many_collection === parentCollection && + relation.many_field === pathParts[0]; - if (operator === '_lt') { - dbQuery.where(key, '<', compareValue); - } + if (isM2O) { + dbQuery.leftJoin( + relation.one_collection!, + `${parentCollection}.${relation.many_field}`, + `${relation.one_collection}.${relation.one_primary}` + ); + } else { + dbQuery.leftJoin( + relation.many_collection, + `${parentCollection}.${relation.one_primary}`, + `${relation.many_collection}.${relation.many_field}` + ); + } - if (operator === '_lte') { - dbQuery.where(key, '<=', compareValue); - } + pathParts.shift(); - if (operator === '_in') { - let value = compareValue; - if (typeof value === 'string') value = value.split(','); + const parent = isM2O ? relation.one_collection! : relation.many_collection; - dbQuery.whereIn(key, value as string[]); - } - - if (operator === '_nin') { - let value = compareValue; - if (typeof value === 'string') value = value.split(','); - - dbQuery.whereNotIn(key, value as string[]); - } - - if (operator === '_null') { - dbQuery.whereNull(key); - } - - if (operator === '_nnull') { - dbQuery.whereNotNull(key); - } - - if (operator === '_empty') { - dbQuery.andWhere((query) => { - query.whereNull(key); - query.orWhere(key, '=', ''); - }); - } - - if (operator === '_nempty') { - dbQuery.andWhere((query) => { - query.whereNotNull(key); - query.orWhere(key, '!=', ''); - }); - } - - if (operator === '_between') { - let value = compareValue; - if (typeof value === 'string') value = value.split(','); - - dbQuery.whereBetween(key, value); - } - - if (operator === '_nbetween') { - let value = compareValue; - if (typeof value === 'string') value = value.split(','); - - dbQuery.whereNotBetween(key, value); + if (pathParts.length) { + followRelation(pathParts, parent); + } + } } } } @@ -163,7 +291,11 @@ export async function applyFilter(dbQuery: QueryBuilder, filter: Filter, collect function getFilterPath(key: string, value: Record) { const path = [key]; - if (Object.keys(value)[0].startsWith('_') === false) { + if (Object.keys(value)[0].startsWith('_') === true) { + return path; + } + + if (isPlainObject(value)) { path.push(...getFilterPath(Object.keys(value)[0], Object.values(value)[0])); } @@ -171,57 +303,11 @@ function getFilterPath(key: string, value: Record) { } function getOperation(key: string, value: Record): { operator: string; value: any } { - if (key.startsWith('_') && key !== '_and' && key !== '_or') + if (key.startsWith('_') && key !== '_and' && key !== '_or') { return { operator: key as string, value }; + } else if (isPlainObject(value) === false) { + return { operator: '_eq', value }; + } + return getOperation(Object.keys(value)[0], Object.values(value)[0]); } - -async function applyJoins(dbQuery: QueryBuilder, path: string[], collection: string) { - path = clone(path); - - let keyName = ''; - - await addJoins(path); - - return keyName; - - async function addJoins(pathParts: string[], parentCollection: string = collection) { - const relation = await database - .select('*') - .from('directus_relations') - .where({ one_collection: parentCollection, one_field: pathParts[0] }) - .orWhere({ many_collection: parentCollection, many_field: pathParts[0] }) - .first(); - - if (!relation) return; - - const isM2O = - relation.many_collection === parentCollection && relation.many_field === pathParts[0]; - - if (isM2O) { - dbQuery.leftJoin( - relation.one_collection, - `${parentCollection}.${relation.many_field}`, - `${relation.one_collection}.${relation.one_primary}` - ); - } else { - dbQuery.leftJoin( - relation.many_collection, - `${relation.one_collection}.${relation.one_primary}`, - `${relation.many_collection}.${relation.many_field}` - ); - } - - pathParts.shift(); - - const parent = isM2O ? relation.one_collection : relation.many_collection; - - if (pathParts.length === 1) { - keyName = `${parent}.${pathParts[0]}`; - } - - if (pathParts.length) { - await addJoins(pathParts, parent); - } - } -} diff --git a/api/src/utils/to-array.ts b/api/src/utils/to-array.ts index 77cf11fae2..ae20f6862b 100644 --- a/api/src/utils/to-array.ts +++ b/api/src/utils/to-array.ts @@ -1,3 +1,7 @@ export function toArray(val: T | T[]): T[] { + if (typeof val === 'string') { + return (val.split(',') as unknown) as T[]; + } + return Array.isArray(val) ? val : [val]; } diff --git a/app/package-lock.json b/app/package-lock.json index c4b4966a16..8720d18275 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -6601,51 +6601,6 @@ "tslint": "^5.20.1", "webpack": "^4.0.0", "yorkie": "^2.0.0" - }, - "dependencies": { - "chalk": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", - "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", - "dev": true, - "optional": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "fork-ts-checker-webpack-plugin-v5": { - "version": "npm:fork-ts-checker-webpack-plugin@5.2.0", - "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-5.2.0.tgz", - "integrity": "sha512-NEKcI0+osT5bBFZ1SFGzJMQETjQWZrSvMO1g0nAR/w0t328Z41eN8BJEIZyFCl2HsuiJpa9AN474Nh2qLVwGLQ==", - "dev": true, - "optional": true, - "requires": { - "@babel/code-frame": "^7.8.3", - "@types/json-schema": "^7.0.5", - "chalk": "^4.1.0", - "cosmiconfig": "^6.0.0", - "deepmerge": "^4.2.2", - "fs-extra": "^9.0.0", - "memfs": "^3.1.2", - "minimatch": "^3.0.4", - "schema-utils": "2.7.0", - "semver": "^7.3.2", - "tapable": "^1.0.0" - } - }, - "schema-utils": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.0.tgz", - "integrity": "sha512-0ilKFI6QQF5nxDZLFn2dMjvc4hjg/Wkg7rHd3jK6/A4a1Hl9VFdQWvgB1UMGoU94pad1P/8N7fMcEnLnSiju8A==", - "dev": true, - "optional": true, - "requires": { - "@types/json-schema": "^7.0.4", - "ajv": "^6.12.2", - "ajv-keywords": "^3.4.1" - } - } } }, "@vue/cli-plugin-unit-jest": { @@ -6785,17 +6740,6 @@ "unique-filename": "^1.1.1" } }, - "chalk": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", - "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", - "dev": true, - "optional": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, "cliui": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", @@ -6879,18 +6823,6 @@ "graceful-fs": "^4.1.6" } }, - "loader-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.0.tgz", - "integrity": "sha512-rP4F0h2RaWSvPEkD7BLDFQnvSf+nK+wr3ESUjNTyAGobqrijmW92zc+SO6d4p4B1wh7+B/Jg1mkQe5NYUEHtHQ==", - "dev": true, - "optional": true, - "requires": { - "big.js": "^5.2.2", - "emojis-list": "^3.0.0", - "json5": "^2.1.2" - } - }, "locate-path": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", @@ -7004,18 +6936,6 @@ "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", "dev": true }, - "vue-loader-v16": { - "version": "npm:vue-loader@16.0.0-beta.8", - "resolved": "https://registry.npmjs.org/vue-loader/-/vue-loader-16.0.0-beta.8.tgz", - "integrity": "sha512-oouKUQWWHbSihqSD7mhymGPX1OQ4hedzAHyvm8RdyHh6m3oIvoRF+NM45i/bhNOlo8jCnuJhaSUf/6oDjv978g==", - "dev": true, - "optional": true, - "requires": { - "chalk": "^4.1.0", - "hash-sum": "^2.0.0", - "loader-utils": "^2.0.0" - } - }, "wrap-ansi": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", @@ -11744,6 +11664,51 @@ } } }, + "fork-ts-checker-webpack-plugin-v5": { + "version": "npm:fork-ts-checker-webpack-plugin@5.2.0", + "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-5.2.0.tgz", + "integrity": "sha512-NEKcI0+osT5bBFZ1SFGzJMQETjQWZrSvMO1g0nAR/w0t328Z41eN8BJEIZyFCl2HsuiJpa9AN474Nh2qLVwGLQ==", + "dev": true, + "optional": true, + "requires": { + "@babel/code-frame": "^7.8.3", + "@types/json-schema": "^7.0.5", + "chalk": "^4.1.0", + "cosmiconfig": "^6.0.0", + "deepmerge": "^4.2.2", + "fs-extra": "^9.0.0", + "memfs": "^3.1.2", + "minimatch": "^3.0.4", + "schema-utils": "2.7.0", + "semver": "^7.3.2", + "tapable": "^1.0.0" + }, + "dependencies": { + "chalk": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", + "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", + "dev": true, + "optional": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "schema-utils": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.0.tgz", + "integrity": "sha512-0ilKFI6QQF5nxDZLFn2dMjvc4hjg/Wkg7rHd3jK6/A4a1Hl9VFdQWvgB1UMGoU94pad1P/8N7fMcEnLnSiju8A==", + "dev": true, + "optional": true, + "requires": { + "@types/json-schema": "^7.0.4", + "ajv": "^6.12.2", + "ajv-keywords": "^3.4.1" + } + } + } + }, "form-data": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", @@ -20377,6 +20342,43 @@ } } }, + "vue-loader-v16": { + "version": "npm:vue-loader@16.0.0-beta.8", + "resolved": "https://registry.npmjs.org/vue-loader/-/vue-loader-16.0.0-beta.8.tgz", + "integrity": "sha512-oouKUQWWHbSihqSD7mhymGPX1OQ4hedzAHyvm8RdyHh6m3oIvoRF+NM45i/bhNOlo8jCnuJhaSUf/6oDjv978g==", + "dev": true, + "optional": true, + "requires": { + "chalk": "^4.1.0", + "hash-sum": "^2.0.0", + "loader-utils": "^2.0.0" + }, + "dependencies": { + "chalk": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", + "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", + "dev": true, + "optional": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "loader-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.0.tgz", + "integrity": "sha512-rP4F0h2RaWSvPEkD7BLDFQnvSf+nK+wr3ESUjNTyAGobqrijmW92zc+SO6d4p4B1wh7+B/Jg1mkQe5NYUEHtHQ==", + "dev": true, + "optional": true, + "requires": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + } + } + } + }, "vue-router": { "version": "3.4.6", "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-3.4.6.tgz", diff --git a/app/src/app.vue b/app/src/app.vue index 3d63279fbb..40f01bd710 100644 --- a/app/src/app.vue +++ b/app/src/app.vue @@ -34,25 +34,25 @@ import setFavicon from '@/utils/set-favicon'; export default defineComponent({ setup() { - const { useAppStore, useUserStore, useSettingsStore } = stores; + const { useAppStore, useUserStore, useServerStore } = stores; const appStore = useAppStore(); const userStore = useUserStore(); - const settingsStore = useSettingsStore(); + const serverStore = useServerStore(); const { hydrating, sidebarOpen } = toRefs(appStore.state); const brandStyle = computed(() => { return { - '--brand': settingsStore.state.settings?.project_color || 'var(--primary)', + '--brand': serverStore.state.info?.project?.project_color || 'var(--primary)', }; }); watch( - [() => settingsStore.state.settings?.project_color, () => settingsStore.state.settings?.project_logo], + [() => serverStore.state.info?.project?.project_color, () => serverStore.state.info?.project?.project_logo], () => { - const hasCustomLogo = !!settingsStore.state.settings?.project_logo; - setFavicon(settingsStore.state.settings?.project_color || '#2f80ed', hasCustomLogo); + const hasCustomLogo = !!serverStore.state.info?.project?.project_logo; + setFavicon(serverStore.state.info?.project?.project_color || '#2f80ed', hasCustomLogo); } ); @@ -90,14 +90,14 @@ export default defineComponent({ ); watch( - () => settingsStore.state.settings?.project_name, + () => serverStore.state.info?.project?.project_name, (projectName) => { - document.title = projectName; + document.title = projectName || 'Directus'; } ); const customCSS = computed(() => { - return settingsStore.state?.settings?.custom_css || ''; + return serverStore.state?.info?.project?.custom_css || ''; }); const error = computed(() => appStore.state.error); diff --git a/app/src/composables/use-collection/use-collection.ts b/app/src/composables/use-collection/use-collection.ts index d14374bf1c..e9efcc3143 100644 --- a/app/src/composables/use-collection/use-collection.ts +++ b/app/src/composables/use-collection/use-collection.ts @@ -16,6 +16,20 @@ export function useCollection(collectionKey: string | Ref) { return fieldsStore.getFieldsForCollection(collection.value); }); + const defaults = computed(() => { + if (!fields.value) return {}; + + const defaults: Record = {}; + + for (const field of fields.value) { + if (field.schema?.default_value) { + defaults[field.field] = field.schema.default_value; + } + } + + return defaults; + }); + const primaryKeyField = computed(() => { // Every collection has a primary key; rules of the land // eslint-disable-next-line @typescript-eslint/no-non-null-assertion @@ -32,5 +46,5 @@ export function useCollection(collectionKey: string | Ref) { return info.value?.meta?.sort_field || null; }); - return { info, fields, primaryKeyField, userCreatedField, sortField }; + return { info, fields, defaults, primaryKeyField, userCreatedField, sortField }; } diff --git a/app/src/composables/use-item/use-item.ts b/app/src/composables/use-item/use-item.ts index a1f0466456..d0850b9d13 100644 --- a/app/src/composables/use-item/use-item.ts +++ b/app/src/composables/use-item/use-item.ts @@ -16,7 +16,7 @@ export function useItem(collection: Ref, primaryKey: Ref>({}); const isNew = computed(() => primaryKey.value === '+'); const isBatch = computed(() => typeof primaryKey.value === 'string' && primaryKey.value.includes(',')); const isSingle = computed(() => !!collectionInfo.value?.meta?.singleton); diff --git a/app/src/hydrate.ts b/app/src/hydrate.ts index 603ed6a16a..d686c18e4a 100644 --- a/app/src/hydrate.ts +++ b/app/src/hydrate.ts @@ -6,6 +6,7 @@ import { useRequestsStore, usePresetsStore, useSettingsStore, + useServerStore, useLatencyStore, useRelationsStore, usePermissionsStore, @@ -30,6 +31,7 @@ export function useStores( useRequestsStore, usePresetsStore, useSettingsStore, + useServerStore, useLatencyStore, useRelationsStore, usePermissionsStore, diff --git a/app/src/interfaces/file/index.ts b/app/src/interfaces/file/index.ts index db37f79171..3940bbbdad 100644 --- a/app/src/interfaces/file/index.ts +++ b/app/src/interfaces/file/index.ts @@ -8,6 +8,7 @@ export default defineInterface(({ i18n }) => ({ icon: 'note_add', component: InterfaceFile, types: ['uuid'], + localTypes: ['file'], relationship: 'm2o', options: [], recommendedDisplays: ['file'], diff --git a/app/src/interfaces/files/index.ts b/app/src/interfaces/files/index.ts index abc0db0cd2..78581adf2a 100644 --- a/app/src/interfaces/files/index.ts +++ b/app/src/interfaces/files/index.ts @@ -8,6 +8,7 @@ export default defineInterface(({ i18n }) => ({ icon: 'note_add', component: InterfaceFiles, types: ['alias'], + localTypes: ['files'], relationship: 'm2m', options: [], recommendedDisplays: ['files'], diff --git a/app/src/interfaces/image/index.ts b/app/src/interfaces/image/index.ts index 73daa4fce7..08e80f9335 100644 --- a/app/src/interfaces/image/index.ts +++ b/app/src/interfaces/image/index.ts @@ -8,6 +8,7 @@ export default defineInterface(({ i18n }) => ({ icon: 'insert_photo', component: InterfaceImage, types: ['uuid'], + localTypes: ['file'], relationship: 'm2o', options: [], recommendedDisplays: ['image'], diff --git a/app/src/interfaces/many-to-many/index.ts b/app/src/interfaces/many-to-many/index.ts index 506a5912ce..62ff560397 100644 --- a/app/src/interfaces/many-to-many/index.ts +++ b/app/src/interfaces/many-to-many/index.ts @@ -10,6 +10,7 @@ export default defineInterface(({ i18n }) => ({ component: InterfaceManyToMany, relationship: 'm2m', types: ['alias'], + localTypes: ['m2m'], options: Options, recommendedDisplays: ['related-values'], })); diff --git a/app/src/interfaces/many-to-one/index.ts b/app/src/interfaces/many-to-one/index.ts index addb0eac52..abab1d4154 100644 --- a/app/src/interfaces/many-to-one/index.ts +++ b/app/src/interfaces/many-to-one/index.ts @@ -10,6 +10,7 @@ export default defineInterface(({ i18n }) => ({ component: InterfaceManyToOne, types: ['uuid', 'string', 'text', 'integer', 'bigInteger'], relationship: 'm2o', + localTypes: ['m2o'], options: Options, recommendedDisplays: ['related-values'], })); diff --git a/app/src/interfaces/one-to-many/index.ts b/app/src/interfaces/one-to-many/index.ts index 9bb215b1e9..27c9e175fc 100644 --- a/app/src/interfaces/one-to-many/index.ts +++ b/app/src/interfaces/one-to-many/index.ts @@ -9,6 +9,7 @@ export default defineInterface(({ i18n }) => ({ icon: 'arrow_right_alt', component: InterfaceOneToMany, types: ['alias'], + localTypes: ['o2m'], relationship: 'o2m', options: Options, recommendedDisplays: ['related-values'], diff --git a/app/src/interfaces/types.ts b/app/src/interfaces/types.ts index 05352bd95a..c9c2d51ee3 100644 --- a/app/src/interfaces/types.ts +++ b/app/src/interfaces/types.ts @@ -1,6 +1,6 @@ import VueI18n from 'vue-i18n'; import { Component } from 'vue'; -import { Field, types } from '@/types'; +import { Field, types, localTypes } from '@/types'; export type InterfaceConfig = { id: string; @@ -10,6 +10,7 @@ export type InterfaceConfig = { component: Component; options: DeepPartial[] | Component; types: typeof types[number][]; + localTypes?: readonly typeof localTypes[number][]; relationship?: null | 'm2o' | 'o2m' | 'm2m' | 'translations'; hideLabel?: boolean; hideLoader?: boolean; diff --git a/app/src/interfaces/user/user.vue b/app/src/interfaces/user/user.vue index 28c7744fd7..00940ee42a 100644 --- a/app/src/interfaces/user/user.vue +++ b/app/src/interfaces/user/user.vue @@ -61,7 +61,7 @@ @click="setCurrent(item)" > - {{ userName(currentUser) }} + {{ userName(item) }} @@ -93,6 +93,7 @@ import useCollection from '@/composables/use-collection'; import api from '@/api'; import DrawerItem from '@/views/private/components/drawer-item'; import DrawerCollection from '@/views/private/components/drawer-collection'; +import { userName } from '@/utils/user-name'; export default defineComponent({ components: { DrawerItem, DrawerCollection }, @@ -141,6 +142,7 @@ export default defineComponent({ edits, stageEdits, editModalActive, + userName, }; function useCurrent() { diff --git a/app/src/interfaces/wysiwyg/tinymce-overrides.css b/app/src/interfaces/wysiwyg/tinymce-overrides.css index 716e9a1276..30665e7434 100644 --- a/app/src/interfaces/wysiwyg/tinymce-overrides.css +++ b/app/src/interfaces/wysiwyg/tinymce-overrides.css @@ -9,7 +9,17 @@ color: var(--foreground-normal); } -.tox .tox-tbtn svg { +.tox .tox-listbox__select-chevron svg, +.tox .tox-collection__item-caret svg { + fill: var(--foreground-normal); +} + +.tox .tox-swatches__picker-btn svg { + fill: var(--foreground-normal); +} + +.tox .tox-tbtn svg, +.tox .tox-tbtn:hover svg { fill: var(--foreground-normal); } @@ -97,6 +107,10 @@ left 0 top 0 var(--background-subdued); } +.tox .tox-pop__dialog .tox-toolbar { + margin-bottom: -2px; +} + body.dark .tox .tox-toolbar, body.dark .tox .tox-toolbar__primary, body.dark .tox .tox-toolbar__overflow { @@ -113,13 +127,34 @@ body.dark .tox .tox-toolbar__overflow { } } +.tox .tox-swatches__picker-btn, +.tox .tox-swatches__picker-btn:hover, +.tox .tox-swatches__picker-btn:active, +.tox .tox-split-button:hover { + -webkit-box-shadow: unset; + box-shadow: unset; +} + .tox .tox-tbtn--enabled, .tox .tox-tbtn--enabled:hover, -.tox .tox-tbtn:hover { +.tox .tox-split-button:hover, +.tox .tox-tbtn:hover, +.tox .tox-split-button:focus { color: var(--foreground-normal); background: var(--border-normal); } +.tox .tox-swatches__picker-btn:hover { + background: transparent; + border: none; +} + +.tox .tox-swatch:hover, +.tox .tox-swatch:focus { + -webkit-transform: scale(1.2); + transform: scale(1.2); +} + .mce-content-body { margin: 20px; } @@ -206,20 +241,16 @@ body.dark .tox .tox-toolbar__overflow { color: var(--foreground-normal); } +.tox .tox-collection--list .tox-collection__item--enabled, .tox .tox-collection--list .tox-collection__item--active { color: var(--foreground-normal) !important; - background-color: var(--background-page) !important; -} - -.tox .tox-collection--list .tox-collection__item--enabled { - color: var(--foreground-normal); - background-color: var(--background-page); + background-color: var(--background-normal-alt) !important; } .tox .tox-textfield:focus, .tox .tox-selectfield select:focus, .tox .tox-textarea:focus { - border-color: var(--foreground-subdued); + border-color: var(--primary); } .tox .tox-button { @@ -314,8 +345,39 @@ body.dark .tox .tox-toolbar__overflow { background-color: var(--background-normal-alt); } +.tox .tox-pop__dialog, +.tox:not([dir='rtl']) .tox-toolbar__group:not(:last-of-type), +.tox .tox-collection--list .tox-collection__group { + border-color: var(--border-normal); +} + + +.tox .tox-insert-table-picker__label { + color: var(--foreground-normal); +} + +.tox .tox-insert-table-picker > div { + border-color: var(--border-normal); +} + +.tox .tox-insert-table-picker .tox-insert-table-picker__selected { + border-color: var(--primary); +} + +.tox .tox-pop.tox-pop--top::after { + border-bottom-color: var(--background-subdued); +} + +.tox .tox-pop.tox-pop--top::before { + border-bottom-color: var(--border-normal); +} + +.tox .tox-dialog-wrap__backdrop .tox-rgba-preview { + visibility: hidden; +} + @media screen and (max-width: 767px) { .tox .tox-dialog__body-nav-item { text-align: center; } -} \ No newline at end of file +} diff --git a/app/src/modules/collections/routes/item.vue b/app/src/modules/collections/routes/item.vue index df71a117f8..2b8cbdf319 100644 --- a/app/src/modules/collections/routes/item.vue +++ b/app/src/modules/collections/routes/item.vue @@ -134,7 +134,7 @@ rounded icon :loading="saving" - :disabled="saveAllowed === false || hasEdits === false" + :disabled="isSavable === false" v-tooltip.bottom="saveAllowed ? $t('save') : $t('not_allowed')" @click="saveAndQuit" > @@ -143,7 +143,7 @@ @@ -33,8 +36,10 @@ import { defineComponent, PropType, ref, computed, toRefs, onUnmounted } from '@ import { Filter } from '@/types'; import usePreset from '@/composables/use-preset'; import useCollection from '@/composables/use-collection'; +import SearchInput from '@/views/private/components/search-input'; export default defineComponent({ + components: { SearchInput }, props: { active: { type: Boolean, @@ -65,7 +70,7 @@ export default defineComponent({ const { collection } = toRefs(props); const { info: collectionInfo } = useCollection(collection); - const { layout, layoutOptions, layoutQuery } = usePreset(collection); + const { layout, layoutOptions, layoutQuery, searchQuery } = usePreset(collection); // This is a local copy of the layout. This means that we can sync it the layout without // having use-preset auto-save the values @@ -73,7 +78,18 @@ export default defineComponent({ const localOptions = ref(layoutOptions.value); const localQuery = ref(layoutQuery.value); - return { save, cancel, _active, _selection, onSelect, localLayout, localOptions, localQuery, collectionInfo }; + return { + save, + cancel, + _active, + _selection, + onSelect, + localLayout, + localOptions, + localQuery, + collectionInfo, + searchQuery, + }; function useActiveState() { const localActive = ref(false); diff --git a/app/src/views/private/components/project-info/project-info.vue b/app/src/views/private/components/project-info/project-info.vue index 028cdb862f..952d79722f 100644 --- a/app/src/views/private/components/project-info/project-info.vue +++ b/app/src/views/private/components/project-info/project-info.vue @@ -8,16 +8,16 @@ diff --git a/package-lock.json b/package-lock.json index 2bcc1aa84e..008b35c12c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15912,6 +15912,7 @@ "lodash": "^4.17.19", "macos-release": "^2.4.1", "memcached": "^2.2.2", + "mime-types": "^2.1.27", "ms": "^2.1.2", "mssql": "^6.2.0", "mysql": "^2.18.1", @@ -15925,6 +15926,7 @@ "pg": "^8.4.1", "pino": "^6.4.1", "pino-colada": "^2.1.0", + "qs": "^6.9.4", "rate-limiter-flexible": "^2.1.10", "resolve-cwd": "^3.0.0", "sharp": "^0.25.4", @@ -15987,6 +15989,11 @@ "integrity": "sha512-64/bYByMrhWULUaCd+6/72c9PMWhiVFs3EVxl9Ct6a3v/U8+rKgqP2w+kKg/BIGgMJyB+Bk/eNivT32Al+Jghw==", "optional": true }, + "qs": { + "version": "6.9.4", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.9.4.tgz", + "integrity": "sha512-A1kFqHekCTM7cz0udomYUoYNWjBebHm/5wzU/XqrBRBNWectVH0QIiN+NEcZ0Dte5hvzHwbr8+XQmguPhJ6WdQ==" + }, "uuid": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.0.tgz",