mirror of
https://github.com/directus/directus.git
synced 2026-04-25 03:00:53 -04:00
Merge branch 'main' into feature-export
This commit is contained in:
8
.gitignore
vendored
8
.gitignore
vendored
@@ -4,11 +4,3 @@ node_modules
|
||||
.env
|
||||
npm-debug.log
|
||||
lerna-debug.log
|
||||
|
||||
# This is always a point of debate, but:
|
||||
# * package-lock is auto-generate by lerna, which generates it differently from npm
|
||||
# * We actually _want_ people to be on the latests semver versions for local development
|
||||
# * package-locks are ignored when publishing to NPM _anyway_, so it doesn't matter for releases
|
||||
# * the app is bundled on release, so its package versions are locked by definition
|
||||
|
||||
package-lock.json
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"singleQuote": true,
|
||||
"useTabs": true
|
||||
"printWidth": 100,
|
||||
"singleQuote": true,
|
||||
"useTabs": true
|
||||
}
|
||||
|
||||
@@ -43,10 +43,9 @@ Pull requests are more than welcome and always appreciated. Seeing this is in ac
|
||||
|
||||
Directus is a GPLv3-licensed open source project with development made possible by support from our core team, contributors, and sponsors. It's not easy building premium open-source software; if you would like to help ensure Directus stays free, please consider becoming a sponsor.
|
||||
|
||||
* [Support us through GitHub Sponsors](https://github.com/sponsors/directus)
|
||||
* [One-time donation through PayPal](https://www.paypal.me/supportdirectus)
|
||||
- [Support us through GitHub Sponsors](https://github.com/sponsors/directus)
|
||||
- [One-time donation through PayPal](https://www.paypal.me/supportdirectus)
|
||||
|
||||
## 📄 License
|
||||
|
||||
Directus is released under [the GPLv3 license](./license). Monospace Inc. owns all Directus trademarks and logos on behalf of our project's community. Copyright © 2006-2020, Monospace Inc.
|
||||
|
||||
|
||||
@@ -19,6 +19,52 @@ DB_PASSWORD="psql1234"
|
||||
## SQLite Example
|
||||
# DB_FILENAME="./data.db"
|
||||
|
||||
####################################################################################################
|
||||
# Rate Limiting
|
||||
|
||||
RATE_LIMITER_ENABLED=true
|
||||
|
||||
RATE_LIMITER_POINTS=50
|
||||
RATE_LIMITER_DURATION=1
|
||||
|
||||
RATE_LIMITER_STORE=memory # memory | redis | memcache
|
||||
|
||||
## Redis (see https://github.com/animir/node-rate-limiter-flexible/wiki/Redis and
|
||||
## https://www.npmjs.com/package/ioredis#connect-to-redis)
|
||||
# RATE_LIMITER_EXEC_EVENLY=false
|
||||
# RATE_LIMITER_BLOCK_DURATION=0
|
||||
# RATE_LIMITER_KEY_PREFIX=rlflx
|
||||
|
||||
# RATE_LIMITER_REDIS="redis://:authpassword@127.0.0.1:6380/4"
|
||||
# --OR--
|
||||
# RATE_LIMITER_REDIS_HOST="127.0.0.1"
|
||||
# RATE_LIMITER_REDIS_PORT="127.0.0.1"
|
||||
# RATE_LIMITER_REDIS_PASSWORD="127.0.0.1"
|
||||
# RATE_LIMITER_REDIS_DB="127.0.0.1"
|
||||
|
||||
## Memcache (see https://github.com/animir/node-rate-limiter-flexible/wiki/Memcache and
|
||||
## https://www.npmjs.com/package/memcached)
|
||||
# RATE_LIMITER_MEMCACHE='localhost:11211'
|
||||
|
||||
####################################################################################################
|
||||
# Caching
|
||||
|
||||
CACHE_ENABLED=true
|
||||
CACHE_TTL="30m"
|
||||
CACHE_NAMESPACE="directus-cache"
|
||||
CACHE_STORE=memory # memory | redis | memcache
|
||||
|
||||
# CACHE_REDIS="redis://:authpassword@127.0.0.1:6380/4"
|
||||
# --OR--
|
||||
# CACHE_REDIS_HOST="127.0.0.1"
|
||||
# CACHE_REDIS_PORT="127.0.0.1"
|
||||
# CACHE_REDIS_PASSWORD="127.0.0.1"
|
||||
# CACHE_REDIS_DB="127.0.0.1"
|
||||
|
||||
## Memcache (see https://github.com/animir/node-rate-limiter-flexible/wiki/Memcache and
|
||||
## https://www.npmjs.com/package/memcached)
|
||||
# CACHE_MEMCACHE='localhost:11211'
|
||||
|
||||
####################################################################################################
|
||||
# File Storage
|
||||
|
||||
|
||||
33272
api/package-lock.json
generated
Normal file
33272
api/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
336
api/package.json
336
api/package.json
@@ -1,168 +1,172 @@
|
||||
{
|
||||
"name": "directus",
|
||||
"version": "9.0.0-alpha.27",
|
||||
"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.",
|
||||
"keywords": [
|
||||
"directus",
|
||||
"realtime",
|
||||
"database",
|
||||
"content",
|
||||
"api",
|
||||
"rest",
|
||||
"graphql",
|
||||
"app",
|
||||
"dashboard",
|
||||
"headless",
|
||||
"cms",
|
||||
"mysql",
|
||||
"postgresql",
|
||||
"sqlite",
|
||||
"framework",
|
||||
"vue"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/directus/next.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/directus/next/issues"
|
||||
},
|
||||
"author": {
|
||||
"name": "Monospace Inc",
|
||||
"email": "info@monospace.io",
|
||||
"url": "https://monospace.io"
|
||||
},
|
||||
"maintainers": [
|
||||
{
|
||||
"name": "Rijk van Zanten",
|
||||
"email": "rijkvanzanten@me.com",
|
||||
"url": "https://github.com/rijkvanzanten"
|
||||
},
|
||||
{
|
||||
"name": "Ben Haynes",
|
||||
"email": "ben@rngr.org",
|
||||
"url": "https://github.com/benhaynes"
|
||||
}
|
||||
],
|
||||
"main": "dist/app.js",
|
||||
"bin": {
|
||||
"directus": "dist/cli/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env NODE_ENV=production node dist/server.js",
|
||||
"build": "rm -rf dist && tsc -b && copyfiles \"src/**/*.*\" -e \"src/**/*.ts\" -u 1 dist",
|
||||
"dev": "cross-env NODE_ENV=development LOG_LEVEL=trace ts-node-dev --files src/server.ts --respawn --watch \"src/**/*.ts\" --transpile-only",
|
||||
"cli": "cross-env NODE_ENV=development ts-node --script-mode --transpile-only src/cli/index.ts",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"LICENSE",
|
||||
"README.md",
|
||||
"example.env"
|
||||
],
|
||||
"dependencies": {
|
||||
"@directus/app": "^9.0.0-alpha.27",
|
||||
"@directus/format-title": "^3.2.0",
|
||||
"@slynova/flydrive": "^1.0.2",
|
||||
"@slynova/flydrive-gcs": "^1.0.2",
|
||||
"@slynova/flydrive-s3": "^1.0.2",
|
||||
"argon2": "^0.26.2",
|
||||
"atob": "^2.1.2",
|
||||
"axios": "^0.19.2",
|
||||
"body-parser": "^1.19.0",
|
||||
"busboy": "^0.3.1",
|
||||
"camelcase": "^6.0.0",
|
||||
"chalk": "^4.1.0",
|
||||
"commander": "^5.1.0",
|
||||
"cookie-parser": "^1.4.5",
|
||||
"cors": "^2.8.5",
|
||||
"csv-express": "^1.2.2",
|
||||
"dotenv": "^8.2.0",
|
||||
"execa": "^4.0.3",
|
||||
"exif-reader": "^1.0.3",
|
||||
"express": "^4.17.1",
|
||||
"express-async-handler": "^1.1.4",
|
||||
"express-pino-logger": "^5.0.0",
|
||||
"express-session": "^1.17.1",
|
||||
"fs-extra": "^9.0.1",
|
||||
"grant": "^5.3.0",
|
||||
"icc": "^2.0.0",
|
||||
"inquirer": "^7.3.3",
|
||||
"joi": "^17.1.1",
|
||||
"js-yaml": "^3.14.0",
|
||||
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"knex": "^0.21.4",
|
||||
"knex-schema-inspector": "0.0.9",
|
||||
"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",
|
||||
"ora": "^4.1.1",
|
||||
"otplib": "^12.0.1",
|
||||
"pino": "^6.4.1",
|
||||
"pino-colada": "^2.1.0",
|
||||
"resolve-cwd": "^3.0.0",
|
||||
"sharp": "^0.25.4",
|
||||
"uuid": "^8.3.0",
|
||||
"uuid-validate": "0.0.3"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"json2csv": "^5.0.1",
|
||||
"mssql": "^6.2.0",
|
||||
"mysql": "^2.18.1",
|
||||
"oracledb": "^5.0.0",
|
||||
"pg": "^8.3.3",
|
||||
"sqlite3": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/atob": "^2.1.2",
|
||||
"@types/busboy": "^0.2.3",
|
||||
"@types/clear": "^0.1.0",
|
||||
"@types/cookie-parser": "^1.4.2",
|
||||
"@types/cors": "^2.8.7",
|
||||
"@types/express": "^4.17.7",
|
||||
"@types/express-pino-logger": "^4.0.2",
|
||||
"@types/express-session": "^1.17.0",
|
||||
"@types/fs-extra": "^9.0.1",
|
||||
"@types/inquirer": "^6.5.0",
|
||||
"@types/joi": "^14.3.4",
|
||||
"@types/js-yaml": "^3.12.5",
|
||||
"@types/jsonwebtoken": "^8.5.0",
|
||||
"@types/lodash": "^4.14.159",
|
||||
"@types/ms": "^0.7.31",
|
||||
"@types/nodemailer": "^6.4.0",
|
||||
"@types/pino": "^6.3.0",
|
||||
"@types/sharp": "^0.25.1",
|
||||
"@types/uuid": "^8.0.0",
|
||||
"@types/uuid-validate": "0.0.1",
|
||||
"concat-map": "0.0.1",
|
||||
"copyfiles": "^2.3.0",
|
||||
"cross-env": "^7.0.2",
|
||||
"eslint": "^7.6.0",
|
||||
"eslint-plugin-prettier": "^3.1.4",
|
||||
"husky": "^4.2.5",
|
||||
"lint-staged": "^10.2.11",
|
||||
"prettier": "^2.0.5",
|
||||
"ts-node": "^8.10.2",
|
||||
"ts-node-dev": "^1.0.0-pre.56",
|
||||
"tslint": "^6.1.3",
|
||||
"typescript": "^3.9.7"
|
||||
},
|
||||
"husky": {
|
||||
"hooks": {
|
||||
"pre-commit": "npx lint-staged"
|
||||
}
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,ts}": [
|
||||
"prettier --write"
|
||||
]
|
||||
},
|
||||
"gitHead": "c22537b6d0dd7dc6cc651a9200a68f6b060353d4"
|
||||
"name": "directus",
|
||||
"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.",
|
||||
"keywords": [
|
||||
"directus",
|
||||
"realtime",
|
||||
"database",
|
||||
"content",
|
||||
"api",
|
||||
"rest",
|
||||
"graphql",
|
||||
"app",
|
||||
"dashboard",
|
||||
"headless",
|
||||
"cms",
|
||||
"mysql",
|
||||
"postgresql",
|
||||
"sqlite",
|
||||
"framework",
|
||||
"vue"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/directus/next.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/directus/next/issues"
|
||||
},
|
||||
"author": {
|
||||
"name": "Monospace Inc",
|
||||
"email": "info@monospace.io",
|
||||
"url": "https://monospace.io"
|
||||
},
|
||||
"maintainers": [
|
||||
{
|
||||
"name": "Rijk van Zanten",
|
||||
"email": "rijkvanzanten@me.com",
|
||||
"url": "https://github.com/rijkvanzanten"
|
||||
},
|
||||
{
|
||||
"name": "Ben Haynes",
|
||||
"email": "ben@rngr.org",
|
||||
"url": "https://github.com/benhaynes"
|
||||
}
|
||||
],
|
||||
"main": "dist/app.js",
|
||||
"bin": {
|
||||
"directus": "dist/cli/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env NODE_ENV=production node dist/server.js",
|
||||
"build": "rm -rf dist && tsc -b && copyfiles \"src/**/*.*\" -e \"src/**/*.ts\" -u 1 dist",
|
||||
"dev": "cross-env NODE_ENV=development LOG_LEVEL=trace ts-node-dev --files src/server.ts --respawn --watch \"src/**/*.ts\" --transpile-only",
|
||||
"cli": "cross-env NODE_ENV=development ts-node --script-mode --transpile-only src/cli/index.ts",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"LICENSE",
|
||||
"README.md",
|
||||
"example.env"
|
||||
],
|
||||
"dependencies": {
|
||||
"@directus/app": "^9.0.0-alpha.33",
|
||||
"@directus/format-title": "^3.2.0",
|
||||
"@slynova/flydrive": "^1.0.2",
|
||||
"@slynova/flydrive-gcs": "^1.0.2",
|
||||
"@slynova/flydrive-s3": "^1.0.2",
|
||||
"argon2": "^0.26.2",
|
||||
"atob": "^2.1.2",
|
||||
"axios": "^0.19.2",
|
||||
"body-parser": "^1.19.0",
|
||||
"busboy": "^0.3.1",
|
||||
"camelcase": "^6.0.0",
|
||||
"chalk": "^4.1.0",
|
||||
"commander": "^5.1.0",
|
||||
"cookie-parser": "^1.4.5",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^8.2.0",
|
||||
"execa": "^4.0.3",
|
||||
"exif-reader": "^1.0.3",
|
||||
"express": "^4.17.1",
|
||||
"express-async-handler": "^1.1.4",
|
||||
"express-pino-logger": "^5.0.0",
|
||||
"express-session": "^1.17.1",
|
||||
"fs-extra": "^9.0.1",
|
||||
"grant": "^5.3.0",
|
||||
"icc": "^2.0.0",
|
||||
"inquirer": "^7.3.3",
|
||||
"joi": "^17.1.1",
|
||||
"js-yaml": "^3.14.0",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"keyv": "^4.0.1",
|
||||
"knex": "^0.21.4",
|
||||
"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",
|
||||
"ora": "^4.1.1",
|
||||
"otplib": "^12.0.1",
|
||||
"pino": "^6.4.1",
|
||||
"pino-colada": "^2.1.0",
|
||||
"rate-limiter-flexible": "^2.1.10",
|
||||
"resolve-cwd": "^3.0.0",
|
||||
"sharp": "^0.25.4",
|
||||
"uuid": "^8.3.0",
|
||||
"uuid-validate": "0.0.3"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@keyv/redis": "^2.1.2",
|
||||
"ioredis": "^4.17.3",
|
||||
"keyv-memcache": "^0.8.0",
|
||||
"memcached": "^2.2.2",
|
||||
"mssql": "^6.2.0",
|
||||
"mysql": "^2.18.1",
|
||||
"oracledb": "^5.0.0",
|
||||
"pg": "^8.3.3",
|
||||
"sqlite3": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/atob": "^2.1.2",
|
||||
"@types/busboy": "^0.2.3",
|
||||
"@types/clear": "^0.1.0",
|
||||
"@types/cookie-parser": "^1.4.2",
|
||||
"@types/cors": "^2.8.7",
|
||||
"@types/express": "^4.17.7",
|
||||
"@types/express-pino-logger": "^4.0.2",
|
||||
"@types/express-session": "^1.17.0",
|
||||
"@types/fs-extra": "^9.0.1",
|
||||
"@types/inquirer": "^6.5.0",
|
||||
"@types/joi": "^14.3.4",
|
||||
"@types/js-yaml": "^3.12.5",
|
||||
"@types/jsonwebtoken": "^8.5.0",
|
||||
"@types/keyv": "^3.1.1",
|
||||
"@types/lodash": "^4.14.159",
|
||||
"@types/ms": "^0.7.31",
|
||||
"@types/nodemailer": "^6.4.0",
|
||||
"@types/pino": "^6.3.0",
|
||||
"@types/sharp": "^0.25.1",
|
||||
"@types/uuid": "^8.0.0",
|
||||
"@types/uuid-validate": "0.0.1",
|
||||
"concat-map": "0.0.1",
|
||||
"copyfiles": "^2.3.0",
|
||||
"cross-env": "^7.0.2",
|
||||
"eslint": "^7.6.0",
|
||||
"eslint-plugin-prettier": "^3.1.4",
|
||||
"husky": "^4.2.5",
|
||||
"lint-staged": "^10.2.11",
|
||||
"prettier": "^2.0.5",
|
||||
"ts-node": "^8.10.2",
|
||||
"ts-node-dev": "^1.0.0-pre.56",
|
||||
"tslint": "^6.1.3",
|
||||
"typescript": "^3.9.7"
|
||||
},
|
||||
"husky": {
|
||||
"hooks": {
|
||||
"pre-commit": "npx lint-staged"
|
||||
}
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,ts}": [
|
||||
"prettier --write"
|
||||
]
|
||||
},
|
||||
"gitHead": "a7d3952ec3b812ee1eec2f1c7b9f44186cbe0498"
|
||||
}
|
||||
|
||||
107
api/src/app.ts
107
api/src/app.ts
@@ -3,15 +3,17 @@ import bodyParser from 'body-parser';
|
||||
import logger from './logger';
|
||||
import expressLogger from 'express-pino-logger';
|
||||
import path from 'path';
|
||||
import cors from 'cors';
|
||||
|
||||
import { validateEnv } from './utils/validate-env';
|
||||
import env from './env';
|
||||
|
||||
import errorHandler from './middleware/error-handler';
|
||||
|
||||
import cors from './middleware/cors';
|
||||
import rateLimiter from './middleware/rate-limiter';
|
||||
import { respond } from './middleware/respond';
|
||||
import cache from './middleware/cache';
|
||||
import extractToken from './middleware/extract-token';
|
||||
import authenticate from './middleware/authenticate';
|
||||
import responseManager from './middleware/response-manager';
|
||||
|
||||
import activityRouter from './controllers/activity';
|
||||
import assetsRouter from './controllers/assets';
|
||||
import authRouter from './controllers/auth';
|
||||
@@ -33,63 +35,68 @@ import utilsRouter from './controllers/utils';
|
||||
import webhooksRouter from './controllers/webhooks';
|
||||
|
||||
import notFoundHandler from './controllers/not-found';
|
||||
import sanitizeQuery from './middleware/sanitize-query';
|
||||
|
||||
const app = express().disable('x-powered-by').set('trust proxy', true);
|
||||
validateEnv(['KEY', 'SECRET']);
|
||||
|
||||
app.use(expressLogger({ logger }))
|
||||
.use(bodyParser.json())
|
||||
.use(extractToken)
|
||||
.use((req, res, next) => {
|
||||
res.setHeader('X-Powered-By', 'Directus');
|
||||
next();
|
||||
});
|
||||
const app = express();
|
||||
app.disable('x-powered-by');
|
||||
app.set('trust proxy', true);
|
||||
|
||||
if (env.CORS_ENABLED === 'true') {
|
||||
app.use(
|
||||
cors({
|
||||
origin: env.CORS_ORIGIN || true,
|
||||
methods: env.CORS_METHODS || 'GET,POST,PATCH,DELETE',
|
||||
allowedHeaders: env.CORS_ALLOWED_HEADERS,
|
||||
exposedHeaders: env.CORS_EXPOSED_HEADERS,
|
||||
credentials: env.CORS_CREDENTIALS === 'true' || undefined,
|
||||
maxAge: Number(env.CORS_MAX_AGE) || undefined,
|
||||
})
|
||||
);
|
||||
app.use(expressLogger({ logger }));
|
||||
app.use(bodyParser.json());
|
||||
app.use(extractToken);
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader('X-Powered-By', 'Directus');
|
||||
next();
|
||||
});
|
||||
|
||||
if (env.CORS_ENABLED === true) {
|
||||
app.use(cors);
|
||||
}
|
||||
|
||||
if (env.NODE_ENV !== 'development') {
|
||||
const adminPath = require.resolve('@directus/app/dist/index.html');
|
||||
|
||||
app.get('/', (req, res) => res.redirect('/admin/'))
|
||||
// the auth endpoints allow you to login/logout etc. It should ignore the authentication check
|
||||
.use('/admin', express.static(path.join(adminPath, '..')))
|
||||
.use('/admin/*', (req, res) => {
|
||||
res.sendFile(adminPath);
|
||||
});
|
||||
app.get('/', (req, res) => res.redirect('/admin/'));
|
||||
app.use('/admin', express.static(path.join(adminPath, '..')));
|
||||
app.use('/admin/*', (req, res) => {
|
||||
res.sendFile(adminPath);
|
||||
});
|
||||
}
|
||||
|
||||
app.use('/auth', authRouter)
|
||||
.use(authenticate)
|
||||
// use the rate limiter - all routes for now
|
||||
if (env.RATE_LIMITER_ENABLED === true) {
|
||||
app.use(rateLimiter);
|
||||
}
|
||||
|
||||
.use('/activity', activityRouter)
|
||||
.use('/assets', assetsRouter)
|
||||
.use('/collections', collectionsRouter, responseManager)
|
||||
.use('/extensions', extensionsRouter)
|
||||
.use('/fields', fieldsRouter)
|
||||
.use('/files', filesRouter)
|
||||
.use('/folders', foldersRouter)
|
||||
.use('/items', itemsRouter, responseManager)
|
||||
.use('/permissions', permissionsRouter)
|
||||
.use('/presets', presetsRouter)
|
||||
.use('/relations', relationsRouter)
|
||||
.use('/revisions', revisionsRouter)
|
||||
.use('/roles', rolesRouter)
|
||||
.use('/server/', serverRouter)
|
||||
.use('/settings', settingsRouter)
|
||||
.use('/users', usersRouter)
|
||||
.use('/utils', utilsRouter)
|
||||
.use('/webhooks', webhooksRouter);
|
||||
app.use('/auth', authRouter);
|
||||
app.use(authenticate);
|
||||
|
||||
app.use(notFoundHandler).use(errorHandler);
|
||||
app.use(sanitizeQuery);
|
||||
app.use(cache);
|
||||
app.use('/activity', activityRouter);
|
||||
app.use('/assets', assetsRouter);
|
||||
app.use('/collections', collectionsRouter);
|
||||
app.use('/extensions', extensionsRouter);
|
||||
app.use('/fields', fieldsRouter);
|
||||
app.use('/files', filesRouter);
|
||||
app.use('/folders', foldersRouter);
|
||||
app.use('/items', itemsRouter);
|
||||
app.use('/permissions', permissionsRouter);
|
||||
app.use('/presets', presetsRouter);
|
||||
app.use('/relations', relationsRouter);
|
||||
app.use('/revisions', revisionsRouter);
|
||||
app.use('/roles', rolesRouter);
|
||||
app.use('/server/', serverRouter);
|
||||
app.use('/settings', settingsRouter);
|
||||
app.use('/users', usersRouter);
|
||||
app.use('/utils', utilsRouter);
|
||||
app.use('/webhooks', webhooksRouter);
|
||||
|
||||
app.use(respond);
|
||||
|
||||
app.use(notFoundHandler);
|
||||
app.use(errorHandler);
|
||||
|
||||
export default app;
|
||||
|
||||
50
api/src/cache.ts
Normal file
50
api/src/cache.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import env from './env';
|
||||
import Keyv, { Options } from 'keyv';
|
||||
import { validateEnv } from './utils/validate-env';
|
||||
import { getConfigFromEnv } from './utils/get-config-from-env';
|
||||
import ms from 'ms';
|
||||
import logger from './logger';
|
||||
|
||||
let cache: Keyv | null = null;
|
||||
|
||||
if (env.CACHE_ENABLED === true) {
|
||||
validateEnv(['CACHE_NAMESPACE', 'CACHE_TTL', 'CACHE_STORE']);
|
||||
cache = getKevyInstance();
|
||||
cache.on('error', logger.error);
|
||||
}
|
||||
|
||||
export default cache;
|
||||
|
||||
function getKevyInstance() {
|
||||
switch (env.CACHE_STORE) {
|
||||
case 'redis':
|
||||
return new Keyv(getConfig('redis'));
|
||||
case 'memcache':
|
||||
return new Keyv(getConfig('memcache'));
|
||||
case 'memory':
|
||||
default:
|
||||
return new Keyv(getConfig());
|
||||
}
|
||||
}
|
||||
|
||||
function getConfig(
|
||||
store: 'memory' | 'redis' | 'memcache' = 'memory'
|
||||
): Options<any> {
|
||||
const config: Options<any> = { namespace: env.CACHE_NAMESPACE, ttl: ms(env.CACHE_TTL as string) };
|
||||
|
||||
if (store === 'redis') {
|
||||
const Redis = require('ioredis');
|
||||
const KeyvRedis = require('@keyv/redis');
|
||||
|
||||
config.store = new KeyvRedis(new Redis(
|
||||
env.CACHE_REDIS || getConfigFromEnv('CACHE_REDIS_')
|
||||
));
|
||||
}
|
||||
|
||||
if (store === 'memcache') {
|
||||
const KeyvMemcache = require('keyv-memcache');
|
||||
config.store = new KeyvMemcache(env.CACHE_MEMCACHE);
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
@@ -50,10 +50,17 @@ const password = () => ({
|
||||
mask: '*',
|
||||
});
|
||||
|
||||
const ssl = () => ({
|
||||
type: 'confirm',
|
||||
name: 'ssl',
|
||||
message: 'Enable SSL:',
|
||||
default: false,
|
||||
});
|
||||
|
||||
export const databaseQuestions = {
|
||||
sqlite3: [filename],
|
||||
mysql: [host, port, database, user, password],
|
||||
pg: [host, port, database, user, password],
|
||||
pg: [host, port, database, user, password, ssl],
|
||||
oracledb: [host, port, database, user, password],
|
||||
mssql: [host, port, database, user, password],
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ export type Credentials = {
|
||||
database?: string;
|
||||
user?: string;
|
||||
password?: string;
|
||||
ssl?: boolean;
|
||||
};
|
||||
export default function createDBConnection(
|
||||
client: 'sqlite3' | 'mysql' | 'pg' | 'oracledb' | 'mssql',
|
||||
@@ -22,15 +23,28 @@ export default function createDBConnection(
|
||||
filename: filename as string,
|
||||
};
|
||||
} else {
|
||||
const { host, port, database, user, password } = credentials as Credentials;
|
||||
if (client !== 'pg') {
|
||||
const { host, port, database, user, password } = credentials as Credentials;
|
||||
|
||||
connection = {
|
||||
host: host,
|
||||
port: port,
|
||||
database: database,
|
||||
user: user,
|
||||
password: password,
|
||||
};
|
||||
connection = {
|
||||
host: host,
|
||||
port: port,
|
||||
database: database,
|
||||
user: user,
|
||||
password: password,
|
||||
};
|
||||
} else {
|
||||
const { host, port, database, user, password, ssl } = credentials as Credentials;
|
||||
|
||||
connection = {
|
||||
host: host,
|
||||
port: port,
|
||||
database: database,
|
||||
user: user,
|
||||
password: password,
|
||||
ssl: ssl,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const knexConfig: Config = {
|
||||
|
||||
@@ -1,34 +1,54 @@
|
||||
####################################################################################################
|
||||
# General
|
||||
## General
|
||||
|
||||
{{ general }}
|
||||
PORT=41201
|
||||
PUBLIC_URL="/"
|
||||
|
||||
####################################################################################################
|
||||
# Database
|
||||
## Database
|
||||
|
||||
{{ database }}
|
||||
|
||||
####################################################################################################
|
||||
# File Storage
|
||||
## Rate Limiting
|
||||
|
||||
{{ storage }}
|
||||
RATE_LIMITER_ENABLED=true
|
||||
RATE_LIMITER_STORE=memory
|
||||
RATE_LIMITER_POINTS=25
|
||||
RATE_LIMITER_DURATION=1
|
||||
|
||||
####################################################################################################
|
||||
# Security
|
||||
## File Storage
|
||||
|
||||
STORAGE_LOCATIONS="local"
|
||||
STORAGE_LOCAL_PUBLIC_URL="/uploads"
|
||||
STORAGE_LOCAL_DRIVER="local"
|
||||
STORAGE_LOCAL_ROOT="./uploads"
|
||||
|
||||
####################################################################################################
|
||||
## Security
|
||||
|
||||
{{ security }}
|
||||
|
||||
####################################################################################################
|
||||
# SSO (OAuth) Providers
|
||||
|
||||
{{ oauth }}
|
||||
ACCESS_TOKEN_TTL="15m"
|
||||
REFRESH_TOKEN_TTL="7d"
|
||||
REFRESH_TOKEN_COOKIE_SECURE=false
|
||||
REFRESH_TOKEN_COOKIE_SAME_SITE="lax"
|
||||
|
||||
####################################################################################################
|
||||
# Extensions
|
||||
## SSO (OAuth) Providers
|
||||
|
||||
{{ extensions }}
|
||||
OAUTH_PROVIDERS=""
|
||||
|
||||
####################################################################################################
|
||||
# Email
|
||||
## Extensions
|
||||
|
||||
{{ email }}
|
||||
EXTENSIONS_PATH="./extensions"
|
||||
|
||||
####################################################################################################
|
||||
## Email
|
||||
|
||||
EMAIL_FROM="no-reply@directus.io"
|
||||
EMAIL_TRANSPORT="sendmail"
|
||||
EMAIL_SENDMAIL_NEW_LINE="unix"
|
||||
EMAIL_SENDMAIL_PATH="/usr/sbin/sendmail"
|
||||
|
||||
@@ -15,35 +15,9 @@ const liquidEngine = new Liquid({
|
||||
});
|
||||
|
||||
const defaults = {
|
||||
general: {
|
||||
PORT: 41201,
|
||||
PUBLIC_URL: '/',
|
||||
},
|
||||
storage: {
|
||||
STORAGE_LOCATIONS: 'local',
|
||||
STORAGE_LOCAL_PUBLIC_URL: '/uploads',
|
||||
STORAGE_LOCAL_DRIVER: 'local',
|
||||
STORAGE_LOCAL_ROOT: './uploads',
|
||||
},
|
||||
security: {
|
||||
KEY: uuidv4(),
|
||||
SECRET: nanoid(32),
|
||||
ACCESS_TOKEN_TTL: '15m',
|
||||
REFRESH_TOKEN_TTL: '7d',
|
||||
REFRESH_TOKEN_COOKIE_SECURE: false,
|
||||
REFRESH_TOKEN_COOKIE_SAME_SITE: 'lax',
|
||||
},
|
||||
oauth: {
|
||||
OAUTH_PROVIDERS: '',
|
||||
},
|
||||
extensions: {
|
||||
EXTENSIONS_PATH: './extensions',
|
||||
},
|
||||
email: {
|
||||
EMAIL_FROM: 'no-reply@directus.io',
|
||||
EMAIL_TRANSPORT: 'sendmail',
|
||||
EMAIL_SENDMAIL_NEW_LINE: 'unix',
|
||||
EMAIL_SENDMAIL_PATH: '/usr/sbin/sendmail',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import express from 'express';
|
||||
import asyncHandler from 'express-async-handler';
|
||||
import sanitizeQuery from '../middleware/sanitize-query';
|
||||
import useCollection from '../middleware/use-collection';
|
||||
import ActivityService from '../services/activity';
|
||||
import MetaService from '../services/meta';
|
||||
import { Action } from '../types';
|
||||
@@ -10,41 +8,39 @@ const router = express.Router();
|
||||
|
||||
router.get(
|
||||
'/',
|
||||
useCollection('directus_activity'),
|
||||
sanitizeQuery,
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const service = new ActivityService({ accountability: req.accountability });
|
||||
const metaService = new MetaService({ accountability: req.accountability });
|
||||
|
||||
const records = await service.readByQuery(req.sanitizedQuery);
|
||||
const meta = await metaService.getMetaForQuery(req.collection, req.sanitizedQuery);
|
||||
const meta = await metaService.getMetaForQuery('directus_activity', req.sanitizedQuery);
|
||||
|
||||
return res.json({
|
||||
res.locals.payload = {
|
||||
data: records || null,
|
||||
meta,
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
return next();
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:pk',
|
||||
useCollection('directus_activity'),
|
||||
sanitizeQuery,
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const service = new ActivityService({ accountability: req.accountability });
|
||||
const record = await service.readByKey(req.params.pk, req.sanitizedQuery);
|
||||
|
||||
return res.json({
|
||||
res.locals.payload = {
|
||||
data: record || null,
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
return next();
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/comment',
|
||||
useCollection('directus_activity'),
|
||||
sanitizeQuery,
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const service = new ActivityService({ accountability: req.accountability });
|
||||
|
||||
const primaryKey = await service.create({
|
||||
@@ -57,36 +53,37 @@ router.post(
|
||||
|
||||
const record = await service.readByKey(primaryKey, req.sanitizedQuery);
|
||||
|
||||
return res.json({
|
||||
res.locals.payload = {
|
||||
data: record || null,
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
return next();
|
||||
}),
|
||||
);
|
||||
|
||||
router.patch(
|
||||
'/comment/:pk',
|
||||
useCollection('directus_activity'),
|
||||
sanitizeQuery,
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const service = new ActivityService({ accountability: req.accountability });
|
||||
const primaryKey = await service.update(req.body, req.params.pk);
|
||||
const record = await service.readByKey(primaryKey, req.sanitizedQuery);
|
||||
|
||||
return res.json({
|
||||
res.locals.payload = {
|
||||
data: record || null,
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
return next();
|
||||
}),
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/comment/:pk',
|
||||
useCollection('directus_activity'),
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const service = new ActivityService({ accountability: req.accountability });
|
||||
await service.delete(req.params.pk);
|
||||
|
||||
return res.status(200).end();
|
||||
})
|
||||
return next();
|
||||
}),
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { SYSTEM_ASSET_ALLOW_LIST, ASSET_TRANSFORM_QUERY_KEYS } from '../constant
|
||||
import { InvalidQueryException, ForbiddenException } from '../exceptions';
|
||||
import AssetsService from '../services/assets';
|
||||
import validate from 'uuid-validate';
|
||||
import { pick, merge } from 'lodash';
|
||||
import { pick } from 'lodash';
|
||||
import { Transformation } from '../types/assets';
|
||||
import storage from '../storage';
|
||||
import PayloadService from '../services/payload';
|
||||
|
||||
@@ -23,7 +23,7 @@ const loginSchema = Joi.object({
|
||||
|
||||
router.post(
|
||||
'/login',
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const accountability = {
|
||||
ip: req.ip,
|
||||
userAgent: req.get('user-agent'),
|
||||
@@ -72,14 +72,15 @@ router.post(
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).json(payload);
|
||||
})
|
||||
res.locals.payload = payload;
|
||||
return next();
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/refresh',
|
||||
cookieParser(),
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const accountability = {
|
||||
ip: req.ip,
|
||||
userAgent: req.get('user-agent'),
|
||||
@@ -122,14 +123,15 @@ router.post(
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).json(payload);
|
||||
})
|
||||
res.locals.payload = payload;
|
||||
return next();
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/logout',
|
||||
cookieParser(),
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const accountability = {
|
||||
ip: req.ip,
|
||||
userAgent: req.get('user-agent'),
|
||||
@@ -149,14 +151,13 @@ router.post(
|
||||
}
|
||||
|
||||
await authenticationService.logout(currentRefreshToken);
|
||||
|
||||
res.status(200).end();
|
||||
})
|
||||
return next();
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/password/request',
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
if (!req.body.email) {
|
||||
throw new InvalidPayloadException(`"email" field is required.`);
|
||||
}
|
||||
@@ -175,14 +176,14 @@ router.post(
|
||||
// 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();
|
||||
return next();
|
||||
}
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/password/reset',
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
if (!req.body.token) {
|
||||
throw new InvalidPayloadException(`"token" field is required.`);
|
||||
}
|
||||
@@ -199,8 +200,8 @@ router.post(
|
||||
|
||||
const service = new UsersService({ accountability });
|
||||
await service.resetPassword(req.body.token, req.body.password);
|
||||
return res.status(200).end();
|
||||
})
|
||||
return next();
|
||||
}),
|
||||
);
|
||||
|
||||
router.use(
|
||||
@@ -215,7 +216,7 @@ router.use(grant.express()(getGrantConfig()));
|
||||
*/
|
||||
router.get(
|
||||
'/sso/:provider/callback',
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const accountability = {
|
||||
ip: req.ip,
|
||||
userAgent: req.get('user-agent'),
|
||||
@@ -232,10 +233,12 @@ router.get(
|
||||
email
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
res.locals.payload = {
|
||||
data: { access_token: accessToken, refresh_token: refreshToken, expires },
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
return next();
|
||||
}),
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,83 +1,75 @@
|
||||
import { Router } from 'express';
|
||||
import asyncHandler from 'express-async-handler';
|
||||
import sanitizeQuery from '../middleware/sanitize-query';
|
||||
import CollectionsService from '../services/collections';
|
||||
import useCollection from '../middleware/use-collection';
|
||||
import CollectionsService from '../services/collections'
|
||||
import MetaService from '../services/meta';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.post(
|
||||
'/',
|
||||
useCollection('directus_collections'),
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const collectionsService = new CollectionsService({ accountability: req.accountability });
|
||||
|
||||
const collectionKey = await collectionsService.create(req.body);
|
||||
const record = await collectionsService.readByKey(collectionKey);
|
||||
|
||||
res.json({ data: record || null });
|
||||
res.locals.payload = { data: record || null };
|
||||
return next();
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/',
|
||||
useCollection('directus_collections'),
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const collectionsService = new CollectionsService({ accountability: req.accountability });
|
||||
const metaService = new MetaService({ accountability: req.accountability });
|
||||
|
||||
const collections = await collectionsService.readByQuery();
|
||||
const meta = await metaService.getMetaForQuery(req.collection, {});
|
||||
|
||||
res.locals.data = { data: collections || null, meta };
|
||||
const meta = await metaService.getMetaForQuery('directus_collections', {});
|
||||
|
||||
res.locals.payload = { data: collections || null, meta };
|
||||
return next();
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:collection',
|
||||
useCollection('directus_collections'),
|
||||
sanitizeQuery,
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const collectionsService = new CollectionsService({ accountability: req.accountability });
|
||||
const collectionKey = req.params.collection.includes(',')
|
||||
? req.params.collection.split(',')
|
||||
: req.params.collection;
|
||||
const collection = await collectionsService.readByKey(collectionKey as any);
|
||||
res.locals.data = { data: collection || null };
|
||||
|
||||
res.locals.payload = { data: collection || null };
|
||||
return next();
|
||||
// can't use res.json as this ends the reponse which is not what we want
|
||||
})
|
||||
);
|
||||
|
||||
router.patch(
|
||||
'/:collection',
|
||||
useCollection('directus_collections'),
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const collectionsService = new CollectionsService({ accountability: req.accountability });
|
||||
const collectionKey = req.params.collection.includes(',')
|
||||
? req.params.collection.split(',')
|
||||
: req.params.collection;
|
||||
await collectionsService.update(req.body, collectionKey as any);
|
||||
const collection = await collectionsService.readByKey(collectionKey as any);
|
||||
res.json({ data: collection || null });
|
||||
res.locals.payload = { data: collection || null };
|
||||
return next();
|
||||
})
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/:collection',
|
||||
useCollection('directus_collections'),
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const collectionsService = new CollectionsService({ accountability: req.accountability });
|
||||
const collectionKey = req.params.collection.includes(',')
|
||||
? req.params.collection.split(',')
|
||||
: req.params.collection;
|
||||
await collectionsService.delete(collectionKey as any);
|
||||
|
||||
res.end();
|
||||
return next();
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ const router = Router();
|
||||
|
||||
router.get(
|
||||
'/:type',
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const typeAllowList = ['interfaces', 'layouts', 'displays', 'modules'];
|
||||
|
||||
if (typeAllowList.includes(req.params.type) === false) {
|
||||
@@ -16,10 +16,12 @@ router.get(
|
||||
|
||||
const interfaces = await ExtensionsService.listExtensions(req.params.type);
|
||||
|
||||
return res.json({
|
||||
res.locals.payload = {
|
||||
data: interfaces,
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
return next();
|
||||
}),
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -3,56 +3,50 @@ import asyncHandler from 'express-async-handler';
|
||||
import FieldsService from '../services/fields';
|
||||
import validateCollection from '../middleware/collection-exists';
|
||||
import { schemaInspector } from '../database';
|
||||
import { FieldNotFoundException, InvalidPayloadException, ForbiddenException } from '../exceptions';
|
||||
import { InvalidPayloadException, ForbiddenException } from '../exceptions';
|
||||
import Joi from 'joi';
|
||||
import { Field } from '../types/field';
|
||||
import useCollection from '../middleware/use-collection';
|
||||
import { Accountability, types } from '../types';
|
||||
import { types } from '../types';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* @TODO
|
||||
*
|
||||
* Add accountability / permissions handling to fields
|
||||
*/
|
||||
|
||||
router.get(
|
||||
'/',
|
||||
useCollection('directus_fields'),
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const service = new FieldsService({ accountability: req.accountability });
|
||||
|
||||
const fields = await service.readAll();
|
||||
return res.json({ data: fields || null });
|
||||
})
|
||||
|
||||
res.locals.payload = { data: fields || null };
|
||||
return next();
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:collection',
|
||||
validateCollection,
|
||||
useCollection('directus_fields'),
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const service = new FieldsService({ accountability: req.accountability });
|
||||
|
||||
const fields = await service.readAll(req.params.collection);
|
||||
return res.json({ data: fields || null });
|
||||
})
|
||||
|
||||
res.locals.payload = { data: fields || null };
|
||||
return next();
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:collection/:field',
|
||||
validateCollection,
|
||||
useCollection('directus_fields'),
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const service = new FieldsService({ accountability: req.accountability });
|
||||
|
||||
const exists = await schemaInspector.hasColumn(req.collection, req.params.field);
|
||||
const exists = await schemaInspector.hasColumn(req.params.collection, req.params.field);
|
||||
if (exists === false) throw new ForbiddenException();
|
||||
|
||||
const field = await service.readOne(req.params.collection, req.params.field);
|
||||
return res.json({ data: field || null });
|
||||
})
|
||||
|
||||
res.locals.payload = { data: field || null };
|
||||
return next();
|
||||
}),
|
||||
);
|
||||
|
||||
const newFieldSchema = Joi.object({
|
||||
@@ -72,8 +66,7 @@ const newFieldSchema = Joi.object({
|
||||
router.post(
|
||||
'/:collection',
|
||||
validateCollection,
|
||||
useCollection('directus_fields'),
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const service = new FieldsService({ accountability: req.accountability });
|
||||
|
||||
const { error } = newFieldSchema.validate(req.body);
|
||||
@@ -88,15 +81,15 @@ router.post(
|
||||
|
||||
const createdField = await service.readOne(req.params.collection, field.field);
|
||||
|
||||
return res.json({ data: createdField || null });
|
||||
})
|
||||
res.locals.payload = { data: createdField || null };
|
||||
return next();
|
||||
}),
|
||||
);
|
||||
|
||||
router.patch(
|
||||
'/:collection',
|
||||
validateCollection,
|
||||
useCollection('directus_fields'),
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const service = new FieldsService({ accountability: req.accountability });
|
||||
|
||||
if (Array.isArray(req.body) === false)
|
||||
@@ -112,15 +105,16 @@ router.patch(
|
||||
results.push(updatedField);
|
||||
}
|
||||
|
||||
return res.json({ data: results || null });
|
||||
})
|
||||
res.locals.payload = { data: results || null };
|
||||
return next();
|
||||
}),
|
||||
);
|
||||
|
||||
router.patch(
|
||||
'/:collection/:field',
|
||||
validateCollection,
|
||||
useCollection('directus_fields'),
|
||||
asyncHandler(async (req, res) => {
|
||||
// @todo: validate field
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const service = new FieldsService({ accountability: req.accountability });
|
||||
const fieldData: Partial<Field> & { field: string; type: typeof types[number] } = req.body;
|
||||
|
||||
@@ -130,20 +124,19 @@ router.patch(
|
||||
|
||||
const updatedField = await service.readOne(req.params.collection, req.params.field);
|
||||
|
||||
return res.json({ data: updatedField || null });
|
||||
})
|
||||
res.locals.payload = { data: updatedField || null };
|
||||
return next();
|
||||
}),
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/:collection/:field',
|
||||
validateCollection,
|
||||
useCollection('directus_fields'),
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const service = new FieldsService({ accountability: req.accountability });
|
||||
await service.deleteField(req.params.collection, req.params.field);
|
||||
|
||||
res.status(200).end();
|
||||
})
|
||||
return next();
|
||||
}),
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import express from 'express';
|
||||
import asyncHandler from 'express-async-handler';
|
||||
import Busboy from 'busboy';
|
||||
import sanitizeQuery from '../middleware/sanitize-query';
|
||||
import FilesService from '../services/files';
|
||||
import MetaService from '../services/meta';
|
||||
import useCollection from '../middleware/use-collection';
|
||||
import { File, PrimaryKey } from '../types';
|
||||
import formatTitle from '@directus/format-title';
|
||||
import env from '../env';
|
||||
@@ -16,8 +14,6 @@ import path from 'path';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(useCollection('directus_files'));
|
||||
|
||||
const multipartHandler = asyncHandler(async (req, res, next) => {
|
||||
if (req.is('multipart/form-data') === false) return next();
|
||||
|
||||
@@ -67,9 +63,17 @@ const multipartHandler = asyncHandler(async (req, res, next) => {
|
||||
storage: payload.storage || disk,
|
||||
};
|
||||
|
||||
const primaryKey = await service.upload(fileStream, payloadWithRequiredFields, existingPrimaryKey);
|
||||
savedFiles.push(primaryKey);
|
||||
tryDone();
|
||||
try {
|
||||
const primaryKey = await service.upload(
|
||||
fileStream,
|
||||
payloadWithRequiredFields,
|
||||
existingPrimaryKey
|
||||
);
|
||||
savedFiles.push(primaryKey);
|
||||
tryDone();
|
||||
} catch (error) {
|
||||
busboy.emit('error', error);
|
||||
}
|
||||
});
|
||||
|
||||
busboy.on('error', (error: Error) => {
|
||||
@@ -92,9 +96,8 @@ const multipartHandler = asyncHandler(async (req, res, next) => {
|
||||
|
||||
router.post(
|
||||
'/',
|
||||
sanitizeQuery,
|
||||
multipartHandler,
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const service = new FilesService({ accountability: req.accountability });
|
||||
let keys: PrimaryKey | PrimaryKey[] = [];
|
||||
|
||||
@@ -107,18 +110,18 @@ router.post(
|
||||
|
||||
const record = await service.readByKey(keys as any, req.sanitizedQuery);
|
||||
|
||||
return res.json({ data: res.locals.savedFiles.length === 1 ? record[0] : record || null });
|
||||
res.locals.payload = { data: res.locals.savedFiles.length === 1 ? record[0] : record || null };
|
||||
return next();
|
||||
})
|
||||
);
|
||||
|
||||
const importSchema = Joi.object({
|
||||
url: Joi.string().required()
|
||||
url: Joi.string().required(),
|
||||
});
|
||||
|
||||
router.post(
|
||||
'/import',
|
||||
sanitizeQuery,
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const { error } = importSchema.validate(req.body);
|
||||
|
||||
if (error) {
|
||||
@@ -128,7 +131,7 @@ router.post(
|
||||
const service = new FilesService({ accountability: req.accountability });
|
||||
|
||||
const fileResponse = await axios.get<NodeJS.ReadableStream>(req.body.url, {
|
||||
responseType: 'stream'
|
||||
responseType: 'stream',
|
||||
});
|
||||
|
||||
const parsedURL = url.parse(fileResponse.request.res.responseUrl);
|
||||
@@ -139,47 +142,47 @@ router.post(
|
||||
storage: (env.STORAGE_LOCATIONS as string).split(',')[0].trim(),
|
||||
type: fileResponse.headers['content-type'],
|
||||
title: formatTitle(filename),
|
||||
...req.body
|
||||
...req.body,
|
||||
};
|
||||
|
||||
delete payload.url;
|
||||
|
||||
const primaryKey = await service.upload(fileResponse.data, payload);
|
||||
const record = await service.readByKey(primaryKey, req.sanitizedQuery);
|
||||
return res.json({ data: record || null });
|
||||
res.locals.payload = { data: record || null };
|
||||
return next();
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/',
|
||||
sanitizeQuery,
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const service = new FilesService({ accountability: req.accountability });
|
||||
const metaService = new MetaService({ accountability: req.accountability });
|
||||
|
||||
const records = await service.readByQuery(req.sanitizedQuery);
|
||||
const meta = await metaService.getMetaForQuery(req.collection, req.sanitizedQuery);
|
||||
const meta = await metaService.getMetaForQuery('directus_files', req.sanitizedQuery);
|
||||
|
||||
return res.json({ data: records || null, meta });
|
||||
res.locals.payload = { data: records || null, meta };
|
||||
return next();
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:pk',
|
||||
sanitizeQuery,
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const keys = req.params.pk.includes(',') ? req.params.pk.split(',') : req.params.pk;
|
||||
const service = new FilesService({ accountability: req.accountability });
|
||||
const record = await service.readByKey(keys as any, req.sanitizedQuery);
|
||||
return res.json({ data: record || null });
|
||||
res.locals.payload = { data: record || null };
|
||||
return next();
|
||||
})
|
||||
);
|
||||
|
||||
router.patch(
|
||||
'/:pk',
|
||||
sanitizeQuery,
|
||||
multipartHandler,
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const service = new FilesService({ accountability: req.accountability });
|
||||
let keys: PrimaryKey | PrimaryKey[] = [];
|
||||
|
||||
@@ -191,17 +194,18 @@ router.patch(
|
||||
}
|
||||
|
||||
const record = await service.readByKey(keys as any, req.sanitizedQuery);
|
||||
return res.json({ data: record || null });
|
||||
res.locals.payload = { data: record || null };
|
||||
return next();
|
||||
})
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/:pk',
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const keys = req.params.pk.includes(',') ? req.params.pk.split(',') : req.params.pk;
|
||||
const service = new FilesService({ accountability: req.accountability });
|
||||
await service.delete(keys as any);
|
||||
return res.status(200).end();
|
||||
return next();
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -1,70 +1,66 @@
|
||||
import express from 'express';
|
||||
import asyncHandler from 'express-async-handler';
|
||||
import sanitizeQuery from '../middleware/sanitize-query';
|
||||
import useCollection from '../middleware/use-collection';
|
||||
import FoldersService from '../services/folders';
|
||||
import MetaService from '../services/meta';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(useCollection('directus_folders'));
|
||||
|
||||
router.post(
|
||||
'/',
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const service = new FoldersService({ accountability: req.accountability });
|
||||
const primaryKey = await service.create(req.body);
|
||||
const record = await service.readByKey(primaryKey, req.sanitizedQuery);
|
||||
|
||||
return res.json({ data: record || null });
|
||||
})
|
||||
res.locals.payload = { data: record || null };
|
||||
return next();
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/',
|
||||
sanitizeQuery,
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const service = new FoldersService({ accountability: req.accountability });
|
||||
const metaService = new MetaService({ accountability: req.accountability });
|
||||
|
||||
const records = await service.readByQuery(req.sanitizedQuery);
|
||||
const meta = await metaService.getMetaForQuery(req.collection, req.sanitizedQuery);
|
||||
const meta = await metaService.getMetaForQuery('directus_files', req.sanitizedQuery);
|
||||
|
||||
return res.json({ data: records || null, meta });
|
||||
})
|
||||
res.locals.payload = { data: records || null, meta };
|
||||
return next();
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:pk',
|
||||
sanitizeQuery,
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const service = new FoldersService({ accountability: req.accountability });
|
||||
const record = await service.readByKey(req.params.pk, req.sanitizedQuery);
|
||||
|
||||
return res.json({ data: record || null });
|
||||
})
|
||||
res.locals.payload = { data: record || null };
|
||||
return next();
|
||||
}),
|
||||
);
|
||||
|
||||
router.patch(
|
||||
'/:pk',
|
||||
sanitizeQuery,
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const service = new FoldersService({ accountability: req.accountability });
|
||||
const primaryKey = await service.update(req.body, req.params.pk);
|
||||
const record = await service.readByKey(primaryKey, req.sanitizedQuery);
|
||||
|
||||
return res.json({ data: record || null });
|
||||
})
|
||||
res.locals.payload = { data: record || null };
|
||||
return next();
|
||||
}),
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/:pk',
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const service = new FoldersService({ accountability: req.accountability });
|
||||
await service.delete(req.params.pk);
|
||||
|
||||
return res.status(200).end();
|
||||
})
|
||||
return next();
|
||||
}),
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import express from 'express';
|
||||
import asyncHandler from 'express-async-handler';
|
||||
import ItemsService from '../services/items';
|
||||
import sanitizeQuery from '../middleware/sanitize-query';
|
||||
import collectionExists from '../middleware/collection-exists';
|
||||
import MetaService from '../services/meta';
|
||||
import { RouteNotFoundException } from '../exceptions';
|
||||
@@ -11,8 +10,7 @@ const router = express.Router();
|
||||
router.post(
|
||||
'/:collection',
|
||||
collectionExists,
|
||||
sanitizeQuery,
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
if (req.singleton) {
|
||||
throw new RouteNotFoundException(req.path);
|
||||
}
|
||||
@@ -21,14 +19,14 @@ router.post(
|
||||
const primaryKey = await service.create(req.body);
|
||||
const result = await service.readByKey(primaryKey, req.sanitizedQuery);
|
||||
|
||||
res.json({ data: result || null });
|
||||
})
|
||||
res.locals.payload = { data: result || null };
|
||||
return next();
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:collection',
|
||||
collectionExists,
|
||||
sanitizeQuery,
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const service = new ItemsService(req.collection, { accountability: req.accountability });
|
||||
const metaService = new MetaService({ accountability: req.accountability });
|
||||
@@ -39,19 +37,17 @@ router.get(
|
||||
|
||||
const meta = await metaService.getMetaForQuery(req.collection, req.sanitizedQuery);
|
||||
|
||||
res.locals.data = {
|
||||
res.locals.payload = {
|
||||
meta: meta,
|
||||
data: records || null,
|
||||
};
|
||||
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:collection/:pk',
|
||||
collectionExists,
|
||||
sanitizeQuery,
|
||||
asyncHandler(async (req, res, next) => {
|
||||
if (req.singleton) {
|
||||
throw new RouteNotFoundException(req.path);
|
||||
@@ -60,39 +56,39 @@ router.get(
|
||||
const service = new ItemsService(req.collection, { accountability: req.accountability });
|
||||
const primaryKey = req.params.pk.includes(',') ? req.params.pk.split(',') : req.params.pk;
|
||||
const result = await service.readByKey(primaryKey as any, req.sanitizedQuery);
|
||||
res.locals.data = {
|
||||
|
||||
res.locals.payload = {
|
||||
data: result || null,
|
||||
};
|
||||
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
router.patch(
|
||||
'/:collection',
|
||||
collectionExists,
|
||||
sanitizeQuery,
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const service = new ItemsService(req.collection, { accountability: req.accountability });
|
||||
|
||||
if (req.singleton === true) {
|
||||
await service.upsertSingleton(req.body);
|
||||
const item = await service.readSingleton(req.sanitizedQuery);
|
||||
|
||||
return res.json({ data: item || null });
|
||||
res.locals.payload = { data: item || null };
|
||||
return next();
|
||||
}
|
||||
|
||||
const primaryKeys = await service.update(req.body);
|
||||
const result = await service.readByKey(primaryKeys, req.sanitizedQuery);
|
||||
return res.json({ data: result || null });
|
||||
})
|
||||
res.locals.payload = { data: result || null };
|
||||
return next();
|
||||
}),
|
||||
);
|
||||
|
||||
router.patch(
|
||||
'/:collection/:pk',
|
||||
collectionExists,
|
||||
sanitizeQuery,
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
if (req.singleton) {
|
||||
throw new RouteNotFoundException(req.path);
|
||||
}
|
||||
@@ -103,20 +99,20 @@ router.patch(
|
||||
const updatedPrimaryKey = await service.update(req.body, primaryKey as any);
|
||||
const result = await service.readByKey(updatedPrimaryKey, req.sanitizedQuery);
|
||||
|
||||
res.json({ data: result || null });
|
||||
})
|
||||
res.locals.payload = { data: result || null };
|
||||
return next();
|
||||
}),
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/:collection/:pk',
|
||||
collectionExists,
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const service = new ItemsService(req.collection, { accountability: req.accountability });
|
||||
const pk = req.params.pk.includes(',') ? req.params.pk.split(',') : req.params.pk;
|
||||
await service.delete(pk as any);
|
||||
|
||||
return res.status(200).end();
|
||||
})
|
||||
return next();
|
||||
}),
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,94 +1,94 @@
|
||||
import express from 'express';
|
||||
import asyncHandler from 'express-async-handler';
|
||||
import sanitizeQuery from '../middleware/sanitize-query';
|
||||
import PermissionsService from '../services/permissions';
|
||||
import useCollection from '../middleware/use-collection';
|
||||
import MetaService from '../services/meta';
|
||||
import { clone } from 'lodash';
|
||||
import { InvalidCredentialsException } from '../exceptions';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(useCollection('directus_permissions'));
|
||||
|
||||
router.post(
|
||||
'/',
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const service = new PermissionsService({ accountability: req.accountability });
|
||||
const primaryKey = await service.create(req.body);
|
||||
const item = await service.readByKey(primaryKey, req.sanitizedQuery);
|
||||
|
||||
return res.json({ data: item || null });
|
||||
})
|
||||
res.locals.payload = { data: item || null };
|
||||
return next();
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/',
|
||||
sanitizeQuery,
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const service = new PermissionsService({ accountability: req.accountability });
|
||||
const metaService = new MetaService({ accountability: req.accountability });
|
||||
|
||||
const item = await service.readByQuery(req.sanitizedQuery);
|
||||
const meta = await metaService.getMetaForQuery(req.collection, req.sanitizedQuery);
|
||||
const meta = await metaService.getMetaForQuery('directus_permissions', req.sanitizedQuery);
|
||||
|
||||
return res.json({ data: item || null, meta });
|
||||
})
|
||||
res.locals.payload = { data: item || null, meta };
|
||||
return next();
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/me',
|
||||
sanitizeQuery,
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
if (!req.accountability?.user || !req.accountability?.role) {
|
||||
throw new InvalidCredentialsException();
|
||||
}
|
||||
|
||||
const service = new PermissionsService();
|
||||
const query = req.sanitizedQuery || {};
|
||||
const query = clone(req.sanitizedQuery || {});
|
||||
|
||||
query.filter = {
|
||||
...(query.filter || {}),
|
||||
role: {
|
||||
_eq: req.accountability.role
|
||||
}
|
||||
}
|
||||
_eq: req.accountability.role,
|
||||
},
|
||||
};
|
||||
|
||||
const items = await service.readByQuery(req.sanitizedQuery);
|
||||
|
||||
return res.json({ data: items || null });
|
||||
})
|
||||
)
|
||||
res.locals.payload = { data: items || null };
|
||||
return next();
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:pk',
|
||||
sanitizeQuery,
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
if (req.path.endsWith('me')) return next();
|
||||
const service = new PermissionsService({ accountability: req.accountability });
|
||||
const record = await service.readByKey(Number(req.params.pk), req.sanitizedQuery);
|
||||
return res.json({ data: record || null });
|
||||
})
|
||||
|
||||
res.locals.payload = { data: record || null };
|
||||
return next();
|
||||
}),
|
||||
);
|
||||
|
||||
router.patch(
|
||||
'/:pk',
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const service = new PermissionsService({ accountability: req.accountability });
|
||||
const primaryKey = await service.update(req.body, Number(req.params.pk));
|
||||
|
||||
const item = await service.readByKey(primaryKey, req.sanitizedQuery);
|
||||
|
||||
return res.json({ data: item || null });
|
||||
})
|
||||
res.locals.payload = { data: item || null };
|
||||
return next();
|
||||
}),
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/:pk',
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const service = new PermissionsService({ accountability: req.accountability });
|
||||
await service.delete(Number(req.params.pk));
|
||||
|
||||
return res.status(200).end();
|
||||
})
|
||||
return next();
|
||||
}),
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,69 +1,65 @@
|
||||
import express from 'express';
|
||||
import asyncHandler from 'express-async-handler';
|
||||
import sanitizeQuery from '../middleware/sanitize-query';
|
||||
import useCollection from '../middleware/use-collection';
|
||||
import PresetsService from '../services/presets';
|
||||
import MetaService from '../services/meta';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(useCollection('directus_presets'));
|
||||
|
||||
router.post(
|
||||
'/',
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const service = new PresetsService({ accountability: req.accountability });
|
||||
const primaryKey = await service.create(req.body);
|
||||
const record = await service.readByKey(primaryKey, req.sanitizedQuery);
|
||||
|
||||
return res.json({ data: record || null });
|
||||
res.locals.payload = { data: record || null };
|
||||
return next();
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/',
|
||||
sanitizeQuery,
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const service = new PresetsService({ accountability: req.accountability });
|
||||
const metaService = new MetaService({ accountability: req.accountability });
|
||||
|
||||
const records = await service.readByQuery(req.sanitizedQuery);
|
||||
const meta = await metaService.getMetaForQuery(req.collection, req.sanitizedQuery);
|
||||
const meta = await metaService.getMetaForQuery('directus_presets', req.sanitizedQuery);
|
||||
|
||||
return res.json({ data: records || null, meta });
|
||||
res.locals.payload = { data: records || null, meta };
|
||||
return next();
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:pk',
|
||||
sanitizeQuery,
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const service = new PresetsService({ accountability: req.accountability });
|
||||
const record = await service.readByKey(req.params.pk, req.sanitizedQuery);
|
||||
|
||||
return res.json({ data: record || null });
|
||||
res.locals.payload = { data: record || null };
|
||||
return next();
|
||||
})
|
||||
);
|
||||
|
||||
router.patch(
|
||||
'/:pk',
|
||||
sanitizeQuery,
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const service = new PresetsService({ accountability: req.accountability });
|
||||
const primaryKey = await service.update(req.body, req.params.pk);
|
||||
const record = await service.readByKey(primaryKey, req.sanitizedQuery);
|
||||
|
||||
return res.json({ data: record || null });
|
||||
res.locals.payload = { data: record || null };
|
||||
return next();
|
||||
})
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/:pk',
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const service = new PresetsService({ accountability: req.accountability });
|
||||
await service.delete(req.params.pk);
|
||||
|
||||
return res.status(200).end();
|
||||
return next();
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -1,68 +1,63 @@
|
||||
import express from 'express';
|
||||
import asyncHandler from 'express-async-handler';
|
||||
import sanitizeQuery from '../middleware/sanitize-query';
|
||||
import useCollection from '../middleware/use-collection';
|
||||
import RelationsService from '../services/relations';
|
||||
import MetaService from '../services/meta';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(useCollection('directus_relations'));
|
||||
|
||||
router.post(
|
||||
'/',
|
||||
sanitizeQuery,
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const service = new RelationsService({ accountability: req.accountability });
|
||||
const primaryKey = await service.create(req.body);
|
||||
const item = await service.readByKey(primaryKey, req.sanitizedQuery);
|
||||
return res.json({ data: item || null });
|
||||
})
|
||||
res.locals.payload = { data: item || null };
|
||||
return next();
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/',
|
||||
sanitizeQuery,
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const service = new RelationsService({ accountability: req.accountability });
|
||||
const metaService = new MetaService({ accountability: req.accountability });
|
||||
|
||||
const records = await service.readByQuery(req.sanitizedQuery);
|
||||
const meta = await metaService.getMetaForQuery(req.collection, req.sanitizedQuery);
|
||||
|
||||
return res.json({ data: records || null, meta });
|
||||
})
|
||||
res.locals.payload = { data: records || null, meta };
|
||||
return next();
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:pk',
|
||||
sanitizeQuery,
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const service = new RelationsService({ accountability: req.accountability });
|
||||
const record = await service.readByKey(req.params.pk, req.sanitizedQuery);
|
||||
return res.json({ data: record || null });
|
||||
})
|
||||
res.locals.payload = { data: record || null };
|
||||
return next();
|
||||
}),
|
||||
);
|
||||
|
||||
router.patch(
|
||||
'/:pk',
|
||||
sanitizeQuery,
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const service = new RelationsService({ accountability: req.accountability });
|
||||
const primaryKey = await service.update(req.body, req.params.pk);
|
||||
const item = await service.readByKey(primaryKey, req.sanitizedQuery);
|
||||
return res.json({ data: item || null });
|
||||
})
|
||||
res.locals.payload = { data: item || null };
|
||||
return next();
|
||||
}),
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/:pk',
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const service = new RelationsService({ accountability: req.accountability });
|
||||
await service.delete(Number(req.params.pk));
|
||||
|
||||
return res.status(200).end();
|
||||
})
|
||||
return next();
|
||||
}),
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import express from 'express';
|
||||
import asyncHandler from 'express-async-handler';
|
||||
import sanitizeQuery from '../middleware/sanitize-query';
|
||||
import useCollection from '../middleware/use-collection';
|
||||
import RevisionsService from '../services/revisions';
|
||||
import MetaService from '../services/meta';
|
||||
|
||||
@@ -9,28 +7,26 @@ const router = express.Router();
|
||||
|
||||
router.get(
|
||||
'/',
|
||||
useCollection('directus_revisions'),
|
||||
sanitizeQuery,
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const service = new RevisionsService({ accountability: req.accountability });
|
||||
const metaService = new MetaService({ accountability: req.accountability });
|
||||
|
||||
const records = await service.readByQuery(req.sanitizedQuery);
|
||||
const meta = await metaService.getMetaForQuery(req.collection, req.sanitizedQuery);
|
||||
const meta = await metaService.getMetaForQuery('directus_revisions', req.sanitizedQuery);
|
||||
|
||||
return res.json({ data: records || null, meta });
|
||||
})
|
||||
res.locals.payload = { data: records || null, meta };
|
||||
return next();
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:pk',
|
||||
useCollection('directus_revisions'),
|
||||
sanitizeQuery,
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const service = new RevisionsService({ accountability: req.accountability });
|
||||
const record = await service.readByKey(req.params.pk, req.sanitizedQuery);
|
||||
return res.json({ data: record || null });
|
||||
})
|
||||
res.locals.payload = { data: record || null };
|
||||
return next();
|
||||
}),
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,68 +1,63 @@
|
||||
import express from 'express';
|
||||
import asyncHandler from 'express-async-handler';
|
||||
import sanitizeQuery from '../middleware/sanitize-query';
|
||||
import useCollection from '../middleware/use-collection';
|
||||
import RolesService from '../services/roles';
|
||||
import MetaService from '../services/meta';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(useCollection('directus_roles'));
|
||||
|
||||
router.post(
|
||||
'/',
|
||||
sanitizeQuery,
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const service = new RolesService({ accountability: req.accountability });
|
||||
const primaryKey = await service.create(req.body);
|
||||
const item = await service.readByKey(primaryKey, req.sanitizedQuery);
|
||||
return res.json({ data: item || null });
|
||||
})
|
||||
res.locals.payload = { data: item || null };
|
||||
return next();
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/',
|
||||
sanitizeQuery,
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const service = new RolesService({ accountability: req.accountability });
|
||||
const metaService = new MetaService({ accountability: req.accountability });
|
||||
|
||||
const records = await service.readByQuery(req.sanitizedQuery);
|
||||
const meta = await metaService.getMetaForQuery(req.collection, req.sanitizedQuery);
|
||||
const meta = await metaService.getMetaForQuery('directus_roles', req.sanitizedQuery);
|
||||
|
||||
return res.json({ data: records || null, meta });
|
||||
})
|
||||
res.locals.payload = { data: records || null, meta };
|
||||
return next();
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:pk',
|
||||
sanitizeQuery,
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const service = new RolesService({ accountability: req.accountability });
|
||||
const record = await service.readByKey(req.params.pk, req.sanitizedQuery);
|
||||
return res.json({ data: record || null });
|
||||
})
|
||||
res.locals.payload = { data: record || null };
|
||||
return next();
|
||||
}),
|
||||
);
|
||||
|
||||
router.patch(
|
||||
'/:pk',
|
||||
sanitizeQuery,
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const service = new RolesService({ accountability: req.accountability });
|
||||
const primaryKey = await service.update(req.body, req.params.pk);
|
||||
const item = await service.readByKey(primaryKey, req.sanitizedQuery);
|
||||
return res.json({ data: item || null });
|
||||
})
|
||||
res.locals.payload = { data: item || null };
|
||||
return next();
|
||||
}),
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/:pk',
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const service = new RolesService({ accountability: req.accountability });
|
||||
await service.delete(req.params.pk);
|
||||
|
||||
return res.status(200).end();
|
||||
})
|
||||
return next();
|
||||
}),
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -5,10 +5,11 @@ const router = Router();
|
||||
|
||||
router.get('/ping', (req, res) => res.send('pong'));
|
||||
|
||||
router.get('/info', (req, res) => {
|
||||
router.get('/info', (req, res, next) => {
|
||||
const service = new ServerService({ accountability: req.accountability });
|
||||
const data = service.serverInfo();
|
||||
res.json({ data });
|
||||
res.locals.payload = data;
|
||||
return next();
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,32 +1,28 @@
|
||||
import express from 'express';
|
||||
import asyncHandler from 'express-async-handler';
|
||||
import sanitizeQuery from '../middleware/sanitize-query';
|
||||
import useCollection from '../middleware/use-collection';
|
||||
import SettingsService from '../services/settings';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get(
|
||||
'/',
|
||||
useCollection('directus_settings'),
|
||||
sanitizeQuery,
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const service = new SettingsService({ accountability: req.accountability });
|
||||
const records = await service.readSingleton(req.sanitizedQuery);
|
||||
return res.json({ data: records || null });
|
||||
res.locals.payload = { data: records || null };
|
||||
return next();
|
||||
})
|
||||
);
|
||||
|
||||
router.patch(
|
||||
'/',
|
||||
useCollection('directus_settings'),
|
||||
sanitizeQuery,
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const service = new SettingsService({ accountability: req.accountability });
|
||||
await service.upsertSingleton(req.body);
|
||||
const record = await service.readSingleton(req.sanitizedQuery);
|
||||
|
||||
return res.json({ data: record || null });
|
||||
res.locals.payload = { data: record || null };
|
||||
return next();
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -1,46 +1,41 @@
|
||||
import express from 'express';
|
||||
import asyncHandler from 'express-async-handler';
|
||||
import sanitizeQuery from '../middleware/sanitize-query';
|
||||
import Joi from 'joi';
|
||||
import { InvalidPayloadException, InvalidCredentialsException } from '../exceptions';
|
||||
import useCollection from '../middleware/use-collection';
|
||||
import UsersService from '../services/users';
|
||||
import MetaService from '../services/meta';
|
||||
import AuthService from '../services/authentication';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(useCollection('directus_users'));
|
||||
|
||||
router.post(
|
||||
'/',
|
||||
sanitizeQuery,
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const service = new UsersService({ accountability: req.accountability });
|
||||
const primaryKey = await service.create(req.body);
|
||||
const item = await service.readByKey(primaryKey, req.sanitizedQuery);
|
||||
return res.json({ data: item || null });
|
||||
})
|
||||
res.locals.payload = { data: item || null };
|
||||
return next();
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/',
|
||||
sanitizeQuery,
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const service = new UsersService({ accountability: req.accountability });
|
||||
const metaService = new MetaService({ accountability: req.accountability });
|
||||
|
||||
const item = await service.readByQuery(req.sanitizedQuery);
|
||||
const meta = await metaService.getMetaForQuery(req.collection, req.sanitizedQuery);
|
||||
const meta = await metaService.getMetaForQuery('directus_users', req.sanitizedQuery);
|
||||
|
||||
return res.json({ data: item || null, meta });
|
||||
})
|
||||
res.locals.payload = { data: item || null, meta };
|
||||
return next();
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/me',
|
||||
sanitizeQuery,
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
if (!req.accountability?.user) {
|
||||
throw new InvalidCredentialsException();
|
||||
}
|
||||
@@ -48,24 +43,25 @@ router.get(
|
||||
|
||||
const item = await service.readByKey(req.accountability.user, req.sanitizedQuery);
|
||||
|
||||
return res.json({ data: item || null });
|
||||
})
|
||||
res.locals.payload = { data: item || null };
|
||||
return next();
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:pk',
|
||||
sanitizeQuery,
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
if (req.path.endsWith('me')) return next();
|
||||
const service = new UsersService({ accountability: req.accountability });
|
||||
const items = await service.readByKey(req.params.pk, req.sanitizedQuery);
|
||||
return res.json({ data: items || null });
|
||||
})
|
||||
res.locals.payload = { data: items || null };
|
||||
return next();
|
||||
}),
|
||||
);
|
||||
|
||||
router.patch(
|
||||
'/me',
|
||||
sanitizeQuery,
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
if (!req.accountability?.user) {
|
||||
throw new InvalidCredentialsException();
|
||||
}
|
||||
@@ -74,14 +70,14 @@ router.patch(
|
||||
const primaryKey = await service.update(req.body, req.accountability.user);
|
||||
const item = await service.readByKey(primaryKey, req.sanitizedQuery);
|
||||
|
||||
return res.json({ data: item || null });
|
||||
})
|
||||
res.locals.payload = { data: item || null };
|
||||
return next();
|
||||
}),
|
||||
);
|
||||
|
||||
router.patch(
|
||||
'/me/track/page',
|
||||
sanitizeQuery,
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
if (!req.accountability?.user) {
|
||||
throw new InvalidCredentialsException();
|
||||
}
|
||||
@@ -93,30 +89,30 @@ router.patch(
|
||||
const service = new UsersService();
|
||||
await service.update({ last_page: req.body.last_page }, req.accountability.user);
|
||||
|
||||
return res.status(200).end();
|
||||
return next();
|
||||
})
|
||||
);
|
||||
|
||||
router.patch(
|
||||
'/:pk',
|
||||
sanitizeQuery,
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const service = new UsersService({ accountability: req.accountability });
|
||||
const primaryKey = await service.update(req.body, req.params.pk);
|
||||
|
||||
const item = await service.readByKey(primaryKey, req.sanitizedQuery);
|
||||
|
||||
return res.json({ data: item || null });
|
||||
})
|
||||
res.locals.payload = { data: item || null };
|
||||
return next();
|
||||
}),
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/:pk',
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const service = new UsersService({ accountability: req.accountability });
|
||||
await service.delete(req.params.pk);
|
||||
|
||||
return res.status(200).end();
|
||||
return next();
|
||||
})
|
||||
);
|
||||
|
||||
@@ -127,13 +123,13 @@ const inviteSchema = Joi.object({
|
||||
|
||||
router.post(
|
||||
'/invite',
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const { error } = inviteSchema.validate(req.body);
|
||||
if (error) throw new InvalidPayloadException(error.message);
|
||||
|
||||
const service = new UsersService({ accountability: req.accountability });
|
||||
await service.inviteUser(req.body.email, req.body.role);
|
||||
res.end();
|
||||
return next();
|
||||
})
|
||||
);
|
||||
|
||||
@@ -144,47 +140,53 @@ const acceptInviteSchema = Joi.object({
|
||||
|
||||
router.post(
|
||||
'/invite/accept',
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const { error } = acceptInviteSchema.validate(req.body);
|
||||
if (error) throw new InvalidPayloadException(error.message);
|
||||
const service = new UsersService({ accountability: req.accountability });
|
||||
await service.acceptInvite(req.body.token, req.body.password);
|
||||
res.end();
|
||||
return next();
|
||||
})
|
||||
);
|
||||
|
||||
router.post('/me/tfa/enable/', asyncHandler(async (req, res) => {
|
||||
if (!req.accountability?.user) {
|
||||
throw new InvalidCredentialsException();
|
||||
}
|
||||
router.post(
|
||||
'/me/tfa/enable/',
|
||||
asyncHandler(async (req, res, next) => {
|
||||
if (!req.accountability?.user) {
|
||||
throw new InvalidCredentialsException();
|
||||
}
|
||||
|
||||
const service = new UsersService({ accountability: req.accountability });
|
||||
const { url, secret } = await service.enableTFA(req.accountability.user);
|
||||
const service = new UsersService({ accountability: req.accountability });
|
||||
const { url, secret } = await service.enableTFA(req.accountability.user);
|
||||
|
||||
return res.json({ data: { secret, otpauth_url: url }});
|
||||
}));
|
||||
res.locals.payload = { data: { secret, otpauth_url: url } };
|
||||
return next();
|
||||
}),
|
||||
);
|
||||
|
||||
router.post('/me/tfa/disable', asyncHandler(async (req, res) => {
|
||||
if (!req.accountability?.user) {
|
||||
throw new InvalidCredentialsException();
|
||||
}
|
||||
router.post(
|
||||
'/me/tfa/disable',
|
||||
asyncHandler(async (req, res, next) => {
|
||||
if (!req.accountability?.user) {
|
||||
throw new InvalidCredentialsException();
|
||||
}
|
||||
|
||||
if (!req.body.otp) {
|
||||
throw new InvalidPayloadException(`"otp" is required`);
|
||||
}
|
||||
if (!req.body.otp) {
|
||||
throw new InvalidPayloadException(`"otp" is required`);
|
||||
}
|
||||
|
||||
const service = new UsersService({ accountability: req.accountability });
|
||||
const authService = new AuthService({ accountability: req.accountability });
|
||||
const service = new UsersService({ accountability: req.accountability });
|
||||
const authService = new AuthService({ accountability: req.accountability });
|
||||
|
||||
const otpValid = await authService.verifyOTP(req.accountability.user, req.body.otp);
|
||||
const otpValid = await authService.verifyOTP(req.accountability.user, req.body.otp);
|
||||
|
||||
if (otpValid === false) {
|
||||
throw new InvalidPayloadException(`"otp" is invalid`);
|
||||
}
|
||||
if (otpValid === false) {
|
||||
throw new InvalidPayloadException(`"otp" is invalid`);
|
||||
}
|
||||
|
||||
await service.disableTFA(req.accountability.user);
|
||||
|
||||
return res.status(200).end();
|
||||
}));
|
||||
await service.disableTFA(req.accountability.user);
|
||||
return next();
|
||||
})
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -68,6 +68,6 @@ router.post(
|
||||
|
||||
return res.status(200).end();
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,69 +1,63 @@
|
||||
import express from 'express';
|
||||
import asyncHandler from 'express-async-handler';
|
||||
import sanitizeQuery from '../middleware/sanitize-query';
|
||||
import WebhooksService from '../services/webhooks';
|
||||
import useCollection from '../middleware/use-collection';
|
||||
import MetaService from '../services/meta';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(useCollection('directus_webhooks'));
|
||||
|
||||
router.post(
|
||||
'/',
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const service = new WebhooksService({ accountability: req.accountability });
|
||||
const primaryKey = await service.create(req.body);
|
||||
const item = await service.readByKey(primaryKey, req.sanitizedQuery);
|
||||
|
||||
return res.json({ data: item || null });
|
||||
})
|
||||
res.locals.payload = { data: item || null };
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/',
|
||||
sanitizeQuery,
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const service = new WebhooksService({ accountability: req.accountability });
|
||||
const metaService = new MetaService({ accountability: req.accountability });
|
||||
|
||||
const records = await service.readByQuery(req.sanitizedQuery);
|
||||
const meta = await metaService.getMetaForQuery(req.collection, req.sanitizedQuery);
|
||||
|
||||
return res.json({ data: records || null, meta });
|
||||
})
|
||||
res.locals.payload = { data: records || null, meta };
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:pk',
|
||||
sanitizeQuery,
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const service = new WebhooksService({ accountability: req.accountability });
|
||||
const record = await service.readByKey(req.params.pk, req.sanitizedQuery);
|
||||
|
||||
return res.json({ data: record || null });
|
||||
})
|
||||
res.locals.payload = { data: record || null };
|
||||
}),
|
||||
);
|
||||
|
||||
router.patch(
|
||||
'/:pk',
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const service = new WebhooksService({ accountability: req.accountability });
|
||||
const primaryKey = await service.update(req.body, req.params.pk);
|
||||
const item = await service.readByKey(primaryKey, req.sanitizedQuery);
|
||||
|
||||
return res.json({ data: item || null });
|
||||
})
|
||||
res.locals.payload = { data: item || null };
|
||||
}),
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/:pk',
|
||||
asyncHandler(async (req, res) => {
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const service = new WebhooksService({ accountability: req.accountability });
|
||||
await service.delete(req.params.pk);
|
||||
|
||||
return res.status(200).end();
|
||||
})
|
||||
return next();
|
||||
}),
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -43,7 +43,7 @@ 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;
|
||||
@@ -119,7 +119,7 @@ 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;
|
||||
batchQuery.limit = -1;
|
||||
}
|
||||
@@ -168,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);
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,10 @@
|
||||
/**
|
||||
* @NOTE
|
||||
* See example.env for all possible keys
|
||||
*/
|
||||
|
||||
import dotenv from 'dotenv';
|
||||
import logger from './logger';
|
||||
import { clone } from 'lodash';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
@@ -12,6 +17,11 @@ const defaults: Record<string, any> = {
|
||||
STORAGE_LOCAL_DRIVER: 'local',
|
||||
STORAGE_LOCAL_ROOT: './uploads',
|
||||
|
||||
RATE_LIMITER_ENABLED: true,
|
||||
RATE_LIMITER_POINTS: 25,
|
||||
RATE_LIMITER_DURATION: 1,
|
||||
RATE_LIMITER_STORE: 'memory',
|
||||
|
||||
ACCESS_TOKEN_TTL: '15m',
|
||||
REFRESH_TOKEN_TTL: '7d',
|
||||
REFRESH_TOKEN_COOKIE_SECURE: false,
|
||||
@@ -19,6 +29,11 @@ const defaults: Record<string, any> = {
|
||||
|
||||
CORS_ENABLED: false,
|
||||
|
||||
CACHE_ENABLED: false,
|
||||
CACHE_STORE: false,
|
||||
CACHE_TTL: '30m',
|
||||
CACHE_NAMESPACE: 'system-cache',
|
||||
|
||||
OAUTH_PROVIDERS: '',
|
||||
|
||||
EXTENSIONS_PATH: './extensions',
|
||||
@@ -29,31 +44,23 @@ const defaults: Record<string, any> = {
|
||||
EMAIL_SENDMAIL_PATH: '/usr/sbin/sendmail',
|
||||
};
|
||||
|
||||
const env: Record<string, any> = {
|
||||
let env: Record<string, any> = {
|
||||
...defaults,
|
||||
...process.env,
|
||||
};
|
||||
|
||||
env = processValues(env);
|
||||
|
||||
export default env;
|
||||
|
||||
export function validateEnv() {
|
||||
const requiredKeys = ['DB_CLIENT', 'KEY', 'SECRET'];
|
||||
function processValues(env: Record<string, any>) {
|
||||
env = clone(env);
|
||||
|
||||
if (env.DB_CLIENT && env.DB_CLIENT === 'sqlite3') {
|
||||
requiredKeys.push('DB_FILENAME');
|
||||
} else {
|
||||
requiredKeys.push('DB_HOST', 'DB_PORT', 'DB_DATABASE', 'DB_USER', 'DB_PASSWORD');
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
if (value === 'true') env[key] = true;
|
||||
if (value === 'false') env[key] = false;
|
||||
if (value === 'null') env[key] = null;
|
||||
}
|
||||
|
||||
for (const requiredKey of requiredKeys) {
|
||||
if (env.hasOwnProperty(requiredKey) === false) {
|
||||
logger.fatal(`Environment is missing the ${requiredKey} key.`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
/**
|
||||
* @NOTE
|
||||
* See example.env for all possible keys
|
||||
*/
|
||||
|
||||
12
api/src/exceptions/hit-rate-limit.ts
Normal file
12
api/src/exceptions/hit-rate-limit.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { BaseException } from './base';
|
||||
|
||||
type Extensions = {
|
||||
limit: number;
|
||||
reset: Date;
|
||||
};
|
||||
|
||||
export class HitRateLimitException extends BaseException {
|
||||
constructor(message: string, extensions: Extensions) {
|
||||
super(message, 429, 'REQUESTS_EXCEEDED', extensions);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ export * from './collection-not-found';
|
||||
export * from './export-failed';
|
||||
export * from './field-not-found';
|
||||
export * from './forbidden';
|
||||
export * from './hit-rate-limit';
|
||||
export * from './invalid-credentials';
|
||||
export * from './invalid-otp';
|
||||
export * from './invalid-payload';
|
||||
@@ -10,3 +11,4 @@ export * from './invalid-query';
|
||||
export * from './item-limit';
|
||||
export * from './item-not-found';
|
||||
export * from './route-not-found';
|
||||
export * from './service-unavailable';
|
||||
|
||||
12
api/src/exceptions/service-unavailable.ts
Normal file
12
api/src/exceptions/service-unavailable.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { BaseException } from './base';
|
||||
|
||||
type Extensions = {
|
||||
service: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
export class ServiceUnavailableException extends BaseException {
|
||||
constructor(message: string, extensions: Extensions) {
|
||||
super(message, 503, 'SERVICE_UNAVAILABLE', extensions);
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,8 @@ import pino, { LoggerOptions } from 'pino';
|
||||
const pinoOptions: LoggerOptions = { level: process.env.LOG_LEVEL || 'info' };
|
||||
|
||||
if (process.env.LOG_STYLE !== 'raw') {
|
||||
pinoOptions.prettyPrint = true;
|
||||
pinoOptions.prettifier = require('pino-colada');
|
||||
pinoOptions.prettyPrint = true;
|
||||
pinoOptions.prettifier = require('pino-colada');
|
||||
}
|
||||
|
||||
const logger = pino(pinoOptions);
|
||||
|
||||
23
api/src/middleware/cache.ts
Normal file
23
api/src/middleware/cache.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { RequestHandler } from 'express';
|
||||
import asyncHandler from 'express-async-handler';
|
||||
import env from '../env';
|
||||
import { getCacheKey } from '../utils/get-cache-key';
|
||||
|
||||
import cache from '../cache';
|
||||
|
||||
const checkCacheMiddleware: RequestHandler = asyncHandler(async (req, res, next) => {
|
||||
if (req.method.toLowerCase() !== 'get') return next();
|
||||
if (env.CACHE_ENABLED !== true) return next();
|
||||
if (!cache) return next();
|
||||
|
||||
const key = getCacheKey(req);
|
||||
const cachedData = await cache.get(key);
|
||||
|
||||
if (cachedData) {
|
||||
return res.json(cachedData);
|
||||
} else {
|
||||
return next();
|
||||
}
|
||||
});
|
||||
|
||||
export default checkCacheMiddleware;
|
||||
18
api/src/middleware/cors.ts
Normal file
18
api/src/middleware/cors.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import cors from 'cors';
|
||||
import { RequestHandler } from 'express';
|
||||
import env from '../env';
|
||||
|
||||
let corsMiddleware: RequestHandler = (req, res, next) => next();
|
||||
|
||||
if (env.CORS_ENABLED === true) {
|
||||
corsMiddleware = cors({
|
||||
origin: env.CORS_ORIGIN || true,
|
||||
methods: env.CORS_METHODS || 'GET,POST,PATCH,DELETE',
|
||||
allowedHeaders: env.CORS_ALLOWED_HEADERS,
|
||||
exposedHeaders: env.CORS_EXPOSED_HEADERS,
|
||||
credentials: env.CORS_CREDENTIALS || undefined,
|
||||
maxAge: env.CORS_MAX_AGE || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export default corsMiddleware;
|
||||
@@ -18,8 +18,8 @@ const errorHandler: ErrorRequestHandler = (err, req, res, next) => {
|
||||
extensions: {
|
||||
...err.extensions,
|
||||
code: err.code,
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
} else {
|
||||
@@ -33,16 +33,16 @@ const errorHandler: ErrorRequestHandler = (err, req, res, next) => {
|
||||
message: err.message,
|
||||
extensions: {
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (env.NODE_ENV === 'development') {
|
||||
payload.errors[0].extensions.exception = {
|
||||
stack: err.stack
|
||||
}
|
||||
stack: err.stack,
|
||||
};
|
||||
}
|
||||
|
||||
return res.json(payload);
|
||||
|
||||
83
api/src/middleware/rate-limiter.ts
Normal file
83
api/src/middleware/rate-limiter.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { RequestHandler } from 'express';
|
||||
import asyncHandler from 'express-async-handler';
|
||||
import {
|
||||
RateLimiterMemory,
|
||||
RateLimiterRedis,
|
||||
RateLimiterMemcache,
|
||||
IRateLimiterOptions,
|
||||
IRateLimiterStoreOptions,
|
||||
} from 'rate-limiter-flexible';
|
||||
import env from '../env';
|
||||
import { getConfigFromEnv } from '../utils/get-config-from-env';
|
||||
import { HitRateLimitException } from '../exceptions';
|
||||
import ms from 'ms';
|
||||
import { validateEnv } from '../utils/validate-env';
|
||||
|
||||
let checkRateLimit: RequestHandler = (req, res, next) => next();
|
||||
|
||||
if (env.RATE_LIMITER_ENABLED === true) {
|
||||
validateEnv(['RATE_LIMITER_STORE', 'RATE_LIMITER_DURATION', 'RATE_LIMITER_POINTS']);
|
||||
|
||||
const rateLimiter = getRateLimiter();
|
||||
|
||||
checkRateLimit = asyncHandler(async (req, res, next) => {
|
||||
try {
|
||||
await rateLimiter.consume(req.ip, 1);
|
||||
} catch (rateLimiterRes) {
|
||||
if (rateLimiterRes instanceof Error) throw rateLimiterRes;
|
||||
|
||||
res.set('Retry-After', String(rateLimiterRes.msBeforeNext / 1000));
|
||||
throw new HitRateLimitException(
|
||||
`Too many requests, retry after ${ms(rateLimiterRes.msBeforeNext)}.`,
|
||||
{
|
||||
limit: +env.RATE_LIMITER_POINTS,
|
||||
reset: new Date(Date.now() + rateLimiterRes.msBeforeNext),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
export default checkRateLimit;
|
||||
|
||||
function getRateLimiter() {
|
||||
switch (env.RATE_LIMITER_STORE) {
|
||||
case 'redis':
|
||||
return new RateLimiterRedis(getConfig('redis'));
|
||||
case 'memcache':
|
||||
return new RateLimiterMemcache(getConfig('memcache'));
|
||||
case 'memory':
|
||||
default:
|
||||
return new RateLimiterMemory(getConfig());
|
||||
}
|
||||
}
|
||||
|
||||
function getConfig(store?: 'memory'): IRateLimiterOptions;
|
||||
function getConfig(store: 'redis' | 'memcache'): IRateLimiterStoreOptions;
|
||||
function getConfig(
|
||||
store: 'memory' | 'redis' | 'memcache' = 'memory'
|
||||
): IRateLimiterOptions | IRateLimiterStoreOptions {
|
||||
const config: any = getConfigFromEnv('RATE_LIMITER_', `RATE_LIMITER_${store}_`);
|
||||
|
||||
if (store === 'redis') {
|
||||
const Redis = require('ioredis');
|
||||
config.storeClient = new Redis(
|
||||
env.RATE_LIMITER_REDIS || getConfigFromEnv('RATE_LIMITER_REDIS_')
|
||||
);
|
||||
}
|
||||
|
||||
if (store === 'memcache') {
|
||||
const Memcached = require('memcached');
|
||||
config.storeClient = new Memcached(
|
||||
env.RATE_LIMITER_MEMCACHE,
|
||||
getConfigFromEnv('RATE_LIMITER_MEMCACHE_')
|
||||
);
|
||||
}
|
||||
|
||||
delete config.enabled;
|
||||
delete config.store;
|
||||
|
||||
return config;
|
||||
}
|
||||
14
api/src/middleware/respond.ts
Normal file
14
api/src/middleware/respond.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { RequestHandler } from "express";
|
||||
import asyncHandler from "express-async-handler";
|
||||
import env from "../env";
|
||||
import { getCacheKey } from "../utils/get-cache-key";
|
||||
import cache from '../cache';
|
||||
|
||||
export const respond: RequestHandler = asyncHandler(async (req, res) => {
|
||||
if (req.method.toLowerCase() === 'get' && env.CACHE_ENABLED === true && cache) {
|
||||
const key = getCacheKey(req);
|
||||
await cache.set(key, res.locals.payload);
|
||||
}
|
||||
|
||||
return res.json(res.locals.payload);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,7 @@ const sanitizeQuery: RequestHandler = (req, res, next) => {
|
||||
}
|
||||
|
||||
req.sanitizedQuery = query;
|
||||
Object.freeze(req.sanitizedQuery);
|
||||
return next();
|
||||
};
|
||||
|
||||
@@ -103,7 +104,7 @@ function sanitizeFilter(rawFilter: any, accountability: Accountability | null) {
|
||||
}
|
||||
|
||||
function sanitizeLimit(rawLimit: any) {
|
||||
if (!rawLimit) return null;
|
||||
if (rawLimit === undefined || rawLimit === null) return null;
|
||||
return Number(rawLimit);
|
||||
}
|
||||
|
||||
@@ -131,4 +132,6 @@ function sanitizeMeta(rawMeta: any) {
|
||||
if (Array.isArray(rawMeta)) {
|
||||
return rawMeta;
|
||||
}
|
||||
|
||||
return [rawMeta];
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import app from './app';
|
||||
import logger from './logger';
|
||||
import env, { validateEnv } from './env';
|
||||
import env from './env';
|
||||
import { validateDBConnection } from './database';
|
||||
|
||||
export default async function start() {
|
||||
validateEnv();
|
||||
await validateDBConnection();
|
||||
|
||||
const port = env.NODE_ENV === 'development' ? 41201 : env.PORT;
|
||||
|
||||
@@ -3,7 +3,11 @@ import jwt from 'jsonwebtoken';
|
||||
import argon2 from 'argon2';
|
||||
import { nanoid } from 'nanoid';
|
||||
import ms from 'ms';
|
||||
import { InvalidCredentialsException, InvalidPayloadException, InvalidOTPException } from '../exceptions';
|
||||
import {
|
||||
InvalidCredentialsException,
|
||||
InvalidPayloadException,
|
||||
InvalidOTPException,
|
||||
} from '../exceptions';
|
||||
import { Session, Accountability, AbstractServiceOptions, Action } from '../types';
|
||||
import Knex from 'knex';
|
||||
import ActivityService from '../services/activity';
|
||||
@@ -111,7 +115,11 @@ export default class AuthenticationService {
|
||||
}
|
||||
|
||||
const record = await database
|
||||
.select<Session & { email: string, id: string }>('directus_sessions.*', 'directus_users.email', 'directus_users.id')
|
||||
.select<Session & { email: string; id: string }>(
|
||||
'directus_sessions.*',
|
||||
'directus_users.email',
|
||||
'directus_users.id'
|
||||
)
|
||||
.from('directus_sessions')
|
||||
.where({ 'directus_sessions.token': refreshToken })
|
||||
.leftJoin('directus_users', 'directus_sessions.user', 'directus_users.id')
|
||||
@@ -128,7 +136,9 @@ export default class AuthenticationService {
|
||||
const newRefreshToken = nanoid(64);
|
||||
const refreshTokenExpiration = new Date(Date.now() + ms(env.REFRESH_TOKEN_TTL as string));
|
||||
|
||||
await this.knex('directus_sessions').update({ token: newRefreshToken, expires: refreshTokenExpiration }).where({ token: refreshToken });
|
||||
await this.knex('directus_sessions')
|
||||
.update({ token: newRefreshToken, expires: refreshTokenExpiration })
|
||||
.where({ token: refreshToken });
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
@@ -148,13 +158,21 @@ export default class AuthenticationService {
|
||||
}
|
||||
|
||||
async generateOTPAuthURL(pk: string, secret: string) {
|
||||
const user = await this.knex.select('first_name', 'last_name').from('directus_users').where({ id: pk }).first();
|
||||
const user = await this.knex
|
||||
.select('first_name', 'last_name')
|
||||
.from('directus_users')
|
||||
.where({ id: pk })
|
||||
.first();
|
||||
const name = `${user.first_name} ${user.last_name}`;
|
||||
return authenticator.keyuri(name, 'Directus', secret);
|
||||
}
|
||||
|
||||
async verifyOTP(pk: string, otp: string): Promise<boolean> {
|
||||
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) {
|
||||
throw new InvalidPayloadException(`User "${pk}" doesn't have TFA enabled.`);
|
||||
|
||||
@@ -6,6 +6,7 @@ import SchemaInspector from 'knex-schema-inspector';
|
||||
import FieldsService from '../services/fields';
|
||||
import { omit } from 'lodash';
|
||||
import ItemsService from '../services/items';
|
||||
import cache from '../cache';
|
||||
|
||||
export default class CollectionsService {
|
||||
knex: Knex;
|
||||
@@ -85,6 +86,10 @@ export default class CollectionsService {
|
||||
}
|
||||
});
|
||||
|
||||
if (cache) {
|
||||
await cache.clear();
|
||||
}
|
||||
|
||||
return Array.isArray(data) ? createdCollections : createdCollections[0];
|
||||
}
|
||||
|
||||
@@ -199,12 +204,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];
|
||||
@@ -218,6 +238,10 @@ export default class CollectionsService {
|
||||
|
||||
await collectionItemsService.update(collectionUpdates);
|
||||
|
||||
if (cache) {
|
||||
await cache.clear();
|
||||
}
|
||||
|
||||
return key!;
|
||||
}
|
||||
|
||||
@@ -281,6 +305,10 @@ export default class CollectionsService {
|
||||
await this.knex.schema.dropTable(collectionKey);
|
||||
}
|
||||
|
||||
if (cache) {
|
||||
await cache.clear();
|
||||
}
|
||||
|
||||
return collection;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,11 @@ import ItemsService from '../services/items';
|
||||
import { ColumnBuilder } from 'knex';
|
||||
import getLocalType from '../utils/get-local-type';
|
||||
import { types } from '../types';
|
||||
import { FieldNotFoundException, ForbiddenException } from '../exceptions';
|
||||
import { ForbiddenException } from '../exceptions';
|
||||
import Knex, { CreateTableBuilder } from 'knex';
|
||||
import PayloadService from '../services/payload';
|
||||
import getDefaultValue from '../utils/get-default-value';
|
||||
import cache from '../cache';
|
||||
|
||||
type RawField = Partial<Field> & { field: string; type: typeof types[number] };
|
||||
|
||||
@@ -205,6 +206,10 @@ export default class FieldsService {
|
||||
field: field.field,
|
||||
});
|
||||
}
|
||||
|
||||
if (cache) {
|
||||
await cache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/** @todo research how to make this happen in SQLite / Redshift */
|
||||
@@ -272,6 +277,10 @@ export default class FieldsService {
|
||||
}
|
||||
}
|
||||
|
||||
if (cache) {
|
||||
await cache.clear();
|
||||
}
|
||||
|
||||
return field.field;
|
||||
}
|
||||
|
||||
@@ -309,6 +318,10 @@ export default class FieldsService {
|
||||
.where({ one_collection: collection, one_field: field });
|
||||
}
|
||||
}
|
||||
|
||||
if (cache) {
|
||||
await cache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
public addColumnToTable(table: CreateTableBuilder, field: Field) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import parseIPTC from '../utils/parse-iptc';
|
||||
import path from 'path';
|
||||
import { AbstractServiceOptions, File, PrimaryKey } from '../types';
|
||||
import { clone } from 'lodash';
|
||||
import cache from '../cache';
|
||||
|
||||
export default class FilesService extends ItemsService {
|
||||
constructor(options?: AbstractServiceOptions) {
|
||||
@@ -77,6 +78,10 @@ export default class FilesService extends ItemsService {
|
||||
const sudoService = new ItemsService('directus_files');
|
||||
await sudoService.update(payload, primaryKey);
|
||||
|
||||
if (cache) {
|
||||
await cache.clear();
|
||||
}
|
||||
|
||||
return primaryKey;
|
||||
}
|
||||
|
||||
@@ -97,6 +102,10 @@ export default class FilesService extends ItemsService {
|
||||
|
||||
await super.delete(keys);
|
||||
|
||||
if (cache) {
|
||||
await cache.clear();
|
||||
}
|
||||
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
AbstractServiceOptions,
|
||||
} from '../types';
|
||||
import Knex from 'knex';
|
||||
import cache from '../cache';
|
||||
|
||||
import PayloadService from './payload';
|
||||
import AuthorizationService from './authorization';
|
||||
@@ -145,6 +146,10 @@ export default class ItemsService implements AbstractService {
|
||||
await trx.insert(revisionRecords).into('directus_revisions');
|
||||
}
|
||||
|
||||
if (cache) {
|
||||
await cache.clear();
|
||||
}
|
||||
|
||||
return primaryKeys;
|
||||
});
|
||||
|
||||
@@ -172,6 +177,7 @@ export default class ItemsService implements AbstractService {
|
||||
query: Query = {},
|
||||
action: PermissionsAction = 'read'
|
||||
): Promise<Item | Item[]> {
|
||||
query = clone(query);
|
||||
const schemaInspector = SchemaInspector(this.knex);
|
||||
const primaryKeyField = await schemaInspector.primary(this.collection);
|
||||
const keys = Array.isArray(key) ? key : [key];
|
||||
@@ -301,6 +307,10 @@ export default class ItemsService implements AbstractService {
|
||||
}
|
||||
});
|
||||
|
||||
if (cache) {
|
||||
await cache.clear();
|
||||
}
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
@@ -359,10 +369,15 @@ export default class ItemsService implements AbstractService {
|
||||
}
|
||||
});
|
||||
|
||||
if (cache) {
|
||||
await cache.clear();
|
||||
}
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
async readSingleton(query: Query) {
|
||||
query = clone(query);
|
||||
const schemaInspector = SchemaInspector(this.knex);
|
||||
query.limit = 1;
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { InvalidPayloadException, ForbiddenException } from '../exceptions';
|
||||
import { Accountability, PrimaryKey, Item, AbstractServiceOptions } from '../types';
|
||||
import Knex from 'knex';
|
||||
import env from '../env';
|
||||
import cache from '../cache';
|
||||
|
||||
export default class UsersService extends ItemsService {
|
||||
knex: Knex;
|
||||
@@ -42,6 +43,10 @@ export default class UsersService extends ItemsService {
|
||||
}
|
||||
}
|
||||
|
||||
if (cache) {
|
||||
await cache.clear();
|
||||
}
|
||||
|
||||
return this.service.update(data, key as any);
|
||||
}
|
||||
|
||||
@@ -78,6 +83,10 @@ export default class UsersService extends ItemsService {
|
||||
await this.knex('directus_users')
|
||||
.update({ password: passwordHashed, status: 'active' })
|
||||
.where({ id: user.id });
|
||||
|
||||
if (cache) {
|
||||
await cache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
async requestPasswordReset(email: string) {
|
||||
@@ -114,6 +123,10 @@ export default class UsersService extends ItemsService {
|
||||
await this.knex('directus_users')
|
||||
.update({ password: passwordHashed, status: 'active' })
|
||||
.where({ id: user.id });
|
||||
|
||||
if (cache) {
|
||||
await cache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
async enableTFA(pk: string) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AbstractServiceOptions, Accountability, PrimaryKey } from "../types";
|
||||
import { AbstractServiceOptions, Accountability, PrimaryKey } from '../types';
|
||||
import database from '../database';
|
||||
import Knex from 'knex';
|
||||
import { InvalidPayloadException, ForbiddenException } from '../exceptions';
|
||||
@@ -13,7 +13,7 @@ export default class UtilsService {
|
||||
this.accountability = options?.accountability || null;
|
||||
}
|
||||
|
||||
async sort(collection: string, { item, to }: { item: PrimaryKey, to: PrimaryKey }) {
|
||||
async sort(collection: string, { item, to }: { item: PrimaryKey; to: PrimaryKey }) {
|
||||
const schemaInspector = SchemaInspector(this.knex);
|
||||
|
||||
const sortFieldResponse = await this.knex
|
||||
@@ -25,7 +25,9 @@ export default class UtilsService {
|
||||
const sortField = sortFieldResponse?.sort_field;
|
||||
|
||||
if (!sortField) {
|
||||
throw new InvalidPayloadException(`Collection "${collection}" doesn't have a sort field.`);
|
||||
throw new InvalidPayloadException(
|
||||
`Collection "${collection}" doesn't have a sort field.`
|
||||
);
|
||||
}
|
||||
|
||||
if (this.accountability?.admin !== true) {
|
||||
@@ -60,10 +62,7 @@ export default class UtilsService {
|
||||
.first();
|
||||
|
||||
if (countResponse?.count && +countResponse.count !== 0) {
|
||||
const lastSortValueResponse = await this.knex
|
||||
.max(sortField)
|
||||
.from(collection)
|
||||
.first();
|
||||
const lastSortValueResponse = await this.knex.max(sortField).from(collection).first();
|
||||
|
||||
const rowsWithoutSortValue = await this.knex
|
||||
.select(primaryKeyField, sortField)
|
||||
@@ -80,14 +79,24 @@ export default class UtilsService {
|
||||
}
|
||||
}
|
||||
|
||||
const targetSortValueResponse = await this.knex.select(sortField).from(collection).where({ [primaryKeyField]: to }).first();
|
||||
const targetSortValueResponse = await this.knex
|
||||
.select(sortField)
|
||||
.from(collection)
|
||||
.where({ [primaryKeyField]: to })
|
||||
.first();
|
||||
const targetSortValue = targetSortValueResponse[sortField];
|
||||
|
||||
const sourceSortValueResponse = await this.knex.select(sortField).from(collection).where({ [primaryKeyField]: item }).first();
|
||||
const sourceSortValueResponse = await this.knex
|
||||
.select(sortField)
|
||||
.from(collection)
|
||||
.where({ [primaryKeyField]: item })
|
||||
.first();
|
||||
const sourceSortValue = sourceSortValueResponse[sortField];
|
||||
|
||||
// Set the target item to the new sort value
|
||||
await this.knex(collection).update({ [sortField]: targetSortValue }).where({ [primaryKeyField]: item });
|
||||
await this.knex(collection)
|
||||
.update({ [sortField]: targetSortValue })
|
||||
.where({ [primaryKeyField]: item });
|
||||
|
||||
if (sourceSortValue < targetSortValue) {
|
||||
await this.knex(collection)
|
||||
|
||||
@@ -4,13 +4,15 @@ import {
|
||||
StorageManagerConfig,
|
||||
Storage,
|
||||
} from '@slynova/flydrive';
|
||||
import camelcase from 'camelcase';
|
||||
import env from './env';
|
||||
import { validateEnv } from './utils/validate-env';
|
||||
import { getConfigFromEnv } from './utils/get-config-from-env';
|
||||
|
||||
/** @todo dynamically load these storage adapters */
|
||||
import { AmazonWebServicesS3Storage } from '@slynova/flydrive-s3';
|
||||
import { GoogleCloudStorage } from '@slynova/flydrive-gcs';
|
||||
|
||||
/** @todo dynamically load storage adapters here */
|
||||
validateEnv(['STORAGE_LOCATIONS']);
|
||||
|
||||
const storage = new StorageManager(getStorageConfig());
|
||||
|
||||
@@ -19,26 +21,25 @@ registerDrivers(storage);
|
||||
export default storage;
|
||||
|
||||
function getStorageConfig(): StorageManagerConfig {
|
||||
const config: any = { disks: {} };
|
||||
const config: StorageManagerConfig = {
|
||||
disks: {},
|
||||
};
|
||||
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
if (key.startsWith('STORAGE') === false) continue;
|
||||
if (key === 'STORAGE_LOCATIONS') continue;
|
||||
if (key.endsWith('PUBLIC_URL')) continue;
|
||||
const locations = env.STORAGE_LOCATIONS.split(',');
|
||||
|
||||
const disk = key.split('_')[1].toLowerCase();
|
||||
if (!config.disks[disk]) config.disks[disk] = { config: {} };
|
||||
locations.forEach((location: string) => {
|
||||
location = location.trim();
|
||||
|
||||
if (key.endsWith('DRIVER')) {
|
||||
config.disks[disk].driver = value;
|
||||
continue;
|
||||
}
|
||||
const diskConfig = {
|
||||
driver: env[`STORAGE_${location.toUpperCase()}_DRIVER`],
|
||||
config: getConfigFromEnv(`STORAGE_${location.toUpperCase()}_`),
|
||||
};
|
||||
|
||||
const configKey = camelcase(
|
||||
key.split('_').filter((_, index) => [0, 1].includes(index) === false)
|
||||
);
|
||||
config.disks[disk].config[configKey] = value;
|
||||
}
|
||||
delete diskConfig.config.publicUrl;
|
||||
delete diskConfig.config.driver;
|
||||
|
||||
config.disks![location] = diskConfig;
|
||||
});
|
||||
|
||||
return config;
|
||||
}
|
||||
@@ -53,6 +54,7 @@ function registerDrivers(storage: StorageManager) {
|
||||
|
||||
usedDrivers.forEach((driver) => {
|
||||
const storageDriver = getStorageDriver(driver);
|
||||
|
||||
if (storageDriver) {
|
||||
storage.registerDriver<Storage>(driver, storageDriver);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
export type PermissionsAction =
|
||||
| 'create'
|
||||
| 'read'
|
||||
| 'update'
|
||||
| 'delete'
|
||||
| 'comment'
|
||||
| 'explain';
|
||||
export type PermissionsAction = 'create' | 'read' | 'update' | 'delete' | 'comment' | 'explain';
|
||||
|
||||
export type Permission = {
|
||||
id: number;
|
||||
|
||||
@@ -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, '!=', '');
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Accountability,
|
||||
} from '../types';
|
||||
import database from '../database';
|
||||
import { clone } from 'lodash';
|
||||
|
||||
export default async function getASTFromQuery(
|
||||
collection: string,
|
||||
@@ -19,6 +20,7 @@ export default async function getASTFromQuery(
|
||||
accountability?: Accountability | null,
|
||||
action?: PermissionsAction
|
||||
): Promise<AST> {
|
||||
query = clone(query);
|
||||
/**
|
||||
* we might not need al this info at all times, but it's easier to fetch it all once, than trying to fetch it for every
|
||||
* requested field. @todo look into utilizing graphql/dataloader for this purpose
|
||||
|
||||
8
api/src/utils/get-cache-key.ts
Normal file
8
api/src/utils/get-cache-key.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Request } from "express";
|
||||
import url from 'url';
|
||||
|
||||
export function getCacheKey(req: Request) {
|
||||
const path = url.parse(req.originalUrl).pathname;
|
||||
const key = `${req.accountability?.user || 'null'}-${path}-${JSON.stringify(req.sanitizedQuery)}`;
|
||||
return key;
|
||||
}
|
||||
14
api/src/utils/get-config-from-env.ts
Normal file
14
api/src/utils/get-config-from-env.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import camelcase from 'camelcase';
|
||||
import env from '../env';
|
||||
|
||||
export function getConfigFromEnv(prefix: string, omitPrefix?: string) {
|
||||
const config: any = {};
|
||||
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
if (key.toLowerCase().startsWith(prefix.toLowerCase()) === false) continue;
|
||||
if (omitPrefix && key.toLowerCase().startsWith(omitPrefix.toLowerCase()) === true) continue;
|
||||
config[camelcase(key.slice(prefix.length))] = value;
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
21
api/src/utils/validate-env.ts
Normal file
21
api/src/utils/validate-env.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import logger from '../logger';
|
||||
import env from '../env';
|
||||
|
||||
export function validateEnv(requiredKeys: string[]) {
|
||||
if (env.DB_CLIENT && env.DB_CLIENT === 'sqlite3') {
|
||||
requiredKeys.push('DB_FILENAME');
|
||||
} else {
|
||||
if (env.DB_CLIENT === 'pg') {
|
||||
requiredKeys.push('DB_HOST', 'DB_PORT', 'DB_DATABASE', 'DB_USER');
|
||||
} else {
|
||||
requiredKeys.push('DB_HOST', 'DB_PORT', 'DB_DATABASE', 'DB_USER', 'DB_PASSWORD');
|
||||
}
|
||||
}
|
||||
|
||||
for (const requiredKey of requiredKeys) {
|
||||
if (env.hasOwnProperty(requiredKey) === false) {
|
||||
logger.fatal(`Environment is missing the ${requiredKey} key.`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"esModuleInterop": true,
|
||||
"target": "es6",
|
||||
"moduleResolution": "node",
|
||||
"sourceMap": false,
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"esModuleInterop": true,
|
||||
"target": "es6",
|
||||
"moduleResolution": "node",
|
||||
"sourceMap": false,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"lib": [
|
||||
"es2019"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
"lib": ["es2019"],
|
||||
"skipLibCheck": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
{
|
||||
"defaultSeverity": "error",
|
||||
"extends": [
|
||||
"tslint:recommended"
|
||||
],
|
||||
"jsRules": {},
|
||||
"rules": {},
|
||||
"rulesDirectory": []
|
||||
}
|
||||
"defaultSeverity": "error",
|
||||
"extends": ["tslint:recommended"],
|
||||
"jsRules": {},
|
||||
"rules": {},
|
||||
"rulesDirectory": []
|
||||
}
|
||||
|
||||
@@ -188,14 +188,14 @@
|
||||
"hidden_browse": false,
|
||||
"required": false,
|
||||
"options": {
|
||||
"viewType": "cards",
|
||||
"viewOptions": {
|
||||
"layout": "cards",
|
||||
"layoutOptions": {
|
||||
"title": "title",
|
||||
"subtitle": "type",
|
||||
"content": "description",
|
||||
"src": "data"
|
||||
},
|
||||
"viewQuery": [],
|
||||
"layoutQuery": [],
|
||||
"filters": []
|
||||
},
|
||||
"locked": 0,
|
||||
@@ -316,14 +316,14 @@
|
||||
"hidden_browse": false,
|
||||
"required": false,
|
||||
"options": {
|
||||
"viewType": "cards",
|
||||
"viewOptions": {
|
||||
"layout": "cards",
|
||||
"layoutOptions": {
|
||||
"title": "title",
|
||||
"subtitle": "type",
|
||||
"content": "description",
|
||||
"src": "data"
|
||||
},
|
||||
"viewQuery": [],
|
||||
"layoutQuery": [],
|
||||
"filters": []
|
||||
},
|
||||
"locked": 0,
|
||||
@@ -4183,14 +4183,14 @@
|
||||
"hidden_browse": false,
|
||||
"required": false,
|
||||
"options": {
|
||||
"viewType": "cards",
|
||||
"viewOptions": {
|
||||
"layout": "cards",
|
||||
"layoutOptions": {
|
||||
"title": "title",
|
||||
"subtitle": "type",
|
||||
"content": "description",
|
||||
"src": "data"
|
||||
},
|
||||
"viewQuery": [],
|
||||
"layoutQuery": [],
|
||||
"filters": []
|
||||
},
|
||||
"locked": 0,
|
||||
@@ -6128,9 +6128,9 @@
|
||||
"group": null,
|
||||
"length": null
|
||||
},
|
||||
"view_type": {
|
||||
"layout": {
|
||||
"collection": "directus_collection_presets",
|
||||
"field": "view_type",
|
||||
"field": "layout",
|
||||
"datatype": "VARCHAR",
|
||||
"unique": false,
|
||||
"primary_key": false,
|
||||
@@ -6156,9 +6156,9 @@
|
||||
"group": null,
|
||||
"length": "100"
|
||||
},
|
||||
"view_query": {
|
||||
"layout_query": {
|
||||
"collection": "directus_collection_presets",
|
||||
"field": "view_query",
|
||||
"field": "layout_query",
|
||||
"datatype": "TEXT",
|
||||
"unique": false,
|
||||
"primary_key": false,
|
||||
@@ -6184,9 +6184,9 @@
|
||||
"group": null,
|
||||
"length": null
|
||||
},
|
||||
"view_options": {
|
||||
"layout_options": {
|
||||
"collection": "directus_collection_presets",
|
||||
"field": "view_options",
|
||||
"field": "layout_options",
|
||||
"datatype": "TEXT",
|
||||
"unique": false,
|
||||
"primary_key": false,
|
||||
|
||||
@@ -186,14 +186,14 @@
|
||||
"hidden_browse": false,
|
||||
"required": false,
|
||||
"options": {
|
||||
"viewType": "cards",
|
||||
"viewOptions": {
|
||||
"layout": "cards",
|
||||
"layoutOptions": {
|
||||
"title": "title",
|
||||
"subtitle": "type",
|
||||
"content": "description",
|
||||
"src": "data"
|
||||
},
|
||||
"viewQuery": [],
|
||||
"layoutQuery": [],
|
||||
"filters": []
|
||||
},
|
||||
"locked": 0,
|
||||
@@ -317,14 +317,14 @@
|
||||
"hidden_browse": false,
|
||||
"required": false,
|
||||
"options": {
|
||||
"viewType": "cards",
|
||||
"viewOptions": {
|
||||
"layout": "cards",
|
||||
"layoutOptions": {
|
||||
"title": "title",
|
||||
"subtitle": "type",
|
||||
"content": "description",
|
||||
"src": "data"
|
||||
},
|
||||
"viewQuery": [],
|
||||
"layoutQuery": [],
|
||||
"filters": []
|
||||
},
|
||||
"locked": 0,
|
||||
@@ -2078,7 +2078,7 @@
|
||||
},
|
||||
{
|
||||
"collection": "directus_collection_presets",
|
||||
"field": "view_type",
|
||||
"field": "layout",
|
||||
"datatype": "VARCHAR",
|
||||
"unique": false,
|
||||
"primary_key": false,
|
||||
@@ -2107,7 +2107,7 @@
|
||||
},
|
||||
{
|
||||
"collection": "directus_collection_presets",
|
||||
"field": "view_query",
|
||||
"field": "layout_query",
|
||||
"datatype": "TEXT",
|
||||
"unique": false,
|
||||
"primary_key": false,
|
||||
@@ -2136,7 +2136,7 @@
|
||||
},
|
||||
{
|
||||
"collection": "directus_collection_presets",
|
||||
"field": "view_options",
|
||||
"field": "layout_options",
|
||||
"datatype": "TEXT",
|
||||
"unique": false,
|
||||
"primary_key": false,
|
||||
@@ -9473,14 +9473,14 @@
|
||||
"hidden_browse": false,
|
||||
"required": false,
|
||||
"options": {
|
||||
"viewType": "cards",
|
||||
"viewOptions": {
|
||||
"layout": "cards",
|
||||
"layoutOptions": {
|
||||
"title": "title",
|
||||
"subtitle": "type",
|
||||
"content": "description",
|
||||
"src": "data"
|
||||
},
|
||||
"viewQuery": [],
|
||||
"layoutQuery": [],
|
||||
"filters": []
|
||||
},
|
||||
"locked": 0,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@directus/app",
|
||||
"version": "9.0.0-alpha.27",
|
||||
"version": "9.0.0-alpha.33",
|
||||
"private": false,
|
||||
"description": "Directus is an Open-Source Headless CMS & API for Managing Custom Databases",
|
||||
"author": "Rijk van Zanten <rijk@rngr.org>",
|
||||
@@ -159,5 +159,5 @@
|
||||
"stylelint --fix"
|
||||
]
|
||||
},
|
||||
"gitHead": "c22537b6d0dd7dc6cc651a9200a68f6b060353d4"
|
||||
"gitHead": "a7d3952ec3b812ee1eec2f1c7b9f44186cbe0498"
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ export const onError = async (error: RequestError) => {
|
||||
try {
|
||||
newToken = await refresh();
|
||||
} catch {
|
||||
logout({ reason: LogoutReason.ERROR_SESSION_EXPIRED });
|
||||
logout({ reason: LogoutReason.SESSION_EXPIRED });
|
||||
return Promise.reject();
|
||||
}
|
||||
|
||||
|
||||
@@ -50,7 +50,13 @@ export default defineComponent({
|
||||
};
|
||||
});
|
||||
|
||||
watch(() => settingsStore.state.settings?.project_color, setFavicon);
|
||||
watch(
|
||||
[() => settingsStore.state.settings?.project_color, () => settingsStore.state.settings?.project_logo],
|
||||
() => {
|
||||
const hasCustomLogo = !!settingsStore.state.settings?.project_logo;
|
||||
setFavicon(settingsStore.state.settings?.project_color || '#2f80ed', hasCustomLogo);
|
||||
}
|
||||
);
|
||||
|
||||
const { width } = useWindowSize();
|
||||
|
||||
|
||||
@@ -49,13 +49,13 @@ export async function refresh({ navigate }: LogoutOptions = { navigate: true })
|
||||
|
||||
return accessToken;
|
||||
} catch (error) {
|
||||
await logout({ navigate, reason: LogoutReason.ERROR_SESSION_EXPIRED });
|
||||
await logout({ navigate, reason: LogoutReason.SESSION_EXPIRED });
|
||||
}
|
||||
}
|
||||
|
||||
export enum LogoutReason {
|
||||
SIGN_OUT = 'SIGN_OUT',
|
||||
ERROR_SESSION_EXPIRED = 'ERROR_SESSION_EXPIRED',
|
||||
SESSION_EXPIRED = 'SESSION_EXPIRED',
|
||||
}
|
||||
|
||||
export type LogoutOptions = {
|
||||
|
||||
@@ -5,4 +5,6 @@ export type FancySelectItem = {
|
||||
value: string | number;
|
||||
text: string | TranslateResult;
|
||||
description?: string | TranslateResult;
|
||||
divider?: boolean;
|
||||
iconRight?: string;
|
||||
};
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import VFancySelect from './v-fancy-select.vue';
|
||||
import VueCompositionAPI from '@vue/composition-api';
|
||||
import { shallowMount, createLocalVue } from '@vue/test-utils';
|
||||
import VIcon from '@/components/v-icon/';
|
||||
|
||||
const localVue = createLocalVue();
|
||||
localVue.use(VueCompositionAPI);
|
||||
localVue.component('v-icon', VIcon);
|
||||
|
||||
describe('Components / Fancy Select', () => {
|
||||
it('Renders', () => {
|
||||
const component = shallowMount(VFancySelect, { localVue, propsData: { items: [] } });
|
||||
expect(component.isVueInstance()).toBe(true);
|
||||
});
|
||||
|
||||
it('Calculates the visible items correctly', async () => {
|
||||
const items = [
|
||||
{
|
||||
value: 'code',
|
||||
icon: 'code',
|
||||
text: 'Raw Value',
|
||||
description: 'This works for most non-relational fields',
|
||||
},
|
||||
{
|
||||
value: 'palette',
|
||||
icon: 'palette',
|
||||
text: 'Formatted Value',
|
||||
description: 'Templated formatting and conditional coloring to text values',
|
||||
},
|
||||
{
|
||||
value: 'label',
|
||||
icon: 'label',
|
||||
text: 'Placard',
|
||||
description: 'Shows the value within a colored badge',
|
||||
},
|
||||
{
|
||||
value: 'assignment_turned_in',
|
||||
icon: 'assignment_turned_in',
|
||||
text: 'Progress',
|
||||
description: 'Converts number values into a progress bar',
|
||||
},
|
||||
];
|
||||
|
||||
const component = shallowMount(VFancySelect, {
|
||||
localVue,
|
||||
propsData: {
|
||||
items: items,
|
||||
},
|
||||
});
|
||||
|
||||
expect((component.vm as any).visibleItems).toEqual(items);
|
||||
|
||||
component.setProps({
|
||||
value: 'label',
|
||||
});
|
||||
|
||||
expect((component.vm as any).visibleItems).toEqual([items[2]]);
|
||||
});
|
||||
});
|
||||
@@ -1,27 +1,35 @@
|
||||
<template>
|
||||
<div class="v-fancy-select">
|
||||
<transition-group tag="div" name="option">
|
||||
<div
|
||||
v-for="(item, index) in visibleItems"
|
||||
:key="item.value"
|
||||
class="v-fancy-select-option"
|
||||
:class="{ active: item.value === value, disabled }"
|
||||
:style="{
|
||||
'--index': index,
|
||||
}"
|
||||
@click="toggle(item)"
|
||||
>
|
||||
<div class="icon">
|
||||
<v-icon :name="item.icon" />
|
||||
</div>
|
||||
<template v-for="(item, index) in visibleItems">
|
||||
<v-divider :key="index" v-if="item.divider === true" />
|
||||
<div
|
||||
v-else
|
||||
:key="item.value"
|
||||
class="v-fancy-select-option"
|
||||
:class="{ active: item.value === value, disabled }"
|
||||
:style="{
|
||||
'--index': index,
|
||||
}"
|
||||
@click="toggle(item)"
|
||||
>
|
||||
<div class="icon">
|
||||
<v-icon :name="item.icon" />
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="text">{{ item.text }}</div>
|
||||
<div class="description">{{ item.description }}</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="text">{{ item.text }}</div>
|
||||
<div class="description">{{ item.description }}</div>
|
||||
</div>
|
||||
|
||||
<v-icon v-if="value === item.value && disabled === false" name="cancel" @click.stop="toggle(item)" />
|
||||
</div>
|
||||
<v-icon
|
||||
v-if="value === item.value && disabled === false"
|
||||
name="cancel"
|
||||
@click.stop="toggle(item)"
|
||||
/>
|
||||
<v-icon class="icon-right" v-else-if="item.iconRight" :name="item.iconRight" />
|
||||
</div>
|
||||
</template>
|
||||
</transition-group>
|
||||
</div>
|
||||
</template>
|
||||
@@ -147,4 +155,12 @@ export default defineComponent({
|
||||
.option-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.icon-right {
|
||||
--v-icon-color: var(--foreground-subdued);
|
||||
}
|
||||
|
||||
.v-divider {
|
||||
margin: 24px 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
21
app/src/components/v-icon/custom-icons/bookmark_save.vue
Normal file
21
app/src/components/v-icon/custom-icons/bookmark_save.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<template functional>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
stroke-linejoin="round"
|
||||
stroke-miterlimit="2"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M18.38 3.6c-.38-.4-.83-.6-1.36-.6H6.98c-.53 0-1 .2-1.4.6-.38.42-.56.88-.56 1.42V21L12 18l6.98 3V5.02c0-.54-.2-1-.6-1.41zm-8.05 11.5l6.7-6.7-1.4-1.4-5.3 5.3-1.92-1.93L7 11.79l3.33 3.33z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {};
|
||||
</script>
|
||||
@@ -16,6 +16,7 @@ import { defineComponent, computed } from '@vue/composition-api';
|
||||
import useSizeClass, { sizeProps } from '@/composables/size-class';
|
||||
|
||||
import CustomIconDirectus from './custom-icons/directus.vue';
|
||||
import CustomIconBookmarkSave from './custom-icons/bookmark_save.vue';
|
||||
import CustomIconBox from './custom-icons/box.vue';
|
||||
import CustomIconCommitNode from './custom-icons/commit_node.vue';
|
||||
import CustomIconGrid1 from './custom-icons/grid_1.vue';
|
||||
@@ -34,6 +35,7 @@ import CustomIconLogout from './custom-icons/logout.vue';
|
||||
|
||||
const customIcons: string[] = [
|
||||
'directus',
|
||||
'bookmark_save',
|
||||
'box',
|
||||
'commit_node',
|
||||
'grid_1',
|
||||
@@ -54,6 +56,7 @@ const customIcons: string[] = [
|
||||
export default defineComponent({
|
||||
components: {
|
||||
CustomIconDirectus,
|
||||
CustomIconBookmarkSave,
|
||||
CustomIconBox,
|
||||
CustomIconCommitNode,
|
||||
CustomIconGrid1,
|
||||
|
||||
@@ -71,6 +71,10 @@ body {
|
||||
&.dense:not(.nav) #{$this} .v-icon {
|
||||
--v-icon-color: var(--v-list-item-icon-color);
|
||||
}
|
||||
|
||||
&.disabled #{$this} .v-icon {
|
||||
--v-icon-color: var(--foreground-subdued) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,6 +158,7 @@ body {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
flex-basis: 220px;
|
||||
flex-shrink: 0;
|
||||
width: 220px;
|
||||
@@ -165,7 +166,6 @@ body {
|
||||
background-color: var(--background-normal);
|
||||
transform: translateX(-100%);
|
||||
transition: transform var(--slow) var(--transition-out);
|
||||
z-index: 2;
|
||||
|
||||
&.active {
|
||||
transform: translateX(0);
|
||||
@@ -183,6 +183,7 @@ body {
|
||||
|
||||
@include breakpoint(medium) {
|
||||
--v-overlay-z-index: none;
|
||||
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,24 +42,28 @@
|
||||
<v-divider />
|
||||
</template>
|
||||
|
||||
<v-list-item
|
||||
v-for="item in _items"
|
||||
:key="item.text + item.value"
|
||||
:active="multiple ? (value || []).includes(item.value) : value === item.value"
|
||||
:disabled="item.disabled"
|
||||
@click="multiple ? null : $emit('input', item.value)"
|
||||
>
|
||||
<v-list-item-content>
|
||||
<span v-if="multiple === false" class="item-text">{{ item.text }}</span>
|
||||
<v-checkbox
|
||||
v-else
|
||||
:inputValue="value || []"
|
||||
:label="item.text"
|
||||
:value="item.value"
|
||||
@change="$emit('input', $event.length > 0 ? $event : null)"
|
||||
/>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
<template v-for="(item, index) in _items">
|
||||
<v-divider :key="index" v-if="item.divider === true" />
|
||||
|
||||
<v-list-item
|
||||
v-else
|
||||
:key="item.text + item.value"
|
||||
:active="multiple ? (value || []).includes(item.value) : value === item.value"
|
||||
:disabled="item.disabled"
|
||||
@click="multiple ? null : $emit('input', item.value)"
|
||||
>
|
||||
<v-list-item-content>
|
||||
<span v-if="multiple === false" class="item-text">{{ item.text }}</span>
|
||||
<v-checkbox
|
||||
v-else
|
||||
:inputValue="value || []"
|
||||
:label="item.text"
|
||||
:value="item.value"
|
||||
@change="$emit('input', $event.length > 0 ? $event : null)"
|
||||
/>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</template>
|
||||
|
||||
<v-list-item v-if="allowOther && multiple === false" :active="usesOtherValue" @click.stop>
|
||||
<v-list-item-content>
|
||||
@@ -206,6 +210,8 @@ export default defineComponent({
|
||||
};
|
||||
}
|
||||
|
||||
if (item.divider === true) return { divider: true };
|
||||
|
||||
return {
|
||||
text: item[props.itemText],
|
||||
value: item[props.itemValue],
|
||||
|
||||
@@ -13,7 +13,7 @@ device.
|
||||
<v-tab><v-icon name="help" left /> Help</v-tab>
|
||||
</v-tabs>
|
||||
|
||||
<v-tabs-items>
|
||||
<v-tabs-items v-model="selection">
|
||||
<v-tab-item>I'm the content for Home!</v-tab-item>
|
||||
<v-tab-item>I'm the content for News!</v-tab-item>
|
||||
<v-tab-item>I'm the content for Help!</v-tab-item>
|
||||
|
||||
@@ -3,17 +3,7 @@ import { nanoid } from 'nanoid';
|
||||
|
||||
type EmitFunction = (event: string, ...args: any[]) => void;
|
||||
|
||||
type Items = Readonly<
|
||||
Ref<
|
||||
| readonly {
|
||||
text: string;
|
||||
value: string | boolean | number;
|
||||
}[]
|
||||
| null
|
||||
>
|
||||
>;
|
||||
|
||||
export function useCustomSelection(currentValue: Ref<string>, items: Items, emit: EmitFunction) {
|
||||
export function useCustomSelection(currentValue: Ref<string>, items: Ref<any[]>, emit: EmitFunction) {
|
||||
const localOtherValue = ref('');
|
||||
|
||||
const otherValue = computed({
|
||||
@@ -46,7 +36,7 @@ export function useCustomSelection(currentValue: Ref<string>, items: Items, emit
|
||||
return { otherValue, usesOtherValue };
|
||||
}
|
||||
|
||||
export function useCustomSelectionMultiple(currentValues: Ref<string[]>, items: Items, emit: EmitFunction) {
|
||||
export function useCustomSelectionMultiple(currentValues: Ref<string[]>, items: Ref<any[]>, emit: EmitFunction) {
|
||||
type OtherValue = {
|
||||
key: string;
|
||||
value: string;
|
||||
|
||||
@@ -5,7 +5,7 @@ import getDefaultInterfaceForType from '@/utils/get-default-interface-for-type';
|
||||
import { getInterfaces } from '@/interfaces';
|
||||
import { FormField } from '@/components/v-form/types';
|
||||
import { Field } from '@/types';
|
||||
import { clone } from 'lodash';
|
||||
import { clone, orderBy } from 'lodash';
|
||||
|
||||
export default function useFormFields(fields: Ref<Field[]>) {
|
||||
const interfaces = getInterfaces();
|
||||
@@ -14,15 +14,7 @@ export default function useFormFields(fields: Ref<Field[]>) {
|
||||
let formFields = clone(fields.value);
|
||||
|
||||
// Sort the fields on the sort column value
|
||||
formFields = formFields.sort((a, b) => {
|
||||
const aSort = a.meta?.sort || null;
|
||||
const bSort = b.meta?.sort || null;
|
||||
|
||||
if (aSort === bSort) return 0;
|
||||
if (aSort === null) return 1;
|
||||
if (bSort === null) return -1;
|
||||
return aSort < bSort ? -1 : 1;
|
||||
});
|
||||
formFields = orderBy(formFields, [(o) => o.meta?.sort || null, (o) => o.meta?.id]);
|
||||
|
||||
formFields = formFields.map((field, index) => {
|
||||
if (!field.meta) return field;
|
||||
|
||||
@@ -14,8 +14,8 @@ export type Preset = {
|
||||
title: string | null;
|
||||
user: number | null;
|
||||
|
||||
view_options: Record<string, any>;
|
||||
layout_options: Record<string, any>;
|
||||
|
||||
view_query: Record<string, any>;
|
||||
view_type: string | null;
|
||||
layout_query: Record<string, any>;
|
||||
layout: string | null;
|
||||
};
|
||||
|
||||
@@ -9,26 +9,47 @@ export function usePreset(collection: Ref<string>, bookmark: Ref<number | null>
|
||||
const presetsStore = usePresetsStore();
|
||||
const userStore = useUserStore();
|
||||
|
||||
const busy = ref(false);
|
||||
|
||||
const { info: collectionInfo } = useCollection(collection);
|
||||
|
||||
const bookmarkExists = computed(() => {
|
||||
if (!bookmark.value) return false;
|
||||
|
||||
return !!presetsStore.state.collectionPresets.find((preset) => preset.id === bookmark.value);
|
||||
});
|
||||
|
||||
const localPreset = ref<Partial<Preset>>({});
|
||||
initLocalPreset();
|
||||
|
||||
const bookmarkSaved = computed(() => localPreset.value.$saved !== false);
|
||||
const bookmarkIsMine = computed(() => localPreset.value.user === userStore.state.currentUser!.id);
|
||||
|
||||
const savePreset = async (preset?: Partial<Preset>) => {
|
||||
busy.value = true;
|
||||
const updatedValues = await presetsStore.savePreset(preset ? preset : localPreset.value);
|
||||
initLocalPreset();
|
||||
localPreset.value.id = updatedValues.id;
|
||||
busy.value = false;
|
||||
return updatedValues;
|
||||
};
|
||||
|
||||
const saveLocal = () => {
|
||||
presetsStore.saveLocal(localPreset.value);
|
||||
initLocalPreset();
|
||||
};
|
||||
|
||||
const clearLocalSave = async () => {
|
||||
busy.value = true;
|
||||
await presetsStore.clearLocalSave(localPreset.value);
|
||||
initLocalPreset();
|
||||
busy.value = false;
|
||||
};
|
||||
|
||||
const autoSave = debounce(async () => {
|
||||
if (!bookmark || bookmark.value === null) {
|
||||
savePreset();
|
||||
} else {
|
||||
saveLocal();
|
||||
}
|
||||
}, 450);
|
||||
|
||||
@@ -36,19 +57,19 @@ export function usePreset(collection: Ref<string>, bookmark: Ref<number | null>
|
||||
initLocalPreset();
|
||||
});
|
||||
|
||||
const viewOptions = computed<Record<string, any>>({
|
||||
const layoutOptions = computed<Record<string, any>>({
|
||||
get() {
|
||||
if (!localPreset.value.view_type) return null;
|
||||
return localPreset.value.view_options?.[localPreset.value.view_type] || null;
|
||||
if (!localPreset.value.layout) return null;
|
||||
return localPreset.value.layout_options?.[localPreset.value.layout] || null;
|
||||
},
|
||||
set(val) {
|
||||
if (!localPreset.value.view_type) return null;
|
||||
if (!localPreset.value.layout) return null;
|
||||
|
||||
localPreset.value = {
|
||||
...localPreset.value,
|
||||
view_options: {
|
||||
...localPreset.value.view_options,
|
||||
[localPreset.value.view_type]: val,
|
||||
layout_options: {
|
||||
...localPreset.value.layout_options,
|
||||
[localPreset.value.layout]: val,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -56,18 +77,18 @@ export function usePreset(collection: Ref<string>, bookmark: Ref<number | null>
|
||||
},
|
||||
});
|
||||
|
||||
const viewQuery = computed<Record<string, any>>({
|
||||
const layoutQuery = computed<Record<string, any>>({
|
||||
get() {
|
||||
if (!localPreset.value.view_type) return null;
|
||||
return localPreset.value.view_query?.[localPreset.value.view_type] || null;
|
||||
if (!localPreset.value.layout) return null;
|
||||
return localPreset.value.layout_query?.[localPreset.value.layout] || null;
|
||||
},
|
||||
set(val) {
|
||||
if (!localPreset.value.view_type) return null;
|
||||
if (!localPreset.value.layout) return null;
|
||||
localPreset.value = {
|
||||
...localPreset.value,
|
||||
view_query: {
|
||||
...localPreset.value.view_query,
|
||||
[localPreset.value.view_type]: val,
|
||||
layout_query: {
|
||||
...localPreset.value.layout_query,
|
||||
[localPreset.value.layout]: val,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -75,14 +96,14 @@ export function usePreset(collection: Ref<string>, bookmark: Ref<number | null>
|
||||
},
|
||||
});
|
||||
|
||||
const viewType = computed<string | null>({
|
||||
const layout = computed<string | null>({
|
||||
get() {
|
||||
return localPreset.value.view_type || 'tabular';
|
||||
return localPreset.value.layout || 'tabular';
|
||||
},
|
||||
set(val) {
|
||||
localPreset.value = {
|
||||
...localPreset.value,
|
||||
view_type: val,
|
||||
layout: val,
|
||||
};
|
||||
|
||||
autoSave();
|
||||
@@ -117,14 +138,14 @@ export function usePreset(collection: Ref<string>, bookmark: Ref<number | null>
|
||||
},
|
||||
});
|
||||
|
||||
const title = computed<string | null>({
|
||||
const bookmarkTitle = computed<string | null>({
|
||||
get() {
|
||||
return localPreset.value?.title || null;
|
||||
return localPreset.value?.bookmark || null;
|
||||
},
|
||||
set(newTitle: string | null) {
|
||||
localPreset.value = {
|
||||
...localPreset.value,
|
||||
title: newTitle,
|
||||
bookmark: newTitle,
|
||||
};
|
||||
|
||||
// This'll save immediately
|
||||
@@ -134,23 +155,27 @@ export function usePreset(collection: Ref<string>, bookmark: Ref<number | null>
|
||||
|
||||
return {
|
||||
bookmarkExists,
|
||||
viewType,
|
||||
viewOptions,
|
||||
viewQuery,
|
||||
layout,
|
||||
layoutOptions,
|
||||
layoutQuery,
|
||||
filters,
|
||||
searchQuery,
|
||||
savePreset,
|
||||
saveCurrentAsBookmark,
|
||||
title,
|
||||
bookmarkTitle,
|
||||
resetPreset,
|
||||
bookmarkSaved,
|
||||
bookmarkIsMine,
|
||||
busy,
|
||||
clearLocalSave,
|
||||
};
|
||||
|
||||
async function resetPreset() {
|
||||
localPreset.value = {
|
||||
...localPreset.value,
|
||||
view_query: null,
|
||||
view_options: null,
|
||||
view_type: 'tabular',
|
||||
layout_query: null,
|
||||
layout_options: null,
|
||||
layout: 'tabular',
|
||||
filters: null,
|
||||
search_query: null,
|
||||
};
|
||||
@@ -171,10 +196,10 @@ export function usePreset(collection: Ref<string>, bookmark: Ref<number | null>
|
||||
};
|
||||
}
|
||||
|
||||
if (!localPreset.value.view_type) {
|
||||
if (!localPreset.value.layout) {
|
||||
localPreset.value = {
|
||||
...localPreset.value,
|
||||
view_type: 'tabular',
|
||||
layout: 'tabular',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import { computed, Ref } from '@vue/composition-api';
|
||||
import { clone } from 'lodash';
|
||||
|
||||
export default function useSync<T, K extends keyof T>(
|
||||
props: T,
|
||||
key: K,
|
||||
|
||||
emit: (event: string, ...args: any[]) => void
|
||||
): Ref<Readonly<T[K]>> {
|
||||
return computed<T[K]>({
|
||||
get() {
|
||||
return clone(props[key]);
|
||||
return props[key];
|
||||
},
|
||||
set(newVal) {
|
||||
emit(`update:${key}`, newVal);
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
<template>
|
||||
<div>
|
||||
<value-null v-if="value === null" />
|
||||
<div class="badge" :style="styles">{{ displayValue }}</div>
|
||||
<div class="dot" :style="styles" v-tooltip="displayValue"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType, computed } from '@vue/composition-api';
|
||||
import { defineComponent, computed, PropType } from '@vue/composition-api';
|
||||
import formatTitle from '@directus/format-title';
|
||||
|
||||
type Choice = {
|
||||
value: string;
|
||||
text: string;
|
||||
foreground: string | null;
|
||||
background: string | null;
|
||||
color: string | null;
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
@@ -26,13 +25,9 @@ export default defineComponent({
|
||||
type: Array as PropType<Choice[]>,
|
||||
default: () => [],
|
||||
},
|
||||
defaultBackground: {
|
||||
defaultColor: {
|
||||
type: String,
|
||||
default: '#eceff1',
|
||||
},
|
||||
defaultForeground: {
|
||||
type: String,
|
||||
default: '#263238',
|
||||
default: '#B0BEC5',
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
@@ -43,14 +38,12 @@ export default defineComponent({
|
||||
});
|
||||
|
||||
const displayValue = computed(() => {
|
||||
if (!currentChoice.value) return formatTitle(props.value);
|
||||
return currentChoice.value.text;
|
||||
return formatTitle(props.value);
|
||||
});
|
||||
|
||||
const styles = computed(() => {
|
||||
return {
|
||||
color: currentChoice.value?.foreground || props.defaultForeground,
|
||||
backgroundColor: currentChoice.value?.background || props.defaultBackground,
|
||||
backgroundColor: currentChoice.value?.color || props.defaultColor,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -60,11 +53,13 @@ export default defineComponent({
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.badge {
|
||||
.dot {
|
||||
display: inline-block;
|
||||
padding: 8px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin: 0 4px;
|
||||
vertical-align: middle;
|
||||
border-radius: var(--border-radius);
|
||||
border-radius: 6px;
|
||||
}
|
||||
</style>
|
||||
58
app/src/displays/color-dot/index.ts
Normal file
58
app/src/displays/color-dot/index.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { defineDisplay } from '@/displays/define';
|
||||
import DisplayColorDot from './color-dot.vue';
|
||||
|
||||
export default defineDisplay(({ i18n }) => ({
|
||||
id: 'color-dot',
|
||||
name: i18n.t('color_dot'),
|
||||
types: ['string'],
|
||||
icon: 'flag',
|
||||
handler: DisplayColorDot,
|
||||
options: [
|
||||
{
|
||||
field: 'defaultColor',
|
||||
name: i18n.t('default_color'),
|
||||
type: 'string',
|
||||
meta: {
|
||||
interface: 'color',
|
||||
width: 'half',
|
||||
},
|
||||
schema: {
|
||||
default_value: '#B0BEC5',
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'choices',
|
||||
name: i18n.t('choices'),
|
||||
type: 'json',
|
||||
meta: {
|
||||
interface: 'repeater',
|
||||
options: {
|
||||
template: '{{text}}',
|
||||
fields: [
|
||||
{
|
||||
field: 'value',
|
||||
name: i18n.t('value'),
|
||||
type: 'string',
|
||||
meta: {
|
||||
interface: 'text-input',
|
||||
options: {
|
||||
font: 'monospace',
|
||||
},
|
||||
width: 'half',
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'color',
|
||||
name: i18n.t('color'),
|
||||
type: 'string',
|
||||
meta: {
|
||||
interface: 'color',
|
||||
width: 'half',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}));
|
||||
@@ -1,6 +1,5 @@
|
||||
import { defineDisplay } from '@/displays/define';
|
||||
import DisplayIcon from './icon.vue';
|
||||
import { types } from '@/types';
|
||||
|
||||
export default defineDisplay(({ i18n }) => ({
|
||||
id: 'icon',
|
||||
@@ -21,5 +20,5 @@ export default defineDisplay(({ i18n }) => ({
|
||||
},
|
||||
},
|
||||
],
|
||||
types: types,
|
||||
types: ['string'],
|
||||
}));
|
||||
|
||||
@@ -1,34 +1,12 @@
|
||||
import DisplayBadge from './badge';
|
||||
import DisplayCollection from './collection';
|
||||
import DisplayDateTime from './datetime';
|
||||
import DisplayFile from './file';
|
||||
import DisplayFilesize from './filesize';
|
||||
import DisplayFormattedValue from './formatted-value';
|
||||
import DisplayIcon from './icon/';
|
||||
import DisplayImage from './image';
|
||||
import DisplayMimeType from './mime-type';
|
||||
import DisplayRating from './rating';
|
||||
import DisplayRaw from './raw';
|
||||
import DisplayStatusDot from './status-dot/';
|
||||
import DisplayTags from './tags/';
|
||||
import DisplayTemplate from './template';
|
||||
import DisplayUser from './user';
|
||||
import { ref, Ref } from '@vue/composition-api';
|
||||
import { DisplayConfig } from './types';
|
||||
|
||||
export const displays = [
|
||||
DisplayRaw,
|
||||
DisplayFormattedValue,
|
||||
DisplayBadge,
|
||||
DisplayCollection,
|
||||
DisplayDateTime,
|
||||
DisplayFile,
|
||||
DisplayFilesize,
|
||||
DisplayIcon,
|
||||
DisplayImage,
|
||||
DisplayMimeType,
|
||||
DisplayRating,
|
||||
DisplayStatusDot,
|
||||
DisplayTags,
|
||||
DisplayTemplate,
|
||||
DisplayUser,
|
||||
];
|
||||
export default displays;
|
||||
let displays: Ref<DisplayConfig[]>;
|
||||
|
||||
export function getDisplays() {
|
||||
if (!displays) {
|
||||
displays = ref([]);
|
||||
}
|
||||
|
||||
return displays;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { defineDisplay } from '@/displays/define';
|
||||
import DisplayBadge from './badge.vue';
|
||||
import DisplayLabels from './labels.vue';
|
||||
|
||||
export default defineDisplay(({ i18n }) => ({
|
||||
id: 'badge',
|
||||
name: i18n.t('badge'),
|
||||
types: ['string'],
|
||||
id: 'labels',
|
||||
name: i18n.t('labels'),
|
||||
types: ['string', 'json'],
|
||||
icon: 'flag',
|
||||
handler: DisplayBadge,
|
||||
handler: DisplayLabels,
|
||||
options: [
|
||||
{
|
||||
field: 'defaultForeground',
|
||||
@@ -32,6 +32,18 @@ export default defineDisplay(({ i18n }) => ({
|
||||
default_value: '#eceff1',
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'format',
|
||||
name: i18n.t('format_text'),
|
||||
type: 'boolean',
|
||||
meta: {
|
||||
width: 'half-left',
|
||||
interface: 'toggle',
|
||||
},
|
||||
schema: {
|
||||
default_value: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'choices',
|
||||
name: i18n.t('choices'),
|
||||
99
app/src/displays/labels/labels.vue
Normal file
99
app/src/displays/labels/labels.vue
Normal file
@@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<div class="display-tags">
|
||||
<v-chip
|
||||
v-for="item in items"
|
||||
:key="item.value"
|
||||
:style="{
|
||||
'--v-chip-color': item.foreground,
|
||||
'--v-chip-background-color': item.background,
|
||||
}"
|
||||
small
|
||||
disabled
|
||||
label
|
||||
>
|
||||
{{ item.text }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed, PropType } from '@vue/composition-api';
|
||||
import formatTitle from '@directus/format-title';
|
||||
|
||||
type Choice = {
|
||||
value: string;
|
||||
text: string;
|
||||
foreground: string | null;
|
||||
background: string | null;
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
value: {
|
||||
type: [String, Array] as PropType<string | string[]>,
|
||||
required: true,
|
||||
},
|
||||
format: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
choices: {
|
||||
type: Array as PropType<Choice[]>,
|
||||
default: () => [],
|
||||
},
|
||||
defaultBackground: {
|
||||
type: String,
|
||||
default: '#eceff1',
|
||||
},
|
||||
defaultForeground: {
|
||||
type: String,
|
||||
default: '#263238',
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
validator: (val: string) => ['json', 'string'].includes(val),
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const items = computed(() => {
|
||||
let items: string[];
|
||||
|
||||
if (props.value === null) items = [];
|
||||
else if (props.type === 'string') items = [props.value as string];
|
||||
else items = props.value as string[];
|
||||
|
||||
return items.map((item) => {
|
||||
const choice = props.choices.find((choice) => choice.value === item);
|
||||
|
||||
if (choice === undefined) {
|
||||
return {
|
||||
value: item,
|
||||
text: props.format ? formatTitle(item) : item,
|
||||
foreground: props.defaultForeground,
|
||||
background: props.defaultBackground,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
value: item,
|
||||
text: choice.text || (props.format ? formatTitle(item) : item),
|
||||
foreground: choice.foreground || props.defaultForeground,
|
||||
background: choice.background || props.defaultBackground,
|
||||
};
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return { items };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.display-tags {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.v-chip + .v-chip {
|
||||
margin-left: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,8 +1,19 @@
|
||||
import { Component } from 'vue';
|
||||
import registerComponent from '@/utils/register-component/';
|
||||
import displays from './index';
|
||||
import { getDisplays } from './index';
|
||||
import { Component } from 'vue';
|
||||
|
||||
displays.forEach((display) => {
|
||||
const displays = getDisplays();
|
||||
|
||||
const context = require.context('.', true, /^.*index\.ts$/);
|
||||
const modules = context
|
||||
.keys()
|
||||
.map((key) => context(key))
|
||||
.map((mod) => mod.default)
|
||||
.filter((m) => m);
|
||||
|
||||
displays.value = modules;
|
||||
|
||||
displays.value.forEach((display) => {
|
||||
if (typeof display.handler !== 'function') {
|
||||
registerComponent('display-' + display.id, display.handler as Component);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineDisplay } from '@/displays/define';
|
||||
import DisplayTemplate from './template.vue';
|
||||
import DisplayRelatedValues from './related-values.vue';
|
||||
import getFieldsFromTemplate from '@/utils/get-fields-from-template';
|
||||
import adjustFieldsForDisplays from '@/utils/adjust-fields-for-displays';
|
||||
import getRelatedCollection from '@/utils/get-related-collection';
|
||||
@@ -11,10 +11,10 @@ type Options = {
|
||||
};
|
||||
|
||||
export default defineDisplay(({ i18n }) => ({
|
||||
id: 'template',
|
||||
name: i18n.t('template'),
|
||||
id: 'related-values',
|
||||
name: i18n.t('related_values'),
|
||||
icon: 'text_fields',
|
||||
handler: DisplayTemplate,
|
||||
handler: DisplayRelatedValues,
|
||||
options: [
|
||||
/** @todo make this a component so we have dynamic collection for display template component */
|
||||
{
|
||||
@@ -1,11 +0,0 @@
|
||||
import { defineDisplay } from '@/displays/define';
|
||||
import DisplayStatusDot from './status-dot.vue';
|
||||
|
||||
export default defineDisplay(({ i18n }) => ({
|
||||
id: 'status-dot',
|
||||
name: i18n.t('status_dot'),
|
||||
types: ['string'],
|
||||
icon: 'flag',
|
||||
handler: DisplayStatusDot,
|
||||
options: null,
|
||||
}));
|
||||
@@ -1,4 +0,0 @@
|
||||
# Status Dot
|
||||
|
||||
Renders the background color as set in the status mapping of the status type interface as a small dot.
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import withPadding from '../../../.storybook/decorators/with-padding';
|
||||
import { withKnobs, text, object } from '@storybook/addon-knobs';
|
||||
import readme from './readme.md';
|
||||
import { defineComponent } from '@vue/composition-api';
|
||||
|
||||
export default {
|
||||
title: 'Displays / Status (Dot)',
|
||||
decorators: [withPadding, withKnobs],
|
||||
parameters: {
|
||||
notes: readme,
|
||||
},
|
||||
};
|
||||
|
||||
const defaultStatusMapping = {
|
||||
published: {
|
||||
name: 'Published',
|
||||
value: 'published',
|
||||
text_color: '#fff',
|
||||
background_color: 'var(--primary)',
|
||||
},
|
||||
draft: {
|
||||
name: 'Draft',
|
||||
value: 'draft',
|
||||
text_color: 'var(--primary-subdued)',
|
||||
background_color: 'var(--background-subdued)',
|
||||
},
|
||||
deleted: {
|
||||
name: 'Deleted',
|
||||
value: 'deleted',
|
||||
text_color: 'var(--danger)',
|
||||
background_color: 'var(--danger-alt)',
|
||||
},
|
||||
};
|
||||
|
||||
export const basic = () =>
|
||||
defineComponent({
|
||||
props: {
|
||||
value: {
|
||||
default: text('Value', 'published'),
|
||||
},
|
||||
statusMapping: {
|
||||
default: object('Status Mapping', defaultStatusMapping),
|
||||
},
|
||||
},
|
||||
template: `
|
||||
<display-status-dot
|
||||
:value="value"
|
||||
:interface-options="{
|
||||
status_mapping: statusMapping,
|
||||
}"
|
||||
/>
|
||||
`,
|
||||
});
|
||||
@@ -1,72 +0,0 @@
|
||||
import DisplayStatusDot from './status-dot.vue';
|
||||
import { createLocalVue, shallowMount } from '@vue/test-utils';
|
||||
import VIcon from '@/components/v-icon';
|
||||
import VueCompositionAPI from '@vue/composition-api';
|
||||
import Tooltip from '@/directives/tooltip';
|
||||
|
||||
const localVue = createLocalVue();
|
||||
localVue.component('v-icon', VIcon);
|
||||
localVue.use(VueCompositionAPI);
|
||||
localVue.directive('tooltip', Tooltip);
|
||||
|
||||
describe('Displays / Status Dot', () => {
|
||||
it('Renders an empty span if no value is passed', () => {
|
||||
const component = shallowMount(DisplayStatusDot, {
|
||||
localVue,
|
||||
propsData: {
|
||||
value: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(component.find('span').exists()).toBe(true);
|
||||
expect(component.find('span').text()).toBe('');
|
||||
});
|
||||
|
||||
it('Renders a question mark icon is status is unknown in interface options', () => {
|
||||
const component = shallowMount(DisplayStatusDot, {
|
||||
localVue,
|
||||
propsData: {
|
||||
value: 'draft',
|
||||
interfaceOptions: {
|
||||
status_mapping: {
|
||||
published: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(component.find(VIcon).exists()).toBe(true);
|
||||
expect(component.attributes('name')).toBe('help_outline');
|
||||
});
|
||||
|
||||
it('Renders the dot with the correct color', () => {
|
||||
const component = shallowMount(DisplayStatusDot, {
|
||||
localVue,
|
||||
propsData: {
|
||||
value: 'draft',
|
||||
interfaceOptions: {
|
||||
status_mapping: {
|
||||
draft: {
|
||||
background_color: 'rgb(171, 202, 188)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(component.exists()).toBe(true);
|
||||
expect(component.attributes('style')).toBe('background-color: rgb(171, 202, 188);');
|
||||
});
|
||||
|
||||
it('Sets status to null if interface options are missing', () => {
|
||||
const component = shallowMount(DisplayStatusDot, {
|
||||
localVue,
|
||||
propsData: {
|
||||
value: 'draft',
|
||||
interfaceOptions: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect((component.vm as any).status).toBe(null);
|
||||
});
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
<template>
|
||||
<span v-if="!value" />
|
||||
<v-icon name="help_outline" small v-else-if="!status" />
|
||||
<div
|
||||
v-else
|
||||
class="dot"
|
||||
v-tooltip="status.name"
|
||||
:style="{
|
||||
backgroundColor: status.background_color,
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed } from '@vue/composition-api';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
value: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
interfaceOptions: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const status = computed(() => {
|
||||
if (props.interfaceOptions === null) return null;
|
||||
|
||||
return props.interfaceOptions.status_mapping?.[props.value];
|
||||
});
|
||||
|
||||
return { status };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.dot {
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin: 0 4px;
|
||||
vertical-align: middle;
|
||||
border-radius: 6px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,24 +0,0 @@
|
||||
import { defineDisplay } from '@/displays/define';
|
||||
import DisplayTags from './tags.vue';
|
||||
|
||||
export default defineDisplay(({ i18n }) => ({
|
||||
id: 'tags',
|
||||
name: i18n.t('tags'),
|
||||
types: ['json'],
|
||||
icon: 'label',
|
||||
handler: DisplayTags,
|
||||
options: [
|
||||
{
|
||||
field: 'format',
|
||||
name: i18n.t('format_text'),
|
||||
type: 'boolean',
|
||||
meta: {
|
||||
width: 'half',
|
||||
interface: 'toggle',
|
||||
},
|
||||
schema: {
|
||||
default_value: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
}));
|
||||
@@ -1,3 +0,0 @@
|
||||
# Tags
|
||||
|
||||
Renders a CSV of strings as individual chips.
|
||||
@@ -1,24 +0,0 @@
|
||||
import withPadding from '../../../.storybook/decorators/with-padding';
|
||||
import { withKnobs, array } from '@storybook/addon-knobs';
|
||||
import readme from './readme.md';
|
||||
import { defineComponent } from '@vue/composition-api';
|
||||
|
||||
export default {
|
||||
title: 'Displays / Tags',
|
||||
decorators: [withPadding, withKnobs],
|
||||
parameters: {
|
||||
notes: readme,
|
||||
},
|
||||
};
|
||||
|
||||
export const basic = () =>
|
||||
defineComponent({
|
||||
props: {
|
||||
value: {
|
||||
default: array('Value', ['vip', 'executive']),
|
||||
},
|
||||
},
|
||||
template: `
|
||||
<display-tags :value="value" />
|
||||
`,
|
||||
});
|
||||
@@ -1,21 +0,0 @@
|
||||
import DisplayTags from './tags.vue';
|
||||
import { createLocalVue, shallowMount } from '@vue/test-utils';
|
||||
import VueCompositionAPI from '@vue/composition-api';
|
||||
import VChip from '@/components/v-chip';
|
||||
|
||||
const localVue = createLocalVue();
|
||||
localVue.use(VueCompositionAPI);
|
||||
localVue.component('v-chip', VChip);
|
||||
|
||||
describe('Displays / Tags', () => {
|
||||
it('Renders a chip for every value', () => {
|
||||
const component = shallowMount(DisplayTags, {
|
||||
localVue,
|
||||
propsData: {
|
||||
value: ['tag 1', 'tag 2', 'tag 3'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(component.findAll(VChip).length).toBe(3);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user