Merge branch 'main' into feature-redis-cache

This commit is contained in:
rijkvanzanten
2020-09-08 16:57:15 -04:00
228 changed files with 36284 additions and 2498 deletions

1
api/.gitignore vendored
View File

@@ -1,6 +1,7 @@
node_modules
.env
.cache
.env
.DS_Store
uploads
debug.db

View File

@@ -1,5 +1,5 @@
{
"printWidth": 100,
"singleQuote": true,
"useTabs": true
"printWidth": 100,
"singleQuote": true,
"useTabs": true
}

View File

@@ -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.

View File

@@ -19,23 +19,33 @@ DB_PASSWORD="psql1234"
## SQLite Example
# DB_FILENAME="./data.db"
####################################################################################################
# REDIS Server
REDIS_HOST="127.0.0.1"
REDIS_PORT="6379"
REDIS_PASSWORD=null
####################################################################################################
# Rate Limiting
RATE_LIMIT_TYPE="redis"
CONSUMED_POINTS_LIMIT=5
CONSUMED_RESET_DURATION=1
EXEC_EVENLY=true
BLOCK_POINT_DURATION=0
INMEMORY_BLOCK_CONSUMED=200
INMEMEMORY_BLOCK_DURATION=30
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

33152
api/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,172 +1,168 @@
{
"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",
"build-windows": "rd /s /q dist 2>nul && tsc -b && copyfiles \"src\\**\\*.*\" -e \"src\\**\\*.ts\" -u 1 dist",
"dev-windows": "cross-env NODE_ENV=development LOG_LEVEL=trace ts-node-dev --files src\\server.ts --respawn --watch \"src\\**\\*.ts\" --transpile-only",
"cli-windows": "cross-env NODE_ENV=development ts-node --script-mode --transpile-only src\\cli\\index.ts",
"prepublishOnly-windows": "npm run build-windows"
},
"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",
"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",
"node-cache": "^5.1.2",
"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": {
"mssql": "^6.2.0",
"mysql": "^2.18.1",
"oracledb": "^5.0.0",
"pg": "^8.3.0",
"sqlite3": "^5.0.0",
"redis": "^3.0.2"
},
"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/redis": "^2.8.25",
"@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",
"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": {
"ioredis": "^4.17.3",
"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/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"
}

View File

@@ -3,11 +3,12 @@ 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 extractToken from './middleware/extract-token';
import authenticate from './middleware/authenticate';
import activityRouter from './controllers/activity';
@@ -32,63 +33,62 @@ import webhooksRouter from './controllers/webhooks';
import notFoundHandler from './controllers/not-found';
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 the rate limiter - all routes for now
if (env.RATE_LIMITER_ENABLED === true) {
app.use(rateLimiter);
}
.use(authenticate)
app.use('/auth', authRouter);
app.use(authenticate);
.use('/activity', activityRouter)
.use('/assets', assetsRouter)
.use('/collections', collectionsRouter)
.use('/extensions', extensionsRouter)
.use('/fields', fieldsRouter)
.use('/files', filesRouter)
.use('/folders', foldersRouter)
.use('/items', itemsRouter)
.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('/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(notFoundHandler).use(errorHandler);
app.use(notFoundHandler);
app.use(errorHandler);
export default app;

View File

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

View File

@@ -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],
};

View File

@@ -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 = {

View File

@@ -1,39 +1,54 @@
####################################################################################################
# General
## General
{{ general }}
PORT=41201
PUBLIC_URL="/"
####################################################################################################
# Database
## Database
{{ database }}
####################################################################################################
# Caching
## Rate Limiting
{{ caching }}
####################################################################################################
# File Storage
{{ 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"

View File

@@ -15,58 +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',
},
redisServer: {
REDIS_HOST: '127.0.0.1',
REDIS_PORT: '6379',
REDIS_PASSWORD: null,
},
rateLimits: {
RATE_LIMIT_TYPE: 'redis',
CONSUMED_POINTS_LIMIT: 5,
CONSUMED_RESET_DURATION: 1,
EXEC_EVENLY: true,
BLOCK_POINT_DURATION: 0,
INMEMORY_BLOCK_CONSUMED: 200,
INMEMEMORY_BLOCK_DURATION: 30,
},
caching: {
CACHE_ENABLED: true,
CACHE_DRIVER: 'redis',
CACHE_HOST: '127.0.0.1',
CACHE_PORT: '6379',
CACHE_REDIS_PASSWORD: null,
CACHE_TTL: 300,
CACHE_CHECK_LIVE: 300,
},
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',
},
};

View File

@@ -67,13 +67,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) => {

View File

@@ -65,8 +65,7 @@ router.get(
const items = await service.readByQuery(req.sanitizedQuery);
return res.json({ data: items || null });
}),
setCacheMiddleware
})
);
router.get(

View File

@@ -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

View File

@@ -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,
@@ -37,31 +47,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
*/

View File

@@ -1,7 +1,12 @@
import { BaseException } from './base';
type Extensions = {
limit: number;
reset: Date;
};
export class HitRateLimitException extends BaseException {
constructor(message: string) {
super(message, 429, 'REQUESTS_EXCEEDED');
constructor(message: string, extensions: Extensions) {
super(message, 429, 'REQUESTS_EXCEEDED', extensions);
}
}

View File

@@ -10,5 +10,5 @@ export * from './invalid-payload';
export * from './invalid-query';
export * from './item-limit';
export * from './item-not-found';
export * from './redis-not-found';
export * from './route-not-found';
export * from './service-unavailable';

View File

@@ -1,7 +0,0 @@
import { BaseException } from './base';
export class RedisNotFoundException extends BaseException {
constructor(message: string) {
super(message, 503, 'REDIS_NOT_FOUND');
}
}

View 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);
}
}

View File

@@ -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);

View 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;

View File

@@ -0,0 +1,84 @@
import { RequestHandler } from 'express';
import asyncHandler from 'express-async-handler';
import {
RateLimiterMemory,
RateLimiterRedis,
RateLimiterMemcache,
IRateLimiterOptions,
IRateLimiterStoreOptions,
RateLimiterStoreAbstract,
} 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;
}

View File

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

View File

@@ -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;

View File

@@ -16,6 +16,7 @@ import { ForbiddenException, InvalidPayloadException } from '../exceptions';
import { uniq, merge } from 'lodash';
import generateJoi from '../utils/generate-joi';
import ItemsService from './items';
import { parseFilter } from '../utils/parse-filter';
export default class AuthorizationService {
knex: Knex;
@@ -64,8 +65,7 @@ export default class AuthorizationService {
}
validateFields(ast);
applyFilters(ast);
applyFilters(ast, this.accountability);
return ast;
@@ -126,7 +126,8 @@ export default class AuthorizationService {
}
function applyFilters(
ast: AST | NestedCollectionAST | FieldAST
ast: AST | NestedCollectionAST | FieldAST,
accountability: Accountability | null
): AST | NestedCollectionAST | FieldAST {
if (ast.type === 'collection') {
const collection = ast.name;
@@ -136,11 +137,12 @@ export default class AuthorizationService {
(permission) => permission.collection === collection
)!;
const parsedPermissions = parseFilter(permissions.permissions, accountability);
ast.query = {
...ast.query,
filter: {
...(ast.query.filter || {}),
...permissions.permissions,
_and: [ast.query.filter || {}, parsedPermissions],
},
};
@@ -155,7 +157,10 @@ export default class AuthorizationService {
ast.query.limit = permissions.limit;
}
ast.children = ast.children.map(applyFilters) as (NestedCollectionAST | FieldAST)[];
ast.children = ast.children.map((child) => applyFilters(child, accountability)) as (
| NestedCollectionAST
| FieldAST
)[];
}
return ast;

View File

@@ -199,12 +199,27 @@ export default class CollectionsService {
const payload = data as Partial<Collection>;
if (!payload.meta) {
throw new InvalidPayloadException(`"system" key is required`);
throw new InvalidPayloadException(`"meta" key is required`);
}
return (await collectionItemsService.update(payload.meta!, key as any)) as
| string
| string[];
const keys = Array.isArray(key) ? key : [key];
for (const key of keys) {
const exists =
(await this.knex
.select('collection')
.from('directus_collections')
.where({ collection: key })
.first()) !== undefined;
if (exists) {
await collectionItemsService.update(payload.meta, key);
} else {
await collectionItemsService.create({ ...payload.meta, collection: key });
}
}
return key;
}
const payloads = Array.isArray(data) ? data : [data];

View File

@@ -253,16 +253,23 @@ export default class FieldsService {
.from('directus_fields')
.where({ collection, field: field.field })
.first();
if (!record) throw new FieldNotFoundException(collection, field.field);
await this.itemsService.update(
{
if (record) {
await this.itemsService.update(
{
...field.meta,
collection: collection,
field: field.field,
},
record.id
);
} else {
await this.itemsService.create({
...field.meta,
collection: collection,
field: field.field,
},
record.id
);
});
}
}
return field.field;

View File

@@ -321,4 +321,3 @@ export default class PayloadService {
}
}
}
0;

View File

@@ -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);
}

View File

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

View File

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

View File

@@ -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;
}

View File

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

View File

@@ -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);
}
}
}

View File

@@ -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
}
}

View File

@@ -1,9 +1,7 @@
{
"defaultSeverity": "error",
"extends": [
"tslint:recommended"
],
"jsRules": {},
"rules": {},
"rulesDirectory": []
}
"defaultSeverity": "error",
"extends": ["tslint:recommended"],
"jsRules": {},
"rules": {},
"rulesDirectory": []
}